#!/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"(?= 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()