836 lines
32 KiB
Python
836 lines
32 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
事件驱动舆情触发选币 v1.7.3
|
||
|
||
目标:重大消息刚发生 → 时间窗/去重/重要性评级 → 单币快速技术检查 → 飞书推送。
|
||
原则:消息只负责触发检查,技术形态决定是否推荐。
|
||
"""
|
||
import os
|
||
import re
|
||
import sys
|
||
import json
|
||
import time
|
||
import hashlib
|
||
import sqlite3
|
||
from datetime import datetime, timedelta, timezone
|
||
from email.utils import parsedate_to_datetime
|
||
from pathlib import Path
|
||
from urllib.parse import quote_plus
|
||
|
||
import ccxt
|
||
import pandas as pd
|
||
import requests
|
||
import yaml
|
||
|
||
sys.path.insert(0, os.path.dirname(__file__))
|
||
|
||
from app.config.config_loader import load_rules, get_meta, get_strategy_direction
|
||
from app.db.altcoin_db import init_db, get_conn, create_recommendation, log_screening, log_cron_run, get_recommendation_for_push
|
||
from app.db.llm_insights import repair_mojibake_json, repair_mojibake_text
|
||
from app.services.altcoin_screener import (
|
||
fetch_all_tickers,
|
||
detect_volume_price_fly,
|
||
detect_static_accumulation,
|
||
STABLECOINS,
|
||
WRAPPED,
|
||
BTC_ETH,
|
||
GOLD_METAL,
|
||
BNB_CHAIN,
|
||
EXCLUDED_BASES,
|
||
EXCLUDED_BASE_SUFFIXES,
|
||
)
|
||
from app.services.altcoin_confirm import fetch_derivatives_context
|
||
from app.core.pa_engine import full_pa_analysis, calc_atr
|
||
from app.integrations.push_orchestrator import push_mainline_state_update
|
||
|
||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||
DB_PATH = os.getenv("ALPHAX_DB_PATH", str(REPO_ROOT / "data" / "altcoin_monitor.db"))
|
||
exchange = ccxt.binance({"enableRateLimit": True})
|
||
|
||
LEVEL_RANK = {"S": 4, "A": 3, "B": 2, "C": 1, "D": 0, "RISK": 5}
|
||
|
||
|
||
def _level_max(a, b):
|
||
"""返回重要性更高的级别。"""
|
||
return a if LEVEL_RANK.get(a, 0) >= LEVEL_RANK.get(b, 0) else b
|
||
|
||
|
||
def _now():
|
||
return datetime.now()
|
||
|
||
|
||
def _cfg():
|
||
return load_rules(force_reload=True).get("event_driven", {})
|
||
|
||
|
||
def _parse_binance_time(ms):
|
||
try:
|
||
return datetime.fromtimestamp(int(ms) / 1000)
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _parse_pubdate(value):
|
||
if not value:
|
||
return None
|
||
try:
|
||
dt = parsedate_to_datetime(value)
|
||
if dt.tzinfo:
|
||
dt = dt.astimezone().replace(tzinfo=None)
|
||
return dt
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _is_recent(dt, max_hours=None):
|
||
if not dt:
|
||
return False
|
||
hours = max_hours or _cfg().get("news_time_window_hours", 3)
|
||
return (_now() - dt) <= timedelta(hours=hours) and dt <= _now() + timedelta(minutes=5)
|
||
|
||
|
||
def _event_hash(source, title, symbol):
|
||
raw = f"{source}|{title}|{symbol}".lower().strip()
|
||
return hashlib.sha256(raw.encode()).hexdigest()[:20]
|
||
|
||
|
||
def init_event_tables():
|
||
conn = get_conn()
|
||
conn.execute("""
|
||
CREATE TABLE IF NOT EXISTS event_news (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
event_hash TEXT UNIQUE,
|
||
source TEXT NOT NULL,
|
||
symbol TEXT NOT NULL,
|
||
title TEXT NOT NULL,
|
||
url TEXT DEFAULT '',
|
||
published_at TEXT NOT NULL,
|
||
detected_at TEXT NOT NULL,
|
||
importance TEXT DEFAULT 'B',
|
||
event_type TEXT DEFAULT '',
|
||
raw_json TEXT DEFAULT '{}',
|
||
processed INTEGER DEFAULT 0,
|
||
decision TEXT DEFAULT '',
|
||
tech_score INTEGER DEFAULT 0,
|
||
rec_id INTEGER DEFAULT 0,
|
||
pushed INTEGER DEFAULT 0
|
||
)
|
||
""")
|
||
conn.execute("CREATE INDEX IF NOT EXISTS idx_event_news_time ON event_news(published_at, detected_at)")
|
||
conn.execute("CREATE INDEX IF NOT EXISTS idx_event_news_symbol ON event_news(symbol)")
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
|
||
def _symbol_from_title(title):
|
||
"""从标题里提取可能的币种,返回 AAA/USDT 列表。"""
|
||
text = title or ""
|
||
candidates = set()
|
||
|
||
# XXXUSDT / XXXUSDT Perpetual
|
||
for m in re.finditer(r"\b([A-Z0-9]{2,15})USDT\b", text):
|
||
base = m.group(1).upper()
|
||
candidates.add(f"{base}/USDT")
|
||
|
||
# Binance Will List XXX (Name) / Add XXX
|
||
patterns = [
|
||
r"Will List\s+([A-Z0-9]{2,15})(?![A-Z0-9]*USDT)\b",
|
||
r"Will Launch\s+([A-Z0-9]{2,15})(?![A-Z0-9]*USDT)\b",
|
||
r"Will Add\s+([A-Z0-9]{2,15})(?![A-Z0-9]*USDT)\b",
|
||
r"Earn\s+([A-Z0-9]{2,15})(?![A-Z0-9]*USDT)\b",
|
||
r"Add\s+([A-Z0-9]{2,15})(?![A-Z0-9]*USDT)\b",
|
||
]
|
||
for pat in patterns:
|
||
for m in re.finditer(pat, text, flags=re.I):
|
||
base = m.group(1).upper()
|
||
if base.endswith("USDT"):
|
||
continue
|
||
candidates.add(f"{base}/USDT")
|
||
|
||
# 括号中的 ticker
|
||
for m in re.finditer(r"\(([A-Z0-9]{2,15})\)", text):
|
||
base = m.group(1).upper()
|
||
candidates.add(f"{base}/USDT")
|
||
|
||
return [s for s in sorted(candidates) if _tradable_symbol(s)]
|
||
|
||
|
||
def _tradable_symbol(symbol):
|
||
base = symbol.split("/")[0].upper()
|
||
if base in STABLECOINS or base in WRAPPED or base in BTC_ETH or base in GOLD_METAL or base in BNB_CHAIN:
|
||
return False
|
||
if base in EXCLUDED_BASES or base.endswith(EXCLUDED_BASE_SUFFIXES):
|
||
return False
|
||
if not base.isascii():
|
||
return False
|
||
return True
|
||
|
||
|
||
def _base_symbol(symbol):
|
||
return (symbol or "").split("/")[0].upper()
|
||
|
||
|
||
def _theme_cfg():
|
||
return _cfg().get("theme_expansion", {}) or {}
|
||
|
||
|
||
def _theme_definitions():
|
||
return _theme_cfg().get("themes", {}) or {}
|
||
|
||
|
||
def _matched_themes(title="", symbol=""):
|
||
"""识别标题/命中币种所属的生态主题。"""
|
||
if not _theme_cfg().get("enabled", False):
|
||
return []
|
||
low = (title or "").lower()
|
||
base = _base_symbol(symbol)
|
||
matched = []
|
||
for theme_name, theme in _theme_definitions().items():
|
||
keywords = [str(k).lower() for k in theme.get("keywords", [])]
|
||
primary = {str(s).upper() for s in theme.get("primary_symbols", [])}
|
||
symbols = {str(s).upper() for s in theme.get("symbols", [])}
|
||
if any(k and k in low for k in keywords) or base in primary or base in symbols:
|
||
matched.append((theme_name, theme))
|
||
return matched
|
||
|
||
|
||
def _event_copy_for_symbol(event, symbol, theme_name, theme, expanded=True):
|
||
min_level = _theme_cfg().get("min_theme_importance", "A")
|
||
importance = _level_max(event.get("importance", "B"), min_level)
|
||
original_title = event.get("title", "")
|
||
return {
|
||
**event,
|
||
"symbol": symbol,
|
||
"importance": importance,
|
||
"event_type": "theme_expansion" if expanded else "theme_direct",
|
||
"title": f"[主题扩散:{theme_name}] {original_title}",
|
||
"raw": {
|
||
"parent_event": event.get("raw", {}),
|
||
"parent_symbol": event.get("symbol", ""),
|
||
"theme": theme_name,
|
||
"theme_symbols": theme.get("symbols", []),
|
||
"expansion_reason": theme.get("note", "生态主题消息扩散"),
|
||
},
|
||
}
|
||
|
||
|
||
def expand_theme_events(events):
|
||
"""重大生态/主题事件扩散到同生态币,解决 TON/DOGS 这类联动行情漏选。"""
|
||
if not _theme_cfg().get("enabled", False):
|
||
return events
|
||
expanded = list(events)
|
||
seen = {(e.get("source"), e.get("title"), e.get("symbol")) for e in expanded}
|
||
for e in events:
|
||
for theme_name, theme in _matched_themes(e.get("title", ""), e.get("symbol", "")):
|
||
direct = _event_copy_for_symbol(e, e.get("symbol"), theme_name, theme, expanded=False)
|
||
key = (direct.get("source"), direct.get("title"), direct.get("symbol"))
|
||
if direct.get("symbol") and key not in seen and _tradable_symbol(direct.get("symbol")):
|
||
expanded.append(direct)
|
||
seen.add(key)
|
||
|
||
for base in theme.get("symbols", []):
|
||
symbol = f"{str(base).upper()}/USDT"
|
||
if symbol == e.get("symbol") or not _tradable_symbol(symbol):
|
||
continue
|
||
child = _event_copy_for_symbol(e, symbol, theme_name, theme, expanded=True)
|
||
key = (child.get("source"), child.get("title"), child.get("symbol"))
|
||
if key not in seen:
|
||
expanded.append(child)
|
||
seen.add(key)
|
||
return expanded
|
||
|
||
|
||
def classify_event(title, source=""):
|
||
cfg = _cfg().get("importance", {})
|
||
low = (title or "").lower()
|
||
negs = [k.lower() for k in cfg.get("negative_keywords", [])]
|
||
s_keys = [k.lower() for k in cfg.get("s_keywords", [])]
|
||
a_keys = [k.lower() for k in cfg.get("a_keywords", [])]
|
||
|
||
if any(k in low for k in negs):
|
||
return "RISK", "risk_negative"
|
||
if any(k in low for k in s_keys):
|
||
return "S", "major_listing_or_contract"
|
||
if any(k in low for k in a_keys):
|
||
return "A", "important_catalyst"
|
||
if "trending" in source.lower() or "coingecko" in source.lower():
|
||
return "B", "market_heat"
|
||
return "C", "minor_or_unknown"
|
||
|
||
|
||
def _passes_min_importance(level):
|
||
min_level = _cfg().get("min_importance_level", "A")
|
||
if level == "RISK":
|
||
return True
|
||
return LEVEL_RANK.get(level, 0) >= LEVEL_RANK.get(min_level, 3)
|
||
|
||
|
||
def fetch_binance_events(source_key, source_cfg):
|
||
if not source_cfg.get("enabled", True):
|
||
return []
|
||
url = source_cfg.get("url")
|
||
events = []
|
||
try:
|
||
r = requests.get(url, timeout=10, headers={"User-Agent": "Mozilla/5.0"})
|
||
if r.status_code != 200:
|
||
return []
|
||
data = r.json()
|
||
catalogs = (data.get("data") or {}).get("catalogs") or []
|
||
for cat in catalogs:
|
||
for a in cat.get("articles", []) or []:
|
||
title = a.get("title", "")
|
||
pub = _parse_binance_time(a.get("releaseDate"))
|
||
if not _is_recent(pub, _cfg().get("news_time_window_hours", 3)):
|
||
continue
|
||
symbols = _symbol_from_title(title)
|
||
if not symbols:
|
||
continue
|
||
importance, event_type = classify_event(title, source_key)
|
||
if not _passes_min_importance(importance):
|
||
continue
|
||
code = a.get("code") or ""
|
||
link = f"https://www.binance.com/en/support/announcement/{code}" if code else ""
|
||
for symbol in symbols:
|
||
events.append({
|
||
"source": source_key,
|
||
"symbol": symbol,
|
||
"title": title,
|
||
"url": link,
|
||
"published_at": pub,
|
||
"importance": importance,
|
||
"event_type": event_type,
|
||
"raw": a,
|
||
})
|
||
except Exception as e:
|
||
print(f"[event] fetch_binance_events error {source_key}: {e}")
|
||
return events
|
||
|
||
|
||
def fetch_coingecko_trending_events():
|
||
cfg = _cfg().get("sources", {}).get("coingecko_trending", {})
|
||
if not cfg.get("enabled", True):
|
||
return []
|
||
try:
|
||
from app.services.sentiment_monitor import fetch_trending_coins, _get_previous_trending
|
||
trending = fetch_trending_coins()
|
||
prev = {r["symbol"] for r in _get_previous_trending()}
|
||
events = []
|
||
now = _now()
|
||
for t in trending[:10]:
|
||
sym = (t.get("symbol") or "").upper()
|
||
full = f"{sym}/USDT"
|
||
if not _tradable_symbol(full):
|
||
continue
|
||
# Trending 只作为热度源:必须同时满足新进Top10/Top5 + 交易所可交易,且后续仍需技术确认。
|
||
if sym not in prev or t.get("trend_rank", 99) <= 5:
|
||
title = f"{sym}({t.get('name','')}) enters CoinGecko Trending #{t.get('trend_rank')}"
|
||
events.append({
|
||
"source": "coingecko_trending",
|
||
"symbol": full,
|
||
"title": title,
|
||
"url": "https://www.coingecko.com/en/trending-crypto",
|
||
"published_at": now,
|
||
"importance": "B",
|
||
"event_type": "market_heat",
|
||
"raw": t,
|
||
})
|
||
return events
|
||
except Exception as e:
|
||
print(f"[event] fetch trending error: {e}")
|
||
return []
|
||
|
||
|
||
def collect_events():
|
||
cfg = _cfg()
|
||
sources = cfg.get("sources", {})
|
||
events = []
|
||
for key in ("binance_listing", "binance_latest"):
|
||
if key in sources:
|
||
events.extend(fetch_binance_events(key, sources[key]))
|
||
events.extend(fetch_coingecko_trending_events())
|
||
return expand_theme_events(events)
|
||
|
||
|
||
def store_events(events):
|
||
init_event_tables()
|
||
conn = get_conn()
|
||
stored = []
|
||
now = _now().isoformat()
|
||
for e in events:
|
||
h = _event_hash(e["source"], e["title"], e["symbol"])
|
||
try:
|
||
conn.execute("""
|
||
INSERT INTO event_news
|
||
(event_hash, source, symbol, title, url, published_at, detected_at, importance, event_type, raw_json)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
""", (
|
||
h, e["source"], e["symbol"], e["title"], e.get("url", ""),
|
||
e["published_at"].isoformat(), now, e["importance"], e["event_type"],
|
||
json.dumps(e.get("raw", {}), ensure_ascii=False),
|
||
))
|
||
e["event_hash"] = h
|
||
stored.append(e)
|
||
except sqlite3.IntegrityError:
|
||
continue
|
||
except Exception as ex:
|
||
print(f"[event] store error {e.get('title')}: {ex}")
|
||
conn.commit()
|
||
conn.close()
|
||
return stored
|
||
|
||
|
||
def _normalize_llm_symbol(value):
|
||
text = str(value or "").strip().upper()
|
||
if not text:
|
||
return ""
|
||
text = text.replace("-", "/").replace("_", "/")
|
||
if "/" in text:
|
||
base = text.split("/")[0]
|
||
else:
|
||
base = re.sub(r"USDT$", "", text)
|
||
base = re.sub(r"[^A-Z0-9]", "", base)
|
||
if not base:
|
||
return ""
|
||
symbol = f"{base}/USDT"
|
||
return symbol if _tradable_symbol(symbol) else ""
|
||
|
||
|
||
def _llm_confidence_score(value):
|
||
try:
|
||
score = float(value or 0)
|
||
except Exception:
|
||
return 0.0
|
||
return score * 100 if 0 < score <= 1 else score
|
||
|
||
|
||
def enqueue_llm_sentiment_candidates(analysis, source_insight_id="", min_confidence=70, max_candidates=10, cooldown_hours=6):
|
||
"""Turn LLM sentiment analysis into event candidates for the existing technical gate.
|
||
|
||
LLM output is allowed to request a technical check, but it never creates a
|
||
recommendation or changes scores directly.
|
||
"""
|
||
analysis = repair_mojibake_json(analysis)
|
||
if not isinstance(analysis, dict):
|
||
return {"queued": 0, "skipped": 0, "symbols": []}
|
||
|
||
candidates = []
|
||
seen = set()
|
||
for item in analysis.get("coin_impacts") or []:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
if item.get("need_technical_check") is not True:
|
||
continue
|
||
direction = str(item.get("direction") or "").lower()
|
||
if direction not in ("positive", "neutral", "bullish", "利好", "正面", "中性"):
|
||
continue
|
||
confidence = _llm_confidence_score(item.get("confidence"))
|
||
if confidence < float(min_confidence or 0):
|
||
continue
|
||
symbol = _normalize_llm_symbol(item.get("symbol"))
|
||
if not symbol or symbol in seen:
|
||
continue
|
||
seen.add(symbol)
|
||
reason = str(repair_mojibake_text(item.get("reason") or "AI 舆情分析认为需要技术检查")).strip()
|
||
candidates.append({
|
||
"source": "llm_sentiment",
|
||
"symbol": symbol,
|
||
"title": f"AI舆情候选 {symbol}: {reason[:160]}",
|
||
"url": "",
|
||
"published_at": _now(),
|
||
"importance": "A",
|
||
"event_type": "llm_sentiment_candidate",
|
||
"raw": {
|
||
"source_insight_id": source_insight_id,
|
||
"direction": direction,
|
||
"confidence": confidence,
|
||
"reason": reason,
|
||
},
|
||
})
|
||
if len(candidates) >= int(max_candidates or 10):
|
||
break
|
||
|
||
if not candidates:
|
||
return {"queued": 0, "skipped": 0, "symbols": []}
|
||
|
||
init_event_tables()
|
||
conn = get_conn()
|
||
queued = []
|
||
skipped = 0
|
||
cooldown_cutoff = (_now() - timedelta(hours=float(cooldown_hours or 6))).isoformat()
|
||
now = _now().isoformat()
|
||
for event in candidates:
|
||
recent = conn.execute(
|
||
"""
|
||
SELECT id FROM event_news
|
||
WHERE source='llm_sentiment' AND symbol=? AND detected_at >= ?
|
||
LIMIT 1
|
||
""",
|
||
(event["symbol"], cooldown_cutoff),
|
||
).fetchone()
|
||
if recent:
|
||
skipped += 1
|
||
continue
|
||
h = _event_hash(event["source"], event["title"], event["symbol"])
|
||
try:
|
||
conn.execute(
|
||
"""
|
||
INSERT INTO event_news
|
||
(event_hash, source, symbol, title, url, published_at, detected_at, importance, event_type, raw_json, processed)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)
|
||
""",
|
||
(
|
||
h,
|
||
event["source"],
|
||
event["symbol"],
|
||
event["title"],
|
||
event.get("url", ""),
|
||
event["published_at"].isoformat(),
|
||
now,
|
||
event["importance"],
|
||
event["event_type"],
|
||
json.dumps(event.get("raw", {}), ensure_ascii=False),
|
||
),
|
||
)
|
||
queued.append(event["symbol"])
|
||
except sqlite3.IntegrityError:
|
||
skipped += 1
|
||
except Exception as exc:
|
||
print(f"[event] llm candidate enqueue error {event.get('symbol')}: {exc}")
|
||
skipped += 1
|
||
conn.commit()
|
||
conn.close()
|
||
return {"queued": len(queued), "skipped": skipped, "symbols": queued}
|
||
|
||
|
||
def fetch_klines(symbol, timeframe, limit=120):
|
||
try:
|
||
ohlcv = exchange.fetch_ohlcv(symbol, timeframe, limit=limit)
|
||
df = pd.DataFrame(ohlcv, columns=["timestamp", "open", "high", "low", "close", "volume"])
|
||
df["timestamp"] = pd.to_datetime(df["timestamp"], unit="ms")
|
||
return df
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _ticker_info(symbol):
|
||
try:
|
||
t = exchange.fetch_ticker(symbol)
|
||
return {
|
||
"price": float(t.get("last") or 0),
|
||
"change_24h": float(t.get("percentage") or 0),
|
||
"volume_24h": float(t.get("quoteVolume") or 0),
|
||
}
|
||
except Exception:
|
||
return {"price": 0, "change_24h": 0, "volume_24h": 0}
|
||
|
||
|
||
def quick_technical_check(event):
|
||
symbol = event["symbol"]
|
||
cfg = _cfg().get("technical_check", {})
|
||
ticker = _ticker_info(symbol)
|
||
price = ticker["price"]
|
||
signals = [f"📢 {event['importance']}级舆情触发: {event['title']}"]
|
||
score = 0
|
||
decision = "ignore"
|
||
reason = ""
|
||
entry_plan = {}
|
||
|
||
if price <= 0:
|
||
return {"decision": "ignore", "reason": "交易对不可用或无价格", "score": 0, "signals": signals, "price": price}
|
||
|
||
if event.get("importance") == "RISK":
|
||
return {"decision": "risk", "reason": "负面重大消息,禁买/风控", "score": 0, "signals": signals, "price": price, "ticker": ticker}
|
||
|
||
reject_gain = cfg.get("reject_if_24h_gain_gt", 30)
|
||
warn_gain = cfg.get("warn_if_24h_gain_gt", 18)
|
||
if ticker["change_24h"] > reject_gain:
|
||
signals.append(f"⛔ 24h已涨{ticker['change_24h']:.1f}%>{reject_gain}%,不追高")
|
||
return {"decision": "risk", "reason": "重大消息但已过度拉升,不追高", "score": 0, "signals": signals, "price": price, "ticker": ticker}
|
||
elif ticker["change_24h"] > warn_gain:
|
||
signals.append(f"⚠️ 24h已涨{ticker['change_24h']:.1f}%,追高风险升高")
|
||
score -= 1
|
||
|
||
h1 = fetch_klines(symbol, "1h", 100)
|
||
h4 = fetch_klines(symbol, "4h", 100)
|
||
if h1 is None or len(h1) < 30:
|
||
return {"decision": "observe", "reason": "K线数据不足,仅观察", "score": 0, "signals": signals, "price": price, "ticker": ticker}
|
||
|
||
current_triggers = [{"type": "news", "label": event.get("event_type") or "消息触发", "source": event.get("source"), "title": event.get("title"), "published_at": event.get("published_at").isoformat() if hasattr(event.get("published_at"), "isoformat") else str(event.get("published_at", ""))}]
|
||
stale_background = []
|
||
|
||
vp = detect_volume_price_fly(h1)
|
||
if vp:
|
||
if vp.get("vp_fly_count", 0) >= 1:
|
||
score += 5
|
||
signals.append(f"1H量价齐飞({vp.get('vp_fly_count')}根, 最大量比{vp.get('max_vol_ratio')}x)")
|
||
current_triggers.append({"type": "technical", "label": "当前1H量价齐飞", "source": "binance_ohlcv_1h", "age_hours": vp.get("latest_vp_age_hours")})
|
||
elif vp.get("relaxed_vp_fly_count", 0) >= 2:
|
||
score += 4
|
||
signals.append(f"1H连续放宽量价齐飞({vp.get('relaxed_vp_fly_count')}根)")
|
||
elif vp.get("stale_vp_fly_count", 0):
|
||
stale = vp.get("stale_vp_fly_details", [{}])[-1]
|
||
signals.append(f"1H历史量价齐飞已过期({stale.get('age_hours')}小时前, 量{stale.get('vol_ratio')}x)")
|
||
stale_background.append({"type": "technical", "label": "历史1H量价齐飞", "source": "binance_ohlcv_1h", "age_hours": stale.get("age_hours"), "vol_ratio": stale.get("vol_ratio")})
|
||
elif vp.get("max_consecutive_3x", 0) >= 2:
|
||
score += 2
|
||
signals.append(f"1H连续{vp.get('max_consecutive_3x')}根3x放量")
|
||
|
||
static_acc = detect_static_accumulation(symbol, h4) if h4 is not None and len(h4) >= 30 else None
|
||
if static_acc:
|
||
score += 3
|
||
signals.append(f"4H静K蓄力({static_acc['static_count']}静K,量比{static_acc['vol_ratio']}x)")
|
||
current_triggers.append({"type": "technical", "label": "当前4H静K蓄力", "source": "pa_engine_4h"})
|
||
|
||
theme_bonus_cfg = _theme_cfg().get("static_accumulation_bonus", {}) or {}
|
||
if event.get("event_type") in ("theme_expansion", "theme_direct") and theme_bonus_cfg.get("enabled", True):
|
||
min_static = theme_bonus_cfg.get("min_static_count", 8)
|
||
bonus = theme_bonus_cfg.get("score_bonus", 3)
|
||
if static_acc.get("static_count", 0) >= min_static:
|
||
score += bonus
|
||
signals.append(f"生态主题+强静K蓄力升权(+{bonus})")
|
||
|
||
pa1 = full_pa_analysis(h1, "1h")
|
||
ignitions = pa1.get("ignition_points", []) if pa1 else []
|
||
max_ig = 0
|
||
stale_igs = []
|
||
for ig in ignitions[-5:]:
|
||
if ig.get("direction") != 1:
|
||
continue
|
||
if ig.get("age_bars", 999) <= 1:
|
||
max_ig = max(max_ig, ig.get("strength_ratio", 0))
|
||
else:
|
||
stale_igs.append(ig)
|
||
if max_ig >= 5:
|
||
score += 3
|
||
signals.append(f"1H静K→阳动K起爆(强度{max_ig}×)")
|
||
current_triggers.append({"type": "technical", "label": "当前1H起爆点", "source": "pa_engine_1h", "strength": max_ig})
|
||
elif stale_igs:
|
||
ig = stale_igs[-1]
|
||
signals.append(f"1H历史起爆点已过期({ig.get('age_bars')}根前, 强度{ig.get('strength_ratio')}×)")
|
||
stale_background.append({"type": "technical", "label": "历史1H起爆点", "source": "pa_engine_1h", "age_bars": ig.get("age_bars"), "strength": ig.get("strength_ratio")})
|
||
|
||
deriv = fetch_derivatives_context(symbol)
|
||
funding = deriv.get("funding_rate", 0) or 0
|
||
if funding > cfg.get("reject_if_funding_gt", 0.003):
|
||
signals.append(f"⛔ Funding过热({funding*100:.3f}%)")
|
||
return {"decision": "risk", "reason": "资金费率过热,不追", "score": score, "signals": signals, "price": price, "ticker": ticker, "derivatives": deriv}
|
||
if deriv.get("top_trader_long_pct", 0) and deriv.get("top_trader_long_pct", 0) > 55:
|
||
score += 1
|
||
signals.append(f"大户偏多({deriv.get('top_trader_long_pct')}%)")
|
||
|
||
atr = calc_atr(h1, 14)
|
||
if atr and atr > 0:
|
||
stop_loss = round(max(price * 0.92, price - 2 * atr), 6)
|
||
tp1 = round(price * 1.05, 6)
|
||
tp2 = round(price * 1.10, 6)
|
||
risk = price - stop_loss
|
||
entry_plan = {
|
||
"entry_price": round(price, 6),
|
||
"entry_method": "事件驱动即时技术确认",
|
||
"entry_action": "可即刻买入" if score >= cfg.get("min_tech_score_recommend", 6) else "等技术确认",
|
||
"stop_loss": stop_loss,
|
||
"stop_pct": round((stop_loss / price - 1) * 100, 1) if price else 0,
|
||
"tp1": tp1,
|
||
"tp2": tp2,
|
||
"rr1": round((tp1 - price) / risk, 2) if risk > 0 else 0,
|
||
"rr2": round((tp2 - price) / risk, 2) if risk > 0 else 0,
|
||
"current_price": round(price, 6),
|
||
"risk_reward_ok": risk > 0,
|
||
"trigger_context": {
|
||
"trigger_status": "news_current" if current_triggers else "background",
|
||
"trigger_label": "消息面触发 + 技术确认" if current_triggers else "消息背景观察",
|
||
"current_triggers": current_triggers,
|
||
"stale_background": stale_background,
|
||
"event_source": event.get("source"),
|
||
"event_title": event.get("title"),
|
||
"event_url": event.get("url"),
|
||
"event_importance": event.get("importance"),
|
||
"published_at": event.get("published_at").isoformat() if hasattr(event.get("published_at"), "isoformat") else str(event.get("published_at", "")),
|
||
},
|
||
}
|
||
|
||
if score >= cfg.get("min_tech_score_recommend", 6) and event.get("importance") in ("S", "A"):
|
||
decision = "recommend"
|
||
reason = "重大消息+技术形态确认"
|
||
elif score >= cfg.get("min_tech_score_observe", 3):
|
||
decision = "observe"
|
||
reason = "消息重大但技术只到观察级"
|
||
else:
|
||
decision = "ignore"
|
||
reason = "技术形态未确认"
|
||
|
||
return {
|
||
"decision": decision,
|
||
"reason": reason,
|
||
"score": score,
|
||
"signals": signals,
|
||
"entry_plan": entry_plan,
|
||
"price": round(price, 6),
|
||
"ticker": ticker,
|
||
"derivatives": deriv,
|
||
"static_accumulation": static_acc,
|
||
"trigger_context": {
|
||
"trigger_status": "news_current" if current_triggers else "background",
|
||
"trigger_label": "消息面触发 + 技术确认" if current_triggers else "消息背景观察",
|
||
"current_triggers": current_triggers,
|
||
"stale_background": stale_background,
|
||
"event_source": event.get("source"),
|
||
"event_title": event.get("title"),
|
||
"event_url": event.get("url"),
|
||
"event_importance": event.get("importance"),
|
||
"published_at": event.get("published_at").isoformat() if hasattr(event.get("published_at"), "isoformat") else str(event.get("published_at", "")),
|
||
},
|
||
}
|
||
|
||
|
||
def process_event(event):
|
||
result = quick_technical_check(event)
|
||
rec_id = 0
|
||
pushed = False
|
||
symbol = event["symbol"]
|
||
decision = result["decision"]
|
||
signals = result.get("signals", [])
|
||
price = result.get("price", 0)
|
||
|
||
log_screening(
|
||
layer="舆情触发",
|
||
symbol=symbol,
|
||
state="爆发" if decision == "recommend" else "蓄力" if decision == "observe" else "风险" if decision == "risk" else "过期",
|
||
score=result.get("score", 0),
|
||
price=price or 0,
|
||
signals=signals,
|
||
sector="",
|
||
leader_status=event.get("source", ""),
|
||
is_meme=0,
|
||
change_24h=(result.get("ticker") or {}).get("change_24h", 0),
|
||
funding_rate=(result.get("derivatives") or {}).get("funding_rate", 0),
|
||
)
|
||
|
||
if decision == "recommend":
|
||
ep = result.get("entry_plan") or {}
|
||
rec_id = create_recommendation(
|
||
symbol=symbol,
|
||
rec_state="爆发",
|
||
rec_score=result.get("score", 0),
|
||
entry_price=price,
|
||
stop_loss=ep.get("stop_loss", 0),
|
||
tp1=ep.get("tp1", 0),
|
||
tp2=ep.get("tp2", 0),
|
||
sector="事件驱动",
|
||
signals=signals,
|
||
is_meme=0,
|
||
entry_plan=ep,
|
||
direction=get_strategy_direction(),
|
||
force_reason="重大舆情触发",
|
||
base_state="舆情触发",
|
||
sector_signal_count=0,
|
||
market_context={"event_source": event.get("source"), "trigger_context": result.get("trigger_context", {}), **(result.get("ticker") or {})},
|
||
derivatives_context=result.get("derivatives") or {},
|
||
sector_context={"event_title": event.get("title"), "event_url": event.get("url"), "event_source": event.get("source"), "event_importance": event.get("importance"), "trigger_context": result.get("trigger_context", {})},
|
||
)
|
||
|
||
# 飞书只是通知层:事件脚本不再直接推 observe/risk,也不允许 rec_id=0 的事件旁路进通知。
|
||
# 只有 decision=recommend 且已创建主推荐记录后,消费主链路派生状态进行通知。
|
||
if decision == "recommend" and rec_id and _cfg().get("push", {}).get(decision, True):
|
||
mainline_item = get_recommendation_for_push(rec_id)
|
||
pushed = push_mainline_state_update(
|
||
symbol,
|
||
rec_id,
|
||
mainline_item,
|
||
title_prefix="事件触发机会",
|
||
entry_push_type="event_entry",
|
||
watch_push_type="event_watch_pool",
|
||
)
|
||
elif decision in ("observe", "risk"):
|
||
print(f"[event] skip push {symbol}: decision={decision} is not a主链路推荐通知")
|
||
|
||
conn = get_conn()
|
||
conn.execute("""
|
||
UPDATE event_news SET processed=1, decision=?, tech_score=?, rec_id=?, pushed=?
|
||
WHERE event_hash=?
|
||
""", (decision, result.get("score", 0), rec_id, int(pushed), event.get("event_hash")))
|
||
conn.commit()
|
||
conn.close()
|
||
return {"event": event, "result": result, "rec_id": rec_id, "pushed": pushed}
|
||
|
||
|
||
def load_unprocessed_events(limit=20):
|
||
init_event_tables()
|
||
conn = get_conn()
|
||
cutoff = (_now() - timedelta(hours=_cfg().get("max_event_age_hours", 6))).isoformat()
|
||
rows = conn.execute("""
|
||
SELECT * FROM event_news
|
||
WHERE processed=0 AND published_at >= ?
|
||
ORDER BY published_at DESC LIMIT ?
|
||
""", (cutoff, limit)).fetchall()
|
||
conn.close()
|
||
events = []
|
||
for r in rows:
|
||
d = dict(r)
|
||
d["published_at"] = datetime.fromisoformat(d["published_at"])
|
||
d["raw"] = json.loads(d.get("raw_json") or "{}")
|
||
events.append(d)
|
||
return events
|
||
|
||
|
||
def run_once(process_existing=True):
|
||
started = _now()
|
||
init_db()
|
||
init_event_tables()
|
||
collected = collect_events()
|
||
stored = store_events(collected)
|
||
to_process = stored if stored else (load_unprocessed_events() if process_existing else [])
|
||
processed = []
|
||
for e in to_process:
|
||
if isinstance(e.get("published_at"), str):
|
||
e["published_at"] = datetime.fromisoformat(e["published_at"])
|
||
processed.append(process_event(e))
|
||
|
||
output = {
|
||
"status": "processed" if processed else "no_new_events",
|
||
"collected_count": len(collected),
|
||
"stored_count": len(stored),
|
||
"processed_count": len(processed),
|
||
"decisions": {k: sum(1 for p in processed if p["result"]["decision"] == k) for k in ["recommend", "observe", "risk", "ignore"]},
|
||
"events": [
|
||
{
|
||
"symbol": p["event"].get("symbol"),
|
||
"importance": p["event"].get("importance"),
|
||
"title": p["event"].get("title"),
|
||
"decision": p["result"].get("decision"),
|
||
"score": p["result"].get("score"),
|
||
"reason": p["result"].get("reason"),
|
||
"rec_id": p.get("rec_id"),
|
||
"pushed": p.get("pushed"),
|
||
}
|
||
for p in processed
|
||
],
|
||
"check_time": _now().isoformat(),
|
||
}
|
||
log_cron_run(
|
||
job_name="事件舆情",
|
||
script_name="event_driven_screener.py",
|
||
run_status="success",
|
||
result_status=output["status"],
|
||
started_at=started.isoformat(),
|
||
finished_at=_now().isoformat(),
|
||
duration_ms=int((_now() - started).total_seconds() * 1000),
|
||
summary={"stored_count": len(stored), "processed_count": len(processed), "decisions": output["decisions"]},
|
||
error_message="",
|
||
)
|
||
return output
|
||
|
||
|
||
def main():
|
||
import argparse
|
||
parser = argparse.ArgumentParser(description="事件驱动舆情触发选币")
|
||
parser.add_argument("--once", action="store_true")
|
||
parser.add_argument("--no-process-existing", action="store_true")
|
||
args = parser.parse_args()
|
||
out = run_once(process_existing=not args.no_process_existing)
|
||
print(json.dumps(out, ensure_ascii=False, indent=2, default=str))
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|