diff --git a/app/db/analytics.py b/app/db/analytics.py index 3b88de8..4ed9012 100644 --- a/app/db/analytics.py +++ b/app/db/analytics.py @@ -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 diff --git a/app/db/scheduler_db.py b/app/db/scheduler_db.py index 6b67436..49a7805 100644 --- a/app/db/scheduler_db.py +++ b/app/db/scheduler_db.py @@ -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", diff --git a/app/integrations/feishu_push.py b/app/integrations/feishu_push.py index 17fc0fd..152a964 100644 --- a/app/integrations/feishu_push.py +++ b/app/integrations/feishu_push.py @@ -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", "") diff --git a/app/services/price_tracker.py b/app/services/price_tracker.py index de15a95..f4ec7bb 100644 --- a/app/services/price_tracker.py +++ b/app/services/price_tracker.py @@ -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, diff --git a/rules.yaml b/rules.yaml index cc96fa9..b339c41 100644 --- a/rules.yaml +++ b/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: 触发时效治理,旧形态只作背景,消息触发显式标记' diff --git a/static/app.html b/static/app.html index 8c9be02..cb25e53 100644 --- a/static/app.html +++ b/static/app.html @@ -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'}; diff --git a/tests/test_history_grouping.py b/tests/test_history_grouping.py index 7962ec1..c7cf72c 100644 --- a/tests/test_history_grouping.py +++ b/tests/test_history_grouping.py @@ -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() diff --git a/tests/test_price_tracker_trailing_stop.py b/tests/test_price_tracker_trailing_stop.py new file mode 100644 index 0000000..edb81f5 --- /dev/null +++ b/tests/test_price_tracker_trailing_stop.py @@ -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"])