1
This commit is contained in:
parent
188eb6015a
commit
d2e2032352
@ -171,6 +171,13 @@ def get_all_recommendations(limit=50, decision_only=False, version="", offset=0,
|
|||||||
total = None
|
total = None
|
||||||
summary = None
|
summary = None
|
||||||
version_counts = []
|
version_counts = []
|
||||||
|
success_case = "status IN ('hit_tp1','hit_tp2') OR (status NOT IN ('stopped_out','expired','invalid','archived') AND COALESCE(max_pnl_pct,0) >= 5)"
|
||||||
|
failure_case = "status='stopped_out' OR COALESCE(pnl_pct,0) <= -3 OR COALESCE(max_drawdown_pct,0) <= -5"
|
||||||
|
realized_pnl_case = (
|
||||||
|
f"CASE WHEN {failure_case} THEN COALESCE(pnl_pct,0) "
|
||||||
|
f"WHEN {success_case} THEN COALESCE(NULLIF(max_pnl_pct,0), pnl_pct, 0) "
|
||||||
|
"ELSE 0 END"
|
||||||
|
)
|
||||||
|
|
||||||
if decision_only:
|
if decision_only:
|
||||||
if with_meta:
|
if with_meta:
|
||||||
@ -193,15 +200,21 @@ def get_all_recommendations(limit=50, decision_only=False, version="", offset=0,
|
|||||||
"""
|
"""
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(*) AS total,
|
COUNT(*) AS total,
|
||||||
SUM(CASE WHEN status IN ('hit_tp1','hit_tp2') OR COALESCE(max_pnl_pct,0) >= 5 THEN 1 ELSE 0 END) AS success_count,
|
SUM(CASE WHEN """
|
||||||
SUM(CASE WHEN status='stopped_out' OR COALESCE(pnl_pct,0) <= -3 OR COALESCE(max_drawdown_pct,0) <= -5 THEN 1 ELSE 0 END) AS failure_count,
|
+ success_case
|
||||||
SUM(CASE WHEN status IN ('hit_tp1','hit_tp2') OR COALESCE(max_pnl_pct,0) >= 5 THEN COALESCE(NULLIF(max_pnl_pct,0), pnl_pct, 0)
|
+ """ THEN 1 ELSE 0 END) AS success_count,
|
||||||
WHEN status='stopped_out' OR COALESCE(pnl_pct,0) <= -3 OR COALESCE(max_drawdown_pct,0) <= -5 THEN COALESCE(pnl_pct,0)
|
SUM(CASE WHEN """
|
||||||
ELSE 0 END) AS total_pnl,
|
+ failure_case
|
||||||
MAX(CASE WHEN status IN ('hit_tp1','hit_tp2') OR COALESCE(max_pnl_pct,0) >= 5 THEN COALESCE(NULLIF(max_pnl_pct,0), pnl_pct, 0)
|
+ """ THEN 1 ELSE 0 END) AS failure_count,
|
||||||
WHEN status='stopped_out' OR COALESCE(pnl_pct,0) <= -3 OR COALESCE(max_drawdown_pct,0) <= -5 THEN COALESCE(pnl_pct,0)
|
SUM("""
|
||||||
ELSE 0 END) AS best_pnl,
|
+ realized_pnl_case
|
||||||
AVG(CASE WHEN status='stopped_out' OR COALESCE(pnl_pct,0) <= -3 OR COALESCE(max_drawdown_pct,0) <= -5 THEN COALESCE(pnl_pct,0) END) AS avg_failure_pnl
|
+ """) AS total_pnl,
|
||||||
|
MAX("""
|
||||||
|
+ realized_pnl_case
|
||||||
|
+ """) AS best_pnl,
|
||||||
|
AVG(CASE WHEN """
|
||||||
|
+ failure_case
|
||||||
|
+ """ THEN COALESCE(pnl_pct,0) END) AS avg_failure_pnl
|
||||||
FROM (
|
FROM (
|
||||||
SELECT r.*
|
SELECT r.*
|
||||||
FROM recommendation r
|
FROM recommendation r
|
||||||
|
|||||||
@ -1,9 +1,31 @@
|
|||||||
"""SQLite-backed scheduler configuration and runtime state."""
|
"""SQLite-backed scheduler configuration and runtime state."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from app.db.schema import get_conn
|
from app.db import altcoin_db
|
||||||
|
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
SCHEDULER_DB_PATH = os.getenv("ALPHAX_SCHEDULER_DB_PATH", str(REPO_ROOT / "data" / "scheduler_state.db"))
|
||||||
|
|
||||||
|
|
||||||
|
def get_scheduler_conn():
|
||||||
|
path = Path(SCHEDULER_DB_PATH)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
conn = sqlite3.connect(str(path), timeout=30, isolation_level=None)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute("PRAGMA busy_timeout=30000")
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
conn.execute("PRAGMA synchronous=NORMAL")
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def get_main_conn():
|
||||||
|
return altcoin_db.get_conn()
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_JOBS = [
|
DEFAULT_JOBS = [
|
||||||
@ -212,7 +234,7 @@ def _seed_scheduler_tables(conn):
|
|||||||
|
|
||||||
|
|
||||||
def init_scheduler_tables():
|
def init_scheduler_tables():
|
||||||
conn = get_conn()
|
conn = get_scheduler_conn()
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS scheduler_job_config (
|
CREATE TABLE IF NOT EXISTS scheduler_job_config (
|
||||||
@ -284,7 +306,7 @@ def init_scheduler_tables():
|
|||||||
|
|
||||||
def get_job_configs():
|
def get_job_configs():
|
||||||
init_scheduler_tables()
|
init_scheduler_tables()
|
||||||
conn = get_conn()
|
conn = get_scheduler_conn()
|
||||||
rows = conn.execute("SELECT * FROM scheduler_job_config ORDER BY sort_order ASC, job_name ASC").fetchall()
|
rows = conn.execute("SELECT * FROM scheduler_job_config ORDER BY sort_order ASC, job_name ASC").fetchall()
|
||||||
conn.close()
|
conn.close()
|
||||||
jobs = []
|
jobs = []
|
||||||
@ -298,7 +320,7 @@ def get_job_configs():
|
|||||||
|
|
||||||
def get_job_config(job_name):
|
def get_job_config(job_name):
|
||||||
init_scheduler_tables()
|
init_scheduler_tables()
|
||||||
conn = get_conn()
|
conn = get_scheduler_conn()
|
||||||
row = conn.execute("SELECT * FROM scheduler_job_config WHERE job_name=?", (job_name,)).fetchone()
|
row = conn.execute("SELECT * FROM scheduler_job_config WHERE job_name=?", (job_name,)).fetchone()
|
||||||
conn.close()
|
conn.close()
|
||||||
if not row:
|
if not row:
|
||||||
@ -312,7 +334,7 @@ def get_job_config(job_name):
|
|||||||
def set_job_enabled(job_name, enabled):
|
def set_job_enabled(job_name, enabled):
|
||||||
init_scheduler_tables()
|
init_scheduler_tables()
|
||||||
now = _now()
|
now = _now()
|
||||||
conn = get_conn()
|
conn = get_scheduler_conn()
|
||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
"UPDATE scheduler_job_config SET enabled=?, updated_at=? WHERE job_name=?",
|
"UPDATE scheduler_job_config SET enabled=?, updated_at=? WHERE job_name=?",
|
||||||
(1 if enabled else 0, now, job_name),
|
(1 if enabled else 0, now, job_name),
|
||||||
@ -326,7 +348,7 @@ def set_job_interval(job_name, every_seconds):
|
|||||||
seconds = max(30, int(every_seconds or 0))
|
seconds = max(30, int(every_seconds or 0))
|
||||||
init_scheduler_tables()
|
init_scheduler_tables()
|
||||||
now = _now()
|
now = _now()
|
||||||
conn = get_conn()
|
conn = get_scheduler_conn()
|
||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
"UPDATE scheduler_job_config SET every_seconds=?, updated_at=? WHERE job_name=?",
|
"UPDATE scheduler_job_config SET every_seconds=?, updated_at=? WHERE job_name=?",
|
||||||
(seconds, now, job_name),
|
(seconds, now, job_name),
|
||||||
@ -345,7 +367,7 @@ def update_runtime(job_name, **fields):
|
|||||||
}
|
}
|
||||||
values = {k: v for k, v in fields.items() if k in allowed}
|
values = {k: v for k, v in fields.items() if k in allowed}
|
||||||
values["updated_at"] = _now()
|
values["updated_at"] = _now()
|
||||||
conn = get_conn()
|
conn = get_scheduler_conn()
|
||||||
try:
|
try:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO scheduler_runtime_status (job_name, updated_at) VALUES (?, ?) ON CONFLICT(job_name) DO NOTHING",
|
"INSERT INTO scheduler_runtime_status (job_name, updated_at) VALUES (?, ?) ON CONFLICT(job_name) DO NOTHING",
|
||||||
@ -375,7 +397,7 @@ def enqueue_manual_trigger(job_name, force=False, requested_by=""):
|
|||||||
init_scheduler_tables()
|
init_scheduler_tables()
|
||||||
if not get_job_config(job_name):
|
if not get_job_config(job_name):
|
||||||
return None
|
return None
|
||||||
conn = get_conn()
|
conn = get_scheduler_conn()
|
||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO scheduler_manual_trigger (job_name, force, status, requested_by, requested_at)
|
INSERT INTO scheduler_manual_trigger (job_name, force, status, requested_by, requested_at)
|
||||||
@ -391,7 +413,7 @@ def enqueue_manual_trigger(job_name, force=False, requested_by=""):
|
|||||||
|
|
||||||
def claim_manual_triggers(limit=10):
|
def claim_manual_triggers(limit=10):
|
||||||
init_scheduler_tables()
|
init_scheduler_tables()
|
||||||
conn = get_conn()
|
conn = get_scheduler_conn()
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT * FROM scheduler_manual_trigger
|
SELECT * FROM scheduler_manual_trigger
|
||||||
@ -411,7 +433,7 @@ def update_manual_trigger(trigger_id, **fields):
|
|||||||
values = {k: v for k, v in fields.items() if k in allowed}
|
values = {k: v for k, v in fields.items() if k in allowed}
|
||||||
if not values:
|
if not values:
|
||||||
return
|
return
|
||||||
conn = get_conn()
|
conn = get_scheduler_conn()
|
||||||
assignments = ", ".join([f"{k}=?" for k in values])
|
assignments = ", ".join([f"{k}=?" for k in values])
|
||||||
conn.execute(
|
conn.execute(
|
||||||
f"UPDATE scheduler_manual_trigger SET {assignments} WHERE id=?",
|
f"UPDATE scheduler_manual_trigger SET {assignments} WHERE id=?",
|
||||||
@ -424,7 +446,7 @@ def update_manual_trigger(trigger_id, **fields):
|
|||||||
def list_manual_triggers(limit=30):
|
def list_manual_triggers(limit=30):
|
||||||
init_scheduler_tables()
|
init_scheduler_tables()
|
||||||
limit = max(1, min(int(limit or 30), 100))
|
limit = max(1, min(int(limit or 30), 100))
|
||||||
conn = get_conn()
|
conn = get_scheduler_conn()
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT * FROM scheduler_manual_trigger ORDER BY requested_at DESC, id DESC LIMIT ?",
|
"SELECT * FROM scheduler_manual_trigger ORDER BY requested_at DESC, id DESC LIMIT ?",
|
||||||
(limit,),
|
(limit,),
|
||||||
@ -435,21 +457,26 @@ def list_manual_triggers(limit=30):
|
|||||||
|
|
||||||
def get_scheduler_overview():
|
def get_scheduler_overview():
|
||||||
init_scheduler_tables()
|
init_scheduler_tables()
|
||||||
conn = get_conn()
|
conn = get_scheduler_conn()
|
||||||
configs = conn.execute("SELECT * FROM scheduler_job_config ORDER BY sort_order ASC, job_name ASC").fetchall()
|
configs = conn.execute("SELECT * FROM scheduler_job_config ORDER BY sort_order ASC, job_name ASC").fetchall()
|
||||||
runtime_rows = conn.execute("SELECT * FROM scheduler_runtime_status").fetchall()
|
runtime_rows = conn.execute("SELECT * FROM scheduler_runtime_status").fetchall()
|
||||||
latest_rows = conn.execute(
|
|
||||||
"""
|
|
||||||
SELECT c.*
|
|
||||||
FROM cron_run_log c
|
|
||||||
JOIN (
|
|
||||||
SELECT job_name, MAX(id) AS max_id
|
|
||||||
FROM cron_run_log
|
|
||||||
GROUP BY job_name
|
|
||||||
) x ON x.max_id = c.id
|
|
||||||
"""
|
|
||||||
).fetchall()
|
|
||||||
conn.close()
|
conn.close()
|
||||||
|
try:
|
||||||
|
main_conn = get_main_conn()
|
||||||
|
latest_rows = main_conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT c.*
|
||||||
|
FROM cron_run_log c
|
||||||
|
JOIN (
|
||||||
|
SELECT job_name, MAX(id) AS max_id
|
||||||
|
FROM cron_run_log
|
||||||
|
GROUP BY job_name
|
||||||
|
) x ON x.max_id = c.id
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
main_conn.close()
|
||||||
|
except Exception:
|
||||||
|
latest_rows = []
|
||||||
runtime = {row["job_name"]: dict(row) for row in runtime_rows}
|
runtime = {row["job_name"]: dict(row) for row in runtime_rows}
|
||||||
latest = {row["job_name"]: dict(row) for row in latest_rows}
|
latest = {row["job_name"]: dict(row) for row in latest_rows}
|
||||||
jobs = []
|
jobs = []
|
||||||
@ -482,6 +509,7 @@ __all__ = [
|
|||||||
"get_job_config",
|
"get_job_config",
|
||||||
"get_job_configs",
|
"get_job_configs",
|
||||||
"get_scheduler_overview",
|
"get_scheduler_overview",
|
||||||
|
"get_scheduler_conn",
|
||||||
"init_scheduler_tables",
|
"init_scheduler_tables",
|
||||||
"list_manual_triggers",
|
"list_manual_triggers",
|
||||||
"set_job_enabled",
|
"set_job_enabled",
|
||||||
|
|||||||
@ -193,10 +193,33 @@ def push_altcoin_tp_sl_alert(symbol, current_price, entry_price, pnl_pct, action
|
|||||||
|
|
||||||
def build_trade_action_card(symbol, current_price, entry_price, pnl_pct, action_status, signals, stop_loss=0, tp1=0, tp2=0):
|
def build_trade_action_card(symbol, current_price, entry_price, pnl_pct, action_status, signals, stop_loss=0, tp1=0, tp2=0):
|
||||||
"""只构建交易执行卡片,不做冷却判断或落库。"""
|
"""只构建交易执行卡片,不做冷却判断或落库。"""
|
||||||
if action_status not in ("可即刻买入", "跟踪止盈"):
|
if action_status not in ("可即刻买入", "跟踪止盈", "移动止盈保护"):
|
||||||
print(f"[飞书跳过] {symbol} {action_status} — 用户要求止盈/止损/衰减不推送,只在网站展示")
|
print(f"[飞书跳过] {symbol} {action_status} — 用户要求止盈/止损/衰减不推送,只在网站展示")
|
||||||
return True, {"skipped": True, "reason": "only_buy_now_and_trailing_stop_push_enabled"}
|
return True, {"skipped": True, "reason": "only_buy_now_and_trailing_stop_push_enabled"}
|
||||||
|
|
||||||
|
if action_status == "移动止盈保护":
|
||||||
|
coin = symbol.replace("/USDT", "")
|
||||||
|
signal_lines = "\n".join([f" • {s}" for s in signals]) or " • 移动止盈保护已启动"
|
||||||
|
trail_info = f"入场${entry_price:.4f} → 当前${current_price:.4f}"
|
||||||
|
if pnl_pct > 0:
|
||||||
|
trail_info += f"\n**当前浮盈: +{pnl_pct:.2f}%**"
|
||||||
|
return {
|
||||||
|
"config": {"wide_screen_mode": True},
|
||||||
|
"header": {
|
||||||
|
"template": "yellow",
|
||||||
|
"title": {"tag": "plain_text", "content": f"🛡️ 移动止盈保护启动 — {coin}"},
|
||||||
|
},
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"tag": "div",
|
||||||
|
"text": {
|
||||||
|
"tag": "lark_md",
|
||||||
|
"content": f"{trail_info}\n\n**保护详情**:\n{signal_lines}\n\n💡 已进入利润保护阶段,后续跌破保护位会触发跟踪止盈。",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
# v1.7.8: 跟踪止盈用独立的醒目卡片
|
# v1.7.8: 跟踪止盈用独立的醒目卡片
|
||||||
if action_status == "跟踪止盈":
|
if action_status == "跟踪止盈":
|
||||||
coin = symbol.replace("/USDT", "")
|
coin = symbol.replace("/USDT", "")
|
||||||
|
|||||||
@ -155,6 +155,8 @@ def analyze_tracking_signals(symbol, rec, current_price):
|
|||||||
rules = load_rules()
|
rules = load_rules()
|
||||||
trail_cfg = rules.get("tracker", {}).get("trailing_stop", {})
|
trail_cfg = rules.get("tracker", {}).get("trailing_stop", {})
|
||||||
trailing_stop_level = entry_plan.get("trailing_stop_level", 0)
|
trailing_stop_level = entry_plan.get("trailing_stop_level", 0)
|
||||||
|
trailing_stop_activated = False
|
||||||
|
trailing_stop_moved = False
|
||||||
|
|
||||||
if trail_cfg.get("enabled", True) and atr_1h > 0 and entry_price > 0:
|
if trail_cfg.get("enabled", True) and atr_1h > 0 and entry_price > 0:
|
||||||
activate_pct = trail_cfg.get("activate_pnl_pct", 3)
|
activate_pct = trail_cfg.get("activate_pnl_pct", 3)
|
||||||
@ -176,15 +178,27 @@ def analyze_tracking_signals(symbol, rec, current_price):
|
|||||||
# 激活条件: pnl_pct ≥ activate_pct (3%)
|
# 激活条件: pnl_pct ≥ activate_pct (3%)
|
||||||
if pnl_pct >= activate_pct:
|
if pnl_pct >= activate_pct:
|
||||||
new_trail = current_price - trail_atr_mult * atr_1h
|
new_trail = current_price - trail_atr_mult * atr_1h
|
||||||
|
min_lock_pct = float(trail_cfg.get("min_lock_profit_pct", 0.5))
|
||||||
|
breakeven_buffer_pct = float(trail_cfg.get("breakeven_buffer_pct", min_lock_pct))
|
||||||
|
# ATR 对高波动山寨币会很宽。利润保护一旦激活,止盈线至少要高于入场价,
|
||||||
|
# 且不能低于原硬止损;否则“移动止盈已激活”实际没有任何保护效果。
|
||||||
|
protection_floor = max(
|
||||||
|
stop_loss or 0,
|
||||||
|
entry_price * (1 + max(min_lock_pct, breakeven_buffer_pct) / 100),
|
||||||
|
)
|
||||||
|
new_trail = max(new_trail, protection_floor)
|
||||||
|
|
||||||
if trailing_stop_level > 0:
|
if trailing_stop_level > 0:
|
||||||
# 已有跟踪位 → 只上移不下移
|
# 已有跟踪位 → 只上移不下移
|
||||||
|
old_trail = trailing_stop_level
|
||||||
trailing_stop_level = max(trailing_stop_level, new_trail)
|
trailing_stop_level = max(trailing_stop_level, new_trail)
|
||||||
|
trailing_stop_moved = trailing_stop_level > old_trail + 1e-12
|
||||||
else:
|
else:
|
||||||
# 首次激活
|
# 首次激活
|
||||||
trailing_stop_level = new_trail
|
trailing_stop_level = new_trail
|
||||||
|
trailing_stop_activated = True
|
||||||
tier_info = f" [{tier_label}·{trail_atr_mult}×ATR]" if tier_label else ""
|
tier_info = f" [{tier_label}·{trail_atr_mult}×ATR]" if tier_label else ""
|
||||||
sell_signals.append(f"🎯 跟踪止盈激活(盈+{pnl_pct:.1f}%≥{activate_pct}%{tier_info}, 回撤{trail_atr_mult}×ATR触发)")
|
sell_signals.append(f"🎯 跟踪止盈激活(盈+{pnl_pct:.1f}%≥{activate_pct}%{tier_info}, 保护位${trailing_stop_level:.4f})")
|
||||||
|
|
||||||
# === 触发检查:当前价跌破跟踪止盈位 → 止盈 ===
|
# === 触发检查:当前价跌破跟踪止盈位 → 止盈 ===
|
||||||
# 🔴 v1.7.8: 跟踪止盈触发时无条件覆盖(利润保护优先级最高)
|
# 🔴 v1.7.8: 跟踪止盈触发时无条件覆盖(利润保护优先级最高)
|
||||||
@ -330,6 +344,8 @@ def analyze_tracking_signals(symbol, rec, current_price):
|
|||||||
"entry_update": entry_update,
|
"entry_update": entry_update,
|
||||||
"pnl_pct": round(pnl_pct, 2),
|
"pnl_pct": round(pnl_pct, 2),
|
||||||
"trailing_stop_level": trailing_stop_level,
|
"trailing_stop_level": trailing_stop_level,
|
||||||
|
"trailing_stop_activated": trailing_stop_activated,
|
||||||
|
"trailing_stop_moved": trailing_stop_moved,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -395,6 +411,17 @@ def track_prices():
|
|||||||
)
|
)
|
||||||
final_action = state_decision.get("action_status", requested_action)
|
final_action = state_decision.get("action_status", requested_action)
|
||||||
push_trade_action_update(symbol, rec["id"], state_decision, final_action, push_type="entry")
|
push_trade_action_update(symbol, rec["id"], state_decision, final_action, push_type="entry")
|
||||||
|
if tracking_signals.get("trailing_stop_activated"):
|
||||||
|
activation_decision = dict(state_decision)
|
||||||
|
activation_decision["push_required"] = True
|
||||||
|
activation_decision["push_signals"] = tracking_signals.get("sell_signals", [])
|
||||||
|
push_trade_action_update(
|
||||||
|
symbol,
|
||||||
|
rec["id"],
|
||||||
|
activation_decision,
|
||||||
|
"移动止盈保护",
|
||||||
|
push_type="profit_protection",
|
||||||
|
)
|
||||||
|
|
||||||
results.append({
|
results.append({
|
||||||
"symbol": symbol,
|
"symbol": symbol,
|
||||||
|
|||||||
10
rules.yaml
10
rules.yaml
@ -180,6 +180,8 @@ tracker:
|
|||||||
trailing_stop:
|
trailing_stop:
|
||||||
enabled: true
|
enabled: true
|
||||||
activate_pnl_pct: 3
|
activate_pnl_pct: 3
|
||||||
|
min_lock_profit_pct: 0.5
|
||||||
|
breakeven_buffer_pct: 0.5
|
||||||
step_ratchet: true
|
step_ratchet: true
|
||||||
tiers:
|
tiers:
|
||||||
- min_pnl_pct: 0
|
- min_pnl_pct: 0
|
||||||
@ -405,11 +407,11 @@ event_driven:
|
|||||||
note: Solana meme主题扩散
|
note: Solana meme主题扩散
|
||||||
meta:
|
meta:
|
||||||
version: 1
|
version: 1
|
||||||
last_review: '2026-05-14T17:09:45.630655'
|
last_review: '2026-05-15T00:15:38.149520'
|
||||||
last_reverse_analysis: '2026-05-14T17:10:41.080069'
|
last_reverse_analysis: '2026-05-15T00:16:18.257946'
|
||||||
total_reviews: 36
|
total_reviews: 38
|
||||||
total_rules_learned: 37
|
total_rules_learned: 37
|
||||||
iteration_count: 41
|
iteration_count: 43
|
||||||
strategy_version: v1.7.11
|
strategy_version: v1.7.11
|
||||||
strategy_revision_started_at: '2026-05-09T01:20:00'
|
strategy_revision_started_at: '2026-05-09T01:20:00'
|
||||||
strategy_revision_note: 'v1.7.11: 触发时效治理,旧形态只作背景,消息触发显式标记'
|
strategy_revision_note: 'v1.7.11: 触发时效治理,旧形态只作背景,消息触发显式标记'
|
||||||
|
|||||||
@ -943,14 +943,14 @@ function historyOutcome(r) {
|
|||||||
var pnl = Number((r && r.pnl_pct) || 0);
|
var pnl = Number((r && r.pnl_pct) || 0);
|
||||||
var maxPnl = Number((r && r.max_pnl_pct) || 0);
|
var maxPnl = Number((r && r.max_pnl_pct) || 0);
|
||||||
var maxDd = Number((r && r.max_drawdown_pct) || 0);
|
var maxDd = Number((r && r.max_drawdown_pct) || 0);
|
||||||
var hitSuccess = status === 'hit_tp1' || status === 'hit_tp2' || maxPnl >= 5;
|
|
||||||
var hitFailure = status === 'stopped_out' || pnl <= -3 || maxDd <= -5;
|
var hitFailure = status === 'stopped_out' || pnl <= -3 || maxDd <= -5;
|
||||||
if (hitSuccess) {
|
|
||||||
return { resolved: true, type: 'success', pnl: maxPnl || pnl, label: '阶段兑现' };
|
|
||||||
}
|
|
||||||
if (hitFailure) {
|
if (hitFailure) {
|
||||||
return { resolved: true, type: 'failure', pnl: pnl, label: '风险边界' };
|
return { resolved: true, type: 'failure', pnl: pnl, label: '风险边界' };
|
||||||
}
|
}
|
||||||
|
var hitSuccess = status === 'hit_tp1' || status === 'hit_tp2' || maxPnl >= 5;
|
||||||
|
if (hitSuccess) {
|
||||||
|
return { resolved: true, type: 'success', pnl: maxPnl || pnl, label: '阶段兑现' };
|
||||||
|
}
|
||||||
return { resolved: false, type: 'pending', pnl: pnl, label: '跟踪中' };
|
return { resolved: false, type: 'pending', pnl: pnl, label: '跟踪中' };
|
||||||
}
|
}
|
||||||
function isResolvedHistory(r) {
|
function isResolvedHistory(r) {
|
||||||
@ -1012,7 +1012,7 @@ async function loadHistoryRecommendations(reset) {
|
|||||||
var isRiskExit = outcome.type === 'failure';
|
var isRiskExit = outcome.type === 'failure';
|
||||||
var hEntryTime = r.rec_time||'', hTpTime = (!isRiskExit && (r.status==='hit_tp1'||r.status==='hit_tp2'||Number(r.max_pnl_pct||0)>=5))?(r.hit_tp1_time||r.last_track_time||''):'';
|
var hEntryTime = r.rec_time||'', hTpTime = (!isRiskExit && (r.status==='hit_tp1'||r.status==='hit_tp2'||Number(r.max_pnl_pct||0)>=5))?(r.hit_tp1_time||r.last_track_time||''):'';
|
||||||
var hSlTime = isRiskExit ? (r.stopped_out_time||r.last_track_time||r.expired_time||'') : '';
|
var hSlTime = isRiskExit ? (r.stopped_out_time||r.last_track_time||r.expired_time||'') : '';
|
||||||
var hEntryPrice = r.entry_price||0, hSl = isRiskExit ? exitP : (r.stop_loss||0), hTp = r.tp1||0, hid = 'hkline'+idx;
|
var hEntryPrice = r.entry_price||0, hSl = isRiskExit ? exitP : (r.stop_loss||0), hTp = isRiskExit ? 0 : (r.tp1||0), hid = 'hkline'+idx;
|
||||||
var score = r.rec_score||0;
|
var score = r.rec_score||0;
|
||||||
function scoreTier(s) {
|
function scoreTier(s) {
|
||||||
if(s>=80) return{label:'强势异动',cls:'tier-strong'}; if(s>=65) return{label:'值得关注',cls:'tier-good'};
|
if(s>=80) return{label:'强势异动',cls:'tier-strong'}; if(s>=65) return{label:'值得关注',cls:'tier-good'};
|
||||||
|
|||||||
@ -97,6 +97,7 @@ class RecommendationHistoryGroupingTests(RecommendationHistoryBase):
|
|||||||
symbol='BBB/USDT',
|
symbol='BBB/USDT',
|
||||||
action_status='等回踩',
|
action_status='等回踩',
|
||||||
status='active',
|
status='active',
|
||||||
|
current_price=105.0,
|
||||||
entry_plan_json=json.dumps({'entry_action': '等回踩', 'entry_price': 100.0}, ensure_ascii=False),
|
entry_plan_json=json.dumps({'entry_action': '等回踩', 'entry_price': 100.0}, ensure_ascii=False),
|
||||||
)
|
)
|
||||||
self._insert_rec(
|
self._insert_rec(
|
||||||
@ -124,6 +125,7 @@ class RecommendationHistoryGroupingTests(RecommendationHistoryBase):
|
|||||||
symbol='CCC/USDT',
|
symbol='CCC/USDT',
|
||||||
action_status='等回踩',
|
action_status='等回踩',
|
||||||
status='active',
|
status='active',
|
||||||
|
current_price=105.0,
|
||||||
entry_plan_json=json.dumps({'entry_action': '等回踩', 'entry_price': 100.0}, ensure_ascii=False),
|
entry_plan_json=json.dumps({'entry_action': '等回踩', 'entry_price': 100.0}, ensure_ascii=False),
|
||||||
)
|
)
|
||||||
self._insert_rec(
|
self._insert_rec(
|
||||||
@ -157,6 +159,7 @@ class DecisionModeHistoryTests(RecommendationHistoryBase):
|
|||||||
symbol='WAIT/USDT',
|
symbol='WAIT/USDT',
|
||||||
action_status='等回踩',
|
action_status='等回踩',
|
||||||
status='active',
|
status='active',
|
||||||
|
current_price=105.0,
|
||||||
entry_plan_json=json.dumps({'entry_action': '等回踩', 'entry_price': 100.0}, ensure_ascii=False),
|
entry_plan_json=json.dumps({'entry_action': '等回踩', 'entry_price': 100.0}, ensure_ascii=False),
|
||||||
)
|
)
|
||||||
self._insert_rec(
|
self._insert_rec(
|
||||||
@ -176,6 +179,45 @@ class DecisionModeHistoryTests(RecommendationHistoryBase):
|
|||||||
self.assertEqual(mapping['TP/USDT'], 'completed')
|
self.assertEqual(mapping['TP/USDT'], 'completed')
|
||||||
self.assertEqual(mapping['STOP/USDT'], 'invalid')
|
self.assertEqual(mapping['STOP/USDT'], 'invalid')
|
||||||
|
|
||||||
|
def test_stopped_out_with_prior_float_profit_stays_failed_in_history_summary(self):
|
||||||
|
self._insert_rec(
|
||||||
|
symbol='MLN/USDT',
|
||||||
|
action_status='止损',
|
||||||
|
status='stopped_out',
|
||||||
|
entry_price=3.61,
|
||||||
|
current_price=3.12,
|
||||||
|
max_price=3.80,
|
||||||
|
min_price=3.12,
|
||||||
|
pnl_pct=-13.57,
|
||||||
|
max_pnl_pct=5.26,
|
||||||
|
max_drawdown_pct=-13.57,
|
||||||
|
stopped_out_time='2026-05-14T21:21:07',
|
||||||
|
last_track_time='2026-05-14T21:21:07',
|
||||||
|
)
|
||||||
|
|
||||||
|
result, label = altcoin_db._classify_recommendation_result({
|
||||||
|
'symbol': 'MLN/USDT',
|
||||||
|
'status': 'stopped_out',
|
||||||
|
'action_status': '止损',
|
||||||
|
'entry_price': 3.61,
|
||||||
|
'current_price': 3.12,
|
||||||
|
'pnl_pct': -13.57,
|
||||||
|
'max_pnl_pct': 5.26,
|
||||||
|
'max_drawdown_pct': -13.57,
|
||||||
|
})
|
||||||
|
self.assertEqual(result, 'failed')
|
||||||
|
self.assertIn('止损', label)
|
||||||
|
|
||||||
|
page = altcoin_db.get_all_recommendations(limit=20, decision_only=True, with_meta=True)
|
||||||
|
item = page['items'][0]
|
||||||
|
self.assertEqual(item['symbol'], 'MLN/USDT')
|
||||||
|
self.assertEqual(item['recommendation_result'], 'failed')
|
||||||
|
self.assertEqual(item['execution_status'], 'invalid')
|
||||||
|
self.assertEqual(page['summary']['success_count'], 0)
|
||||||
|
self.assertEqual(page['summary']['failure_count'], 1)
|
||||||
|
self.assertAlmostEqual(page['summary']['total_pnl'], -13.57, places=2)
|
||||||
|
self.assertAlmostEqual(page['summary']['best_pnl'], -13.57, places=2)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
54
tests/test_price_tracker_trailing_stop.py
Normal file
54
tests/test_price_tracker_trailing_stop.py
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
PROJECT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
if PROJECT_DIR not in sys.path:
|
||||||
|
sys.path.insert(0, PROJECT_DIR)
|
||||||
|
|
||||||
|
from app.services import price_tracker
|
||||||
|
|
||||||
|
|
||||||
|
def test_trailing_stop_activation_uses_profit_floor_when_atr_is_too_wide(monkeypatch):
|
||||||
|
"""MLN-style case: +5% float profit with huge ATR must still protect above entry."""
|
||||||
|
monkeypatch.setattr(price_tracker, "calc_atr", lambda df, period=14: 0.447143)
|
||||||
|
monkeypatch.setattr(price_tracker, "detect_trend_exhaustion", lambda df, atr: {"exhausted": False, "signals": [], "severity": "low"})
|
||||||
|
monkeypatch.setattr(price_tracker, "full_pa_analysis", lambda df, timeframe: {"candles_class": []})
|
||||||
|
monkeypatch.setattr(price_tracker, "load_rules", lambda: {
|
||||||
|
"tracker": {
|
||||||
|
"trailing_stop": {
|
||||||
|
"enabled": True,
|
||||||
|
"activate_pnl_pct": 3,
|
||||||
|
"min_lock_profit_pct": 0.5,
|
||||||
|
"breakeven_buffer_pct": 0.5,
|
||||||
|
"tiers": [
|
||||||
|
{"min_pnl_pct": 0, "atr_mult": 3.0, "label": "防震"},
|
||||||
|
{"min_pnl_pct": 5, "atr_mult": 2.0, "label": "锁利"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
df = pd.DataFrame(
|
||||||
|
[
|
||||||
|
{"open": 3.5, "high": 3.8, "low": 3.4, "close": 3.7, "volume": 100},
|
||||||
|
{"open": 3.7, "high": 3.9, "low": 3.6, "close": 3.8, "volume": 120},
|
||||||
|
]
|
||||||
|
* 20
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(price_tracker, "fetch_klines", lambda symbol, timeframe, limit=100: df)
|
||||||
|
|
||||||
|
rec = {
|
||||||
|
"entry_price": 3.61,
|
||||||
|
"stop_loss": 3.249,
|
||||||
|
"tp1": 4.822857,
|
||||||
|
"tp2": 5.631429,
|
||||||
|
"entry_plan": {"trailing_stop_level": 0.0},
|
||||||
|
}
|
||||||
|
|
||||||
|
result = price_tracker.analyze_tracking_signals("MLN/USDT", rec, 3.8)
|
||||||
|
|
||||||
|
assert result["trailing_stop_activated"] is True
|
||||||
|
assert result["trailing_stop_level"] == max(3.249, 3.61 * 1.005)
|
||||||
|
assert result["trailing_stop_level"] > rec["entry_price"]
|
||||||
|
assert any("跟踪止盈激活" in signal for signal in result["sell_signals"])
|
||||||
Loading…
Reference in New Issue
Block a user