This commit is contained in:
aaron 2026-05-15 00:23:04 +08:00
parent 188eb6015a
commit d2e2032352
8 changed files with 232 additions and 43 deletions

View File

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

View File

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

View File

@ -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", "")

View File

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

View File

@ -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: 触发时效治理,旧形态只作背景,消息触发显式标记'

View File

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

View File

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

View 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"])