183 lines
6.7 KiB
Python
183 lines
6.7 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
山寨币实时价格监控 — ccxt REST 高频轮询版。
|
||
|
||
职责边界:本进程只做实时价格采集和“候选状态”提交;最终状态落库、是否推送、推送价格口径
|
||
全部由 altcoin_db.apply_recommendation_state_transition 主链路决定。
|
||
"""
|
||
import sys
|
||
import os
|
||
import time
|
||
import ccxt
|
||
from datetime import datetime
|
||
|
||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||
from app.db.altcoin_db import (
|
||
init_db,
|
||
get_active_recommendations_deduped,
|
||
update_recommendation_tracking,
|
||
apply_recommendation_state_transition,
|
||
should_push,
|
||
log_push,
|
||
)
|
||
from app.integrations.feishu_push import push_altcoin_tp_sl_alert
|
||
|
||
POLL_INTERVAL = 5
|
||
REFRESH_INTERVAL = 60
|
||
BATCH_SIZE = 50
|
||
|
||
exchange = ccxt.binance({"enableRateLimit": True})
|
||
last_refresh = 0
|
||
active_map = {}
|
||
|
||
|
||
def load_active():
|
||
try:
|
||
recs = get_active_recommendations_deduped(actionable_only=False)
|
||
# 只监控 active 最新去重记录;结案记录由主链路/网站展示,不在实时入口反复触发。
|
||
return {r["symbol"]: r for r in recs if r.get("symbol") and r.get("status") == "active"}
|
||
except Exception as e:
|
||
print(f"[{datetime.now():%H:%M:%S}] 加载推荐失败: {e}", flush=True)
|
||
return {}
|
||
|
||
|
||
def check_triggers(symbol, rec, current_price):
|
||
"""向后兼容旧测试:结案记录不得再产生入场触发。"""
|
||
if rec.get("status") != "active":
|
||
return None
|
||
action, signals = detect_candidate_action(rec, current_price, {})
|
||
if not action:
|
||
return None
|
||
return {"action_status": action, "signals": signals}
|
||
|
||
|
||
def detect_candidate_action(rec, current_price, track_result):
|
||
"""仅产生候选状态;不得在这里推送或落最终状态。"""
|
||
terminal_action = {
|
||
"hit_tp1": "止盈1",
|
||
"hit_tp2": "止盈2",
|
||
"stopped_out": "止损",
|
||
}.get((track_result or {}).get("status"))
|
||
if terminal_action:
|
||
return terminal_action, [f"状态机检测到{terminal_action}"]
|
||
|
||
ep = rec.get("entry_plan") or {}
|
||
entry_action = ep.get("entry_action", "") or rec.get("initial_action", "")
|
||
plan_entry_price = ep.get("entry_price", 0) or 0
|
||
entry_price = rec.get("entry_price", 0) or 0
|
||
cur_action = rec.get("action_status", "持有")
|
||
|
||
if entry_action == "等回踩" and plan_entry_price > 0 and current_price <= plan_entry_price:
|
||
ep["entry_trigger_confirmed"] = True
|
||
return "可即刻买入", [f"回踩到位 ${plan_entry_price:.6f}"]
|
||
if entry_action in ("即刻买入", "可即刻买入") and current_price <= (plan_entry_price or entry_price):
|
||
ep["entry_trigger_confirmed"] = True
|
||
return "可即刻买入", ["入场条件仍满足"]
|
||
if cur_action == "可即刻买入":
|
||
return "可即刻买入", ["入场窗口延续"]
|
||
return None, []
|
||
|
||
|
||
def fetch_prices(symbols):
|
||
all_tickers = {}
|
||
for i in range(0, len(symbols), BATCH_SIZE):
|
||
batch = symbols[i:i + BATCH_SIZE]
|
||
try:
|
||
tickers = exchange.fetch_tickers(batch)
|
||
for s in batch:
|
||
if s in tickers:
|
||
all_tickers[s] = tickers[s]
|
||
except Exception as e:
|
||
print(f"[{datetime.now():%H:%M:%S}] 拉取价格失败(批次{i//BATCH_SIZE}): {e}", flush=True)
|
||
time.sleep(1)
|
||
return all_tickers
|
||
|
||
|
||
def maybe_push_from_decision(symbol, decision):
|
||
"""推送层只消费主链路 decision,不自行判断状态。"""
|
||
action = decision.get("action_status")
|
||
if not decision.get("push_required"):
|
||
return
|
||
if not should_push(symbol, "entry", action):
|
||
print(f"[{datetime.now():%H:%M:%S}] ⏭ 跳过推送 {symbol}: entry/{action} 冷却中", flush=True)
|
||
return
|
||
push_altcoin_tp_sl_alert(
|
||
decision["push_symbol"],
|
||
decision["push_current_price"],
|
||
decision["push_entry_price"],
|
||
decision["push_pnl_pct"],
|
||
action,
|
||
decision.get("push_signals", []),
|
||
decision.get("stop_loss", 0),
|
||
decision.get("tp1", 0),
|
||
decision.get("tp2", 0),
|
||
)
|
||
log_push(symbol, "entry", action, rec_id=decision.get("id", 0))
|
||
print(f"[{datetime.now():%H:%M:%S}] 📲 {symbol} → {action} (盈亏{decision['push_pnl_pct']}%)", flush=True)
|
||
|
||
|
||
def main_loop():
|
||
global active_map, last_refresh
|
||
init_db()
|
||
active_map = load_active()
|
||
last_refresh = time.time()
|
||
print(f"[{datetime.now():%H:%M:%S}] 🚀 实时价格监控启动,活跃: {len(active_map)}只,轮询间隔{POLL_INTERVAL}s", flush=True)
|
||
|
||
while True:
|
||
loop_start = time.time()
|
||
if loop_start - last_refresh >= REFRESH_INTERVAL:
|
||
active_map = load_active()
|
||
last_refresh = loop_start
|
||
|
||
if not active_map:
|
||
time.sleep(POLL_INTERVAL)
|
||
continue
|
||
|
||
tickers = fetch_prices(list(active_map.keys()))
|
||
for symbol, rec in list(active_map.items()):
|
||
ticker = tickers.get(symbol)
|
||
if not ticker:
|
||
continue
|
||
current_price = ticker.get("last", 0)
|
||
if current_price <= 0:
|
||
continue
|
||
|
||
try:
|
||
track_result = update_recommendation_tracking(rec["id"], current_price) or {}
|
||
except Exception as e:
|
||
print(f"[{datetime.now():%H:%M:%S}] 跟踪价格失败 {symbol}: {e}", flush=True)
|
||
continue
|
||
|
||
action, signals = detect_candidate_action(rec, current_price, track_result)
|
||
if not action:
|
||
continue
|
||
|
||
decision = apply_recommendation_state_transition(
|
||
rec["id"],
|
||
requested_action=action,
|
||
current_price=current_price,
|
||
event_time=datetime.now().strftime("%Y-%m-%dT%H:%M:%S"),
|
||
signals=signals,
|
||
)
|
||
if decision.get("action_status") in ("止盈1", "止盈2", "止损"):
|
||
active_map.pop(symbol, None)
|
||
else:
|
||
rec["action_status"] = decision.get("action_status", rec.get("action_status"))
|
||
rec["entry_price"] = decision.get("entry_price", rec.get("entry_price"))
|
||
rec["current_price"] = decision.get("current_price", current_price)
|
||
rec["pnl_pct"] = decision.get("pnl_pct", rec.get("pnl_pct"))
|
||
active_map[symbol] = rec
|
||
|
||
try:
|
||
maybe_push_from_decision(symbol, decision)
|
||
except Exception as e:
|
||
print(f"[{datetime.now():%H:%M:%S}] 推送失败 {symbol}: {e}", flush=True)
|
||
|
||
elapsed = time.time() - loop_start
|
||
time.sleep(max(0.5, POLL_INTERVAL - elapsed))
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main_loop()
|