From 44e56089f3d287c355572fe0f1f9fcb5bbc01599 Mon Sep 17 00:00:00 2001 From: xxm Date: Thu, 20 Aug 2026 01:08:17 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20=E5=B0=8F=E6=9E=9C=E6=94=B6=E5=B0=B8?= =?UTF-8?q?=E5=AE=8C=E6=88=90=E2=80=94=E2=80=94server.py=E5=81=9C=E7=94=A8?= =?UTF-8?q?/api/xiaoguo-scan,xiaoguo=5Fscanner.py=E7=A7=BB=E9=99=A4,?= =?UTF-8?q?=E6=81=A2=E5=A4=8D.sh=E6=89=A7=E8=A1=8C=E6=9D=83=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server.py | 12 +- xiaoguo_scanner.py | 357 --------------------------------------------- 2 files changed, 2 insertions(+), 367 deletions(-) delete mode 100644 xiaoguo_scanner.py diff --git a/server.py b/server.py index d740d358..e0a7fd82 100644 --- a/server.py +++ b/server.py @@ -1163,17 +1163,9 @@ def api_signals(): @app.route("/api/xiaoguo-scan") def api_xiaoguo_scan(): - """小果扫描统计""" + """小果扫描统计(2026-08-19 小果已废弃收尸,返回空)""" try: - from mofin_db import get_conn - conn = get_conn() - total = conn.execute("SELECT COUNT(*) FROM xiaoguo_scan_tracker").fetchone()[0] - found = conn.execute("SELECT COUNT(*) FROM xiaoguo_scan_tracker WHERE found_count>0").fetchone()[0] - recent = conn.execute(""" - SELECT code, name, last_scanned_at, found_count - FROM xiaoguo_scan_tracker - ORDER BY last_scanned_at DESC LIMIT 20 - """).fetchall() + total = 0; found = 0; recent = [] source_count = conn.execute(""" SELECT source, COUNT(*) as cnt FROM signal_news WHERE datetime(created_at) > datetime('now', '-1 day') diff --git a/xiaoguo_scanner.py b/xiaoguo_scanner.py deleted file mode 100644 index 286ac430..00000000 --- a/xiaoguo_scanner.py +++ /dev/null @@ -1,357 +0,0 @@ -#!/usr/bin/env python3 -"""xiaoguo_scanner.py — 小果独立扫描线 - -每5分钟跑一轮,全市场排行榜主动发现潜在标的。 -不依赖 trend_detector 信号,独立产出到 signal_news。 -""" - -import json, os, re, time, urllib.request -from pathlib import Path -from datetime import datetime - -try: - import akshare as ak - HAS_AKSHARE = True -except ImportError: - HAS_AKSHARE = False - -DATA_DIR = Path(__file__).parent / "data" -DB_PATH = DATA_DIR / "mofin.db" -XIAOGUO_API = "http://node122:18003/v1/chat/completions" # fallback, /etc/hosts resolves to LAN or EasyTier - -def _get_xiaoguo_url(): - try: - from mo_config import get_config - return get_config().xiaoguo_api_url - except Exception: - return XIAOGUO_API -XIAOGUO_MODEL = "Qwen3.6-27B-MTPLX-Optimized-Speed" -SCAN_INTERVAL = 3600 # 同一只股1小时内不重复搜 -MAX_STOCKS_PER_RUN = 15 -ARTICLES_PER_STOCK = 3 - -# 同花顺看多榜(挖掘潜力股) -BULLISH_BOARDS = [ - ("创新高", "stock_rank_cxg_ths"), - ("量价齐升", "stock_rank_ljqs_ths"), - ("向上突破", "stock_rank_xstp_ths"), - ("连续上涨", "stock_rank_lxsz_ths"), - ("持续放量", "stock_rank_cxfl_ths"), - ("险资举牌", "stock_rank_xzjp_ths"), -] - -# 同花顺看空榜(持仓风险预警) -BEARISH_BOARDS = [ - ("创新低", "stock_rank_cxd_ths"), - ("持续缩量", "stock_rank_cxsl_ths"), - ("量价齐跌", "stock_rank_ljqd_ths"), - ("连续下跌", "stock_rank_lxxd_ths"), - ("向下突破", "stock_rank_xxtp_ths"), -] - -ALL_BOARDS = BULLISH_BOARDS + BEARISH_BOARDS -BULLISH_COUNT = len(BULLISH_BOARDS) - -# 行业领涨股扫描(不轮换,每轮都跑) -# 从 market.json 读热门行业领涨股 -SECTOR_HOT_THRESHOLD = 2.5 # 板块涨幅>2.5%时捞它的领涨股 - - -def clean_proxy(): - for k in ['http_proxy','https_proxy','HTTP_PROXY','HTTPS_PROXY']: - os.environ.pop(k, None) - - -def get_conn(): - import sqlite3 - conn = sqlite3.connect(str(DB_PATH)) - conn.row_factory = sqlite3.Row - return conn - - -def fetch_hot_board(): - """东方财富热榜""" - if not HAS_AKSHARE: - return [] - try: - clean_proxy() - df = ak.stock_hot_rank_em() - if df is None or len(df) == 0: - return [] - # 东方财富热榜列名变化较大,自动检测 - cols = list(df.columns) - code_candidates = [c for c in cols if any(x in c for x in ['代码', 'code', 'CODE'])] - name_candidates = [c for c in cols if any(x in c for x in ['简称', '名称', 'name', 'NAME'])] - code_col = code_candidates[0] if code_candidates else cols[1] - name_col = name_candidates[0] if name_candidates else cols[2] - return [{"code": str(r[code_col]).zfill(6).strip(), "name": str(r[name_col]).strip(), - "rank": i+1, "source": "东方财富热榜"} - for i, (_, r) in enumerate(df.head(30).iterrows())] - except Exception: - pass - return [] - - -def fetch_rotating_board(): - """同花顺轮流榜(每轮一个),返回 (股票列表, 是否看多)""" - if not HAS_AKSHARE: - return [], True - conn = get_conn() - row = conn.execute("SELECT val FROM state_meta WHERE key='xiaoguo_board_round'").fetchone() - round_idx = (int(row[0]) if row else 0) % len(ALL_BOARDS) - conn.execute("INSERT OR REPLACE INTO state_meta (key, val) VALUES ('xiaoguo_board_round', ?)", - (str((round_idx + 1) % len(ALL_BOARDS)),)) - conn.commit() - conn.close() - - board_name, func_name = ALL_BOARDS[round_idx] - is_bullish = round_idx < BULLISH_COUNT - print(f" 同花顺榜: {board_name} {'📈看多' if is_bullish else '📉看空'}", flush=True) - - try: - clean_proxy() - fn = getattr(ak, func_name) - df = fn() - cols = list(df.columns) - code_col = [c for c in cols if '代码' in c][0] - name_col = [c for c in cols if '简称' in c or '名称' in c][0] - return [{"code": str(r[code_col]).zfill(6), "name": str(r[name_col]).strip(), - "source": f"同花顺{board_name}"} - for _, r in df.head(15).iterrows()], is_bullish - except Exception as e: - print(f" {board_name}失败: {e}", flush=True) - return [], is_bullish - - -def fetch_sector_leaders(): - """从 market.json 读热门行业领涨股""" - mkt_path = DATA_DIR / "market.json" - if not mkt_path.exists(): - return [] - try: - mkt = json.loads(mkt_path.read_text()) - sectors = mkt.get("sectors", []) - - # 代码→名称映射(优先用本地缓存,避免每次跑都调akshare) - cache_path = DATA_DIR / "stock_name_code_cache.json" - name_to_code = {} - if cache_path.exists(): - name_to_code = json.loads(cache_path.read_text()) - if not name_to_code: - try: - import akshare as ak - df = ak.stock_info_a_code_name() - for _, r in df.iterrows(): - name_to_code[r["简称"]] = r["代码"] - cache_path.write_text(json.dumps(name_to_code, ensure_ascii=False)) - print(f" 名称代码映射: {len(name_to_code)}只已缓存", flush=True) - except Exception as e: - print(f" 名称代码映射加载失败: {e}", flush=True) - - leaders = [] - seen = set() - for s in sectors: - chg = s.get("change", 0) or 0 - lead_name = s.get("lead_stock", "") - if chg < SECTOR_HOT_THRESHOLD or not lead_name or lead_name in seen: - continue - seen.add(lead_name) - code = name_to_code.get(lead_name, "") - if not code: - continue - leaders.append({ - "code": code, - "name": lead_name, - "source": f"行业领涨-{s['name']}+{chg:+.1f}%", - }) - return leaders - except Exception as e: - print(f" 行业领涨获取失败: {e}", flush=True) - return [] - - -def get_scanned_codes(conn): - """取1小时内已扫描过的代码""" - rows = conn.execute( - "SELECT code FROM xiaoguo_scan_tracker WHERE datetime(last_scanned_at) > datetime('now', '-1 hour')" - ).fetchall() - return {r[0] for r in rows} - - -def mark_scanned(conn, code, name, found): - conn.execute( - "INSERT OR REPLACE INTO xiaoguo_scan_tracker (code, name, last_scanned_at, found_count) " - "VALUES (?, ?, datetime('now','localtime'), COALESCE((SELECT found_count FROM xiaoguo_scan_tracker WHERE code=?),0)+?)", - (code, name, code, 1 if found else 0) - ) - conn.commit() - - -def search_news(code, max_results=3): - """akshare搜个股新闻""" - articles = [] - if not HAS_AKSHARE: - return articles - try: - clean_proxy() - df = ak.stock_news_em(symbol=code) - for _, r in df.head(max_results).iterrows(): - title = r.get('新闻标题', '') - content = r.get('新闻内容', '') - if title and len(title) > 5: - articles.append({"title": title, "content": content}) - except: - pass - return articles - - -def check_stock(code, name, articles): - """小果LLM判断这只股票是否有料(一次调用判断所有文章)""" - if not articles: - return None, None - - lines = [f"{i+1}. {a['title']}" for i, a in enumerate(articles[:3])] - prompt = f"""以下是最新关于{name}({code})的新闻标题。 -该股今日上了人气热榜/技术榜单。 - -新闻: -{chr(10).join(lines)} - -这只股上榜是否跟这些新闻有关?有关的话是利好还是利空? -回答格式:有关(利好|利空|中性) 或 无关 -回答:""" - - payload = json.dumps({ - "model": XIAOGUO_MODEL, - "messages": [{"role": "user", "content": prompt}], - "temperature": 0.1, "max_tokens": 100, - }).encode() - - opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) - req = urllib.request.Request(_get_xiaoguo_url(), data=payload, - headers={"Content-Type": "application/json"}, method="POST") - try: - resp = opener.open(req, timeout=30) - reply = json.loads(resp.read())["choices"][0]["message"]["content"] - if "有关" in reply or "利好" in reply or "利空" in reply: - for s in ["利好", "利空", "中性"]: - if s in reply: - return True, s - return True, "中性" - except Exception as e: - # LLM不可达 → 降级:标记为unknown,不阻塞扫描流程 - print(f" ⚠️ 小果LLM不可达({str(e)[:30]}),降级为unknown", flush=True) - return True, "unknown" - return None, None - - -def main(): - start_time = time.time() - conn = get_conn() - - # 1. 拉板 - hot = fetch_hot_board() - rotating, is_bullish = fetch_rotating_board() - leaders = fetch_sector_leaders() - elapsed = time.time() - start_time - print(f"榜单: 东方财富{len(hot)}只, 同花顺{len(rotating)}只, 行业领涨{len(leaders)}只 ({elapsed:.0f}s)", flush=True) - - if not hot and not rotating and not leaders: - conn.close() - return - - # 加载持仓代码(用于看空榜比对) - holdings = set() - if not is_bullish: - cur = conn.execute("SELECT code FROM holdings WHERE is_active=1") - holdings = {r[0].lstrip("0") for r in cur.fetchall()} - # 也查自选 - cur2 = conn.execute("SELECT code FROM watchlist_stocks") - holdings.update({r[0].lstrip("0") for r in cur2.fetchall()}) - - # 2. 合并去重 + 看空榜只保留持仓股 - all_stocks = {} - # 行业领涨优先(热门板块龙头) - for s in (leaders if is_bullish else []) + hot + rotating: - code = s["code"] - code_stripped = code.lstrip("0") - if not is_bullish: - # 看空榜:只处理持仓/自选中的股票 - if code_stripped not in holdings: - continue - if code not in all_stocks: - all_stocks[code] = {"code": code, "name": s["name"], "sources": []} - all_stocks[code]["sources"].append(s["source"]) - - if not all_stocks: - if is_bullish: - print("榜单为空", flush=True) - else: - print(f"看空榜无持仓股命中", flush=True) - conn.close() - return - - # 3. 排除已搜索过的(看空榜不排除——每次都要检查风险) - scanned = get_scanned_codes(conn) - if is_bullish: - candidates = [s for code, s in all_stocks.items() - if code not in scanned and len(code) == 6 and code.isdigit()][:MAX_STOCKS_PER_RUN] - else: - # 看空榜:不限数量,全检 - candidates = [s for code, s in all_stocks.items() - if len(code) == 6 and code.isdigit()] - - if not candidates: - print(f"无新候选(已有 {len(scanned)} 只已扫描)", flush=True) - conn.close() - return - - print(f"待扫描: {len(candidates)} 只({'看多' if is_bullish else '看空'}榜)", flush=True) - - # 4. 逐只处理 - found_any = False - for stock in candidates: - code, name = stock["code"], stock["name"] - sources = "|".join(stock["sources"]) - - articles = search_news(code, ARTICLES_PER_STOCK) if is_bullish else [] - if not articles and is_bullish: - mark_scanned(conn, code, name, False) - continue - - has_found = False - if is_bullish: - ok, sentiment = check_stock(code, name, articles) - if ok: - has_found = True - found_any = True - conn.execute( - "INSERT INTO signal_news (signal_id, sector, overall_sentiment, summary, key_articles, searched_stocks, source) " - "VALUES (NULL, ?, ?, ?, ?, ?, 'xiaoguo')", - (f"扫描-{name}", sentiment, f"[{sources}] {articles[0]['title'][:80]}", - json.dumps([{"title": a["title"], "sentiment": sentiment, "summary": (a.get("content") or "")[:100]} for a in articles[:3]], ensure_ascii=False), - json.dumps([name], ensure_ascii=False)) - ) - print(f" ✅ {name}({code}) [{sources}] {sentiment}: {articles[0]['title'][:50]}", flush=True) - mark_scanned(conn, code, name, has_found) - else: - # 看空榜:直接写入风险信号 - has_found = True - found_any = True - conn.execute( - "INSERT INTO signal_news (signal_id, sector, overall_sentiment, summary, key_articles, searched_stocks, source) " - "VALUES (NULL, ?, ?, ?, ?, ?, 'xiaoguo_risk')", - (f"预警-{name}", "偏空", f"[{sources}] {name}登上{sources}榜,需关注持仓风险", - json.dumps([{"title": name, "sentiment": "偏空", "summary": f"上榜{sources}"}], ensure_ascii=False), - json.dumps([name], ensure_ascii=False)) - ) - print(f" ⚠️ {name}({code}) [{sources}] 持仓风险信号", flush=True) - mark_scanned(conn, code, name, has_found) - - total_time = time.time() - start_time - print(f"完成: {len(candidates)}只{'看多' if is_bullish else '看空'}扫描, {'有发现' if found_any else '无发现'} ({total_time:.0f}s)", flush=True) - conn.close() - - -if __name__ == "__main__": - main()