alphax/app/services/event_driven_screener.py
2026-05-17 23:58:24 +08:00

945 lines
36 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
事件驱动舆情触发选币 v1.7.3
目标:重大消息刚发生 → 时间窗/去重/重要性评级 → 单币快速技术检查 → 飞书推送。
原则:消息只负责触发检查,技术形态决定是否推荐。
"""
import os
import re
import sys
import json
import time
import hashlib
from datetime import datetime, timedelta, timezone
from email.utils import parsedate_to_datetime
from pathlib import Path
from urllib.parse import quote_plus
import xml.etree.ElementTree as ET
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.postgres_connection import ensure_migrations_once
from app.db.llm_insights import repair_mojibake_json, repair_mojibake_text
from app.core.opportunity_funnel import build_screening_detail
from app.services.altcoin_screener import (
fetch_all_tickers,
detect_volume_price_fly,
detect_static_accumulation,
STABLECOINS,
WRAPPED,
GOLD_METAL,
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
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 _xml_text(node, names):
for name in names:
child = node.find(name)
if child is not None and child.text:
return child.text.strip()
return ""
def _find_feed_items(root):
items = root.findall(".//item")
if items:
return items
return root.findall(".//{http://www.w3.org/2005/Atom}entry")
def _feed_entry_title(item):
return _xml_text(item, ["title", "{http://www.w3.org/2005/Atom}title"])
def _feed_entry_url(item):
link = item.find("link")
if link is not None:
href = link.attrib.get("href") or ""
if href:
return href.strip()
if link.text:
return link.text.strip()
atom_link = item.find("{http://www.w3.org/2005/Atom}link")
if atom_link is not None:
href = atom_link.attrib.get("href") or ""
if href:
return href.strip()
if atom_link.text:
return atom_link.text.strip()
guid = _xml_text(item, ["guid", "{http://www.w3.org/2005/Atom}id"])
return guid
def _feed_entry_time(item):
value = _xml_text(item, ["pubDate", "published", "updated", "{http://www.w3.org/2005/Atom}published", "{http://www.w3.org/2005/Atom}updated"])
return _parse_pubdate(value)
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():
ensure_migrations_once()
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 _symbols_from_text(text, aliases=None):
aliases = aliases or {}
symbols = set(_symbol_from_title(text))
clean = str(text or "")
for base in re.findall(r"(?<![A-Za-z0-9])\$?([A-Z][A-Z0-9]{1,12})(?![A-Za-z0-9])", clean):
symbol = f"{base.upper()}/USDT"
if _tradable_symbol(symbol):
symbols.add(symbol)
low = clean.lower()
for name, base in aliases.items():
if str(name or "").lower() in low:
symbol = f"{str(base or '').upper()}/USDT"
if _tradable_symbol(symbol):
symbols.add(symbol)
return sorted(symbols)
def _tradable_symbol(symbol):
base = symbol.split("/")[0].upper()
if base in STABLECOINS or base in WRAPPED or base in GOLD_METAL:
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 fetch_rss_events(source_key, source_cfg):
"""Fetch generic RSS/Atom sources such as WuBlockchain."""
if not source_cfg.get("enabled", True):
return []
url = source_cfg.get("url")
if not url:
return []
max_items = int(source_cfg.get("max_items", 30) or 30)
default_importance = str(source_cfg.get("weight") or "B").upper()
symbol_aliases = source_cfg.get("symbol_aliases") or {}
events = []
try:
r = requests.get(url, timeout=int(source_cfg.get("timeout", 12) or 12), headers={"User-Agent": "Mozilla/5.0 AlphaX/1.0"})
if r.status_code != 200:
return []
root = ET.fromstring(r.content)
for item in _find_feed_items(root)[:max_items]:
title = _feed_entry_title(item)
if not title:
continue
pub = _feed_entry_time(item) or _now()
if not _is_recent(pub, _cfg().get("news_time_window_hours", 3)):
continue
symbols = _symbols_from_text(title, symbol_aliases)
if not symbols:
continue
importance, event_type = classify_event(title, source_key)
importance = _level_max(importance, default_importance)
if not _passes_min_importance(importance):
continue
raw = {
"title": title,
"link": _feed_entry_url(item),
"published_at": pub.isoformat(),
"source_label": source_cfg.get("label") or source_key,
}
for symbol in symbols[: int(source_cfg.get("max_symbols_per_item", 6) or 6)]:
events.append({
"source": source_key,
"symbol": symbol,
"title": title,
"url": raw["link"],
"published_at": pub,
"importance": importance,
"event_type": event_type if event_type != "minor_or_unknown" else "news",
"raw": raw,
})
return events
except Exception as e:
print(f"[event] fetch_rss_events error {source_key}: {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]))
for key, source_cfg in sources.items():
if source_cfg.get("type") in ("rss", "atom"):
events.extend(fetch_rss_events(key, source_cfg))
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:
cur = conn.execute("""
INSERT INTO event_news
(event_hash, source, symbol, title, url, published_at, detected_at, importance, event_type, raw_json)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT(event_hash) DO NOTHING
RETURNING id
""", (
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),
))
if cur.fetchone():
e["event_hash"] = h
stored.append(e)
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=%s AND detected_at >= %s
LIMIT 1
""",
(event["symbol"], cooldown_cutoff),
).fetchone()
if recent:
skipped += 1
continue
h = _event_hash(event["source"], event["title"], event["symbol"])
try:
cur = conn.execute(
"""
INSERT INTO event_news
(event_hash, source, symbol, title, url, published_at, detected_at, importance, event_type, raw_json, processed)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 0)
ON CONFLICT(event_hash) DO NOTHING
RETURNING id
""",
(
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),
),
)
if not cur.fetchone():
skipped += 1
continue
queued.append(event["symbol"])
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),
detail=build_screening_detail(
layer="舆情触发",
state="爆发" if decision == "recommend" else "蓄力" if decision == "observe" else "风险" if decision == "risk" else "过期",
signals=signals,
detail={
"candidate_stage": "discovery_candidate",
"decision": decision,
"reason": result.get("reason", ""),
"event_source": event.get("source"),
"event_title": event.get("title"),
"event_importance": event.get("importance"),
"trigger_context": result.get("trigger_context") or {},
"signal_codes": [],
},
),
)
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=%s, tech_score=%s, rec_id=%s, pushed=%s
WHERE event_hash=%s
""", (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 >= %s
ORDER BY published_at DESC LIMIT %s
""", (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()