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
|
||||
summary = None
|
||||
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 with_meta:
|
||||
@ -193,15 +200,21 @@ def get_all_recommendations(limit=50, decision_only=False, version="", offset=0,
|
||||
"""
|
||||
SELECT
|
||||
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 status='stopped_out' OR COALESCE(pnl_pct,0) <= -3 OR COALESCE(max_drawdown_pct,0) <= -5 THEN 1 ELSE 0 END) AS failure_count,
|
||||
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)
|
||||
WHEN status='stopped_out' OR COALESCE(pnl_pct,0) <= -3 OR COALESCE(max_drawdown_pct,0) <= -5 THEN COALESCE(pnl_pct,0)
|
||||
ELSE 0 END) AS total_pnl,
|
||||
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)
|
||||
WHEN status='stopped_out' OR COALESCE(pnl_pct,0) <= -3 OR COALESCE(max_drawdown_pct,0) <= -5 THEN COALESCE(pnl_pct,0)
|
||||
ELSE 0 END) AS best_pnl,
|
||||
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
|
||||
SUM(CASE WHEN """
|
||||
+ success_case
|
||||
+ """ THEN 1 ELSE 0 END) AS success_count,
|
||||
SUM(CASE WHEN """
|
||||
+ failure_case
|
||||
+ """ THEN 1 ELSE 0 END) AS failure_count,
|
||||
SUM("""
|
||||
+ realized_pnl_case
|
||||
+ """) 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 (
|
||||
SELECT r.*
|
||||
FROM recommendation r
|
||||
|
||||
@ -1,9 +1,31 @@
|
||||
"""SQLite-backed scheduler configuration and runtime state."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
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 = [
|
||||
@ -212,7 +234,7 @@ def _seed_scheduler_tables(conn):
|
||||
|
||||
|
||||
def init_scheduler_tables():
|
||||
conn = get_conn()
|
||||
conn = get_scheduler_conn()
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS scheduler_job_config (
|
||||
@ -284,7 +306,7 @@ def init_scheduler_tables():
|
||||
|
||||
def get_job_configs():
|
||||
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()
|
||||
conn.close()
|
||||
jobs = []
|
||||
@ -298,7 +320,7 @@ def get_job_configs():
|
||||
|
||||
def get_job_config(job_name):
|
||||
init_scheduler_tables()
|
||||
conn = get_conn()
|
||||
conn = get_scheduler_conn()
|
||||
row = conn.execute("SELECT * FROM scheduler_job_config WHERE job_name=?", (job_name,)).fetchone()
|
||||
conn.close()
|
||||
if not row:
|
||||
@ -312,7 +334,7 @@ def get_job_config(job_name):
|
||||
def set_job_enabled(job_name, enabled):
|
||||
init_scheduler_tables()
|
||||
now = _now()
|
||||
conn = get_conn()
|
||||
conn = get_scheduler_conn()
|
||||
cur = conn.execute(
|
||||
"UPDATE scheduler_job_config SET enabled=?, updated_at=? WHERE 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))
|
||||
init_scheduler_tables()
|
||||
now = _now()
|
||||
conn = get_conn()
|
||||
conn = get_scheduler_conn()
|
||||
cur = conn.execute(
|
||||
"UPDATE scheduler_job_config SET every_seconds=?, updated_at=? WHERE 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["updated_at"] = _now()
|
||||
conn = get_conn()
|
||||
conn = get_scheduler_conn()
|
||||
try:
|
||||
conn.execute(
|
||||
"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()
|
||||
if not get_job_config(job_name):
|
||||
return None
|
||||
conn = get_conn()
|
||||
conn = get_scheduler_conn()
|
||||
cur = conn.execute(
|
||||
"""
|
||||
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):
|
||||
init_scheduler_tables()
|
||||
conn = get_conn()
|
||||
conn = get_scheduler_conn()
|
||||
rows = conn.execute(
|
||||
"""
|
||||
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}
|
||||
if not values:
|
||||
return
|
||||
conn = get_conn()
|
||||
conn = get_scheduler_conn()
|
||||
assignments = ", ".join([f"{k}=?" for k in values])
|
||||
conn.execute(
|
||||
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):
|
||||
init_scheduler_tables()
|
||||
limit = max(1, min(int(limit or 30), 100))
|
||||
conn = get_conn()
|
||||
conn = get_scheduler_conn()
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM scheduler_manual_trigger ORDER BY requested_at DESC, id DESC LIMIT ?",
|
||||
(limit,),
|
||||
@ -435,21 +457,26 @@ def list_manual_triggers(limit=30):
|
||||
|
||||
def get_scheduler_overview():
|
||||
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()
|
||||
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()
|
||||
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}
|
||||
latest = {row["job_name"]: dict(row) for row in latest_rows}
|
||||
jobs = []
|
||||
@ -482,6 +509,7 @@ __all__ = [
|
||||
"get_job_config",
|
||||
"get_job_configs",
|
||||
"get_scheduler_overview",
|
||||
"get_scheduler_conn",
|
||||
"init_scheduler_tables",
|
||||
"list_manual_triggers",
|
||||
"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):
|
||||
"""只构建交易执行卡片,不做冷却判断或落库。"""
|
||||
if action_status not in ("可即刻买入", "跟踪止盈"):
|
||||
if action_status not in ("可即刻买入", "跟踪止盈", "移动止盈保护"):
|
||||
print(f"[飞书跳过] {symbol} {action_status} — 用户要求止盈/止损/衰减不推送,只在网站展示")
|
||||
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: 跟踪止盈用独立的醒目卡片
|
||||
if action_status == "跟踪止盈":
|
||||
coin = symbol.replace("/USDT", "")
|
||||
|
||||
@ -155,6 +155,8 @@ def analyze_tracking_signals(symbol, rec, current_price):
|
||||
rules = load_rules()
|
||||
trail_cfg = rules.get("tracker", {}).get("trailing_stop", {})
|
||||
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:
|
||||
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%)
|
||||
if pnl_pct >= activate_pct:
|
||||
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:
|
||||
# 已有跟踪位 → 只上移不下移
|
||||
old_trail = trailing_stop_level
|
||||
trailing_stop_level = max(trailing_stop_level, new_trail)
|
||||
trailing_stop_moved = trailing_stop_level > old_trail + 1e-12
|
||||
else:
|
||||
# 首次激活
|
||||
trailing_stop_level = new_trail
|
||||
trailing_stop_activated = True
|
||||
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: 跟踪止盈触发时无条件覆盖(利润保护优先级最高)
|
||||
@ -330,6 +344,8 @@ def analyze_tracking_signals(symbol, rec, current_price):
|
||||
"entry_update": entry_update,
|
||||
"pnl_pct": round(pnl_pct, 2),
|
||||
"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)
|
||||
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({
|
||||
"symbol": symbol,
|
||||
|
||||
10
rules.yaml
10
rules.yaml
@ -180,6 +180,8 @@ tracker:
|
||||
trailing_stop:
|
||||
enabled: true
|
||||
activate_pnl_pct: 3
|
||||
min_lock_profit_pct: 0.5
|
||||
breakeven_buffer_pct: 0.5
|
||||
step_ratchet: true
|
||||
tiers:
|
||||
- min_pnl_pct: 0
|
||||
@ -405,11 +407,11 @@ event_driven:
|
||||
note: Solana meme主题扩散
|
||||
meta:
|
||||
version: 1
|
||||
last_review: '2026-05-14T17:09:45.630655'
|
||||
last_reverse_analysis: '2026-05-14T17:10:41.080069'
|
||||
total_reviews: 36
|
||||
last_review: '2026-05-15T00:15:38.149520'
|
||||
last_reverse_analysis: '2026-05-15T00:16:18.257946'
|
||||
total_reviews: 38
|
||||
total_rules_learned: 37
|
||||
iteration_count: 41
|
||||
iteration_count: 43
|
||||
strategy_version: v1.7.11
|
||||
strategy_revision_started_at: '2026-05-09T01:20:00'
|
||||
strategy_revision_note: 'v1.7.11: 触发时效治理,旧形态只作背景,消息触发显式标记'
|
||||
|
||||
@ -943,14 +943,14 @@ function historyOutcome(r) {
|
||||
var pnl = Number((r && r.pnl_pct) || 0);
|
||||
var maxPnl = Number((r && r.max_pnl_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;
|
||||
if (hitSuccess) {
|
||||
return { resolved: true, type: 'success', pnl: maxPnl || pnl, label: '阶段兑现' };
|
||||
}
|
||||
if (hitFailure) {
|
||||
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: '跟踪中' };
|
||||
}
|
||||
function isResolvedHistory(r) {
|
||||
@ -1012,7 +1012,7 @@ async function loadHistoryRecommendations(reset) {
|
||||
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 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;
|
||||
function scoreTier(s) {
|
||||
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',
|
||||
action_status='等回踩',
|
||||
status='active',
|
||||
current_price=105.0,
|
||||
entry_plan_json=json.dumps({'entry_action': '等回踩', 'entry_price': 100.0}, ensure_ascii=False),
|
||||
)
|
||||
self._insert_rec(
|
||||
@ -124,6 +125,7 @@ class RecommendationHistoryGroupingTests(RecommendationHistoryBase):
|
||||
symbol='CCC/USDT',
|
||||
action_status='等回踩',
|
||||
status='active',
|
||||
current_price=105.0,
|
||||
entry_plan_json=json.dumps({'entry_action': '等回踩', 'entry_price': 100.0}, ensure_ascii=False),
|
||||
)
|
||||
self._insert_rec(
|
||||
@ -157,6 +159,7 @@ class DecisionModeHistoryTests(RecommendationHistoryBase):
|
||||
symbol='WAIT/USDT',
|
||||
action_status='等回踩',
|
||||
status='active',
|
||||
current_price=105.0,
|
||||
entry_plan_json=json.dumps({'entry_action': '等回踩', 'entry_price': 100.0}, ensure_ascii=False),
|
||||
)
|
||||
self._insert_rec(
|
||||
@ -176,6 +179,45 @@ class DecisionModeHistoryTests(RecommendationHistoryBase):
|
||||
self.assertEqual(mapping['TP/USDT'], 'completed')
|
||||
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__':
|
||||
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