Repository navigation
Expand file tree
/
Copy pathmain.py
More file actions
1763 lines (1574 loc) · 87.1 KB
/
Copy pathmain.py
File metadata and controls
1763 lines (1574 loc) · 87.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""
自动交易机器人 — 主入口。
流水线:
Telegram 监听 (Telethon)
↓
DeepSeek 解析 → 标准化信号
↓
风控检查 → 仓位计算
↓
CCXT 执行入场 (OKX) → 挂止损 → 挂分批止盈
↓
订单管理循环:追踪入场成交、止损更新、止盈检查
"""
from __future__ import annotations
import asyncio
import signal as unix_signal
import os
import time
from datetime import datetime, timezone, timedelta
from utils.time import ensure_utc, dt_seconds_ago
from dotenv import load_dotenv
from loguru import logger
from database.db import init_db, get_session, close_db
from database.models import Trade, Order, SignalLog
from telegram_engine.listener import TelegramListener
from signal_engine.parser import SignalParser
from signal_engine.groups import SIGNAL_GROUPS, get_group_full_names
from exchange_engine import exchange as ex
from exchange_engine.trade_executor import execute_entry, risk_check, calculate_position_size, execute_trade_exit
from exchange_engine.order_manager import check_entry_order, post_entry_setup
from exchange_engine.signal_updater import apply_update_signal, apply_close_signal, apply_cancel_signal
from exit.manager import ExitManager
# ———————— 核心模块(生命周期 / 状态 / 任务管理)————————
from core.states import (
ServiceRegistry, ModuleState,
MOD_EXCHANGE, MOD_TELEGRAM,
MOD_ORDER_MONITOR, MOD_SIGNAL_CONSUMER,
)
from core.task_manager import TaskManager
from core.exchange_supervisor import ExchangeSupervisor
from core.lifecycle import AppLifecycle
from core.deps_check import check_all_dependencies
from core.trade_lock import trade_lock_manager
from core.transaction import atomic_transaction
load_dotenv(override=True) # 强制使用 .env 文件覆盖系统环境变量
# ———————— 代理设置(国内访问交易所必需)————
_proxy = os.getenv("EXCHANGE_PROXY", "").strip()
if _proxy:
os.environ["HTTP_PROXY"] = _proxy
os.environ["HTTPS_PROXY"] = _proxy
# ———————— 协调器节奏控制 ————————
# v5: Single 600s cycle. No more dual quick/heavy cycles.
# Reconcile is a health check, NOT a continuous repair loop.
_last_reconcile_time: float = 0.0 # 600s 低频健康检查
_last_reconcile_log_time: float = 0.0 # 3600s 日志
# ———————— 看门狗心跳状态 ————————
_HEARTBEAT: dict = {
"consumer_last_tick": 0.0,
"consumer_last_signal": 0.0,
"monitor_last_tick": 0.0,
}
_last_error_log: dict = {"time": 0.0, "msg": ""} # 错误日志去重:同一消息 30s 内不重复打印
_cleaned_symbols: set[str] = set() # 已清理残余订单的币种列表,避免重复请求
# ———————— 日志 ————————
def setup_logging():
os.makedirs("user_data/logs", exist_ok=True)
logger.remove()
logger.add("user_data/logs/bot_{time:YYYY-MM-DD}.log", level="INFO",
rotation="50 MB", retention="14 days", compression="zip",
encoding="utf-8", enqueue=True, backtrace=True, diagnose=False)
# 控制台输出 — Windows 下强制 UTF-8
import sys, logging as std_logging
if sys.platform == "win32":
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
logger.add(lambda msg: print(msg), level="INFO",
format="<green>{time:HH:mm:ss}</green> | <level>{level:<8}</level> | <level>{message}</level>")
# 屏蔽 SQLAlchemy / 数据库相关日志噪音
for name in ("sqlalchemy", "sqlalchemy.engine", "sqlalchemy.pool", "sqlalchemy.orm"):
std_logging.getLogger(name).setLevel(std_logging.ERROR)
# ———————— 启动自检 ————————
def _startup_self_check() -> bool:
"""
P0-1: 启动前自检 — 验证当前 main.py 及其他核心模块可被 Python 正常编译。
防止:磁盘源码更新后运维忘记重启服务,进程仍执行旧代码。
返回 True 表示编译通过,False 表示存在语法错误(拒绝启动)。
"""
import py_compile
import sys as _sys
_core_files = [
"main.py",
"database/models.py",
"core/reconciler.py",
"core/protection_creator.py",
"exchange_engine/exchange.py",
"exchange_engine/trade_executor.py",
"exit/manager.py",
"exit/protection.py",
]
_base = os.path.dirname(__file__)
for _fname in _core_files:
_path = os.path.join(_base, _fname)
if not os.path.exists(_path):
logger.warning(f"[启动自检] 跳过不存在的文件: {_fname}")
continue
try:
py_compile.compile(_path, doraise=True)
except py_compile.PyCompileError as e:
logger.critical(f"[启动自检] {_fname} 语法错误,拒绝启动: {e}")
return False
logger.success(f"[启动自检] {len(_core_files)} 个核心文件编译通过")
return True
# ———————— 订单监控循环 ————————
async def order_monitor(shutdown_event: asyncio.Event, exit_manager: ExitManager):
"""
订单监控循环 — 使用 ExitManager 统一退出决策。
每 5 秒检查一次所有 open trade(OKX):
1. 入场单是否已成交
2. 入场首次成交 → 挂止损止盈
3. ExitManager 链式检查 → 按优先级只有一个退出模块激活
状态门控:交易所未连接时自动暂停,恢复后自动继续。
"""
from core.exchange_runtime import runtime
logger.info("订单监控已启动 (5s 间隔, ExitManager 模式)")
ServiceRegistry.set_state(MOD_ORDER_MONITOR, ModuleState.RUNNING)
while not shutdown_event.is_set():
try:
await asyncio.wait_for(shutdown_event.wait(), timeout=5.0)
except asyncio.TimeoutError:
pass
if shutdown_event.is_set():
break
# ———— 状态门控:交易所未连接时暂停监控 ————
if not ServiceRegistry.can_trade():
if ServiceRegistry.get_state(MOD_ORDER_MONITOR) != ModuleState.PAUSED:
logger.warning("[订单监控] 交易所未连接,暂停监控(等待重连...)")
ServiceRegistry.set_state(MOD_ORDER_MONITOR, ModuleState.PAUSED)
continue
# ———— 后台熔断监控:双通道权益校验 ————
# Channel 1 (WS): 每 5s 同步评估 BalanceTracker 权益
# Channel 2 (REST): 每 60s 主动调用 OKX REST 查询权益(绕过缓存)
# 任一通道触发 → 设 _tripped flag → 开仓路径拦截
# 开仓路径只读 is_tripped,不在此处重复计算
try:
from core.daily_loss_breaker import daily_loss_breaker
# Channel 1: WS 通道 — 每 5s 轻量同步评估
daily_loss_breaker.evaluate()
# Channel 2: REST 通道 — 每 60s 主动 REST 查询,双重保障
if daily_loss_breaker.needs_rest_evaluation():
await daily_loss_breaker.evaluate_rest()
except Exception:
pass
# ———— BugFix: Runtime READY gate ————
# Coordinator/Repair/Protection must NOT run before Runtime is fully initialized
if not runtime.is_ready:
if ServiceRegistry.get_state(MOD_ORDER_MONITOR) != ModuleState.PAUSED:
logger.warning("[订单监控] ExchangeRuntime 尚未 READY,等待初始化完成...")
ServiceRegistry.set_state(MOD_ORDER_MONITOR, ModuleState.PAUSED)
continue
# 交易所已恢复 → 标记运行
if ServiceRegistry.get_state(MOD_ORDER_MONITOR) == ModuleState.PAUSED:
logger.success("[订单监控] 交易所已恢复,恢复监控")
ServiceRegistry.set_state(MOD_ORDER_MONITOR, ModuleState.RUNNING)
session = None
try:
session = get_session()
active_trades = Trade.get_active_trades(session)
# --- Step 0a: position sync + state reconciliation (v5: 600s health check)
try:
from core.reconciler import reconcile
global _last_reconcile_time, _last_reconcile_log_time
now_t = time.time()
# v5: Single 600s low-frequency health check
if now_t - _last_reconcile_time >= 600:
stats = await reconcile(session)
from diagnostics.telemetry import observe
observe("reconciliation", "NORMAL" if stats.get("status") == "ok" else "DEGRADED", result=stats)
_last_reconcile_time = now_t
else:
stats = {}
# 日志输出:有修复立即打,无修复每小时打一次
total = sum(v for v in stats.values() if isinstance(v, int))
if total > 0:
detail = " | ".join(f"{k}={v}" for k, v in stats.items() if v)
logger.info(f"协调器健康检查 {total} 项: {detail}")
elif now_t - _last_reconcile_log_time >= 3600:
logger.info(f"协调器健康检查完成,无异常")
_last_reconcile_log_time = now_t
except Exception as e:
logger.warning(f"[监控-协调] 状态协调异常: {e}")
from diagnostics.telemetry import observe
observe("reconciliation", "FAILED", error_type=type(e).__name__)
# 同步后重新查询活跃 trade(部分可能已被同步标记为关闭)
active_trades = Trade.get_active_trades(session)
# Batch REST position fetch once per order_monitor cycle (not per-trade)
_okx_positions_cache = None
async def _get_okx_positions():
nonlocal _okx_positions_cache
if _okx_positions_cache is None:
try:
from exchange_engine.exchange import fetch_positions
_okx_positions_cache = await fetch_positions(exchange="okx")
except Exception as exc:
from diagnostics.telemetry import observe
observe("rest_positions", "UNKNOWN", error_type=type(exc).__name__)
raise # Failed query is not evidence of zero positions.
return _okx_positions_cache
for trade in active_trades:
trade_ex = trade.exchange or "okx"
# P1-1: 获取 Trade 锁(细粒度锁,按 trade.id)
# 整个 trade 处理逻辑都在锁的保护范围内
trade_lock = await trade_lock_manager.acquire(trade.id)
async with trade_lock:
# --- Step 0: cleanup residual algo orders (fallback)
# Primary: cleanup_trade_orders() called from execute_trade_exit().
# Fallback: handle positions closed externally (e.g., OKX web).
#
# ⚠️ REST-first: use OKX REST (source of truth) to check position.
# Batch-fetched once per order_monitor cycle, NOT per trade.
# GUARD: only cleanup for trades marked closed or position_state=closed.
# Open positions should not have their SL/TP orders cancelled.
try:
if trade.position_state == "closed" or not trade.is_open:
if trade.pair in _cleaned_symbols:
_pos = runtime.get_position(trade.pair)
if _pos and _pos.get("contracts", 0) > 0:
_cleaned_symbols.discard(trade.pair)
else:
_okx_positions = await _get_okx_positions()
_has_position = False
for _p in _okx_positions:
if _p.get("symbol", "") == trade.pair and float(_p.get("contracts", 0)) > 0:
_has_position = True
break
if not _has_position:
logger.warning(f"[OrderMonitor] Step0 REST: {trade.pair} 仓位不存在 (OKX返回{len(_okx_positions)}笔), 清理残留订单")
from exit.protection import cleanup_trade_orders
await cleanup_trade_orders(trade, session=session, exchange=trade_ex)
_cleaned_symbols.add(trade.pair)
else:
logger.debug(f"[OrderMonitor] Step0 REST: {trade.pair} 仓位确认存在, 跳过清理")
except Exception as _sce:
logger.warning(
f"[OrderMonitor] {trade.pair} "
f"cleanup exception: {_sce}"
)
# ——— Step 0.5: 交易所侧平仓实时检测 (≤5s 完整记账) ———
# 挂单的 SL/TP 算法单在 OKX 触发成交时本地没有成交事件, 此前只能等
# reconcile(10min) 标记关闭且不写 close_rate/盈亏, 价格要等午夜修复回填。
# 此处当 tick 检测: WS 无仓位 → REST 复核(真相源) → bookkeep_external_close
# 完整记账(close_rate/盈亏/CloseHistory + _pnl_estimated 标记)。
try:
_s05_state = getattr(trade, 'position_state', None) or "pending_entry"
if (trade.is_open and (trade.amount or 0) > 0
and _s05_state in ("open", "tp1_filled", "partial_tp")):
_ws_pos = runtime.get_position(trade.pair)
_ws_contracts = _ws_pos.get("contracts", 0) if _ws_pos else 0
if _ws_contracts <= 0:
# REST 复核: WS 空缓存/刚开仓事件未推送时不能误关
from exchange_engine.trade_executor import _normalize_pair
_sym_norm = _normalize_pair(trade.pair)
_rest_gone = False
try:
_rest_positions = await ex.fetch_positions(exchange=trade_ex)
_rest_gone = not any(
_normalize_pair(str(_p.get("symbol", ""))) == _sym_norm
and float(_p.get("contracts", 0) or 0) > 0
for _p in _rest_positions
)
except Exception as _pe:
logger.warning(f"[监控-Step0.5] {trade_ex}:{trade.pair} REST复核失败: {_pe}, 等下轮")
if _rest_gone:
# 分批入场保护: 有待成交的限价入场单 → 保持等待(镜像 position_sync)
from core.reconciler import _is_entry_order, _is_entry_order_stale
_has_pending_entry = any(
o.ft_is_open and _is_entry_order(o)
and not _is_entry_order_stale(o)
for o in (trade.orders or [])
)
if _has_pending_entry:
logger.info(
f"[监控-Step0.5] {trade_ex}:{trade.pair} "
f"OKX无仓位但有未过期入场单, 保持等待"
)
else:
from core.reconciler import bookkeep_external_close
if await bookkeep_external_close(session, trade):
logger.warning(
f"[监控-Step0.5] {trade_ex}:{trade.pair} "
f"OKX仓位已归零(交易所侧平仓) → 实时完整记账"
)
continue # 跳过本 tick 其余处理
except Exception as _s05e:
logger.warning(f"[监控-Step0.5] {trade_ex}:{trade.pair} 检测异常: {_s05e}")
# --- Step 1: entry order fill check ———
try:
entry_filled = await check_entry_order(trade, session)
except Exception as e:
logger.error(f"[监控-Step1] {trade_ex}:{trade.pair} {e}")
continue
# ——— Step 2: 入场首次成交 → 挂止损止盈 (v5: event-driven, once only) ———
try:
meta = trade.signal_meta or {}
setup_done = meta.get("_setup_done", False)
if entry_filled and not trade.has_open_orders and not setup_done:
from core.reconciler import ensure_protection
await ensure_protection(trade, session)
# _setup_done 由 ensure_protection 内部按实际结果设置
# 只有 SL+TP 都成功创建(或确认已存在)才会置位
# 失败时不置位,允许下次轮询或 EntryFillHandler 重试
# 记录 TP1 初始创建时的仓位(用于分批挂单 TP1 重算)
if meta.get("entry_strategy") == "limit_range":
okx_pos = runtime.get_position(trade.pair)
okx_contracts = okx_pos["contracts"] if okx_pos else 0
if okx_contracts > 0:
meta["_tp1_setup_amount"] = okx_contracts
trade.signal_meta = meta
except Exception as e:
logger.error(f"[监控-Step2] {trade_ex}:{trade.pair} {e}")
# ——— Step 2.5: pending_entry → open (timeout recovery, no repair queue) ———
if trade.position_state == "pending_entry" and trade.amount > 0:
open_time = trade.open_date or trade.open_date
if open_time:
elapsed = (datetime.now(timezone.utc) - ensure_utc(open_time)).total_seconds()
if elapsed > 60:
logger.warning(f"[{trade_ex}] {trade.pair} pending_entry 超时 ({elapsed:.0f}s),状态转换")
pos = runtime.get_position(trade.pair)
if pos and pos["contracts"] > 0:
# v5: Event-driven — ensure protection ONCE, no repair queue
async with atomic_transaction(session, "pending_entry → open (超时恢复)"):
trade.position_state = "open"
if not trade.opened_at:
trade.opened_at = datetime.now(timezone.utc)
# Ensure protection immediately
try:
from core.reconciler import ensure_protection
await ensure_protection(trade, session)
logger.info(f"[{trade_ex}] {trade.pair} pending_entry → open + protection ensured")
except Exception as e:
logger.error(f"[监控-恢复] {trade_ex}:{trade.pair} ensure_protection 失败: {e}")
else:
# No position — just transition state
async with atomic_transaction(session, "pending_entry → open (无仓位)"):
trade.position_state = "open"
logger.info(f"[{trade_ex}] {trade.pair} pending_entry → open (无仓位)")
# ——— Step 2.6: TP1 价格检查(基于开仓价)—————
# 仅 OPEN 状态下运行一次。当价格达到开仓价的 +2%(多头)或 -2%(空头)
# 时触发:状态 → tp1_filled,SL → 保本价。
# ⚠️ 交易所原始 TP1 算法单负责实际平仓 30%,此处仅做状态转换。
# ⚠️ 使用 open_rate(开仓价)计算利润,不使用仓位比例。
if trade.position_state == "open" and trade.open_rate > 0:
try:
okx_pos = runtime.get_position(trade.pair)
okx_contracts = okx_pos["contracts"] if okx_pos else 0
if okx_contracts <= 0:
continue
# 从 Runtime 获取最新价格
ticker = await runtime.fetch_ticker(trade.pair)
current_price = ticker.get("last", 0)
if current_price <= 0:
continue
# 以开仓价计算利润
if trade.is_short:
profit_pct = (trade.open_rate - current_price) / trade.open_rate
else:
profit_pct = (current_price - trade.open_rate) / trade.open_rate
from core.protection_targets import teacher_tp_prices, teacher_tp_stage_confirmed
teacher_tps = teacher_tp_prices(trade)
if teacher_tps:
tp1_reached = teacher_tp_stage_confirmed(trade, 1, okx_contracts)
tp1_target = abs(teacher_tps[0] - trade.open_rate) / trade.open_rate
else:
from core.config_loader import load_config as _ld_cfg1
tp1_target = float(_ld_cfg1().get("risk", {}).get("tp1", {}).get("profit_pct", 0.03))
tp1_reached = profit_pct >= tp1_target
if tp1_reached:
logger.info(f"[TP1检查] {trade_ex}:{trade.pair} "
f"开仓价={trade.open_rate:.4f} 当前价={current_price:.4f} "
f"利润={profit_pct*100:.2f}% ≥ {tp1_target*100:.1f}% → TP1触发")
async with atomic_transaction(session, "open → tp1_filled (TP1价格检查)"):
trade.position_state = "tp1_filled"
trade.amount = okx_contracts
trade.tp1_filled_at = datetime.now(timezone.utc)
# SL → 保本价(交易所原始SL继续有效)
if trade.open_rate > 0:
trade.stop_loss = trade.open_rate
logger.info(f"[TP1检查] {trade_ex}:{trade.pair} → tp1_filled, SL保本={trade.open_rate}")
except Exception as e:
logger.warning(f"[TP1检查] {trade_ex}:{trade.pair} 检查异常: {e}")
# ——— Step 2.7: TP2 价格检查(基于开仓价)—————
# 仅 TP1_FILLED 状态下运行一次。当价格达到开仓价的 +4%(多头)或 -4%(空头)
# 时触发:状态 → partial_tp,激活 Trailing。
# ⚠️ 使用 open_rate(开仓价)计算利润,不使用仓位比例。
if trade.position_state == "tp1_filled" and trade.open_rate > 0:
try:
okx_pos = runtime.get_position(trade.pair)
okx_contracts = okx_pos["contracts"] if okx_pos else 0
if okx_contracts <= 0:
continue
# 从 Runtime 获取最新价格
ticker = await runtime.fetch_ticker(trade.pair)
current_price = ticker.get("last", 0)
if current_price <= 0:
continue
# 以开仓价计算利润
if trade.is_short:
profit_pct = (trade.open_rate - current_price) / trade.open_rate
else:
profit_pct = (current_price - trade.open_rate) / trade.open_rate
from core.protection_targets import teacher_tp_prices, tp_price_for, teacher_tp_stage_confirmed
teacher_tps = teacher_tp_prices(trade)
if teacher_tps:
tp2_reached = teacher_tp_stage_confirmed(trade, 2, okx_contracts)
teacher_tp2 = tp_price_for(trade, 2)
tp2_target = abs(teacher_tp2 - trade.open_rate) / trade.open_rate if teacher_tp2 else 0
else:
from core.config_loader import load_config as _ld_cfg2
tp2_target = float(_ld_cfg2().get("risk", {}).get("tp2", {}).get("profit_pct", 0.06))
tp2_reached = profit_pct >= tp2_target
if tp2_reached:
logger.info(f"[TP2检查] {trade_ex}:{trade.pair} "
f"开仓价={trade.open_rate:.4f} 当前价={current_price:.4f} "
f"利润={profit_pct*100:.2f}% ≥ {tp2_target*100:.1f}% → TP2触发")
async with atomic_transaction(session, "tp1_filled → partial_tp (TP2价格检查)"):
trade.position_state = "partial_tp"
trade.tp2_filled_at = datetime.now(timezone.utc)
logger.success(f"[TP2检查] {trade_ex}:{trade.pair} → partial_tp, 激活Trailing")
# 激活 Trailing(仅DB记录,交易所SL由TrailingChecker管理)
try:
if current_price > 0 and trade.open_rate > 0:
from core.config_loader import load_config as _ld_cfg
cfg = _ld_cfg().get("trailing", {})
activate_pct = cfg.get("activate_profit_pct", 0.06)
if profit_pct >= activate_pct:
lock_gap = cfg.get("lock_gap_pct", 0.06)
sl_profit_pct = max(0.0, profit_pct - lock_gap)
trailing_sl = (trade.open_rate * (1 - sl_profit_pct)
if trade.is_short
else trade.open_rate * (1 + sl_profit_pct))
trade.trailing_activated = True
trade.trailing_highest_profit_pct = profit_pct
trade.trailing_highest_price = current_price
trade.trailing_current_sl = trailing_sl
trade.trailing_last_sl_update_at = datetime.now(timezone.utc)
logger.info(f"[TP2检查] {trade_ex}:{trade.pair} Trailing激活: "
f"profit={profit_pct*100:.2f}% trailing_sl={trailing_sl:.4f}")
except Exception as e:
logger.warning(f"[TP2检查] {trade_ex}:{trade.pair} Trailing激活失败: {e}")
except Exception as e:
logger.warning(f"[TP2检查] {trade_ex}:{trade.pair} 检查异常: {e}")
# ——— Step 3: ExitManager 统一退出决策 ———
if not trade.is_open or trade.amount <= 0:
continue
try:
exit_result = await exit_manager.check(trade, trade_ex, session)
except Exception as e:
logger.error(f"[监控-ExitManager] {trade_ex}:{trade.pair} 检查异常: {e}")
continue
if exit_result is None or not exit_result.should_exit:
continue
# 老师 TP 由交易所条件单执行;监控只在真实仓位减少后推进状态。
from core.protection_targets import teacher_tp_prices
if teacher_tp_prices(trade) and exit_result.exit_type in ("tp1", "tp2"):
continue
exit_price = exit_result.exit_price or 0
# ——— TP1: 状态转换 + 保本损 ———
# ⚠️ 平仓 30% 由交易所 TP1 算法单负责,此处不做本地平仓,避免重复成交
# ⚠️ OKX 全仓止损单在减仓后依然有效 — 不需要创建新 SL!
# 原始的 reduce-only 止损单会在价格触发时清掉所有剩余仓位。
# ⚠️ TP1 不激活 Trailing — Trailing 由 TP2 在 4% 时激活
if exit_result.exit_type == "tp1":
if exit_result.move_sl_to_breakeven and trade.open_rate > 0:
# 仅更新 DB 中的 stop_loss 用于追踪显示
# 交易所上的原始 SL 单继续有效,不创建新单
old_sl = trade.stop_loss
trade.stop_loss = trade.open_rate
logger.info(
f"[{trade_ex}] {trade.pair} TP1 成交,SL 记录更新: "
f"{old_sl:.4f} → {trade.open_rate:.4f} (保本,交易所原始SL继续有效)"
)
# P0-4: 原子事务 — open → tp1_filled (TP1 退出)
async with atomic_transaction(session, "open → tp1_filled (TP1 退出)"):
trade.position_state = "tp1_filled"
trade.tp1_filled_at = datetime.now(timezone.utc) # 记录 TP1 成交时间
logger.success(f"[{trade_ex}] {exit_result.reason} | 保本{trade.open_rate}")
# TP1 不激活 Trailing(等待 TP2 在 4% 时激活)
# ——— TP2: 状态转换 + 激活 Trailing ———
# ⚠️ 平仓剩余 50% 由交易所 TP2 算法单负责,此处不做本地平仓,避免重复成交
elif exit_result.exit_type == "tp2":
# SL 已在 TP1 时设为保本价,此处确保保本
if exit_result.move_sl_to_breakeven and trade.open_rate > 0:
trade.stop_loss = trade.open_rate
# P0-4: 原子事务 — tp1_filled → partial_tp (TP2 退出)
async with atomic_transaction(session, "tp1_filled → partial_tp (TP2 退出)"):
trade.position_state = "partial_tp"
trade.tp2_filled_at = datetime.now(timezone.utc) # 记录 TP2 成交时间
logger.success(f"[{trade_ex}] {exit_result.reason} | 激活 Trailing")
# 激活 Trailing 状态(仅 DB,交易所 SL 由下一轮 order_monitor 更新)
try:
ticker = await ex.fetch_ticker(trade.pair, trade_ex)
current_price = ticker.get("last", 0)
if current_price > 0 and trade.open_rate > 0:
cfg = exit_manager.config.get("trailing", {})
activate_pct = cfg.get("activate_profit_pct", 0.06)
if trade.is_short:
profit_pct = (trade.open_rate - current_price) / trade.open_rate
else:
profit_pct = (current_price - trade.open_rate) / trade.open_rate
if profit_pct >= activate_pct:
# 锁利间隔 4%:每涨 1%,SL 从开仓价上移 1%,回调 4% 才触发
lock_gap = cfg.get("lock_gap_pct", 0.06)
sl_profit_pct = max(0.0, profit_pct - lock_gap)
if trade.is_short:
trailing_sl = trade.open_rate * (1 - sl_profit_pct)
else:
trailing_sl = trade.open_rate * (1 + sl_profit_pct)
# 只设置 trailing 状态字段,不修改 trade.stop_loss
# stop_loss 已在上方设为保本价(trade.open_rate)
# TrailingChecker 下一个 tick 会接管并更新 stop_loss
trade.trailing_activated = True
trade.trailing_highest_profit_pct = profit_pct
trade.trailing_highest_price = current_price
trade.trailing_current_sl = trailing_sl
trade.trailing_last_sl_update_at = datetime.now(timezone.utc)
logger.info(
f"[{trade_ex}] {trade.pair} TP2 后激活 Trailing: "
f"profit={profit_pct*100:.2f}% "
f"trailing_sl={trailing_sl:.4f} "
f"锁利间隔={lock_gap*100:.0f}% "
f"(仅DB记录,stop_loss保持保本价{trade.open_rate},"
f"TrailingChecker下一tick接管)"
)
except Exception as e:
logger.warning(f"[{trade_ex}] {trade.pair} TP2 后 Trailing 激活失败: {e}")
# ——— 其他退出: 全平 ———
else:
ok = await execute_trade_exit(
trade, session,
exit_reason=exit_result.reason,
ordertype="market",
)
if not ok:
# ⚠️ 平仓未确认(下单失败/订单未成交): 保留仓位,不标记 closed。
# 由下轮监控重试 / 仓位同步对账收尾。
# (此前无视返回值无条件假关闭 → TRIA #586 残留 amount=63、
# 无 CloseHistory 的脏账根源)
logger.warning(
f"[{trade_ex}] {trade.pair} 平仓执行未确认 "
f"({exit_result.reason}),保留仓位由下轮监控处理"
)
continue
# P0-3: 原子事务 — open/partial_tp → closed (全平)
async with atomic_transaction(session, "open/partial_tp → closed (全平)"):
trade.is_open = False
trade.exit_reason = exit_result.reason
trade.close_date = datetime.now(timezone.utc)
trade.position_state = "closed"
logger.success(f"[{trade_ex}] {exit_result.reason} | 全平")
# 写入 exit_type 字段(统计退出方式分布)
trade.exit_type = exit_result.exit_type
continue
# 定期报告
open_positions = [t for t in active_trades if t.is_open and t.amount > 0]
if open_positions:
positions_str = ", ".join(
f"[{t.exchange}]{t.pair} {'SHORT' if t.is_short else 'LONG'} "
f"entry={t.open_rate:.4f} pnl={t.calc_profit_ratio(t.open_rate)*100:+.2f}%"
for t in open_positions
)
logger.debug(f"持仓监控: {len(open_positions)} 笔 | {positions_str}")
session.commit()
_HEARTBEAT["monitor_last_tick"] = time.time()
except Exception as e:
err_msg = str(e)
now_t = time.time()
if err_msg != _last_error_log["msg"] or now_t - _last_error_log["time"] > 30:
logger.exception(f"[监控循环] 异常: {e}")
_last_error_log["time"] = now_t
_last_error_log["msg"] = err_msg
# 显式回滚未提交的事务
if session is not None:
try:
session.rollback()
except Exception:
pass
finally:
# 确保 session 在任何情况下都被关闭(异常、正常退出均需释放连接)
try:
if session is not None:
session.close()
except Exception:
pass
# ———————— 信号消费者 ————————
async def signal_consumer(listener: TelegramListener, parser: SignalParser, shutdown_event: asyncio.Event):
"""
信号消费循环:
1. 从 Telegram 消息队列获取新消息
2. DeepSeek 解析
3. 风控检查
4. 在 OKX 执行交易
状态门控:交易所未连接时仍消费消息 + 解析 + 记录,但跳过交易执行。
"""
logger.info("信号消费者已启动")
ServiceRegistry.set_state(MOD_SIGNAL_CONSUMER, ModuleState.RUNNING)
max_positions = int(os.getenv("MAX_CONCURRENT_POSITIONS", "5"))
max_position_pct = float(os.getenv("MAX_POSITION_PCT", "10"))
daily_max_loss_pct = float(os.getenv("DAILY_MAX_LOSS_PCT", "10"))
min_trade = float(os.getenv("MIN_TRADE_AMOUNT_USDT", "20"))
while not shutdown_event.is_set():
try:
payload = await asyncio.wait_for(listener.message_queue.get(), timeout=1.0)
except asyncio.TimeoutError:
_HEARTBEAT["consumer_last_tick"] = time.time()
continue
session = None
try:
session = get_session()
text = payload["text"]
sender = payload.get("tg_sender_name", "")
group = payload.get("tg_group_title", "")
tg_msg_id = payload.get("tg_msg_id")
chat_id = payload.get("tg_group_id", "")
reply_to_id = payload.get("reply_to_msg_id")
reply_text = payload.get("reply_text", "")
original_signal = payload.get("original_signal", "")
reply_chain = payload.get("reply_chain", [])
t0 = time.perf_counter()
logger.info(f"[计时] 收到Telegram")
logger.info(f"来源: {group} | ChatID: {chat_id} | MsgID: {tg_msg_id}")
# 回复上下文日志
if reply_to_id:
logger.info(f"[Reply Context] 检测到回复消息")
logger.info(f" 当前消息 ID: {tg_msg_id}")
logger.info(f" 回复至消息 ID: {reply_to_id}")
if reply_chain:
logger.info(f" 回复链深度: {len(reply_chain)} 层")
for i, chain_msg in enumerate(reply_chain[:3]): # 最多显示前 3 层
logger.info(f" 第 {i+1} 层: MsgID={chain_msg.get('msg_id')}, 内容={chain_msg.get('text', '')[:50]}...")
if original_signal:
logger.info(f" 原始交易信号: {original_signal[:100]}...")
logger.info(f"\n{'='*50}")
logger.info(f"原始交易信号:")
logger.info(original_signal)
logger.info(f"\n当前回复:")
logger.info(text)
logger.info(f"{'='*50}\n")
# Step 1: LLM 解析
result = await parser.parse(text, sender=sender, group=group, original_signal=original_signal)
signal = parser.standardize(result, text, sender=sender, group=group)
logger.info(f"[计时] AI解析完成 +{time.perf_counter()-t0:.2f}s")
# ====== 原始信号摘要 ======
_first_line = text.strip().split("\n")[0] if text.strip() else ""
logger.info(f"[Signal] {_first_line[:120]}")
# ====== 消息类型驱动日志 ======
msg_type = (result or {}).get("message_type", "UNKNOWN")
sig_type = signal.get("signal_type", "?") if signal else "None"
logger.info(f"消息类型: {msg_type}")
logger.info(f"执行类型: {sig_type}")
debug = (result or {}).get("parse_debug", {})
if isinstance(debug, dict):
logger.info("=" * 50)
# ———— 识别字段(recognized)————
rec = debug.get("recognized", {})
if isinstance(rec, dict) and rec:
logger.info("识别字段:")
for k, v in rec.items():
logger.info(f" {k:<14}{v}")
elif isinstance(rec, str) and rec:
logger.info(f"识别字段(原文): {rec[:200]}")
# ———— 推断字段(inferred)————
inf = debug.get("inferred", {})
if isinstance(inf, dict) and inf:
logger.info("推断字段:")
for k, v in inf.items():
logger.info(f" {k:<14}{v}")
elif isinstance(inf, str) and inf:
logger.info(f"推断字段(原文): {inf[:200]}")
# ———— 总结 ————
summary = debug.get("summary", "")
if summary:
logger.info(f"总结: {summary}")
logger.info("=" * 50)
elif isinstance(debug, str) and debug:
logger.info(f"AI 返回文本(期望 dict):parse_debug={debug[:200]}")
# ———— 写入 TelegramMessage(所有消息都持久化)————
try:
from database.models import TelegramMessage
tm = TelegramMessage(
telegram_message_id=tg_msg_id,
reply_to_message_id=reply_to_id,
chat_id=chat_id,
chat_name=group,
sender_id=payload.get("tg_sender_id", ""),
sender_name=sender,
text=text,
raw_text=payload.get("text", text),
message_type=(result or {}).get("message_type", "chat") if signal else "chat",
receive_time=datetime.now(timezone.utc),
)
session.add(tm)
except Exception:
pass
# 记录信号日志(v2: 含 tg_msg_id, reply_to_msg_id, chat_id, teacher, signal_status)
log_entry = SignalLog(
tg_group_id=payload.get("tg_group_id", ""),
tg_group_title=group,
tg_sender_name=sender,
raw_text=text[:500],
direction=signal.get("direction") if signal else None,
pair=signal["symbol"] if signal else None,
parsed_json=result,
is_trading_signal=signal is not None,
error=result.get("error") if result and "error" in result else None,
# v2 新增字段
tg_msg_id=tg_msg_id,
reply_to_msg_id=reply_to_id,
chat_id=chat_id,
teacher=sender,
signal_status="executed" if signal else "ignored",
)
session.add(log_entry)
session.flush()
# ———— 写入 TelegramMessage → SignalLog 映射(ReplyMapping)————
# 如果这条消息是开仓信号,建立 mapping 供后续 Reply 匹配
if signal and signal.get("signal_type") == "new" and tg_msg_id:
try:
from sqlalchemy.exc import IntegrityError
from database.models import ReplyMapping
existing = session.query(ReplyMapping).filter(
ReplyMapping.telegram_message_id == tg_msg_id,
ReplyMapping.chat_id == chat_id,
).first()
if existing is not None:
# 本群已处理过同 MsgID(重复投递),跳过创建,不影响信号执行
logger.info(f"[映射] ReplyMapping 已存在(本群重复消息),跳过创建: MsgID={tg_msg_id}")
else:
mapping = ReplyMapping(
telegram_message_id=tg_msg_id,
signal_id=log_entry.signal_id,
teacher=sender,
symbol=signal.get("symbol", ""),
chat_id=chat_id,
signal_type="new",
)
session.add(mapping)
session.flush()
logger.info(f"[映射] ReplyMapping 创建: MsgID={tg_msg_id} SignalID={log_entry.signal_id}")
except IntegrityError:
# 唯一键冲突(历史遗留:该 MsgID 已在其他群建立过映射)。
# 回滚恢复会话并重建已暂存的消息/信号日志,绝不影响后续开仓执行。
session.rollback()
try:
session.add(tm)
except Exception:
pass
session.add(log_entry)
session.flush()
logger.warning(f"[映射] ReplyMapping 已存在(跨群 MsgID 冲突),跳过创建: MsgID={tg_msg_id}")
except Exception as e:
logger.warning(f"[映射] ReplyMapping 创建失败: {e}")
if signal is None:
logger.info("执行结果: 信号被过滤/拒绝,不开仓")
logger.info("=" * 50)
continue
sig_type = signal.get("signal_type", "new")
# ———— BREAKOUT_ORDER / PULLBACK_ORDER ————
if sig_type in ("breakout", "pullback"):
msg_type_label = signal.get("message_type", sig_type.upper())
logger.info(
f"执行结果: {msg_type_label} — "
f"当前版本暂未支持 {msg_type_label},已忽略执行"
)
logger.info("=" * 50)
session.commit()
_HEARTBEAT["consumer_last_signal"] = time.time()
continue
# ———— 状态门控:交易所未连接时仅记录信号,不执行交易 ————
# 消息已消费 + 已解析 + 已写入 SignalLog,但跳过交易执行
if not ServiceRegistry.can_trade():
logger.warning(
f"[信号消费者] 交易所未连接,跳过 {sig_type} 信号执行: "
f"{signal['symbol']} (来源: {group})"
)
log_entry.error = "SKIP: 交易所未连接"
session.commit()
_HEARTBEAT["consumer_last_signal"] = time.time()
continue
# 注入来源上下文(用于方向解析 + 来源绑定)
signal["reply_to_msg_id"] = reply_to_id
signal["reply_text"] = reply_text
signal["source_chat_id"] = chat_id
signal["source_group_name"] = group
signal["source_message_id"] = tg_msg_id
# ———— cancel 信号:取消挂单 ————
if sig_type == "cancel":
active_exchanges = ex.get_active_exchanges()
ok_count = 0
for trade_ex in active_exchanges:
ok, info = await apply_cancel_signal(session, signal, exchange=trade_ex)
if ok:
ok_count += 1
logger.success(f"[cancel] {trade_ex}:{signal['symbol']} | {info}")
else:
logger.warning(f"[cancel] {trade_ex}:{signal['symbol']} | {info}")
session.commit()
_HEARTBEAT["consumer_last_signal"] = time.time()
continue
# ———— update / close 信号:方向解析 → 找对应 trade ————
if sig_type in ("update", "close"):
active_exchanges = ex.get_active_exchanges()
ok_count = 0
for trade_ex in active_exchanges:
if sig_type == "update":
ok, info = await apply_update_signal(session, signal, exchange=trade_ex)
else:
ok, info = await apply_close_signal(session, signal, exchange=trade_ex)
if ok:
ok_count += 1
logger.success(f"[{sig_type}] {trade_ex}:{signal['symbol']} | {info}")
else:
logger.warning(f"[{sig_type}] {trade_ex}:{signal['symbol']} | {info}")
if ok_count == 0:
log_entry.error = f"FAIL: 未找到 {signal['symbol']} 的活跃 trade"
session.commit()
_HEARTBEAT["consumer_last_signal"] = time.time()
continue
# ———— new 信号 ————
# Step A: 获取可用余额(计算全局熔断用合并余额,仓位按单个交易所独立计算)
# P2: 信号入口层过滤无效 symbol -- 在 balance fetch / risk_check / execute_entry
# 之前校验。以 OKX markets 缓存为准(零 API),无效 symbol 直接跳过,
# 不创建 DB trade,避免浪费 balance/risk API 调用。
_valid_sym = None
for _ex_cand in ex.get_active_exchanges():
_ok, _ccxt_sym = ex.validate_symbol(signal["symbol"], exchange=_ex_cand)
if _ok:
_valid_sym = _ccxt_sym
break
if _valid_sym is None:
logger.warning(f"[信号过滤] 无效 symbol,跳过开仓: {signal['symbol']}")
log_entry.error = f"SKIP: invalid symbol {signal['symbol']}"
log_entry.signal_status = "invalid_symbol"
session.commit()
_HEARTBEAT["consumer_last_signal"] = time.time()
continue
signal["symbol"] = _valid_sym # 统一为 ccxt 标准符号(如 SOL/USDT:USDT)
total_balance = 0.0
exchange_balances = {}
for trade_ex in ex.get_active_exchanges():
bal = await ex.fetch_balance(exchange=trade_ex)
exchange_balances[trade_ex] = bal
total_balance += bal["total"]
# ———— Step A.5: 每日熔断检查(双通道后台监控,此处只读 flag)————
# DailyLossBreaker 在 order_monitor 中双通道评估:
# Channel 1 (WS): 每 5s 评估 BalanceTracker 权益
# Channel 2 (REST): 每 60s 主动 OKX REST 查询(绕过缓存)
# 此处只检查 flag,不做重复计算
from core.daily_loss_breaker import daily_loss_breaker
daily_loss_breaker.max_loss_pct = daily_max_loss_pct / 100.0
if daily_loss_breaker.is_tripped:
trip_reason = daily_loss_breaker._trip_reason
logger.warning(f"[熔断] {trip_reason}")
if signal:
log_entry.error = f"BREAKER: {trip_reason}"
log_entry.signal_status = "blocked_breaker"
session.commit()
_HEARTBEAT["consumer_last_signal"] = time.time()
continue
# Step B: 风控检查(熔断额 = 总权益 × 百分比)
# 传入 exchange_balances 复用已获取的余额,避免重复 fetch_balance
daily_max_loss = total_balance * daily_max_loss_pct / 100.0
passed, reason = await risk_check(
session, signal["symbol"], signal.get("direction", ""),
max_positions, daily_max_loss,
exchange_balances=exchange_balances,
)
if not passed:
logger.info(f"[风控拒绝] {signal['symbol']}: {reason}")
continue
# Step C: 老师月度分层仓位 —— 单笔比例由当月快照锁定
# (core/position_tier:每月1号快照 90 天收益率榜;榜外/无快照 → 0.01 兜底)
from core.position_tier import ratio_for as _tier_ratio_for
teacher_ratio = _tier_ratio_for(sender)
logger.info(
f"[PositionSizing] 信号 teacher={sender!r} 当月锁定仓位比例={teacher_ratio:.0%}"
)
entry_strategy = signal.get("entry_strategy", "market")
entry_low = signal.get("entry_low")
entry_high = signal.get("entry_high")
trigger_price = signal.get("trigger_price")
# Step C: 在每个交易所执行入场(使用该交易所自己的余额计算仓位)
logger.info(f"[计时] 开始下单 +{time.perf_counter()-t0:.2f}s")
executed_on = []
for trade_ex in ex.get_active_exchanges():
ex_balance = exchange_balances.get(trade_ex, {})
ex_free = ex_balance.get("free", 0.0)
margin = await calculate_position_size(ex_free, teacher_ratio)
# 每个交易所独立执行
trade = await execute_entry(
session, signal["symbol"], signal["direction"],
margin, signal["leverage"], log_entry.signal_id,
exchange=trade_ex,
signal_meta={
"take_profit": signal.get("take_profit") or [],
"stop_loss": signal.get("stop_loss"),
"sl_type": signal.get("sl_type", "fixed"),
"entry_strategy": entry_strategy,
"source_sender": sender, "source_group": group,
"tg_msg_id": tg_msg_id,
},
entry_low=entry_low,
entry_high=entry_high,
entry_strategy=entry_strategy,
trigger_price=trigger_price,
source_chat_id=chat_id,