update
This commit is contained in:
parent
15900b9f53
commit
82b61bd808
11
app/cli.py
11
app/cli.py
@ -58,6 +58,11 @@ def build_parser():
|
|||||||
repair_strategy.add_argument("--limit", type=int, default=500, help="最多扫描的 recommendation 数量")
|
repair_strategy.add_argument("--limit", type=int, default=500, help="最多扫描的 recommendation 数量")
|
||||||
repair_strategy.add_argument("--dry-run", action="store_true", help="只预览不写库")
|
repair_strategy.add_argument("--dry-run", action="store_true", help="只预览不写库")
|
||||||
|
|
||||||
|
reset_strategy = subparsers.add_parser("reset-strategy-samples", help="清理已下线策略的历史样本")
|
||||||
|
reset_strategy.add_argument("--scope", choices=["legacy-strategies"], required=True)
|
||||||
|
reset_strategy.add_argument("--confirm", required=True, help="必须传 CLEAN_LEGACY_STRATEGY_SAMPLES")
|
||||||
|
reset_strategy.add_argument("--no-backup", action="store_true", help="跳过 pg_dump 备份,仅用于测试")
|
||||||
|
|
||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
||||||
@ -132,6 +137,12 @@ def main():
|
|||||||
result = repair_strategy_direction_mismatches(limit=args.limit, dry_run=args.dry_run)
|
result = repair_strategy_direction_mismatches(limit=args.limit, dry_run=args.dry_run)
|
||||||
print(sentiment_monitor.json.dumps(result, ensure_ascii=False, indent=2))
|
print(sentiment_monitor.json.dumps(result, ensure_ascii=False, indent=2))
|
||||||
return result
|
return result
|
||||||
|
if args.command == "reset-strategy-samples":
|
||||||
|
from app.db.strategy_sample_cleanup import cleanup_legacy_strategy_samples
|
||||||
|
|
||||||
|
result = cleanup_legacy_strategy_samples(confirm=args.confirm, create_backup=not args.no_backup)
|
||||||
|
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}")
|
||||||
|
|
||||||
|
|||||||
@ -5,13 +5,19 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
|
||||||
MAIN_COMPOSITE_STRATEGY = "main_composite_v1"
|
LONG_MOMENTUM_BREAKOUT_STRATEGY = "long_momentum_breakout_15m_1h_v1"
|
||||||
BOX_RETEST_1H_STRATEGY = "box_retest_1h_v1"
|
LONG_SECOND_WAVE_PULLBACK_STRATEGY = "long_second_wave_pullback_1h_v1"
|
||||||
BOX_RETEST_4H_STRATEGY = "box_retest_4h_v1"
|
SHORT_BREAKDOWN_RETEST_STRATEGY = "short_breakdown_retest_1h_v1"
|
||||||
VOLUME_IGNITION_1H_STRATEGY = "volume_ignition_1h_v1"
|
|
||||||
COMPRESSION_BREAKOUT_4H_STRATEGY = "compression_breakout_4h_v1"
|
# Compatibility aliases for old imports. These aliases intentionally map old
|
||||||
INTRADAY_MOMENTUM_15M_STRATEGY = "intraday_momentum_15m_v1"
|
# names to the new active strategy pool so new data never emits retired codes.
|
||||||
BREAKDOWN_RETEST_SHORT_1H_STRATEGY = "breakdown_retest_short_1h_v1"
|
MAIN_COMPOSITE_STRATEGY = LONG_MOMENTUM_BREAKOUT_STRATEGY
|
||||||
|
BOX_RETEST_1H_STRATEGY = LONG_SECOND_WAVE_PULLBACK_STRATEGY
|
||||||
|
BOX_RETEST_4H_STRATEGY = LONG_SECOND_WAVE_PULLBACK_STRATEGY
|
||||||
|
VOLUME_IGNITION_1H_STRATEGY = LONG_MOMENTUM_BREAKOUT_STRATEGY
|
||||||
|
COMPRESSION_BREAKOUT_4H_STRATEGY = LONG_SECOND_WAVE_PULLBACK_STRATEGY
|
||||||
|
INTRADAY_MOMENTUM_15M_STRATEGY = LONG_MOMENTUM_BREAKOUT_STRATEGY
|
||||||
|
BREAKDOWN_RETEST_SHORT_1H_STRATEGY = SHORT_BREAKDOWN_RETEST_STRATEGY
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@ -27,67 +33,17 @@ class StrategyDefinition:
|
|||||||
|
|
||||||
|
|
||||||
STRATEGY_DEFINITIONS: dict[str, StrategyDefinition] = {
|
STRATEGY_DEFINITIONS: dict[str, StrategyDefinition] = {
|
||||||
MAIN_COMPOSITE_STRATEGY: StrategyDefinition(
|
LONG_MOMENTUM_BREAKOUT_STRATEGY: StrategyDefinition(
|
||||||
strategy_code=MAIN_COMPOSITE_STRATEGY,
|
strategy_code=LONG_MOMENTUM_BREAKOUT_STRATEGY,
|
||||||
strategy_name="综合确认策略",
|
strategy_name="多头动量启动",
|
||||||
description="迁移期兼容综合策略,承载现有综合筛选与确认逻辑;它与其他策略平等运行。",
|
description="15m当前突破叠加1H成交量/波动增强,捕捉山寨币日内到1-3天启动段。",
|
||||||
direction="both",
|
direction="long",
|
||||||
mode="paper_enabled",
|
mode="paper_enabled",
|
||||||
),
|
|
||||||
BOX_RETEST_1H_STRATEGY: StrategyDefinition(
|
|
||||||
strategy_code=BOX_RETEST_1H_STRATEGY,
|
|
||||||
strategy_name="1H箱体突破回踩",
|
|
||||||
description="小时级底部箱体突破后回踩箱体上沿或EMA承接的早期结构策略。",
|
|
||||||
mode="paper_only",
|
|
||||||
entry_gate_config={
|
entry_gate_config={
|
||||||
"min_entry_score_buy_now": 1,
|
"min_entry_score_buy_now": 3,
|
||||||
"min_entry_score_wait_pullback": 0,
|
"min_entry_score_wait_pullback": 2,
|
||||||
"min_rr_buy_now": 1.2,
|
"min_rr_buy_now": 1.5,
|
||||||
"max_wait_pullback_deviation_pct": 20,
|
"breakout_distance_wait_pct": 8,
|
||||||
"breakout_distance_wait_pct": 25,
|
|
||||||
"gain_24h_wait_pct": 12,
|
|
||||||
},
|
|
||||||
paper_config={
|
|
||||||
"entry_min_rr": 1.5,
|
|
||||||
"order_min_rr": 1.5,
|
|
||||||
"order_min_distance_to_entry_pct": 0,
|
|
||||||
"order_require_current_trigger": False,
|
|
||||||
"dynamic_leverage_enabled": True,
|
|
||||||
"dynamic_leverage_min": 3,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
BOX_RETEST_4H_STRATEGY: StrategyDefinition(
|
|
||||||
strategy_code=BOX_RETEST_4H_STRATEGY,
|
|
||||||
strategy_name="4H箱体突破回踩",
|
|
||||||
description="底部箱体突破后回踩箱体上沿或EMA承接的结构策略。",
|
|
||||||
mode="paper_only",
|
|
||||||
entry_gate_config={
|
|
||||||
"min_entry_score_buy_now": 1,
|
|
||||||
"min_entry_score_wait_pullback": 0,
|
|
||||||
"min_rr_buy_now": 1.2,
|
|
||||||
"max_wait_pullback_deviation_pct": 20,
|
|
||||||
"breakout_distance_wait_pct": 25,
|
|
||||||
"gain_24h_wait_pct": 12,
|
|
||||||
},
|
|
||||||
paper_config={
|
|
||||||
"entry_min_rr": 1.5,
|
|
||||||
"order_min_rr": 1.5,
|
|
||||||
"order_min_distance_to_entry_pct": 0,
|
|
||||||
"order_require_current_trigger": False,
|
|
||||||
"dynamic_leverage_enabled": True,
|
|
||||||
"dynamic_leverage_min": 3,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
VOLUME_IGNITION_1H_STRATEGY: StrategyDefinition(
|
|
||||||
strategy_code=VOLUME_IGNITION_1H_STRATEGY,
|
|
||||||
strategy_name="1H放量突破启动",
|
|
||||||
description="1H量价齐飞或连续放量后的启动策略,适合捕捉日内到3天的第一段加速。",
|
|
||||||
mode="paper_only",
|
|
||||||
entry_gate_config={
|
|
||||||
"min_entry_score_buy_now": 2,
|
|
||||||
"min_entry_score_wait_pullback": 1,
|
|
||||||
"min_rr_buy_now": 1.25,
|
|
||||||
"breakout_distance_wait_pct": 12,
|
|
||||||
"gain_24h_wait_pct": 10,
|
"gain_24h_wait_pct": 10,
|
||||||
},
|
},
|
||||||
paper_config={
|
paper_config={
|
||||||
@ -99,55 +55,35 @@ STRATEGY_DEFINITIONS: dict[str, StrategyDefinition] = {
|
|||||||
"dynamic_leverage_min": 3,
|
"dynamic_leverage_min": 3,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
COMPRESSION_BREAKOUT_4H_STRATEGY: StrategyDefinition(
|
LONG_SECOND_WAVE_PULLBACK_STRATEGY: StrategyDefinition(
|
||||||
strategy_code=COMPRESSION_BREAKOUT_4H_STRATEGY,
|
strategy_code=LONG_SECOND_WAVE_PULLBACK_STRATEGY,
|
||||||
strategy_name="4H压缩蓄力突破",
|
strategy_name="多头二波回踩",
|
||||||
description="4H静K蓄力、底部抬高或压缩放量后的突破策略,偏向捕捉1周以内主升前段。",
|
description="强势榜或放量币第一波后回踩EMA、箱体上沿或前高转支撑,再次承接的短线策略。",
|
||||||
mode="paper_only",
|
direction="long",
|
||||||
|
mode="paper_enabled",
|
||||||
entry_gate_config={
|
entry_gate_config={
|
||||||
"min_entry_score_buy_now": 2,
|
"min_entry_score_buy_now": 2,
|
||||||
"min_entry_score_wait_pullback": 0,
|
"min_entry_score_wait_pullback": 0,
|
||||||
"min_rr_buy_now": 1.3,
|
"min_rr_buy_now": 1.5,
|
||||||
"max_wait_pullback_deviation_pct": 18,
|
"max_wait_pullback_deviation_pct": 10,
|
||||||
"breakout_distance_wait_pct": 18,
|
"breakout_distance_wait_pct": 12,
|
||||||
"gain_24h_wait_pct": 12,
|
"gain_24h_wait_pct": 18,
|
||||||
},
|
},
|
||||||
paper_config={
|
paper_config={
|
||||||
"entry_min_rr": 1.6,
|
"entry_min_rr": 1.5,
|
||||||
"order_min_rr": 1.6,
|
"order_min_rr": 1.5,
|
||||||
"order_min_distance_to_entry_pct": 0,
|
"order_min_distance_to_entry_pct": 1.5,
|
||||||
"order_require_current_trigger": False,
|
"order_require_current_trigger": False,
|
||||||
"dynamic_leverage_enabled": True,
|
"dynamic_leverage_enabled": True,
|
||||||
"dynamic_leverage_min": 3,
|
"dynamic_leverage_min": 3,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
INTRADAY_MOMENTUM_15M_STRATEGY: StrategyDefinition(
|
SHORT_BREAKDOWN_RETEST_STRATEGY: StrategyDefinition(
|
||||||
strategy_code=INTRADAY_MOMENTUM_15M_STRATEGY,
|
strategy_code=SHORT_BREAKDOWN_RETEST_STRATEGY,
|
||||||
strategy_name="15m日内动量延续",
|
strategy_name="空头破位反抽",
|
||||||
description="短周期放量突破与1H背景共振的日内动量策略,只做当前触发,不做纯观察追高。",
|
description="1H支撑或箱体下沿破位后反抽失败,叠加15m弱确认和相对弱势的空头短线策略。",
|
||||||
mode="paper_only",
|
|
||||||
entry_gate_config={
|
|
||||||
"min_entry_score_buy_now": 3,
|
|
||||||
"min_entry_score_wait_pullback": 2,
|
|
||||||
"min_rr_buy_now": 1.4,
|
|
||||||
"breakout_distance_wait_pct": 8,
|
|
||||||
"gain_24h_wait_pct": 8,
|
|
||||||
},
|
|
||||||
paper_config={
|
|
||||||
"entry_min_rr": 1.6,
|
|
||||||
"order_min_rr": 1.6,
|
|
||||||
"order_min_distance_to_entry_pct": 0,
|
|
||||||
"order_require_current_trigger": True,
|
|
||||||
"dynamic_leverage_enabled": True,
|
|
||||||
"dynamic_leverage_min": 3,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
BREAKDOWN_RETEST_SHORT_1H_STRATEGY: StrategyDefinition(
|
|
||||||
strategy_code=BREAKDOWN_RETEST_SHORT_1H_STRATEGY,
|
|
||||||
strategy_name="1H破位反抽做空",
|
|
||||||
description="箱体或关键均线破位后反抽失败的空头策略;只用于独立空头样本,不与多头突破策略共享入场门槛。",
|
|
||||||
direction="short",
|
direction="short",
|
||||||
mode="paper_only",
|
mode="paper_enabled",
|
||||||
entry_gate_config={
|
entry_gate_config={
|
||||||
"direction": "short",
|
"direction": "short",
|
||||||
"min_entry_score_buy_now": 2,
|
"min_entry_score_buy_now": 2,
|
||||||
@ -170,7 +106,18 @@ STRATEGY_DEFINITIONS: dict[str, StrategyDefinition] = {
|
|||||||
|
|
||||||
def normalize_strategy_code(strategy_code: str | None) -> str:
|
def normalize_strategy_code(strategy_code: str | None) -> str:
|
||||||
code = str(strategy_code or "").strip()
|
code = str(strategy_code or "").strip()
|
||||||
return code or MAIN_COMPOSITE_STRATEGY
|
legacy_map = {
|
||||||
|
"main_composite_v1": LONG_MOMENTUM_BREAKOUT_STRATEGY,
|
||||||
|
"volume_ignition_1h_v1": LONG_MOMENTUM_BREAKOUT_STRATEGY,
|
||||||
|
"intraday_momentum_15m_v1": LONG_MOMENTUM_BREAKOUT_STRATEGY,
|
||||||
|
"box_retest_1h_v1": LONG_SECOND_WAVE_PULLBACK_STRATEGY,
|
||||||
|
"box_retest_4h_v1": LONG_SECOND_WAVE_PULLBACK_STRATEGY,
|
||||||
|
"compression_breakout_4h_v1": LONG_SECOND_WAVE_PULLBACK_STRATEGY,
|
||||||
|
"breakdown_retest_short_1h_v1": SHORT_BREAKDOWN_RETEST_STRATEGY,
|
||||||
|
}
|
||||||
|
if code in legacy_map:
|
||||||
|
return legacy_map[code]
|
||||||
|
return code or LONG_MOMENTUM_BREAKOUT_STRATEGY
|
||||||
|
|
||||||
|
|
||||||
def strategy_definition(strategy_code: str | None) -> StrategyDefinition:
|
def strategy_definition(strategy_code: str | None) -> StrategyDefinition:
|
||||||
|
|||||||
26
app/db/migrations/0019_short_term_strategy_pool.sql
Normal file
26
app/db/migrations/0019_short_term_strategy_pool.sql
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
INSERT INTO strategy_catalog (
|
||||||
|
strategy_code, strategy_name, strategy_version, status, mode, description, config_json, created_at, updated_at
|
||||||
|
) VALUES
|
||||||
|
('long_momentum_breakout_15m_1h_v1', '多头动量启动', '', 'active', 'paper_enabled', '15m当前突破叠加1H成交量/波动增强,捕捉山寨币日内到1-3天启动段。', '{}', NOW()::TEXT, NOW()::TEXT),
|
||||||
|
('long_second_wave_pullback_1h_v1', '多头二波回踩', '', 'active', 'paper_enabled', '强势榜或放量币第一波后回踩EMA、箱体上沿或前高转支撑,再次承接的短线策略。', '{}', NOW()::TEXT, NOW()::TEXT),
|
||||||
|
('short_breakdown_retest_1h_v1', '空头破位反抽', '', 'active', 'paper_enabled', '1H支撑或箱体下沿破位后反抽失败,叠加15m弱确认和相对弱势的空头短线策略。', '{}', NOW()::TEXT, NOW()::TEXT)
|
||||||
|
ON CONFLICT(strategy_code) DO UPDATE SET
|
||||||
|
strategy_name=EXCLUDED.strategy_name,
|
||||||
|
status=EXCLUDED.status,
|
||||||
|
mode=EXCLUDED.mode,
|
||||||
|
description=EXCLUDED.description,
|
||||||
|
updated_at=NOW()::TEXT;
|
||||||
|
|
||||||
|
UPDATE strategy_catalog
|
||||||
|
SET status='retired',
|
||||||
|
mode='disabled',
|
||||||
|
updated_at=NOW()::TEXT
|
||||||
|
WHERE strategy_code IN (
|
||||||
|
'main_composite_v1',
|
||||||
|
'volume_ignition_1h_v1',
|
||||||
|
'intraday_momentum_15m_v1',
|
||||||
|
'box_retest_1h_v1',
|
||||||
|
'box_retest_4h_v1',
|
||||||
|
'compression_breakout_4h_v1',
|
||||||
|
'breakdown_retest_short_1h_v1'
|
||||||
|
);
|
||||||
@ -27,11 +27,24 @@ from app.core.trade_math import (
|
|||||||
stop_loss_distance_pct as side_stop_loss_distance_pct,
|
stop_loss_distance_pct as side_stop_loss_distance_pct,
|
||||||
tighter_stop,
|
tighter_stop,
|
||||||
)
|
)
|
||||||
from app.core.strategy_registry import normalize_strategy_code, strategy_label, strategy_paper_config
|
from app.core.strategy_registry import (
|
||||||
|
LONG_MOMENTUM_BREAKOUT_STRATEGY,
|
||||||
|
LONG_SECOND_WAVE_PULLBACK_STRATEGY,
|
||||||
|
SHORT_BREAKDOWN_RETEST_STRATEGY,
|
||||||
|
normalize_strategy_code,
|
||||||
|
strategy_label,
|
||||||
|
strategy_paper_config,
|
||||||
|
)
|
||||||
from app.db.schema import get_conn
|
from app.db.schema import get_conn
|
||||||
from app.db.system_logs import record_system_error
|
from app.db.system_logs import record_system_error
|
||||||
from app.integrations.feishu_push import push_card
|
from app.integrations.feishu_push import push_card
|
||||||
|
|
||||||
|
ACTIVE_PAPER_STRATEGIES = {
|
||||||
|
LONG_MOMENTUM_BREAKOUT_STRATEGY,
|
||||||
|
LONG_SECOND_WAVE_PULLBACK_STRATEGY,
|
||||||
|
SHORT_BREAKDOWN_RETEST_STRATEGY,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _now() -> str:
|
def _now() -> str:
|
||||||
return datetime.now().isoformat()
|
return datetime.now().isoformat()
|
||||||
@ -277,9 +290,29 @@ def _entry_plan(rec: dict) -> dict:
|
|||||||
return _loads_json(rec.get("entry_plan_json"), {})
|
return _loads_json(rec.get("entry_plan_json"), {})
|
||||||
|
|
||||||
|
|
||||||
def _strategy_code_from_rec(rec: dict) -> str:
|
def _raw_strategy_code_from_rec(rec: dict) -> str:
|
||||||
plan = _entry_plan(rec)
|
plan = _entry_plan(rec)
|
||||||
return normalize_strategy_code(rec.get("strategy_code") or plan.get("strategy_code"))
|
raw_code = str(rec.get("strategy_code") or plan.get("strategy_code") or "").strip()
|
||||||
|
if raw_code:
|
||||||
|
return raw_code
|
||||||
|
rec_id = _safe_int(rec.get("id"))
|
||||||
|
if rec_id <= 0:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
with get_conn() as conn:
|
||||||
|
row = conn.execute("SELECT strategy_code FROM recommendation WHERE id=%s", (rec_id,)).fetchone()
|
||||||
|
return str((row or {}).get("strategy_code") or "").strip()
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _is_active_paper_strategy_rec(rec: dict) -> bool:
|
||||||
|
raw_code = _raw_strategy_code_from_rec(rec)
|
||||||
|
return raw_code in ACTIVE_PAPER_STRATEGIES
|
||||||
|
|
||||||
|
|
||||||
|
def _strategy_code_from_rec(rec: dict) -> str:
|
||||||
|
return normalize_strategy_code(_raw_strategy_code_from_rec(rec))
|
||||||
|
|
||||||
|
|
||||||
def _paper_cfg_for_rec(rec: dict, config: dict | None = None) -> dict:
|
def _paper_cfg_for_rec(rec: dict, config: dict | None = None) -> dict:
|
||||||
@ -1529,6 +1562,13 @@ def sync_recommendation(rec: dict, current_price: float, event_time: str = "") -
|
|||||||
current_price = _safe_float(current_price)
|
current_price = _safe_float(current_price)
|
||||||
if rec_id <= 0 or not symbol or current_price <= 0:
|
if rec_id <= 0 or not symbol or current_price <= 0:
|
||||||
return {"enabled": True, "skipped": True, "reason": "invalid_input"}
|
return {"enabled": True, "skipped": True, "reason": "invalid_input"}
|
||||||
|
if not _is_active_paper_strategy_rec(rec):
|
||||||
|
return {
|
||||||
|
"enabled": True,
|
||||||
|
"skipped": True,
|
||||||
|
"reason": "legacy_strategy_disabled",
|
||||||
|
"strategy_code": _raw_strategy_code_from_rec(rec),
|
||||||
|
}
|
||||||
execution_status = str(rec.get("execution_status") or "").strip()
|
execution_status = str(rec.get("execution_status") or "").strip()
|
||||||
action_status = str(rec.get("action_status") or "").strip()
|
action_status = str(rec.get("action_status") or "").strip()
|
||||||
event_time = event_time or _now()
|
event_time = event_time or _now()
|
||||||
@ -1712,6 +1752,26 @@ def sync_pending_paper_orders(limit: int = 100, event_time: str = "", config: di
|
|||||||
"derivatives_context_json": item.get("derivatives_context_json"),
|
"derivatives_context_json": item.get("derivatives_context_json"),
|
||||||
"sector_context_json": item.get("sector_context_json"),
|
"sector_context_json": item.get("sector_context_json"),
|
||||||
}
|
}
|
||||||
|
if not _is_active_paper_strategy_rec(rec):
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE paper_orders
|
||||||
|
SET status='canceled',
|
||||||
|
cancel_reason='legacy_strategy_disabled',
|
||||||
|
canceled_at=%s,
|
||||||
|
updated_at=%s
|
||||||
|
WHERE id=%s AND status='pending'
|
||||||
|
""",
|
||||||
|
(event_time, event_time, order.get("id")),
|
||||||
|
)
|
||||||
|
results.append({
|
||||||
|
"skipped": True,
|
||||||
|
"reason": "legacy_strategy_disabled",
|
||||||
|
"paper_order_id": order.get("id"),
|
||||||
|
"symbol": order.get("symbol"),
|
||||||
|
"strategy_code": _raw_strategy_code_from_rec(rec),
|
||||||
|
})
|
||||||
|
continue
|
||||||
if current_price <= 0:
|
if current_price <= 0:
|
||||||
result = {
|
result = {
|
||||||
"skipped": True,
|
"skipped": True,
|
||||||
|
|||||||
@ -12,7 +12,11 @@ from app.core.opportunity_lifecycle import (
|
|||||||
from app.core.signal_taxonomy import signal_codes as build_signal_codes, signal_labels as build_signal_labels
|
from app.core.signal_taxonomy import signal_codes as build_signal_codes, signal_labels as build_signal_labels
|
||||||
from app.core.strategy_contract import signal_to_recommendation_context
|
from app.core.strategy_contract import signal_to_recommendation_context
|
||||||
from app.core.trade_direction import direction_label, trade_side_from_payload
|
from app.core.trade_direction import direction_label, trade_side_from_payload
|
||||||
from app.core.strategy_registry import normalize_strategy_code
|
from app.core.strategy_registry import (
|
||||||
|
LONG_MOMENTUM_BREAKOUT_STRATEGY,
|
||||||
|
LONG_SECOND_WAVE_PULLBACK_STRATEGY,
|
||||||
|
normalize_strategy_code,
|
||||||
|
)
|
||||||
from app.db.recommendation_state import (
|
from app.db.recommendation_state import (
|
||||||
derive_minimal_state_fields,
|
derive_minimal_state_fields,
|
||||||
entry_window_policy,
|
entry_window_policy,
|
||||||
@ -80,6 +84,9 @@ def create_recommendation(
|
|||||||
):
|
):
|
||||||
"""Create or merge the current recommendation record for one symbol."""
|
"""Create or merge the current recommendation record for one symbol."""
|
||||||
entry_plan = dict(entry_plan or {})
|
entry_plan = dict(entry_plan or {})
|
||||||
|
explicit_strategy_code = str(strategy_code or "").strip()
|
||||||
|
if not explicit_strategy_code and str(entry_plan.get("entry_action") or "").strip() in {"等回踩", "等待回踩"}:
|
||||||
|
strategy_code = LONG_SECOND_WAVE_PULLBACK_STRATEGY
|
||||||
raw_pct = round(rec_score * 100.0 / 30) if rec_score else 0
|
raw_pct = round(rec_score * 100.0 / 30) if rec_score else 0
|
||||||
rec_score_pct = min(raw_pct, 100)
|
rec_score_pct = min(raw_pct, 100)
|
||||||
strategy_context = signal_to_recommendation_context(
|
strategy_context = signal_to_recommendation_context(
|
||||||
@ -137,7 +144,7 @@ def create_recommendation(
|
|||||||
UPDATE recommendation
|
UPDATE recommendation
|
||||||
SET rec_state=%s, rec_score=%s, sector=COALESCE(NULLIF(%s, ''), sector),
|
SET rec_state=%s, rec_score=%s, sector=COALESCE(NULLIF(%s, ''), sector),
|
||||||
signals=%s, signal_codes_json=%s, signal_labels_json=%s, is_meme=%s, direction=%s, strategy_version=%s,
|
signals=%s, signal_codes_json=%s, signal_labels_json=%s, is_meme=%s, direction=%s, strategy_version=%s,
|
||||||
strategy_code=COALESCE(NULLIF(%s, ''), NULLIF(strategy_code, ''), 'main_composite_v1'),
|
strategy_code=COALESCE(NULLIF(%s, ''), NULLIF(strategy_code, ''), %s),
|
||||||
strategy_signal_id=CASE WHEN %s > 0 THEN %s ELSE COALESCE(strategy_signal_id, 0) END,
|
strategy_signal_id=CASE WHEN %s > 0 THEN %s ELSE COALESCE(strategy_signal_id, 0) END,
|
||||||
strategy_snapshot_json=CASE WHEN %s != '{}' THEN %s ELSE COALESCE(strategy_snapshot_json, '{}') END,
|
strategy_snapshot_json=CASE WHEN %s != '{}' THEN %s ELSE COALESCE(strategy_snapshot_json, '{}') END,
|
||||||
factor_roles_json=CASE WHEN %s != '{}' THEN %s ELSE COALESCE(factor_roles_json, '{}') END,
|
factor_roles_json=CASE WHEN %s != '{}' THEN %s ELSE COALESCE(factor_roles_json, '{}') END,
|
||||||
@ -170,6 +177,7 @@ def create_recommendation(
|
|||||||
direction,
|
direction,
|
||||||
strategy_version,
|
strategy_version,
|
||||||
strategy_code,
|
strategy_code,
|
||||||
|
LONG_MOMENTUM_BREAKOUT_STRATEGY,
|
||||||
strategy_signal_id,
|
strategy_signal_id,
|
||||||
strategy_signal_id,
|
strategy_signal_id,
|
||||||
json.dumps(strategy_snapshot or {}, ensure_ascii=False),
|
json.dumps(strategy_snapshot or {}, ensure_ascii=False),
|
||||||
|
|||||||
152
app/db/strategy_sample_cleanup.py
Normal file
152
app/db/strategy_sample_cleanup.py
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
"""One-time cleanup for retired strategy samples."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.db.postgres_connection import get_database_url
|
||||||
|
from app.db.schema import get_conn
|
||||||
|
|
||||||
|
LEGACY_STRATEGY_CODES = (
|
||||||
|
"main_composite_v1",
|
||||||
|
"volume_ignition_1h_v1",
|
||||||
|
"intraday_momentum_15m_v1",
|
||||||
|
"box_retest_1h_v1",
|
||||||
|
"box_retest_4h_v1",
|
||||||
|
"compression_breakout_4h_v1",
|
||||||
|
"breakdown_retest_short_1h_v1",
|
||||||
|
)
|
||||||
|
|
||||||
|
CONFIRM_TOKEN = "CLEAN_LEGACY_STRATEGY_SAMPLES"
|
||||||
|
|
||||||
|
|
||||||
|
def _repo_root() -> Path:
|
||||||
|
return Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
|
||||||
|
def backup_database() -> str:
|
||||||
|
backup_dir = _repo_root() / "data" / "backups"
|
||||||
|
backup_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
out_file = backup_dir / f"legacy_strategy_cleanup_before_{stamp}.dump"
|
||||||
|
subprocess.run(
|
||||||
|
["pg_dump", get_database_url(), "--format=custom", "--file", str(out_file)],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
return str(out_file)
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_legacy_strategy_samples(*, confirm: str, create_backup: bool = True) -> dict:
|
||||||
|
if confirm != CONFIRM_TOKEN:
|
||||||
|
raise ValueError(f"confirm must be {CONFIRM_TOKEN}")
|
||||||
|
|
||||||
|
backup_path = backup_database() if create_backup else ""
|
||||||
|
conn = get_conn()
|
||||||
|
deleted = {
|
||||||
|
"paper_trade_events": 0,
|
||||||
|
"paper_orders": 0,
|
||||||
|
"paper_trades": 0,
|
||||||
|
"recommendation": 0,
|
||||||
|
"strategy_signals": 0,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
trade_ids = [
|
||||||
|
int(row["id"])
|
||||||
|
for row in conn.execute(
|
||||||
|
"SELECT id FROM paper_trades WHERE strategy_code = ANY(%s)",
|
||||||
|
(list(LEGACY_STRATEGY_CODES),),
|
||||||
|
).fetchall()
|
||||||
|
]
|
||||||
|
recommendation_ids = [
|
||||||
|
int(row["id"])
|
||||||
|
for row in conn.execute(
|
||||||
|
"SELECT id FROM recommendation WHERE strategy_code = ANY(%s)",
|
||||||
|
(list(LEGACY_STRATEGY_CODES),),
|
||||||
|
).fetchall()
|
||||||
|
]
|
||||||
|
|
||||||
|
with conn.transaction():
|
||||||
|
if trade_ids:
|
||||||
|
deleted["paper_trade_events"] += int(
|
||||||
|
conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM paper_trade_events WHERE trade_id = ANY(%s)",
|
||||||
|
(trade_ids,),
|
||||||
|
).fetchone()[0]
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
conn.execute("DELETE FROM paper_trade_events WHERE trade_id = ANY(%s)", (trade_ids,))
|
||||||
|
if recommendation_ids:
|
||||||
|
deleted["paper_trade_events"] += int(
|
||||||
|
conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM paper_trade_events WHERE recommendation_id = ANY(%s)",
|
||||||
|
(recommendation_ids,),
|
||||||
|
).fetchone()[0]
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
conn.execute("DELETE FROM paper_trade_events WHERE recommendation_id = ANY(%s)", (recommendation_ids,))
|
||||||
|
|
||||||
|
deleted["paper_orders"] = int(
|
||||||
|
conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM paper_orders WHERE recommendation_id = ANY(%s) OR strategy_code = ANY(%s)",
|
||||||
|
(recommendation_ids, list(LEGACY_STRATEGY_CODES)),
|
||||||
|
).fetchone()[0]
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM paper_orders WHERE recommendation_id = ANY(%s) OR strategy_code = ANY(%s)",
|
||||||
|
(recommendation_ids, list(LEGACY_STRATEGY_CODES)),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
deleted["paper_orders"] = int(
|
||||||
|
conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM paper_orders WHERE strategy_code = ANY(%s)",
|
||||||
|
(list(LEGACY_STRATEGY_CODES),),
|
||||||
|
).fetchone()[0]
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
conn.execute("DELETE FROM paper_orders WHERE strategy_code = ANY(%s)", (list(LEGACY_STRATEGY_CODES),))
|
||||||
|
|
||||||
|
deleted["paper_trades"] = int(
|
||||||
|
conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM paper_trades WHERE strategy_code = ANY(%s)",
|
||||||
|
(list(LEGACY_STRATEGY_CODES),),
|
||||||
|
).fetchone()[0]
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
conn.execute("DELETE FROM paper_trades WHERE strategy_code = ANY(%s)", (list(LEGACY_STRATEGY_CODES),))
|
||||||
|
|
||||||
|
deleted["recommendation"] = int(
|
||||||
|
conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM recommendation WHERE strategy_code = ANY(%s)",
|
||||||
|
(list(LEGACY_STRATEGY_CODES),),
|
||||||
|
).fetchone()[0]
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
conn.execute("DELETE FROM recommendation WHERE strategy_code = ANY(%s)", (list(LEGACY_STRATEGY_CODES),))
|
||||||
|
|
||||||
|
deleted["strategy_signals"] = int(
|
||||||
|
conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM strategy_signals WHERE strategy_code = ANY(%s)",
|
||||||
|
(list(LEGACY_STRATEGY_CODES),),
|
||||||
|
).fetchone()[0]
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
conn.execute("DELETE FROM strategy_signals WHERE strategy_code = ANY(%s)", (list(LEGACY_STRATEGY_CODES),))
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "completed",
|
||||||
|
"scope": "legacy-strategies",
|
||||||
|
"legacy_strategy_codes": list(LEGACY_STRATEGY_CODES),
|
||||||
|
"backup_path": backup_path,
|
||||||
|
"deleted": deleted,
|
||||||
|
"run_time": datetime.now().isoformat(),
|
||||||
|
}
|
||||||
@ -42,10 +42,9 @@ from app.config.config_loader import (
|
|||||||
)
|
)
|
||||||
from app.core.opportunity_lifecycle import apply_entry_quality_gate
|
from app.core.opportunity_lifecycle import apply_entry_quality_gate
|
||||||
from app.core.strategy_registry import (
|
from app.core.strategy_registry import (
|
||||||
BOX_RETEST_1H_STRATEGY,
|
|
||||||
BOX_RETEST_4H_STRATEGY,
|
|
||||||
BREAKDOWN_RETEST_SHORT_1H_STRATEGY,
|
BREAKDOWN_RETEST_SHORT_1H_STRATEGY,
|
||||||
MAIN_COMPOSITE_STRATEGY,
|
LONG_MOMENTUM_BREAKOUT_STRATEGY,
|
||||||
|
LONG_SECOND_WAVE_PULLBACK_STRATEGY,
|
||||||
is_strategy_allowed_for_side,
|
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
|
||||||
@ -63,11 +62,9 @@ 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
|
||||||
from app.services.market_overview import get_crypto_market_overview
|
from app.services.market_overview import get_crypto_market_overview
|
||||||
from app.strategies.altcoin_breakout import (
|
from app.strategies.altcoin_breakout import (
|
||||||
build_compression_breakout_4h_signal,
|
build_long_momentum_breakout_signal,
|
||||||
build_intraday_momentum_15m_signal,
|
build_long_second_wave_pullback_signal,
|
||||||
build_volume_ignition_1h_signal,
|
|
||||||
)
|
)
|
||||||
from app.strategies.box_retest_4h import build_box_retest_1h_signal, build_box_retest_4h_signal
|
|
||||||
from app.strategies.short_breakdown import build_breakdown_retest_short_1h_signal, detect_breakdown_retest_short_1h
|
from app.strategies.short_breakdown import build_breakdown_retest_short_1h_signal, detect_breakdown_retest_short_1h
|
||||||
from app.config.config_loader import _get_section as _get_cfg_section
|
from app.config.config_loader import _get_section as _get_cfg_section
|
||||||
from app.core.pa_engine import (
|
from app.core.pa_engine import (
|
||||||
@ -118,32 +115,9 @@ def _strategy_context_for_recommendation(symbol: str, result: dict, entry_plan:
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
signal_candidates.extend([
|
signal_candidates.extend([
|
||||||
build_volume_ignition_1h_signal(symbol=symbol, result=result, entry_plan=entry_plan or {}),
|
build_long_momentum_breakout_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_long_second_wave_pullback_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]:
|
||||||
payload = signal.to_json_dict() if hasattr(signal, "to_json_dict") else dict(signal)
|
payload = signal.to_json_dict() if hasattr(signal, "to_json_dict") else dict(signal)
|
||||||
@ -1209,6 +1183,18 @@ def confirm_burst(symbol, cand):
|
|||||||
except Exception:
|
except Exception:
|
||||||
cand_change_24h = 0.0
|
cand_change_24h = 0.0
|
||||||
cand_signal_text = " ".join(str(x) for x in (json.loads(cand.get("signals", "[]")) if isinstance(cand.get("signals"), str) and cand.get("signals", "").strip().startswith("[") else [cand.get("signals", "")]))
|
cand_signal_text = " ".join(str(x) for x in (json.loads(cand.get("signals", "[]")) if isinstance(cand.get("signals"), str) and cand.get("signals", "").strip().startswith("[") else [cand.get("signals", "")]))
|
||||||
|
cand_short_hint = (
|
||||||
|
cand_detail.get("short_breakdown_retest_1h")
|
||||||
|
or (cand_detail.get("market_context") or {}).get("short_breakdown_retest_1h")
|
||||||
|
or {}
|
||||||
|
)
|
||||||
|
if trade_side != "short" and (
|
||||||
|
bool(cand_short_hint.get("detected"))
|
||||||
|
or "破位反抽做空" in cand_signal_text
|
||||||
|
or "等待反抽失败" in cand_signal_text
|
||||||
|
or "breakdown_retest_1h_short" in cand_signal_text
|
||||||
|
):
|
||||||
|
trade_side = "short"
|
||||||
cand_is_top_gainer = bool(cand_detail.get("top_gainer_24h") or "24h强势榜" in cand_signal_text or cand_change_24h >= get_burst_threshold(symbol) * 1.5)
|
cand_is_top_gainer = bool(cand_detail.get("top_gainer_24h") or "24h强势榜" in cand_signal_text or cand_change_24h >= get_burst_threshold(symbol) * 1.5)
|
||||||
|
|
||||||
h1_df = fetch_klines(symbol, "1h", limit=100)
|
h1_df = fetch_klines(symbol, "1h", limit=100)
|
||||||
@ -2038,11 +2024,14 @@ def confirm_burst(symbol, cand):
|
|||||||
entry_plan = short_entry_plan
|
entry_plan = short_entry_plan
|
||||||
gate_strategy_code = BREAKDOWN_RETEST_SHORT_1H_STRATEGY
|
gate_strategy_code = BREAKDOWN_RETEST_SHORT_1H_STRATEGY
|
||||||
else:
|
else:
|
||||||
gate_strategy_code = (
|
signal_text = " ".join(str(x or "") for x in signals)
|
||||||
BOX_RETEST_1H_STRATEGY if bp_1h.get("detected")
|
has_second_wave_context = (
|
||||||
else BOX_RETEST_4H_STRATEGY if bp_4h.get("detected")
|
"24h强势榜" in signal_text
|
||||||
else MAIN_COMPOSITE_STRATEGY
|
or "回踩" in signal_text
|
||||||
|
or bp_1h.get("detected")
|
||||||
|
or bp_4h.get("detected")
|
||||||
)
|
)
|
||||||
|
gate_strategy_code = LONG_SECOND_WAVE_PULLBACK_STRATEGY if has_second_wave_context else LONG_MOMENTUM_BREAKOUT_STRATEGY
|
||||||
entry_plan.setdefault("strategy_code", gate_strategy_code)
|
entry_plan.setdefault("strategy_code", gate_strategy_code)
|
||||||
|
|
||||||
# v1.7.5 买点质量闸门:确认强势 ≠ 允许现价追买。
|
# v1.7.5 买点质量闸门:确认强势 ≠ 允许现价追买。
|
||||||
@ -2288,10 +2277,13 @@ def _result_brief(item: dict) -> dict:
|
|||||||
decision = item.get("decision_log") or ctx.get("decision_log") or {}
|
decision = item.get("decision_log") or ctx.get("decision_log") or {}
|
||||||
signal_text = " ".join(str(x) for x in (item.get("signals") or []))
|
signal_text = " ".join(str(x) for x in (item.get("signals") or []))
|
||||||
inferred_strategy = ""
|
inferred_strategy = ""
|
||||||
if "1H箱体突破回踩" in signal_text:
|
side = normalize_trade_side(item.get("side") or ctx.get("side") or item.get("direction"))
|
||||||
inferred_strategy = "box_retest_1h_v1"
|
if side == "short" or any(key in signal_text for key in ("破位反抽做空", "等待反抽失败", "breakdown_retest_1h_short")):
|
||||||
elif "4H箱体突破回踩" in signal_text:
|
inferred_strategy = BREAKDOWN_RETEST_SHORT_1H_STRATEGY
|
||||||
inferred_strategy = "box_retest_4h_v1"
|
elif any(key in signal_text for key in ("24h强势榜", "回踩", "箱体突破回踩")):
|
||||||
|
inferred_strategy = LONG_SECOND_WAVE_PULLBACK_STRATEGY
|
||||||
|
elif any(key in signal_text for key in ("15min", "短周期", "量价齐飞", "放量")):
|
||||||
|
inferred_strategy = LONG_MOMENTUM_BREAKOUT_STRATEGY
|
||||||
return {
|
return {
|
||||||
"symbol": item.get("symbol"),
|
"symbol": item.get("symbol"),
|
||||||
"confirmed": bool(item.get("confirmed")),
|
"confirmed": bool(item.get("confirmed")),
|
||||||
|
|||||||
@ -219,6 +219,12 @@ def _kline_scan_config():
|
|||||||
"enabled": bool(cfg.get("enabled", True)),
|
"enabled": bool(cfg.get("enabled", True)),
|
||||||
"main_min_volume_usd": float(cfg.get("main_min_volume_usd", MIN_24H_VOLUME_USD) or 0),
|
"main_min_volume_usd": float(cfg.get("main_min_volume_usd", MIN_24H_VOLUME_USD) or 0),
|
||||||
"bypass_min_volume_usd": float(cfg.get("bypass_min_volume_usd", 2_000_000) or 0),
|
"bypass_min_volume_usd": float(cfg.get("bypass_min_volume_usd", 2_000_000) or 0),
|
||||||
|
"discovery_min_volume_usd": float(cfg.get("discovery_min_volume_usd", cfg.get("bypass_min_volume_usd", 2_000_000)) or 0),
|
||||||
|
"tier_a_budget": max(0, int(cfg.get("tier_a_budget", 90) or 0)),
|
||||||
|
"tier_b_budget": max(0, int(cfg.get("tier_b_budget", 90) or 0)),
|
||||||
|
"bypass_extra_budget": max(0, int(cfg.get("bypass_extra_budget", 50) or 0)),
|
||||||
|
"tier_a_min_score": float(cfg.get("tier_a_min_score", 45) or 0),
|
||||||
|
"tier_b_min_score": float(cfg.get("tier_b_min_score", 20) or 0),
|
||||||
"short_tf_min_volume_usd": float(cfg.get("short_tf_min_volume_usd", 5_000_000) or 0),
|
"short_tf_min_volume_usd": float(cfg.get("short_tf_min_volume_usd", 5_000_000) or 0),
|
||||||
"short_tf_min_abs_change_pct": float(cfg.get("short_tf_min_abs_change_pct", 1.0) or 0),
|
"short_tf_min_abs_change_pct": float(cfg.get("short_tf_min_abs_change_pct", 1.0) or 0),
|
||||||
"short_tf_high_volume_usd": float(cfg.get("short_tf_high_volume_usd", 20_000_000) or 0),
|
"short_tf_high_volume_usd": float(cfg.get("short_tf_high_volume_usd", 20_000_000) or 0),
|
||||||
@ -260,6 +266,123 @@ def _symbol_priority_score(symbol: str, info: dict, recently_screened: set) -> t
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _range_24h_pct(info: dict) -> float:
|
||||||
|
price = float((info or {}).get("price") or 0)
|
||||||
|
high = float((info or {}).get("high_24h") or 0)
|
||||||
|
low = float((info or {}).get("low_24h") or 0)
|
||||||
|
base = price or low
|
||||||
|
if base <= 0 or high <= 0 or low <= 0 or high < low:
|
||||||
|
return 0.0
|
||||||
|
return (high - low) / base * 100
|
||||||
|
|
||||||
|
|
||||||
|
def _discovery_priority_score(symbol: str, info: dict, recently_screened: set, cfg: dict) -> dict:
|
||||||
|
"""Cheap all-market score used only to decide which symbols deserve K-line calls."""
|
||||||
|
volume = float((info or {}).get("volume_24h") or 0)
|
||||||
|
change = float((info or {}).get("change_24h") or 0)
|
||||||
|
abs_change = abs(change)
|
||||||
|
range_pct = _range_24h_pct(info)
|
||||||
|
main_min = float(cfg.get("main_min_volume_usd") or 0)
|
||||||
|
high_volume = max(float(cfg.get("short_tf_high_volume_usd") or 0), main_min * 3)
|
||||||
|
top_gainer = _is_top_gainer_candidate(
|
||||||
|
symbol,
|
||||||
|
info,
|
||||||
|
min_volume=float(cfg.get("discovery_min_volume_usd") or 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
score = 0.0
|
||||||
|
reasons = []
|
||||||
|
if top_gainer:
|
||||||
|
score += 40
|
||||||
|
reasons.append("24h强势榜")
|
||||||
|
if symbol in recently_screened:
|
||||||
|
score += 12
|
||||||
|
reasons.append("近期已关注")
|
||||||
|
if high_volume and volume >= high_volume:
|
||||||
|
score += 22
|
||||||
|
reasons.append("高成交额")
|
||||||
|
elif main_min and volume >= main_min * 2:
|
||||||
|
score += 14
|
||||||
|
reasons.append("成交额活跃")
|
||||||
|
elif main_min and volume >= main_min:
|
||||||
|
score += 8
|
||||||
|
reasons.append("成交额达标")
|
||||||
|
if abs_change >= 12:
|
||||||
|
score += 26
|
||||||
|
reasons.append("价格大幅波动")
|
||||||
|
elif abs_change >= 6:
|
||||||
|
score += 18
|
||||||
|
reasons.append("价格波动扩大")
|
||||||
|
elif abs_change >= 2:
|
||||||
|
score += 8
|
||||||
|
reasons.append("价格开始活跃")
|
||||||
|
if range_pct >= 18:
|
||||||
|
score += 18
|
||||||
|
reasons.append("24h振幅很大")
|
||||||
|
elif range_pct >= 8:
|
||||||
|
score += 10
|
||||||
|
reasons.append("24h振幅扩大")
|
||||||
|
|
||||||
|
tier_a_min = float(cfg.get("tier_a_min_score") or 45)
|
||||||
|
tier_b_min = float(cfg.get("tier_b_min_score") or 20)
|
||||||
|
tier = "A" if score >= tier_a_min else "B" if score >= tier_b_min else "C"
|
||||||
|
return {
|
||||||
|
"symbol": symbol,
|
||||||
|
"score": round(score, 2),
|
||||||
|
"tier": tier,
|
||||||
|
"reasons": reasons or ["轻量观察"],
|
||||||
|
"volume_24h": volume,
|
||||||
|
"change_24h": change,
|
||||||
|
"range_24h_pct": round(range_pct, 2),
|
||||||
|
"top_gainer_24h": top_gainer,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_discovery_priority_queue(tickers: dict, *, recently_screened: set, cfg: dict) -> list[dict]:
|
||||||
|
min_volume = float(cfg.get("discovery_min_volume_usd") or 0)
|
||||||
|
queue = []
|
||||||
|
for symbol, info in (tickers or {}).items():
|
||||||
|
volume = float((info or {}).get("volume_24h") or 0)
|
||||||
|
if min_volume and volume < min_volume and not _is_top_gainer_candidate(symbol, info):
|
||||||
|
continue
|
||||||
|
queue.append(_discovery_priority_score(symbol, info, recently_screened, cfg))
|
||||||
|
queue.sort(
|
||||||
|
key=lambda item: (
|
||||||
|
item.get("tier") == "A",
|
||||||
|
item.get("score", 0),
|
||||||
|
item.get("top_gainer_24h", False),
|
||||||
|
item.get("volume_24h", 0),
|
||||||
|
),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
return queue
|
||||||
|
|
||||||
|
|
||||||
|
def _select_discovery_tiers(queue: list[dict], *, tier_a_budget: int, tier_b_budget: int, extra_tiers=(), extra_budget: int = 0) -> list[str]:
|
||||||
|
selected = []
|
||||||
|
selected_set = set()
|
||||||
|
|
||||||
|
def add(items, budget):
|
||||||
|
if budget <= 0:
|
||||||
|
return
|
||||||
|
count = 0
|
||||||
|
for item in items:
|
||||||
|
symbol = item.get("symbol")
|
||||||
|
if not symbol or symbol in selected_set:
|
||||||
|
continue
|
||||||
|
if count >= budget:
|
||||||
|
break
|
||||||
|
selected.append(symbol)
|
||||||
|
selected_set.add(symbol)
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
add([item for item in queue if item.get("tier") == "A"], tier_a_budget)
|
||||||
|
add([item for item in queue if item.get("tier") == "B"], tier_b_budget)
|
||||||
|
if extra_tiers and extra_budget:
|
||||||
|
add([item for item in queue if item.get("tier") in set(extra_tiers)], extra_budget)
|
||||||
|
return selected
|
||||||
|
|
||||||
|
|
||||||
def _rule_based_kline_scan_symbols(tickers: dict, *, recently_screened: set, min_volume: float = 0, emergency_max: int = 0) -> list[str]:
|
def _rule_based_kline_scan_symbols(tickers: dict, *, recently_screened: set, min_volume: float = 0, emergency_max: int = 0) -> list[str]:
|
||||||
"""Select K-line scan universe by rules first; emergency_max is off by default."""
|
"""Select K-line scan universe by rules first; emergency_max is off by default."""
|
||||||
items = []
|
items = []
|
||||||
@ -988,14 +1111,16 @@ def _static_bypass_resonance(cand, *, static_cfg, sector_signal_count=0, top_tra
|
|||||||
return list(dict.fromkeys(signals))
|
return list(dict.fromkeys(signals))
|
||||||
|
|
||||||
|
|
||||||
def _attach_top_gainer_discovery(candidates, tickers, recently_screened):
|
def _attach_top_gainer_discovery(candidates, tickers, recently_screened, priority_context=None):
|
||||||
"""为强势榜补发现入口;追高风险留给细筛/确认处理。"""
|
"""为强势榜补发现入口;追高风险留给细筛/确认处理。"""
|
||||||
|
priority_context = priority_context or {}
|
||||||
added = 0
|
added = 0
|
||||||
for symbol, info in tickers.items():
|
for symbol, info in tickers.items():
|
||||||
if symbol in candidates:
|
if symbol in candidates:
|
||||||
if _is_top_gainer_candidate(symbol, info):
|
if _is_top_gainer_candidate(symbol, info):
|
||||||
candidates[symbol]["top_gainer_24h"] = True
|
candidates[symbol]["top_gainer_24h"] = True
|
||||||
candidates[symbol]["top_gainer_chase_risk"] = symbol not in recently_screened
|
candidates[symbol]["top_gainer_chase_risk"] = symbol not in recently_screened
|
||||||
|
candidates[symbol].setdefault("discovery_priority", priority_context.get(symbol, {}))
|
||||||
continue
|
continue
|
||||||
if not _is_top_gainer_candidate(symbol, info):
|
if not _is_top_gainer_candidate(symbol, info):
|
||||||
continue
|
continue
|
||||||
@ -1025,6 +1150,7 @@ def _attach_top_gainer_discovery(candidates, tickers, recently_screened):
|
|||||||
"top_gainer_24h": True,
|
"top_gainer_24h": True,
|
||||||
"top_gainer_chase_risk": symbol not in recently_screened,
|
"top_gainer_chase_risk": symbol not in recently_screened,
|
||||||
"bypass_origin": "top_gainer_24h",
|
"bypass_origin": "top_gainer_24h",
|
||||||
|
"discovery_priority": priority_context.get(symbol, {}),
|
||||||
}
|
}
|
||||||
added += 1
|
added += 1
|
||||||
return added
|
return added
|
||||||
@ -1104,21 +1230,39 @@ def layer1_coarse_filter():
|
|||||||
else {}
|
else {}
|
||||||
)
|
)
|
||||||
cached_runtime_skip_count = 0
|
cached_runtime_skip_count = 0
|
||||||
main_scan_symbols = set(_rule_based_kline_scan_symbols(
|
discovery_queue = _build_discovery_priority_queue(
|
||||||
tickers,
|
tickers,
|
||||||
recently_screened=recently_screened,
|
recently_screened=recently_screened,
|
||||||
min_volume=main_min_vol,
|
cfg=scan_cfg,
|
||||||
emergency_max=scan_cfg["emergency_main_max_symbols"],
|
)
|
||||||
))
|
priority_context = {item["symbol"]: item for item in discovery_queue}
|
||||||
bypass_scan_symbols = set(_rule_based_kline_scan_symbols(
|
main_selected = _select_discovery_tiers(
|
||||||
tickers,
|
discovery_queue,
|
||||||
recently_screened=recently_screened,
|
tier_a_budget=scan_cfg["tier_a_budget"],
|
||||||
min_volume=low_turnover_threshold,
|
tier_b_budget=scan_cfg["tier_b_budget"],
|
||||||
emergency_max=scan_cfg["emergency_bypass_max_symbols"],
|
)
|
||||||
))
|
bypass_selected = _select_discovery_tiers(
|
||||||
|
discovery_queue,
|
||||||
|
tier_a_budget=scan_cfg["tier_a_budget"],
|
||||||
|
tier_b_budget=0,
|
||||||
|
extra_tiers=("B", "C"),
|
||||||
|
extra_budget=scan_cfg["bypass_extra_budget"],
|
||||||
|
)
|
||||||
|
if scan_cfg["emergency_main_max_symbols"] > 0:
|
||||||
|
main_selected = main_selected[:scan_cfg["emergency_main_max_symbols"]]
|
||||||
|
if scan_cfg["emergency_bypass_max_symbols"] > 0:
|
||||||
|
bypass_selected = bypass_selected[:scan_cfg["emergency_bypass_max_symbols"]]
|
||||||
|
main_scan_symbols = set(main_selected)
|
||||||
|
bypass_scan_symbols = set(bypass_selected)
|
||||||
|
tier_counts = {
|
||||||
|
"A": sum(1 for item in discovery_queue if item.get("tier") == "A"),
|
||||||
|
"B": sum(1 for item in discovery_queue if item.get("tier") == "B"),
|
||||||
|
"C": sum(1 for item in discovery_queue if item.get("tier") == "C"),
|
||||||
|
}
|
||||||
print(
|
print(
|
||||||
f" K线扫描规则: 主扫描{len(main_scan_symbols)}/{len(tickers)},"
|
f" K线扫描队列: A{tier_counts['A']} B{tier_counts['B']} C{tier_counts['C']};"
|
||||||
f"旁路扫描{len(bypass_scan_symbols)}/{len(tickers)},动态缓存{len(cached_runtime_exclusions)}"
|
f"主扫描{len(main_scan_symbols)}/{len(tickers)},旁路扫描{len(bypass_scan_symbols)}/{len(tickers)},"
|
||||||
|
f"动态缓存{len(cached_runtime_exclusions)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@ -1337,6 +1481,7 @@ def layer1_coarse_filter():
|
|||||||
"weighted_avg_price": round(weighted_avg_price, 6) if weighted_avg_price else 0,
|
"weighted_avg_price": round(weighted_avg_price, 6) if weighted_avg_price else 0,
|
||||||
"top_gainer_24h": _is_top_gainer_candidate(symbol, info),
|
"top_gainer_24h": _is_top_gainer_candidate(symbol, info),
|
||||||
"top_gainer_chase_risk": is_unseen_top_gainer,
|
"top_gainer_chase_risk": is_unseen_top_gainer,
|
||||||
|
"discovery_priority": priority_context.get(symbol, {}),
|
||||||
}
|
}
|
||||||
|
|
||||||
# ==== 第二遍扫描:低成交量静K蓄力旁路 + 底部抬高 + 压缩放量 ====
|
# ==== 第二遍扫描:低成交量静K蓄力旁路 + 底部抬高 + 压缩放量 ====
|
||||||
@ -1420,6 +1565,7 @@ def layer1_coarse_filter():
|
|||||||
"bypass_origin": True,
|
"bypass_origin": True,
|
||||||
"top_gainer_24h": _is_top_gainer_candidate(symbol, info),
|
"top_gainer_24h": _is_top_gainer_candidate(symbol, info),
|
||||||
"top_gainer_chase_risk": is_unseen_top_gainer,
|
"top_gainer_chase_risk": is_unseen_top_gainer,
|
||||||
|
"discovery_priority": priority_context.get(symbol, {}),
|
||||||
}
|
}
|
||||||
bypass_count += 1
|
bypass_count += 1
|
||||||
added = True
|
added = True
|
||||||
@ -1454,6 +1600,7 @@ def layer1_coarse_filter():
|
|||||||
"bypass_origin": "higher_lows",
|
"bypass_origin": "higher_lows",
|
||||||
"top_gainer_24h": _is_top_gainer_candidate(symbol, info),
|
"top_gainer_24h": _is_top_gainer_candidate(symbol, info),
|
||||||
"top_gainer_chase_risk": is_unseen_top_gainer,
|
"top_gainer_chase_risk": is_unseen_top_gainer,
|
||||||
|
"discovery_priority": priority_context.get(symbol, {}),
|
||||||
}
|
}
|
||||||
hl_count_total += 1
|
hl_count_total += 1
|
||||||
added = True
|
added = True
|
||||||
@ -1488,11 +1635,12 @@ def layer1_coarse_filter():
|
|||||||
"bypass_origin": "compression_surge",
|
"bypass_origin": "compression_surge",
|
||||||
"top_gainer_24h": _is_top_gainer_candidate(symbol, info),
|
"top_gainer_24h": _is_top_gainer_candidate(symbol, info),
|
||||||
"top_gainer_chase_risk": is_unseen_top_gainer,
|
"top_gainer_chase_risk": is_unseen_top_gainer,
|
||||||
|
"discovery_priority": priority_context.get(symbol, {}),
|
||||||
}
|
}
|
||||||
cs_count_total += 1
|
cs_count_total += 1
|
||||||
added = True
|
added = True
|
||||||
|
|
||||||
top_gainer_count = _attach_top_gainer_discovery(candidates, tickers, recently_screened)
|
top_gainer_count = _attach_top_gainer_discovery(candidates, tickers, recently_screened, priority_context)
|
||||||
|
|
||||||
# 第一道漏斗:把明确不可交易/太低成交额的资产写成独立阶段,研发侧可审计,
|
# 第一道漏斗:把明确不可交易/太低成交额的资产写成独立阶段,研发侧可审计,
|
||||||
# 但不让它们进入后续机会链路。
|
# 但不让它们进入后续机会链路。
|
||||||
@ -1580,6 +1728,7 @@ def layer1_coarse_filter():
|
|||||||
"bypass_origin": cand.get("bypass_origin", ""),
|
"bypass_origin": cand.get("bypass_origin", ""),
|
||||||
"source_types": discovery_source_types(cand),
|
"source_types": discovery_source_types(cand),
|
||||||
"signal_codes": build_signal_codes(signals),
|
"signal_codes": build_signal_codes(signals),
|
||||||
|
"discovery_priority": cand.get("discovery_priority") or {},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@ -1600,7 +1749,15 @@ def layer1_coarse_filter():
|
|||||||
"kline_h4_success_count": len(h4_success_symbols),
|
"kline_h4_success_count": len(h4_success_symbols),
|
||||||
"coarse_candidate_count": len(candidates),
|
"coarse_candidate_count": len(candidates),
|
||||||
"top_gainer_discovery_count": top_gainer_count,
|
"top_gainer_discovery_count": top_gainer_count,
|
||||||
|
"discovery_queue_count": len(discovery_queue),
|
||||||
|
"discovery_tier_a_count": tier_counts["A"],
|
||||||
|
"discovery_tier_b_count": tier_counts["B"],
|
||||||
|
"discovery_tier_c_count": tier_counts["C"],
|
||||||
|
"discovery_tier_a_budget": scan_cfg["tier_a_budget"],
|
||||||
|
"discovery_tier_b_budget": scan_cfg["tier_b_budget"],
|
||||||
|
"discovery_bypass_extra_budget": scan_cfg["bypass_extra_budget"],
|
||||||
"main_kline_min_volume_usd": scan_cfg["main_min_volume_usd"],
|
"main_kline_min_volume_usd": scan_cfg["main_min_volume_usd"],
|
||||||
|
"discovery_min_volume_usd": scan_cfg["discovery_min_volume_usd"],
|
||||||
"bypass_kline_min_volume_usd": low_turnover_threshold,
|
"bypass_kline_min_volume_usd": low_turnover_threshold,
|
||||||
"emergency_main_kline_scan_budget": scan_cfg["emergency_main_max_symbols"],
|
"emergency_main_kline_scan_budget": scan_cfg["emergency_main_max_symbols"],
|
||||||
"emergency_bypass_kline_scan_budget": scan_cfg["emergency_bypass_max_symbols"],
|
"emergency_bypass_kline_scan_budget": scan_cfg["emergency_bypass_max_symbols"],
|
||||||
|
|||||||
@ -10,9 +10,8 @@ from __future__ import annotations
|
|||||||
from app.core.factor_roles import CONFIRMATION, ENTRY, PREREQUISITE, RISK, TRIGGER
|
from app.core.factor_roles import CONFIRMATION, ENTRY, PREREQUISITE, RISK, TRIGGER
|
||||||
from app.core.strategy_contract import StrategySignal, current_strategy_version
|
from app.core.strategy_contract import StrategySignal, current_strategy_version
|
||||||
from app.core.strategy_registry import (
|
from app.core.strategy_registry import (
|
||||||
COMPRESSION_BREAKOUT_4H_STRATEGY,
|
LONG_MOMENTUM_BREAKOUT_STRATEGY,
|
||||||
INTRADAY_MOMENTUM_15M_STRATEGY,
|
LONG_SECOND_WAVE_PULLBACK_STRATEGY,
|
||||||
VOLUME_IGNITION_1H_STRATEGY,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -53,24 +52,25 @@ def _status_for_entry(result: dict, entry_plan: dict | None = None, *, require_c
|
|||||||
return "observe", reasons
|
return "observe", reasons
|
||||||
|
|
||||||
|
|
||||||
def build_volume_ignition_1h_signal(*, symbol: str, result: dict, entry_plan: dict | None = None) -> StrategySignal | None:
|
def build_long_momentum_breakout_signal(*, symbol: str, result: dict, entry_plan: dict | None = None) -> StrategySignal | None:
|
||||||
text = _signals_text(result)
|
text = _signals_text(result)
|
||||||
has_vp = "量价齐飞" in text or ("连续" in text and "放量" in text)
|
has_15m = any(key in text for key in ("15min", "15m", "短周期", "5m/15m"))
|
||||||
has_breakout = "1H" in text and ("突破" in text or "起爆" in text)
|
has_1h_participation = "量价齐飞" in text or ("连续" in text and "放量" in text) or ("1H" in text and ("突破" in text or "起爆" in text))
|
||||||
if not (has_vp or has_breakout):
|
if not (has_15m and has_1h_participation):
|
||||||
return None
|
return None
|
||||||
status, reasons = _status_for_entry(result, entry_plan, require_current_trigger=False, allow_wait=True)
|
status, reasons = _status_for_entry(result, entry_plan, require_current_trigger=True, allow_wait=True)
|
||||||
score = _safe_float(result.get("score"))
|
score = _safe_float(result.get("score"))
|
||||||
confidence = min(100.0, max(0.0, score * 7 + (12 if _has_current_trigger(result) else 0)))
|
confidence = min(100.0, max(0.0, score * 7 + 18))
|
||||||
trigger = {
|
trigger = {
|
||||||
"factor_code": "vp_fly_1h_current" if has_vp else "ignition_1h_current",
|
"factor_code": "momentum_breakout_15m_1h",
|
||||||
"factor_label": "1H放量突破启动",
|
"factor_label": "15m突破 + 1H放量/波动增强",
|
||||||
"has_current_trigger": _has_current_trigger(result),
|
"has_current_trigger": _has_current_trigger(result),
|
||||||
"trigger_status": _trigger_context(result).get("trigger_status") or "",
|
"trigger_status": _trigger_context(result).get("trigger_status") or "",
|
||||||
"entry_action": (entry_plan or {}).get("entry_action") or "",
|
"entry_action": (entry_plan or {}).get("entry_action") or "",
|
||||||
|
"opportunity_level": "intraday",
|
||||||
}
|
}
|
||||||
return StrategySignal(
|
return StrategySignal(
|
||||||
strategy_code=VOLUME_IGNITION_1H_STRATEGY,
|
strategy_code=LONG_MOMENTUM_BREAKOUT_STRATEGY,
|
||||||
strategy_version=current_strategy_version(),
|
strategy_version=current_strategy_version(),
|
||||||
symbol=symbol,
|
symbol=symbol,
|
||||||
direction="long",
|
direction="long",
|
||||||
@ -79,8 +79,9 @@ def build_volume_ignition_1h_signal(*, symbol: str, result: dict, entry_plan: di
|
|||||||
score=score,
|
score=score,
|
||||||
trigger=trigger,
|
trigger=trigger,
|
||||||
factor_roles={
|
factor_roles={
|
||||||
"vp_fly_1h_current": TRIGGER,
|
"momentum_breakout_15m_1h": TRIGGER,
|
||||||
"volume_consecutive_1h": CONFIRMATION,
|
"volume_consecutive_1h": CONFIRMATION,
|
||||||
|
"vp_fly_1h_current": CONFIRMATION,
|
||||||
"breakout_15m_current": ENTRY,
|
"breakout_15m_current": ENTRY,
|
||||||
"pullback_15m_confirm": ENTRY,
|
"pullback_15m_confirm": ENTRY,
|
||||||
"false_breakout": RISK,
|
"false_breakout": RISK,
|
||||||
@ -88,24 +89,25 @@ def build_volume_ignition_1h_signal(*, symbol: str, result: dict, entry_plan: di
|
|||||||
},
|
},
|
||||||
entry_plan=entry_plan or {},
|
entry_plan=entry_plan or {},
|
||||||
risk_plan={
|
risk_plan={
|
||||||
"invalid_if": ["放量后不能延续", "15m假突破", "跌回启动K低点", "RR不足"],
|
"invalid_if": ["15m跌回突破K低点", "1H放量后不能延续", "快速冲高回落", "RR不足"],
|
||||||
"risk_reasons": reasons,
|
"risk_reasons": reasons,
|
||||||
},
|
},
|
||||||
decision_log={"module": VOLUME_IGNITION_1H_STRATEGY, "decision": status, "reasons": reasons},
|
decision_log={"module": LONG_MOMENTUM_BREAKOUT_STRATEGY, "decision": status, "reasons": reasons},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def build_compression_breakout_4h_signal(*, symbol: str, result: dict, entry_plan: dict | None = None) -> StrategySignal | None:
|
def build_long_second_wave_pullback_signal(*, symbol: str, result: dict, entry_plan: dict | None = None) -> StrategySignal | None:
|
||||||
text = _signals_text(result)
|
text = _signals_text(result)
|
||||||
has_compression = any(key in text for key in ("静K", "压缩", "布林收窄", "底部抬高"))
|
market_context = (result or {}).get("market_context") or {}
|
||||||
has_breakout_context = any(key in text for key in ("突破", "起爆", "量价齐飞", "回踩"))
|
has_first_wave = bool((result or {}).get("top_gainer_24h") or market_context.get("top_gainer_24h")) or "24h强势榜" in text or "量价齐飞" in text or "放量" in text
|
||||||
if not (has_compression and has_breakout_context):
|
has_pullback_context = any(key in text for key in ("回踩", "箱体", "EMA", "前高", "底部抬高", "静K"))
|
||||||
|
if not (has_first_wave and has_pullback_context):
|
||||||
return None
|
return None
|
||||||
status, reasons = _status_for_entry(result, entry_plan, require_current_trigger=False, allow_wait=True)
|
status, reasons = _status_for_entry(result, entry_plan, require_current_trigger=False, allow_wait=True)
|
||||||
score = _safe_float(result.get("score"))
|
score = _safe_float(result.get("score"))
|
||||||
confidence = min(100.0, max(0.0, score * 6 + (10 if "底部" in text else 0) + (8 if _has_current_trigger(result) else 0)))
|
confidence = min(100.0, max(0.0, score * 6 + (12 if "24h强势榜" in text else 0) + (8 if _has_current_trigger(result) else 0)))
|
||||||
return StrategySignal(
|
return StrategySignal(
|
||||||
strategy_code=COMPRESSION_BREAKOUT_4H_STRATEGY,
|
strategy_code=LONG_SECOND_WAVE_PULLBACK_STRATEGY,
|
||||||
strategy_version=current_strategy_version(),
|
strategy_version=current_strategy_version(),
|
||||||
symbol=symbol,
|
symbol=symbol,
|
||||||
direction="long",
|
direction="long",
|
||||||
@ -113,64 +115,37 @@ def build_compression_breakout_4h_signal(*, symbol: str, result: dict, entry_pla
|
|||||||
confidence=confidence,
|
confidence=confidence,
|
||||||
score=score,
|
score=score,
|
||||||
trigger={
|
trigger={
|
||||||
"factor_code": "compression_surge_4h",
|
"factor_code": "second_wave_pullback_1h",
|
||||||
"factor_label": "4H压缩蓄力突破",
|
"factor_label": "强势第一波后回踩承接",
|
||||||
"has_current_trigger": _has_current_trigger(result),
|
"has_current_trigger": _has_current_trigger(result),
|
||||||
"trigger_status": _trigger_context(result).get("trigger_status") or "",
|
"trigger_status": _trigger_context(result).get("trigger_status") or "",
|
||||||
|
"opportunity_level": "swing_1_3d",
|
||||||
},
|
},
|
||||||
factor_roles={
|
factor_roles={
|
||||||
"static_accum_4h": PREREQUISITE,
|
"cex_top_gainer": PREREQUISITE,
|
||||||
"higher_lows_4h": PREREQUISITE,
|
|
||||||
"compression_surge_4h": TRIGGER,
|
|
||||||
"ignition_4h_current": CONFIRMATION,
|
|
||||||
"vp_fly_1h_current": CONFIRMATION,
|
"vp_fly_1h_current": CONFIRMATION,
|
||||||
|
"box_breakout_pullback_1h": CONFIRMATION,
|
||||||
|
"box_breakout_pullback_4h": CONFIRMATION,
|
||||||
|
"second_wave_pullback_1h": TRIGGER,
|
||||||
"pullback_15m_confirm": ENTRY,
|
"pullback_15m_confirm": ENTRY,
|
||||||
"false_breakout": RISK,
|
"false_breakout": RISK,
|
||||||
},
|
},
|
||||||
entry_plan=entry_plan or {},
|
entry_plan=entry_plan or {},
|
||||||
risk_plan={
|
risk_plan={
|
||||||
"invalid_if": ["突破后跌回压缩区间", "回踩放量跌破", "无量反抽失败", "市场风险升高"],
|
"invalid_if": ["跌回第一波启动区", "回踩放量跌破", "高位追涨无承接", "RR不足"],
|
||||||
"risk_reasons": reasons,
|
"risk_reasons": reasons,
|
||||||
},
|
},
|
||||||
decision_log={"module": COMPRESSION_BREAKOUT_4H_STRATEGY, "decision": status, "reasons": reasons},
|
decision_log={"module": LONG_SECOND_WAVE_PULLBACK_STRATEGY, "decision": status, "reasons": reasons},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_volume_ignition_1h_signal(*, symbol: str, result: dict, entry_plan: dict | None = None) -> StrategySignal | None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def build_compression_breakout_4h_signal(*, symbol: str, result: dict, entry_plan: dict | None = None) -> StrategySignal | None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def build_intraday_momentum_15m_signal(*, symbol: str, result: dict, entry_plan: dict | None = None) -> StrategySignal | None:
|
def build_intraday_momentum_15m_signal(*, symbol: str, result: dict, entry_plan: dict | None = None) -> StrategySignal | None:
|
||||||
text = _signals_text(result)
|
return None
|
||||||
has_short_tf = any(key in text for key in ("15min", "15m", "短周期", "5m/15m"))
|
|
||||||
if not has_short_tf:
|
|
||||||
return None
|
|
||||||
status, reasons = _status_for_entry(result, entry_plan, require_current_trigger=True, allow_wait=False)
|
|
||||||
score = _safe_float(result.get("score"))
|
|
||||||
confidence = min(100.0, max(0.0, score * 6 + 18))
|
|
||||||
return StrategySignal(
|
|
||||||
strategy_code=INTRADAY_MOMENTUM_15M_STRATEGY,
|
|
||||||
strategy_version=current_strategy_version(),
|
|
||||||
symbol=symbol,
|
|
||||||
direction="long",
|
|
||||||
status=status,
|
|
||||||
confidence=confidence,
|
|
||||||
score=score,
|
|
||||||
trigger={
|
|
||||||
"factor_code": "short_tf_15m_ignition",
|
|
||||||
"factor_label": "15m日内动量延续",
|
|
||||||
"has_current_trigger": _has_current_trigger(result),
|
|
||||||
"trigger_status": _trigger_context(result).get("trigger_status") or "",
|
|
||||||
},
|
|
||||||
factor_roles={
|
|
||||||
"short_tf_5m_ignition": PREREQUISITE,
|
|
||||||
"short_tf_15m_ignition": TRIGGER,
|
|
||||||
"short_tf_resonance": CONFIRMATION,
|
|
||||||
"vp_fly_1h_current": CONFIRMATION,
|
|
||||||
"breakout_15m_current": ENTRY,
|
|
||||||
"false_breakout": RISK,
|
|
||||||
"trend_exhaustion": RISK,
|
|
||||||
},
|
|
||||||
entry_plan=entry_plan or {},
|
|
||||||
risk_plan={
|
|
||||||
"invalid_if": ["15m跌回突破K", "短周期量能衰减", "快速冲高回落", "RR不足"],
|
|
||||||
"risk_reasons": reasons,
|
|
||||||
},
|
|
||||||
decision_log={"module": INTRADAY_MOMENTUM_15M_STRATEGY, "decision": status, "reasons": reasons},
|
|
||||||
)
|
|
||||||
|
|||||||
@ -1,171 +1,21 @@
|
|||||||
"""4H box breakout retest strategy candidate.
|
"""Retired box-retest strategy builders.
|
||||||
|
|
||||||
The detector factor is not the strategy. This module wraps that factor with
|
Box/retest detection remains available as a factor inside the new short-term
|
||||||
market, freshness, entry-distance and risk semantics to produce a standard
|
strategy pool, but these legacy builders no longer emit standalone signals.
|
||||||
StrategySignal.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from app.core.factor_roles import ENTRY, RISK, TRIGGER
|
from app.core.strategy_contract import StrategySignal
|
||||||
from app.core.strategy_contract import StrategySignal, current_strategy_version
|
|
||||||
from app.core.strategy_registry import BOX_RETEST_1H_STRATEGY, BOX_RETEST_4H_STRATEGY
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_float(value, default=0.0) -> float:
|
def build_box_retest_4h_signal(**kwargs) -> StrategySignal | None:
|
||||||
try:
|
return None
|
||||||
if value is None or value == "":
|
|
||||||
return default
|
|
||||||
return float(value)
|
|
||||||
except Exception:
|
|
||||||
return default
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_int(value, default=999) -> int:
|
def build_box_retest_1h_signal(**kwargs) -> StrategySignal | None:
|
||||||
try:
|
return None
|
||||||
if value is None or value == "":
|
|
||||||
return default
|
|
||||||
return int(value)
|
|
||||||
except Exception:
|
|
||||||
return default
|
|
||||||
|
|
||||||
|
|
||||||
def _build_box_retest_signal(
|
|
||||||
*,
|
|
||||||
symbol: str,
|
|
||||||
current_price: float,
|
|
||||||
detection: dict,
|
|
||||||
strategy_code: str,
|
|
||||||
timeframe_label: str,
|
|
||||||
factor_code: str,
|
|
||||||
factor_label: str,
|
|
||||||
max_fresh_age_bars: int,
|
|
||||||
max_chase_distance_pct: float,
|
|
||||||
entry_plan: dict | None = None,
|
|
||||||
market_regime: dict | None = None,
|
|
||||||
decision_log: dict | None = None,
|
|
||||||
) -> StrategySignal | None:
|
|
||||||
if not (detection or {}).get("detected"):
|
|
||||||
return None
|
|
||||||
entry_plan = entry_plan or {}
|
|
||||||
market_regime = market_regime or {}
|
|
||||||
risk_level = str(market_regime.get("risk_level") or "medium").lower()
|
|
||||||
entry_zone = _safe_float(detection.get("entry_zone"))
|
|
||||||
current_price = _safe_float(current_price)
|
|
||||||
distance_pct = (current_price / entry_zone - 1) * 100 if entry_zone > 0 and current_price > 0 else 0
|
|
||||||
age = _safe_int(detection.get("pullback_age_bars"))
|
|
||||||
quality = str(detection.get("quality") or "")
|
|
||||||
status = "candidate"
|
|
||||||
reasons = []
|
|
||||||
if risk_level == "critical":
|
|
||||||
status = "observe"
|
|
||||||
reasons.append("全局风险 critical,仅观察")
|
|
||||||
if age > max_fresh_age_bars:
|
|
||||||
status = "observe"
|
|
||||||
reasons.append(f"回踩已过去 {age} 根{timeframe_label},时效偏旧")
|
|
||||||
if quality not in {"良好", "优质"}:
|
|
||||||
status = "observe"
|
|
||||||
reasons.append(f"形态质量 {quality or '未知'},不直接交易")
|
|
||||||
if distance_pct > max_chase_distance_pct:
|
|
||||||
status = "observe"
|
|
||||||
reasons.append(f"当前价离箱体上沿 {distance_pct:.1f}%,禁止追高")
|
|
||||||
|
|
||||||
score = _safe_float(detection.get("score"))
|
|
||||||
confidence = min(100.0, max(0.0, score * 8))
|
|
||||||
trigger = {
|
|
||||||
"factor_code": factor_code,
|
|
||||||
"factor_label": factor_label,
|
|
||||||
"box_high": detection.get("box_high"),
|
|
||||||
"box_low": detection.get("box_low"),
|
|
||||||
"entry_zone": detection.get("entry_zone"),
|
|
||||||
"stop_level": detection.get("stop_level"),
|
|
||||||
"pullback_kind": detection.get("pullback_kind"),
|
|
||||||
"pullback_age_bars": age,
|
|
||||||
"quality": quality,
|
|
||||||
"distance_to_entry_zone_pct": round(distance_pct, 4),
|
|
||||||
"market_regime": market_regime.get("regime") or "",
|
|
||||||
"risk_level": risk_level,
|
|
||||||
}
|
|
||||||
risk_plan = {
|
|
||||||
"invalid_if": ["跌回箱体", "放量冲高回落", "回踩过久", "全局风险升为critical"],
|
|
||||||
"stop_level": detection.get("stop_level"),
|
|
||||||
"risk_reasons": reasons,
|
|
||||||
}
|
|
||||||
return StrategySignal(
|
|
||||||
strategy_code=strategy_code,
|
|
||||||
strategy_version=current_strategy_version(),
|
|
||||||
symbol=symbol,
|
|
||||||
direction="long",
|
|
||||||
status=status,
|
|
||||||
confidence=confidence,
|
|
||||||
score=score,
|
|
||||||
trigger=trigger,
|
|
||||||
factor_roles={
|
|
||||||
factor_code: TRIGGER,
|
|
||||||
"pullback_15m_confirm": ENTRY,
|
|
||||||
"trend_exhaustion": RISK,
|
|
||||||
"false_breakout": RISK,
|
|
||||||
},
|
|
||||||
entry_plan=entry_plan,
|
|
||||||
risk_plan=risk_plan,
|
|
||||||
decision_log=decision_log or {
|
|
||||||
"module": "box_retest_4h_v1",
|
|
||||||
"decision": status,
|
|
||||||
"reasons": reasons,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def build_box_retest_4h_signal(
|
|
||||||
*,
|
|
||||||
symbol: str,
|
|
||||||
current_price: float,
|
|
||||||
detection: dict,
|
|
||||||
entry_plan: dict | None = None,
|
|
||||||
market_regime: dict | None = None,
|
|
||||||
decision_log: dict | None = None,
|
|
||||||
) -> StrategySignal | None:
|
|
||||||
return _build_box_retest_signal(
|
|
||||||
symbol=symbol,
|
|
||||||
current_price=current_price,
|
|
||||||
detection=detection,
|
|
||||||
strategy_code=BOX_RETEST_4H_STRATEGY,
|
|
||||||
timeframe_label="4H",
|
|
||||||
factor_code="box_breakout_pullback_4h",
|
|
||||||
factor_label="4H箱体突破回踩",
|
|
||||||
max_fresh_age_bars=4,
|
|
||||||
max_chase_distance_pct=8,
|
|
||||||
entry_plan=entry_plan,
|
|
||||||
market_regime=market_regime,
|
|
||||||
decision_log=decision_log,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def build_box_retest_1h_signal(
|
|
||||||
*,
|
|
||||||
symbol: str,
|
|
||||||
current_price: float,
|
|
||||||
detection: dict,
|
|
||||||
entry_plan: dict | None = None,
|
|
||||||
market_regime: dict | None = None,
|
|
||||||
decision_log: dict | None = None,
|
|
||||||
) -> StrategySignal | None:
|
|
||||||
return _build_box_retest_signal(
|
|
||||||
symbol=symbol,
|
|
||||||
current_price=current_price,
|
|
||||||
detection=detection,
|
|
||||||
strategy_code=BOX_RETEST_1H_STRATEGY,
|
|
||||||
timeframe_label="1H",
|
|
||||||
factor_code="box_breakout_pullback_1h",
|
|
||||||
factor_label="1H箱体突破回踩",
|
|
||||||
max_fresh_age_bars=6,
|
|
||||||
max_chase_distance_pct=6,
|
|
||||||
entry_plan=entry_plan,
|
|
||||||
market_regime=market_regime,
|
|
||||||
decision_log=decision_log,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def build_box_retest_signal(**kwargs) -> StrategySignal | None:
|
def build_box_retest_signal(**kwargs) -> StrategySignal | None:
|
||||||
"""Backward-compatible alias for the original 4H strategy builder."""
|
return None
|
||||||
return build_box_retest_4h_signal(**kwargs)
|
|
||||||
|
|||||||
@ -56,6 +56,12 @@ screener:
|
|||||||
enabled: true
|
enabled: true
|
||||||
main_min_volume_usd: 5000000
|
main_min_volume_usd: 5000000
|
||||||
bypass_min_volume_usd: 2000000
|
bypass_min_volume_usd: 2000000
|
||||||
|
discovery_min_volume_usd: 2000000
|
||||||
|
tier_a_budget: 90
|
||||||
|
tier_b_budget: 90
|
||||||
|
bypass_extra_budget: 50
|
||||||
|
tier_a_min_score: 45
|
||||||
|
tier_b_min_score: 20
|
||||||
short_tf_min_volume_usd: 5000000
|
short_tf_min_volume_usd: 5000000
|
||||||
short_tf_min_abs_change_pct: 1.0
|
short_tf_min_abs_change_pct: 1.0
|
||||||
short_tf_high_volume_usd: 20000000
|
short_tf_high_volume_usd: 20000000
|
||||||
|
|||||||
@ -943,7 +943,7 @@ function renderRecCard(r) {
|
|||||||
return '<span class="sig '+cls+'">'+displaySignalText(s)+'</span>';
|
return '<span class="sig '+cls+'">'+displaySignalText(s)+'</span>';
|
||||||
}).join('');
|
}).join('');
|
||||||
var score = r.rec_score||0, st = scoreTier(score), ver = r.strategy_version||'';
|
var score = r.rec_score||0, st = scoreTier(score), ver = r.strategy_version||'';
|
||||||
var strategyLabel = r.strategy_name || ({main_composite_v1:'综合确认策略',box_retest_1h_v1:'1H箱体突破回踩',box_retest_4h_v1:'4H箱体突破回踩',breakdown_retest_short_1h_v1:'1H破位反抽做空',volume_ignition_1h_v1:'1H放量突破启动',compression_breakout_4h_v1:'4H压缩蓄力突破',intraday_momentum_15m_v1:'15m日内动量延续'}[r.strategy_code||''] || r.strategy_code || '');
|
var strategyLabel = r.strategy_name || ({long_momentum_breakout_15m_1h_v1:'多头动量启动',long_second_wave_pullback_1h_v1:'多头二波回踩',short_breakdown_retest_1h_v1:'空头破位反抽'}[r.strategy_code||''] || r.strategy_code || '');
|
||||||
var hasQualityGate = ep.entry_quality_gate && Array.isArray(ep.entry_quality_gate.reasons) && ep.entry_quality_gate.reasons.length;
|
var hasQualityGate = ep.entry_quality_gate && Array.isArray(ep.entry_quality_gate.reasons) && ep.entry_quality_gate.reasons.length;
|
||||||
var entryLabel = isWait ? (side === 'short' ? '反抽参考' : '回踩参考') : (hasQualityGate ? '失效参考' : '参考价位');
|
var entryLabel = isWait ? (side === 'short' ? '反抽参考' : '回踩参考') : (hasQualityGate ? '失效参考' : '参考价位');
|
||||||
var entryRef = (isWait || hasQualityGate) ? (ep.entry_price || r.entry_price || 0) : (r.entry_price || ep.entry_price || 0);
|
var entryRef = (isWait || hasQualityGate) ? (ep.entry_price || r.entry_price || 0) : (r.entry_price || ep.entry_price || 0);
|
||||||
|
|||||||
@ -228,7 +228,7 @@ function renderOrders(items){if(!items.length){$('orderRows').innerHTML='<tr><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 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)||({long_momentum_breakout_15m_1h_v1:'多头动量启动',long_second_wave_pullback_1h_v1:'多头二波回踩',short_breakdown_retest_1h_v1:'空头破位反抽'}[(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 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 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 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 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>'}}
|
||||||
|
|||||||
@ -2,14 +2,13 @@ from app.core.factor_roles import RISK, TRIGGER, factor_role, factor_roles_for_c
|
|||||||
from app.core.signal_direction import sanitize_factor_breakdown_for_side, sanitize_signals_for_side
|
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_4H_STRATEGY,
|
|
||||||
COMPRESSION_BREAKOUT_4H_STRATEGY,
|
|
||||||
BREAKDOWN_RETEST_SHORT_1H_STRATEGY,
|
BREAKDOWN_RETEST_SHORT_1H_STRATEGY,
|
||||||
INTRADAY_MOMENTUM_15M_STRATEGY,
|
LONG_MOMENTUM_BREAKOUT_STRATEGY,
|
||||||
|
LONG_SECOND_WAVE_PULLBACK_STRATEGY,
|
||||||
MAIN_COMPOSITE_STRATEGY,
|
MAIN_COMPOSITE_STRATEGY,
|
||||||
VOLUME_IGNITION_1H_STRATEGY,
|
SHORT_BREAKDOWN_RETEST_STRATEGY,
|
||||||
is_strategy_allowed_for_side,
|
is_strategy_allowed_for_side,
|
||||||
|
registered_strategy_codes,
|
||||||
strategy_direction,
|
strategy_direction,
|
||||||
strategy_label,
|
strategy_label,
|
||||||
)
|
)
|
||||||
@ -18,6 +17,8 @@ from app.db.paper_trading import _open_trade, _order_payload_from_rec
|
|||||||
from app.db.strategy_signal_queries import insert_strategy_signal
|
from app.db.strategy_signal_queries import insert_strategy_signal
|
||||||
from app.db.strategy_insights import evaluate_strategy_decision
|
from app.db.strategy_insights import evaluate_strategy_decision
|
||||||
from app.strategies.altcoin_breakout import (
|
from app.strategies.altcoin_breakout import (
|
||||||
|
build_long_momentum_breakout_signal,
|
||||||
|
build_long_second_wave_pullback_signal,
|
||||||
build_compression_breakout_4h_signal,
|
build_compression_breakout_4h_signal,
|
||||||
build_intraday_momentum_15m_signal,
|
build_intraday_momentum_15m_signal,
|
||||||
build_volume_ignition_1h_signal,
|
build_volume_ignition_1h_signal,
|
||||||
@ -37,7 +38,7 @@ def test_factor_roles_never_promote_unknown_to_trigger():
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_default_main_composite_strategy_signal_is_stable():
|
def test_active_strategy_pool_only_contains_short_term_three_strategies():
|
||||||
signal = default_main_composite_signal(
|
signal = default_main_composite_signal(
|
||||||
symbol="AAA/USDT",
|
symbol="AAA/USDT",
|
||||||
score=70,
|
score=70,
|
||||||
@ -45,23 +46,26 @@ def test_default_main_composite_strategy_signal_is_stable():
|
|||||||
entry_plan={"entry_action": "观察"},
|
entry_plan={"entry_action": "观察"},
|
||||||
).to_json_dict()
|
).to_json_dict()
|
||||||
|
|
||||||
assert signal["strategy_code"] == MAIN_COMPOSITE_STRATEGY
|
assert registered_strategy_codes() == [
|
||||||
assert signal["strategy_name"] == "综合确认策略"
|
LONG_MOMENTUM_BREAKOUT_STRATEGY,
|
||||||
|
LONG_SECOND_WAVE_PULLBACK_STRATEGY,
|
||||||
|
SHORT_BREAKDOWN_RETEST_STRATEGY,
|
||||||
|
]
|
||||||
|
assert signal["strategy_code"] == LONG_MOMENTUM_BREAKOUT_STRATEGY
|
||||||
|
assert signal["strategy_name"] == "多头动量启动"
|
||||||
assert signal["factor_roles"]["vp_fly_1h_current"] == "trigger"
|
assert signal["factor_roles"]["vp_fly_1h_current"] == "trigger"
|
||||||
assert strategy_label(BOX_RETEST_1H_STRATEGY) == "1H箱体突破回踩"
|
assert strategy_label(LONG_MOMENTUM_BREAKOUT_STRATEGY) == "多头动量启动"
|
||||||
assert strategy_label(VOLUME_IGNITION_1H_STRATEGY) == "1H放量突破启动"
|
assert strategy_label(LONG_SECOND_WAVE_PULLBACK_STRATEGY) == "多头二波回踩"
|
||||||
assert strategy_label(COMPRESSION_BREAKOUT_4H_STRATEGY) == "4H压缩蓄力突破"
|
assert strategy_label(SHORT_BREAKDOWN_RETEST_STRATEGY) == "空头破位反抽"
|
||||||
assert strategy_label(INTRADAY_MOMENTUM_15M_STRATEGY) == "15m日内动量延续"
|
assert strategy_direction(LONG_MOMENTUM_BREAKOUT_STRATEGY) == "long"
|
||||||
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 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(MAIN_COMPOSITE_STRATEGY, "short") is False
|
||||||
assert is_strategy_allowed_for_side(VOLUME_IGNITION_1H_STRATEGY, "short") is False
|
assert is_strategy_allowed_for_side(LONG_MOMENTUM_BREAKOUT_STRATEGY, "short") is False
|
||||||
assert is_strategy_allowed_for_side(BREAKDOWN_RETEST_SHORT_1H_STRATEGY, "short") is True
|
assert is_strategy_allowed_for_side(BREAKDOWN_RETEST_SHORT_1H_STRATEGY, "short") is True
|
||||||
|
|
||||||
|
|
||||||
def test_volume_ignition_strategy_builds_independent_signal():
|
def test_long_momentum_breakout_strategy_builds_independent_signal():
|
||||||
signal = build_volume_ignition_1h_signal(
|
signal = build_long_momentum_breakout_signal(
|
||||||
symbol="VOL/USDT",
|
symbol="VOL/USDT",
|
||||||
result={
|
result={
|
||||||
"score": 8,
|
"score": 8,
|
||||||
@ -73,10 +77,28 @@ def test_volume_ignition_strategy_builds_independent_signal():
|
|||||||
)
|
)
|
||||||
payload = signal.to_json_dict()
|
payload = signal.to_json_dict()
|
||||||
|
|
||||||
assert payload["strategy_code"] == VOLUME_IGNITION_1H_STRATEGY
|
assert payload["strategy_code"] == LONG_MOMENTUM_BREAKOUT_STRATEGY
|
||||||
assert payload["status"] == "candidate"
|
assert payload["status"] == "candidate"
|
||||||
assert payload["trigger"]["factor_code"] == "vp_fly_1h_current"
|
assert payload["trigger"]["factor_code"] == "momentum_breakout_15m_1h"
|
||||||
assert payload["factor_roles"]["vp_fly_1h_current"] == "trigger"
|
assert payload["factor_roles"]["momentum_breakout_15m_1h"] == "trigger"
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_strategy_builders_no_longer_emit_signals():
|
||||||
|
assert build_volume_ignition_1h_signal(
|
||||||
|
symbol="OLD/USDT",
|
||||||
|
result={"score": 9, "signals": ["1H量价齐飞", "15min即刻入场"], "trigger_context": {"current_triggers": ["15m"]}},
|
||||||
|
entry_plan={"entry_action": "可即刻买入"},
|
||||||
|
) is None
|
||||||
|
assert build_compression_breakout_4h_signal(
|
||||||
|
symbol="OLD/USDT",
|
||||||
|
result={"score": 9, "signals": ["4H静K压缩,突破箱体上沿"]},
|
||||||
|
entry_plan={"entry_action": "等回踩"},
|
||||||
|
) is None
|
||||||
|
assert build_intraday_momentum_15m_signal(
|
||||||
|
symbol="OLD/USDT",
|
||||||
|
result={"score": 9, "signals": ["15min强突破"], "trigger_context": {"current_triggers": ["15m"]}},
|
||||||
|
entry_plan={"entry_action": "可即刻买入"},
|
||||||
|
) is None
|
||||||
|
|
||||||
|
|
||||||
def test_long_strategy_cannot_emit_short_signal():
|
def test_long_strategy_cannot_emit_short_signal():
|
||||||
@ -84,7 +106,7 @@ def test_long_strategy_cannot_emit_short_signal():
|
|||||||
|
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
StrategySignal(
|
StrategySignal(
|
||||||
strategy_code=VOLUME_IGNITION_1H_STRATEGY,
|
strategy_code=LONG_MOMENTUM_BREAKOUT_STRATEGY,
|
||||||
symbol="BAD/USDT",
|
symbol="BAD/USDT",
|
||||||
direction="short",
|
direction="short",
|
||||||
factor_roles={"vp_fly_1h_current": "trigger"},
|
factor_roles={"vp_fly_1h_current": "trigger"},
|
||||||
@ -136,35 +158,35 @@ def test_short_factor_breakdown_removes_long_only_positive_factors():
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_compression_breakout_strategy_requires_structure_and_breakout_context():
|
def test_second_wave_strategy_requires_first_wave_and_pullback_context():
|
||||||
signal = build_compression_breakout_4h_signal(
|
signal = build_long_second_wave_pullback_signal(
|
||||||
symbol="QUIET/USDT",
|
symbol="QUIET/USDT",
|
||||||
result={"score": 6, "signals": ["4H静K压缩,突破箱体上沿"], "entry_plan": {"entry_action": "等回踩"}},
|
result={"score": 6, "signals": ["24h强势榜异动", "1H箱体突破回踩"], "entry_plan": {"entry_action": "等回踩"}},
|
||||||
entry_plan={"entry_action": "等回踩", "entry_price": 1.0},
|
entry_plan={"entry_action": "等回踩", "entry_price": 1.0},
|
||||||
)
|
)
|
||||||
payload = signal.to_json_dict()
|
payload = signal.to_json_dict()
|
||||||
|
|
||||||
assert payload["strategy_code"] == COMPRESSION_BREAKOUT_4H_STRATEGY
|
assert payload["strategy_code"] == LONG_SECOND_WAVE_PULLBACK_STRATEGY
|
||||||
assert payload["status"] == "candidate"
|
assert payload["status"] == "candidate"
|
||||||
assert payload["factor_roles"]["compression_surge_4h"] == "trigger"
|
assert payload["factor_roles"]["second_wave_pullback_1h"] == "trigger"
|
||||||
assert build_compression_breakout_4h_signal(
|
assert build_long_second_wave_pullback_signal(
|
||||||
symbol="NOBOX/USDT",
|
symbol="NOBOX/USDT",
|
||||||
result={"score": 6, "signals": ["1H量价齐飞"], "entry_plan": {"entry_action": "可即刻买入"}},
|
result={"score": 6, "signals": ["1H量价齐飞"], "entry_plan": {"entry_action": "可即刻买入"}},
|
||||||
entry_plan={"entry_action": "可即刻买入"},
|
entry_plan={"entry_action": "可即刻买入"},
|
||||||
) is None
|
) is None
|
||||||
|
|
||||||
|
|
||||||
def test_intraday_momentum_strategy_requires_current_trigger():
|
def test_long_momentum_strategy_requires_current_trigger():
|
||||||
stale = build_intraday_momentum_15m_signal(
|
stale = build_long_momentum_breakout_signal(
|
||||||
symbol="FAST/USDT",
|
symbol="FAST/USDT",
|
||||||
result={"score": 7, "signals": ["15m短周期启动"], "entry_plan": {"entry_action": "可即刻买入"}},
|
result={"score": 7, "signals": ["15m短周期启动", "1H量价齐飞"], "entry_plan": {"entry_action": "可即刻买入"}},
|
||||||
entry_plan={"entry_action": "可即刻买入"},
|
entry_plan={"entry_action": "可即刻买入"},
|
||||||
).to_json_dict()
|
).to_json_dict()
|
||||||
fresh = build_intraday_momentum_15m_signal(
|
fresh = build_long_momentum_breakout_signal(
|
||||||
symbol="FAST/USDT",
|
symbol="FAST/USDT",
|
||||||
result={
|
result={
|
||||||
"score": 7,
|
"score": 7,
|
||||||
"signals": ["15min强突破"],
|
"signals": ["15min强突破", "1H量价齐飞"],
|
||||||
"trigger_context": {"current_triggers": ["15m突破"], "trigger_status": "current"},
|
"trigger_context": {"current_triggers": ["15m突破"], "trigger_status": "current"},
|
||||||
"entry_plan": {"entry_action": "可即刻买入"},
|
"entry_plan": {"entry_action": "可即刻买入"},
|
||||||
},
|
},
|
||||||
@ -263,12 +285,12 @@ def test_strategy_evaluation_recommends_promote_or_pause():
|
|||||||
def test_strategy_signal_insert_and_recommendation_lineage(pg_conn):
|
def test_strategy_signal_insert_and_recommendation_lineage(pg_conn):
|
||||||
signal = insert_strategy_signal(
|
signal = insert_strategy_signal(
|
||||||
StrategySignal(
|
StrategySignal(
|
||||||
strategy_code=BOX_RETEST_4H_STRATEGY,
|
strategy_code=LONG_SECOND_WAVE_PULLBACK_STRATEGY,
|
||||||
symbol="BOX/USDT",
|
symbol="BOX/USDT",
|
||||||
score=10,
|
score=10,
|
||||||
confidence=80,
|
confidence=80,
|
||||||
trigger={"factor_code": "box_breakout_pullback_4h"},
|
trigger={"factor_code": "second_wave_pullback_1h"},
|
||||||
factor_roles={"box_breakout_pullback_4h": "trigger"},
|
factor_roles={"second_wave_pullback_1h": "trigger"},
|
||||||
entry_plan={"entry_action": "等回踩", "entry_price": 1.0},
|
entry_plan={"entry_action": "等回踩", "entry_price": 1.0},
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@ -289,9 +311,9 @@ def test_strategy_signal_insert_and_recommendation_lineage(pg_conn):
|
|||||||
)
|
)
|
||||||
row = pg_conn.execute("SELECT * FROM recommendation WHERE id=%s", (rec_id,)).fetchone()
|
row = pg_conn.execute("SELECT * FROM recommendation WHERE id=%s", (rec_id,)).fetchone()
|
||||||
|
|
||||||
assert row["strategy_code"] == BOX_RETEST_4H_STRATEGY
|
assert row["strategy_code"] == LONG_SECOND_WAVE_PULLBACK_STRATEGY
|
||||||
assert row["strategy_signal_id"] == signal["strategy_signal_id"]
|
assert row["strategy_signal_id"] == signal["strategy_signal_id"]
|
||||||
assert "box_breakout_pullback_4h" in row["factor_roles_json"]
|
assert "second_wave_pullback_1h" in row["factor_roles_json"]
|
||||||
|
|
||||||
|
|
||||||
def test_box_retest_strategy_preserves_zero_age_as_fresh():
|
def test_box_retest_strategy_preserves_zero_age_as_fresh():
|
||||||
@ -308,11 +330,7 @@ def test_box_retest_strategy_preserves_zero_age_as_fresh():
|
|||||||
},
|
},
|
||||||
market_regime={"regime": "altcoin_rotation", "risk_level": "medium"},
|
market_regime={"regime": "altcoin_rotation", "risk_level": "medium"},
|
||||||
)
|
)
|
||||||
payload = signal.to_json_dict()
|
assert signal is None
|
||||||
|
|
||||||
assert payload["status"] == "candidate"
|
|
||||||
assert payload["trigger"]["pullback_age_bars"] == 0
|
|
||||||
assert payload["risk_plan"]["risk_reasons"] == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_paper_order_and_trade_inherit_strategy_lineage(pg_conn):
|
def test_paper_order_and_trade_inherit_strategy_lineage(pg_conn):
|
||||||
@ -327,15 +345,15 @@ def test_paper_order_and_trade_inherit_strategy_lineage(pg_conn):
|
|||||||
"execution_status": "buy_now",
|
"execution_status": "buy_now",
|
||||||
"action_status": "可即刻买入",
|
"action_status": "可即刻买入",
|
||||||
"strategy_version": "v-test",
|
"strategy_version": "v-test",
|
||||||
"strategy_code": BOX_RETEST_4H_STRATEGY,
|
"strategy_code": LONG_SECOND_WAVE_PULLBACK_STRATEGY,
|
||||||
"strategy_signal_id": 42,
|
"strategy_signal_id": 42,
|
||||||
"strategy_snapshot_json": '{"strategy_code":"box_retest_4h_v1"}',
|
"strategy_snapshot_json": '{"strategy_code":"long_second_wave_pullback_1h_v1"}',
|
||||||
"factor_roles_json": '{"box_breakout_pullback_4h":"trigger"}',
|
"factor_roles_json": '{"second_wave_pullback_1h":"trigger"}',
|
||||||
"entry_plan": {"entry_action": "可即刻买入", "entry_price": 1.0, "stop_loss": 0.94, "tp1": 1.12},
|
"entry_plan": {"entry_action": "可即刻买入", "entry_price": 1.0, "stop_loss": 0.94, "tp1": 1.12},
|
||||||
}
|
}
|
||||||
|
|
||||||
payload = _order_payload_from_rec(rec, 1.01, "2026-05-27T00:00:00", {"trade_notional_usdt": 100})
|
payload = _order_payload_from_rec(rec, 1.01, "2026-05-27T00:00:00", {"trade_notional_usdt": 100})
|
||||||
assert payload["strategy_code"] == BOX_RETEST_4H_STRATEGY
|
assert payload["strategy_code"] == LONG_SECOND_WAVE_PULLBACK_STRATEGY
|
||||||
assert payload["strategy_signal_id"] == 42
|
assert payload["strategy_signal_id"] == 42
|
||||||
|
|
||||||
result = _open_trade(
|
result = _open_trade(
|
||||||
@ -362,7 +380,7 @@ def test_paper_order_and_trade_inherit_strategy_lineage(pg_conn):
|
|||||||
row = pg_conn.execute("SELECT * FROM paper_trades WHERE id=%s", (result["trade_id"],)).fetchone()
|
row = pg_conn.execute("SELECT * FROM paper_trades WHERE id=%s", (result["trade_id"],)).fetchone()
|
||||||
event = pg_conn.execute("SELECT * FROM paper_trade_events WHERE trade_id=%s", (result["trade_id"],)).fetchone()
|
event = pg_conn.execute("SELECT * FROM paper_trade_events WHERE trade_id=%s", (result["trade_id"],)).fetchone()
|
||||||
|
|
||||||
assert row["strategy_code"] == BOX_RETEST_4H_STRATEGY
|
assert row["strategy_code"] == LONG_SECOND_WAVE_PULLBACK_STRATEGY
|
||||||
assert row["strategy_signal_id"] == 42
|
assert row["strategy_signal_id"] == 42
|
||||||
assert event["strategy_code"] == BOX_RETEST_4H_STRATEGY
|
assert event["strategy_code"] == LONG_SECOND_WAVE_PULLBACK_STRATEGY
|
||||||
assert strategy_label(row["strategy_code"]) == "4H箱体突破回踩"
|
assert strategy_label(row["strategy_code"]) == "多头二波回踩"
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import sys
|
|||||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||||
|
|
||||||
from app.core.opportunity_lifecycle import apply_entry_quality_gate
|
from app.core.opportunity_lifecycle import apply_entry_quality_gate
|
||||||
from app.core.strategy_registry import BOX_RETEST_1H_STRATEGY, MAIN_COMPOSITE_STRATEGY
|
from app.core.strategy_registry import LONG_MOMENTUM_BREAKOUT_STRATEGY, LONG_SECOND_WAVE_PULLBACK_STRATEGY
|
||||||
from app.services.price_tracker import reconcile_buy_signals_after_gate
|
from app.services.price_tracker import reconcile_buy_signals_after_gate
|
||||||
|
|
||||||
|
|
||||||
@ -48,7 +48,7 @@ def test_buy_now_with_bad_rr_sets_real_pullback_price():
|
|||||||
|
|
||||||
assert action == '等回踩'
|
assert action == '等回踩'
|
||||||
assert plan['entry_price'] < 0.11455
|
assert plan['entry_price'] < 0.11455
|
||||||
assert round(plan['entry_price'], 6) == 0.113199
|
assert round(plan['entry_price'], 6) == 0.11251
|
||||||
assert plan['rr_target_entry'] == plan['entry_price']
|
assert plan['rr_target_entry'] == plan['entry_price']
|
||||||
assert any('现价不买' in r for r in reasons)
|
assert any('现价不买' in r for r in reasons)
|
||||||
|
|
||||||
@ -88,29 +88,29 @@ def test_entry_gate_uses_strategy_specific_thresholds():
|
|||||||
'risk_reward_ok': True,
|
'risk_reward_ok': True,
|
||||||
'rr1': 2.4,
|
'rr1': 2.4,
|
||||||
'entry_trigger_confirmed': True,
|
'entry_trigger_confirmed': True,
|
||||||
'score_components': {'opportunity_score': 12, 'entry_score': 1, 'risk_score': 1},
|
'score_components': {'opportunity_score': 12, 'entry_score': 2, 'risk_score': 1},
|
||||||
}
|
}
|
||||||
main_action, main_plan, _ = apply_entry_quality_gate(
|
momentum_action, momentum_plan, _ = apply_entry_quality_gate(
|
||||||
action_status='可即刻买入',
|
action_status='可即刻买入',
|
||||||
entry_plan=dict(base_plan),
|
entry_plan=dict(base_plan),
|
||||||
signals=['当前15min即刻入场信号'],
|
signals=['当前15min即刻入场信号'],
|
||||||
current_price=1.0,
|
current_price=1.0,
|
||||||
market_context={'change_24h': 2.0},
|
market_context={'change_24h': 2.0},
|
||||||
strategy_code=MAIN_COMPOSITE_STRATEGY,
|
strategy_code=LONG_MOMENTUM_BREAKOUT_STRATEGY,
|
||||||
)
|
)
|
||||||
box_action, box_plan, _ = apply_entry_quality_gate(
|
second_wave_action, second_wave_plan, _ = apply_entry_quality_gate(
|
||||||
action_status='可即刻买入',
|
action_status='可即刻买入',
|
||||||
entry_plan=dict(base_plan),
|
entry_plan=dict(base_plan),
|
||||||
signals=['当前15min即刻入场信号'],
|
signals=['当前15min即刻入场信号'],
|
||||||
current_price=1.0,
|
current_price=1.0,
|
||||||
market_context={'change_24h': 2.0},
|
market_context={'change_24h': 2.0},
|
||||||
strategy_code=BOX_RETEST_1H_STRATEGY,
|
strategy_code=LONG_SECOND_WAVE_PULLBACK_STRATEGY,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert main_action != '可即刻买入'
|
assert momentum_action != '可即刻买入'
|
||||||
assert main_plan['entry_quality_gate']['strategy_code'] == MAIN_COMPOSITE_STRATEGY
|
assert momentum_plan['entry_quality_gate']['strategy_code'] == LONG_MOMENTUM_BREAKOUT_STRATEGY
|
||||||
assert box_action == '可即刻买入'
|
assert second_wave_action == '可即刻买入'
|
||||||
assert box_plan['strategy_code'] == BOX_RETEST_1H_STRATEGY
|
assert second_wave_plan['strategy_code'] == LONG_SECOND_WAVE_PULLBACK_STRATEGY
|
||||||
|
|
||||||
|
|
||||||
def test_buy_now_requires_current_trigger_and_no_bearish_flow_risk():
|
def test_buy_now_requires_current_trigger_and_no_bearish_flow_risk():
|
||||||
|
|||||||
@ -18,6 +18,10 @@ from app.db.paper_trading import (
|
|||||||
sync_recommendation,
|
sync_recommendation,
|
||||||
)
|
)
|
||||||
from app.db.recommendation_queries import get_opportunity_detail
|
from app.db.recommendation_queries import get_opportunity_detail
|
||||||
|
from app.core.strategy_registry import (
|
||||||
|
LONG_MOMENTUM_BREAKOUT_STRATEGY,
|
||||||
|
SHORT_BREAKDOWN_RETEST_STRATEGY,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _visible_card_text(card: dict) -> str:
|
def _visible_card_text(card: dict) -> str:
|
||||||
@ -72,18 +76,18 @@ def buy_now_rec(monkeypatch):
|
|||||||
rec_state="爆发",
|
rec_state="爆发",
|
||||||
rec_score=28,
|
rec_score=28,
|
||||||
entry_price=100,
|
entry_price=100,
|
||||||
stop_loss=95,
|
stop_loss=96,
|
||||||
tp1=106,
|
tp1=106,
|
||||||
tp2=112,
|
tp2=112,
|
||||||
signals=["当前15min即刻入场信号"],
|
signals=["当前15min即刻入场信号"],
|
||||||
entry_plan={
|
entry_plan={
|
||||||
"entry_action": "可即刻买入",
|
"entry_action": "可即刻买入",
|
||||||
"entry_price": 100,
|
"entry_price": 100,
|
||||||
"stop_loss": 95,
|
"stop_loss": 96,
|
||||||
"tp1": 106,
|
"tp1": 106,
|
||||||
"tp2": 112,
|
"tp2": 112,
|
||||||
"risk_reward_ok": True,
|
"risk_reward_ok": True,
|
||||||
"rr1": 1.2,
|
"rr1": 1.5,
|
||||||
"entry_trigger_confirmed": True,
|
"entry_trigger_confirmed": True,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@ -992,7 +996,7 @@ def test_touched_order_uses_order_snapshot_side_for_global_risk(monkeypatch, pg_
|
|||||||
) VALUES (
|
) VALUES (
|
||||||
301, 'SNAPSHORT/USDT', '2026-05-16T10:00:00', '蓄力', 88, 105,
|
301, 'SNAPSHORT/USDT', '2026-05-16T10:00:00', '蓄力', 88, 105,
|
||||||
'active', 'wait_pullback', '等回踩', 'watch_pool', 0,
|
'active', 'wait_pullback', '等回踩', 'watch_pool', 0,
|
||||||
95, 115, 122, 'volume_ignition_1h_v1', %s
|
95, 115, 122, 'long_momentum_breakout_15m_1h_v1', %s
|
||||||
)
|
)
|
||||||
""",
|
""",
|
||||||
(json.dumps({"side": "long", "entry_action": "等回踩", "entry_price": 105, "stop_loss": 95, "tp1": 115, "rr1": 2.0}, ensure_ascii=False),),
|
(json.dumps({"side": "long", "entry_action": "等回踩", "entry_price": 105, "stop_loss": 95, "tp1": 115, "rr1": 2.0}, ensure_ascii=False),),
|
||||||
@ -1008,12 +1012,12 @@ def test_touched_order_uses_order_snapshot_side_for_global_risk(monkeypatch, pg_
|
|||||||
) VALUES (
|
) VALUES (
|
||||||
301, 'SNAPSHORT/USDT', 'short', 'limit', 'pending',
|
301, 'SNAPSHORT/USDT', 'short', 'limit', 'pending',
|
||||||
'wait_pullback', '等反抽', 105, 100,
|
'wait_pullback', '等反抽', 105, 100,
|
||||||
5000, 110, 95, 90, 'v-test', 'breakdown_retest_short_1h_v1',
|
5000, 110, 95, 90, 'v-test', 'short_breakdown_retest_1h_v1',
|
||||||
0, %s, '{}', %s, '2026-05-16T10:00:00', '2026-05-16T10:00:00', '2026-05-17T10:00:00'
|
0, %s, '{}', %s, '2026-05-16T10:00:00', '2026-05-16T10:00:00', '2026-05-17T10:00:00'
|
||||||
)
|
)
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
json.dumps({"strategy_code": "breakdown_retest_short_1h_v1"}, ensure_ascii=False),
|
json.dumps({"strategy_code": "short_breakdown_retest_1h_v1"}, ensure_ascii=False),
|
||||||
json.dumps({"side": "short", "entry_action": "等反抽", "entry_price": 105, "stop_loss": 110, "tp1": 95, "rr1": 2.0}, ensure_ascii=False),
|
json.dumps({"side": "short", "entry_action": "等反抽", "entry_price": 105, "stop_loss": 110, "tp1": 95, "rr1": 2.0}, ensure_ascii=False),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@ -1025,10 +1029,10 @@ def test_touched_order_uses_order_snapshot_side_for_global_risk(monkeypatch, pg_
|
|||||||
assert result["filled_count"] == 1
|
assert result["filled_count"] == 1
|
||||||
assert captured
|
assert captured
|
||||||
assert all(x.get("side") == "short" for x in captured)
|
assert all(x.get("side") == "short" for x in captured)
|
||||||
assert all(x.get("strategy_code") == "breakdown_retest_short_1h_v1" for x in captured)
|
assert all(x.get("strategy_code") == "short_breakdown_retest_1h_v1" for x in captured)
|
||||||
trade = list_paper_trades()["items"][0]
|
trade = list_paper_trades()["items"][0]
|
||||||
assert trade["side"] == "short"
|
assert trade["side"] == "short"
|
||||||
assert trade["strategy_code"] == "breakdown_retest_short_1h_v1"
|
assert trade["strategy_code"] == "short_breakdown_retest_1h_v1"
|
||||||
|
|
||||||
|
|
||||||
def test_wait_pullback_order_cancels_when_recommendation_invalid(monkeypatch):
|
def test_wait_pullback_order_cancels_when_recommendation_invalid(monkeypatch):
|
||||||
|
|||||||
@ -31,7 +31,7 @@ class RecommendationStateMainlineTests(unittest.TestCase):
|
|||||||
rec_score=27,
|
rec_score=27,
|
||||||
entry_price=0.06557,
|
entry_price=0.06557,
|
||||||
stop_loss=0.061846,
|
stop_loss=0.061846,
|
||||||
tp1=0.071156,
|
tp1=0.072000,
|
||||||
tp2=0.074881,
|
tp2=0.074881,
|
||||||
sector='',
|
sector='',
|
||||||
signals=json.dumps(['15min 回踩确认'], ensure_ascii=False),
|
signals=json.dumps(['15min 回踩确认'], ensure_ascii=False),
|
||||||
@ -54,7 +54,7 @@ class RecommendationStateMainlineTests(unittest.TestCase):
|
|||||||
'risk_reward_ok': True,
|
'risk_reward_ok': True,
|
||||||
'rr1': 1.5,
|
'rr1': 1.5,
|
||||||
'stop_loss': 0.061846,
|
'stop_loss': 0.061846,
|
||||||
'tp1': 0.071156,
|
'tp1': 0.072000,
|
||||||
'entry_trigger_confirmed': True,
|
'entry_trigger_confirmed': True,
|
||||||
}, ensure_ascii=False),
|
}, ensure_ascii=False),
|
||||||
action_status='等回踩',
|
action_status='等回踩',
|
||||||
|
|||||||
@ -92,7 +92,7 @@ def test_review_center_strategy_counts_use_same_closed_trade_window(pg_conn):
|
|||||||
old_close = now - timedelta(days=40)
|
old_close = now - timedelta(days=40)
|
||||||
old_open_recent_close = now - timedelta(days=20)
|
old_open_recent_close = now - timedelta(days=20)
|
||||||
outside_open = now - timedelta(days=60)
|
outside_open = now - timedelta(days=60)
|
||||||
strategy_code = "volume_ignition_1h_v1"
|
strategy_code = "long_momentum_breakout_15m_1h_v1"
|
||||||
|
|
||||||
pg_conn.execute(
|
pg_conn.execute(
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -189,6 +189,52 @@ def test_kline_scan_selection_is_rule_based_without_default_count_cap(monkeypatc
|
|||||||
assert len(capped) == 2
|
assert len(capped) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_discovery_priority_queue_promotes_top_gainers_before_kline_scan(monkeypatch):
|
||||||
|
monkeypatch.setattr(altcoin_screener, "get_burst_threshold", lambda symbol: 4)
|
||||||
|
monkeypatch.setattr(altcoin_screener, "is_meme_coin", lambda symbol: False)
|
||||||
|
cfg = {
|
||||||
|
"main_min_volume_usd": 5_000_000,
|
||||||
|
"discovery_min_volume_usd": 2_000_000,
|
||||||
|
"short_tf_high_volume_usd": 20_000_000,
|
||||||
|
"tier_a_min_score": 45,
|
||||||
|
"tier_b_min_score": 20,
|
||||||
|
}
|
||||||
|
tickers = {
|
||||||
|
"SLEEP/USDT": {"price": 1, "volume_24h": 30_000_000, "change_24h": 0.2, "high_24h": 1.01, "low_24h": 0.99},
|
||||||
|
"MOVE/USDT": {"price": 1, "volume_24h": 3_000_000, "change_24h": 12, "high_24h": 1.16, "low_24h": 0.96},
|
||||||
|
"LOW/USDT": {"price": 1, "volume_24h": 300_000, "change_24h": 50, "high_24h": 1.8, "low_24h": 0.9},
|
||||||
|
}
|
||||||
|
|
||||||
|
queue = altcoin_screener._build_discovery_priority_queue(tickers, recently_screened=set(), cfg=cfg)
|
||||||
|
by_symbol = {item["symbol"]: item for item in queue}
|
||||||
|
|
||||||
|
assert by_symbol["MOVE/USDT"]["tier"] == "A"
|
||||||
|
assert by_symbol["SLEEP/USDT"]["tier"] in {"B", "A"}
|
||||||
|
assert "LOW/USDT" not in by_symbol
|
||||||
|
|
||||||
|
|
||||||
|
def test_discovery_tier_selection_respects_a_b_budgets():
|
||||||
|
queue = [
|
||||||
|
{"symbol": "A1/USDT", "tier": "A", "score": 90},
|
||||||
|
{"symbol": "A2/USDT", "tier": "A", "score": 80},
|
||||||
|
{"symbol": "B1/USDT", "tier": "B", "score": 30},
|
||||||
|
{"symbol": "B2/USDT", "tier": "B", "score": 25},
|
||||||
|
{"symbol": "C1/USDT", "tier": "C", "score": 5},
|
||||||
|
]
|
||||||
|
|
||||||
|
selected = altcoin_screener._select_discovery_tiers(queue, tier_a_budget=1, tier_b_budget=1)
|
||||||
|
bypass = altcoin_screener._select_discovery_tiers(
|
||||||
|
queue,
|
||||||
|
tier_a_budget=1,
|
||||||
|
tier_b_budget=0,
|
||||||
|
extra_tiers=("B", "C"),
|
||||||
|
extra_budget=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert selected == ["A1/USDT", "B1/USDT"]
|
||||||
|
assert bypass == ["A1/USDT", "B1/USDT", "B2/USDT"]
|
||||||
|
|
||||||
|
|
||||||
def _mock_weights():
|
def _mock_weights():
|
||||||
return {
|
return {
|
||||||
"量价齐飞": 5,
|
"量价齐飞": 5,
|
||||||
|
|||||||
68
tests/test_strategy_sample_cleanup.py
Normal file
68
tests/test_strategy_sample_cleanup.py
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
from app.db.strategy_sample_cleanup import CONFIRM_TOKEN, cleanup_legacy_strategy_samples
|
||||||
|
|
||||||
|
|
||||||
|
def test_cleanup_legacy_strategy_samples_removes_only_retired_strategy_data(pg_conn):
|
||||||
|
pg_conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO recommendation (
|
||||||
|
id, symbol, rec_time, rec_state, rec_score, entry_price,
|
||||||
|
status, execution_status, action_status, display_bucket, entry_triggered,
|
||||||
|
strategy_code, entry_plan_json
|
||||||
|
) VALUES
|
||||||
|
(9001, 'OLD/USDT', '2026-06-04T00:00:00', '爆发', 50, 1,
|
||||||
|
'active', 'buy_now', '可即刻买入', 'realtime', 1, 'volume_ignition_1h_v1', '{}'),
|
||||||
|
(9002, 'NEW/USDT', '2026-06-04T00:00:00', '爆发', 50, 1,
|
||||||
|
'active', 'buy_now', '可即刻买入', 'realtime', 1, 'long_momentum_breakout_15m_1h_v1', '{}')
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
pg_conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO strategy_signals (
|
||||||
|
id, strategy_code, symbol, direction, signal_status, created_at
|
||||||
|
) VALUES
|
||||||
|
(9101, 'volume_ignition_1h_v1', 'OLD/USDT', 'long', 'candidate', '2026-06-04T00:00:00'),
|
||||||
|
(9102, 'long_momentum_breakout_15m_1h_v1', 'NEW/USDT', 'long', 'candidate', '2026-06-04T00:00:00')
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
pg_conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO paper_orders (
|
||||||
|
id, recommendation_id, symbol, side, order_type, status, target_price,
|
||||||
|
current_price_at_create, notional_usdt, strategy_code, created_at, updated_at, expires_at
|
||||||
|
) VALUES
|
||||||
|
(9201, 9001, 'OLD/USDT', 'long', 'limit', 'pending', 1, 1, 5000, 'volume_ignition_1h_v1', '2026-06-04T00:00:00', '2026-06-04T00:00:00', '2026-06-05T00:00:00'),
|
||||||
|
(9202, 9002, 'NEW/USDT', 'long', 'limit', 'pending', 1, 1, 5000, 'long_momentum_breakout_15m_1h_v1', '2026-06-04T00:00:00', '2026-06-04T00:00:00', '2026-06-05T00:00:00')
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
pg_conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO paper_trades (
|
||||||
|
id, recommendation_id, symbol, side, status, opened_at, entry_price,
|
||||||
|
qty, notional_usdt, strategy_code, created_at, updated_at
|
||||||
|
) VALUES
|
||||||
|
(9301, 9001, 'OLD/USDT', 'long', 'open', '2026-06-04T00:00:00', 1, 1, 5000, 'volume_ignition_1h_v1', '2026-06-04T00:00:00', '2026-06-04T00:00:00'),
|
||||||
|
(9302, 9002, 'NEW/USDT', 'long', 'open', '2026-06-04T00:00:00', 1, 1, 5000, 'long_momentum_breakout_15m_1h_v1', '2026-06-04T00:00:00', '2026-06-04T00:00:00')
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
pg_conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO paper_trade_events (
|
||||||
|
id, trade_id, recommendation_id, symbol, event_type, event_time, detail_json, strategy_code
|
||||||
|
) VALUES
|
||||||
|
(9401, 9301, 9001, 'OLD/USDT', 'open', '2026-06-04T00:00:00', '{}', 'volume_ignition_1h_v1'),
|
||||||
|
(9402, 9302, 9002, 'NEW/USDT', 'open', '2026-06-04T00:00:00', '{}', 'long_momentum_breakout_15m_1h_v1')
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
pg_conn.commit()
|
||||||
|
|
||||||
|
result = cleanup_legacy_strategy_samples(confirm=CONFIRM_TOKEN, create_backup=False)
|
||||||
|
|
||||||
|
assert result["deleted"]["recommendation"] == 1
|
||||||
|
assert result["deleted"]["strategy_signals"] == 1
|
||||||
|
assert result["deleted"]["paper_orders"] == 1
|
||||||
|
assert result["deleted"]["paper_trades"] == 1
|
||||||
|
assert result["deleted"]["paper_trade_events"] >= 1
|
||||||
|
assert pg_conn.execute("SELECT COUNT(*) FROM recommendation WHERE symbol='OLD/USDT'").fetchone()[0] == 0
|
||||||
|
assert pg_conn.execute("SELECT COUNT(*) FROM recommendation WHERE symbol='NEW/USDT'").fetchone()[0] == 1
|
||||||
Loading…
Reference in New Issue
Block a user