alphax/event_driven_screener.py
2026-05-13 22:32:50 +08:00

715 lines
28 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
import sqlite3
from datetime import datetime, timedelta, timezone
from email.utils import parsedate_to_datetime
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 config_loader import load_rules, get_meta, get_strategy_direction
from altcoin_db import init_db, get_conn, create_recommendation, log_screening, log_cron_run, should_push, log_push, get_recommendation_for_push
from 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 altcoin_confirm import fetch_derivatives_context
from pa_engine import full_pa_analysis, calc_atr
from feishu_push import push_recommendation_state_alert
DB_PATH = os.getenv("ALPHAX_DB_PATH", os.path.join(os.path.dirname(__file__), "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 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 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)
if mainline_item and mainline_item.get("execution_status") in ("buy_now", "wait_pullback"):
push_type = "event_entry" if mainline_item.get("execution_status") == "buy_now" else "event_watch_pool"
action = mainline_item.get("action_status", "")
if should_push(symbol, push_type, action):
ok, resp = push_recommendation_state_alert(mainline_item, title_prefix="事件触发机会")
if ok:
pushed = True
log_push(symbol, push_type, action, rec_id=rec_id)
else:
print(f"[event] push failed {symbol}: {resp}")
else:
status = mainline_item.get("execution_status") if mainline_item else "missing"
print(f"[event] skip push {symbol}: mainline_status={status}")
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()