diff --git a/.continue/rules/new-rule.md b/.continue/rules/new-rule.md new file mode 100644 index 0000000..27eb2df --- /dev/null +++ b/.continue/rules/new-rule.md @@ -0,0 +1,5 @@ +--- +description: A description of your rule +--- + +work in windows wsl. \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..7a73a41 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,2 @@ +{ +} \ No newline at end of file diff --git a/20260613T161709_session.md b/20260613T161709_session.md new file mode 100644 index 0000000..52619af --- /dev/null +++ b/20260613T161709_session.md @@ -0,0 +1,1321 @@ +### [Continue](https://continue.dev) session transcript + Exported: 6/13/2026, 4:17:09 PM + +#### _User_ + +> 你是什么模型 版本号多少 + +#### _Assistant_ + +> 我是 Claude,由 Anthropic 开发,具体是 Claude Opus 4.6(模型 ID:claude-opus-4-6)。有什么可以帮你的? + +#### _User_ + +> 帮我分析一下当前项目 有没有什么优化的功能 + +#### _Assistant_ + +> 我先来了解一下你的项目结构和内容。 +> +> + +#### _Assistant_ + +> .git/ +> .gitignore +> backend/ +> prototype/ +> README.md +> 功能架构.md +> 架构总结.md + +#### _Assistant_ + +> 让我先看看项目的架构文档和主要代码结构。 +> +> + +#### _Assistant_ + +> # 智策 StockTerminal > +> > +> 个人/小团队 A 股分析·复盘·智能辅助系统。后端提供行情、回测、AI 诊断与定时任务;前端为纯 HTML + ECharts 原型界面,由 FastAPI 统一托管。 > +> > +> ## 功能概览 > +> > +> | 模块 | 能力 | > +> |---|---| > +> | 大盘行情 | 三大指数、情绪温度计、板块云图、热股榜、龙虎榜 | > +> | 自选股 | 自选列表、内置策略选股、多因子条件过滤 | > +> | 复盘中心 | 每日复盘、AI 七段式日报、个股 K 线回放 | > +> | 策略与中台 | MA 交叉回测、数据入库状态、任务日志 | > +> | AI 分析 | 个股诊断(6 维证据链)、信号胜率、预测留痕与核验 | > +> | 组合交易 | 持仓 P&L、资金曲线、交易日志、盈亏归因 | > +> | 智能预警 | 价格/涨跌幅规则、多通道推送、触发记录 | > +> | 资讯中心 | 财经快讯、情绪标注、自选相关资讯 | > +> > +> 更完整的架构说明见 [架构总结.md](./架构总结.md)。 > +> > +> ## 技术栈 > +> > +> - **前端**:HTML + CSS + 原生 JS,ECharts 5(CDN) > +> - **后端**:Python 3.12 · FastAPI · uvicorn > +> - **数据库**:PostgreSQL · SQLAlchemy 2.0 > +> - **数据源**:AkShare(行情/情绪/资讯),Sina 实时报价 > +> - **调度**:APScheduler > +> - **AI**:OpenAI 兼容接口(DeepSeek / 通义 / Kimi 等),无 Key 时规则降级 > +> > +> ## 项目结构 > +> > +> ``` > +> stock_cs/ > +> ├── backend/ # FastAPI 后端 > +> │ ├── main.py # API 入口 + 静态文件托管 > +> │ ├── cli.py # 建库 / 入库命令行工具 > +> │ ├── .env.example # 环境变量模板 > +> │ └── requirements.txt > +> ├── prototype/ # 前端原型(index.html / app.js / style.css) > +> ├── 架构总结.md > +> └── 功能架构.md > +> ``` > +> > +> ## 环境要求 > +> > +> - Python 3.12+ > +> - PostgreSQL 14+(本地或远程均可) > +> - 可选:大模型 API Key、推送渠道密钥(见下方配置) > +> > +> ## 快速开始 > +> > +> ### 1. 安装依赖 > +> > +> ```bash > +> cd backend > +> python -m venv .venv > +> > +> # Windows > +> .venv\Scripts\activate > +> > +> # Linux / macOS > +> source .venv/bin/activate > +> > +> pip install -r requirements.txt > +> ``` > +> > +> ### 2. 配置环境变量 > +> > +> ```bash > +> cp .env.example .env > +> ``` > +> > +> 编辑 `backend/.env`,至少确认 PostgreSQL 连接信息(也可通过环境变量 `PG_USER` / `PG_PASSWORD` / `PG_HOST` / `PG_PORT` / `PG_DB` 设置): > +> > +> ```env > +> PG_USER=postgres > +> PG_PASSWORD=your_password > +> PG_HOST=localhost > +> PG_PORT=5432 > +> PG_DB=stock_cs > +> ``` > +> > +> 可选:填入 `LLM_API_KEY` 启用大模型分析;填入 SMTP / Server酱 / 企业微信 / PushPlus 启用推送。 > +> > +> ### 3. 初始化数据库并入库 > +> > +> ```bash > +> cd backend > +> > +> # 建库建表 > +> python cli.py init > +> > +> # 抓取当日板块/资金流/情绪/龙虎榜等快照 > +> python cli.py ingest > +> > +> # 全市场日线历史入库(默认 250 交易日,耗时较长) > +> python cli.py ingest_all > +> python cli.py ingest_all 500 # 指定天数 > +> ``` > +> > +> 指定股票入库: > +> > +> ```bash > +> python cli.py ingest 600519 000001 > +> ``` > +> > +> ### 4. 启动服务 > +> > +> ```bash > +> cd backend > +> python main.py > +> ``` > +> > +> 浏览器访问:**http://127.0.0.1:8000** > +> > +> 健康检查:`GET /api/health` > +> > +> ## 定时任务 > +> > +> 服务启动后,APScheduler 会在工作日自动执行(可在 `config.py` 或环境变量中调整时间): > +> > +> | 任务 | 默认时间 | 说明 | > +> |---|---|---| > +> | `daily_ingest` | 15:35 | 收盘后增量入库 | > +> | `alert_check` | 每 60 秒 | 实时报价预警核查 | > +> | `daily_report` | 15:45 | 生成 AI 复盘日报并推送 | > +> | `verify_pred` | 15:50 | 核验到期 AI 预测 | > +> | `signal_stats` | 周六 09:00 | 全市场信号胜率回测 | > +> > +> ## 推送渠道 > +> > +> 在 `.env` 中配置任意一种即可启用,互不依赖: > +> > +> | 渠道 | 配置项 | > +> |---|---| > +> | SMTP 邮件 | `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `SMTP_PASSWORD` / `SMTP_TO` | > +> | Server酱 | `SERVERCHAN_KEY` | > +> | 企业微信 | `WECOM_WEBHOOK` | > +> | PushPlus | `PUSHPLUS_TOKEN` | > +> > +> ## 开发说明 > +> > +> - 前端静态资源由 `main.py` 挂载 `prototype/` 目录,修改前端后刷新浏览器即可。 > +> - 自选股列表持久化在 `backend/watchlist.json`。 > +> - AkShare 不可用时部分接口会降级为 mock 数据,详见 `/api/health` 中的 `akshare` 字段。 > +> - 敏感文件(`.env`、虚拟环境等)已在 `.gitignore` 中排除,请勿提交密钥。 > +> > +> ## 文档 > +> > +> - [架构总结.md](./架构总结.md) — 分层设计、数据模型、AI 分析流程 > +> - [功能架构.md](./功能架构.md) — 功能模块说明 > +> > +> ## 许可证 > +> > +> 本项目仅供学习与研究使用。行情数据来源于第三方公开接口,请遵守相应数据源的使用条款。 > +> + +#### _Assistant_ + +> # 智策 StockTerminal — 架构总结 > +> > +> > 版本:当前实现态(2026-06) > +> > 定位:个人/小团队 A 股分析·复盘·智能辅助系统 > +> > +> --- > +> > +> ## 一、整体分层 > +> > +> ``` > +> ┌─────────────────────────────────────────────────────────────┐ > +> │ 展示层(前端) │ > +> │ HTML + ECharts 5 + 原生 JS │ > +> │ prototype/index.html · style.css · app.js │ > +> └────────────────────────┬────────────────────────────────────┘ > +> │ HTTP / REST > +> ┌────────────────────────▼────────────────────────────────────┐ > +> │ 服务层(后端 FastAPI) │ > +> │ main.py —— 50+ API 端点,静态文件托管 │ > +> │ │ > +> │ 数据服务 akshare_service.py (行情·情绪·资讯·实时报价) │ > +> │ AI 分析 ai.py (证据链·置信度·LLM/规则) │ > +> │ 信号统计 signals.py (胜率回测·预测留痕·核验) │ > +> │ RAG 检索 rag.py (资讯检索·情绪标注) │ > +> │ 复盘日报 report.py (结构化日报·推送) │ > +> │ 组合计算 portfolio.py (持仓·P&L·资金曲线) │ > +> │ 回测引擎 backtest.py (MA交叉·净值曲线) │ > +> │ 预警引擎 alerts.py (实时报价判断·触发事件) │ > +> │ 推送通知 notifier.py (SMTP·Server酱·企微·PP) │ > +> │ 大模型客户端 llm.py (OpenAI 兼容接口) │ > +> │ 定时调度 scheduler.py (APScheduler 5 个任务) │ > +> │ ETL 入库 ingest.py (AkShare→PostgreSQL) │ > +> │ CLI 工具 cli.py (init/ingest/ingest_all) │ > +> │ 配置 config.py + .env (DB·LLM·推送密钥) │ > +> └────────────────────────┬────────────────────────────────────┘ > +> │ SQLAlchemy ORM / psycopg2 > +> ┌────────────────────────▼────────────────────────────────────┐ > +> │ 数据层(PostgreSQL) │ > +> │ 13 张业务表,见下方数据模型 │ > +> └─────────────────────────────────────────────────────────────┘ > +> ``` > +> > +> --- > +> > +> ## 二、后端模块职责 > +> > +> | 文件 | 职责 | 关键依赖 | > +> |---|---|---| > +> | `main.py` | FastAPI 入口,50+ REST 端点,静态托管 | 所有模块 | > +> | `config.py` | 环境变量读取,支持 `.env` 文件 | python-dotenv | > +> | `db.py` | 引擎/Session,自动建库建表 | SQLAlchemy, psycopg2 | > +> | `models.py` | 13 个 ORM 表(见下节) | SQLAlchemy | > +> | `akshare_service.py` | AkShare 数据抓取,带 TTL 缓存和 mock 降级 | akshare, cachetools | > +> | `ingest.py` | ETL:AkShare → PostgreSQL 增量 upsert | akshare, models | > +> | `scheduler.py` | APScheduler 后台任务(5 个定时任务) | apscheduler | > +> | `backtest.py` | MA 交叉策略回测,读 DB 日线,输出净值曲线 | sqlalchemy, pandas | > +> | `ai.py` | AI 分析:证据链构造、置信度计算、LLM 调用/规则降级 | llm, signals, rag | > +> | `signals.py` | 6 类信号历史胜率回测;预测留痕 + 到期核验 | sqlalchemy, pandas, numpy | > +> | `rag.py` | 资讯检索 + 利好/利空情绪标注,作为 LLM 上下文 | akshare_service | > +> | `report.py` | 七段式 AI 复盘日报生成、落库、推送精简版 | ai, notifier | > +> | `portfolio.py` | 持仓计算(移动加权均价)、P&L 归因、逐日资金曲线 | sqlalchemy | > +> | `alerts.py` | 实时报价轮询 → 规则命中 → 写事件 + 推送 | akshare_service, notifier | > +> | `notifier.py` | 四渠道推送:SMTP 邮件、Server酱、企业微信 Webhook、PushPlus | requests, smtplib | > +> | `llm.py` | OpenAI 兼容客户端(DeepSeek/通义/Kimi 均适用) | requests | > +> | `cli.py` | 命令行工具:`init` / `ingest` / `ingest_all` | ingest, db | > +> > +> --- > +> > +> ## 三、数据模型(PostgreSQL) > +> > +> | 表名 | 说明 | 主键 / 唯一约束 | > +> |---|---|---| > +> | `securities` | 证券基础信息(代码·名称·市场) | `code` | > +> | `quotes_daily` | 个股日线(OHLCV,前复权) | `(code, date)` | > +> | `index_daily` | 指数日线(上证/深证/沪深300) | `(code, date)` | > +> | `sector_daily` | 板块每日快照(涨跌·成交额·龙头) | `(date, name)` | > +> | `fund_flow_daily` | 行业主力资金流每日快照 | `(date, name)` | > +> | `sentiment_daily` | 全市场情绪(涨跌家数/涨跌停) | `date` | > +> | `dragon_tiger` | 龙虎榜明细(代码·席位·净买额) | `(date, code, reason)` | > +> | `stock_metrics` | 个股最新因子快照(MA/RSI/MACD/量比/分位/连涨 等 15 个因子) | `code` | > +> | `signal_stats` | 6 类技术信号历史胜率(回测样本·N日上涨概率·平均收益) | `(signal, horizon)` | > +> | `predictions` | AI 诊断留痕(方向·置信度·N日后核验·命中与否) | `(code, date, kind)` | > +> | `daily_reports` | AI 复盘日报(markdown 正文·来源·是否已推送) | `date` | > +> | `trades` | 交易记录(买卖·价格·数量·手续费·理由·情绪标签) | `id` | > +> | `alert_rules` | 预警规则(价格上穿/下穿/涨跌幅条件·状态) | `id` | > +> | `alert_events` | 预警触发事件(站内通知·已读状态) | `id` | > +> | `job_runs` | 定时任务执行日志 | `id` | > +> > +> --- > +> > +> ## 四、定时任务(APScheduler,周一至周五) > +> > +> | 任务 ID | 触发时间 | 功能 | > +> |---|---|---| > +> | `daily_ingest` | 15:35(收盘后,可配) | 抓取当日板块/资金流/情绪/龙虎榜/自选股日线入库 | > +> | `alert_check` | 每 60 秒 | 实时报价核查所有 active 预警规则,触发则写事件并推送 | > +> | `daily_report` | 15:45(入库+10分) | 生成 AI 七段式复盘日报并推送微信/邮件 | > +> | `verify_pred` | 15:50 | 核验到期 AI 预测,计算命中率 | > +> | `signal_stats` | 每周六 09:00 | 对全市场样本股回测 6 类信号历史胜率(可手动触发) | > +> > +> --- > +> > +> ## 五、AI 分析层设计(可回溯) > +> > +> ``` > +> 用户请求 /api/ai/diagnose > +> │ > +> ▼ > +> _stock_context() 从 stock_metrics + DailyQuote 取最新因子 > +> │ > +> ├─ signals.get_stats() 读 signal_stats 表,取各信号历史胜率 > +> │ > +> ├─ rag.stock_context() 拉近期资讯 → 情绪标注(利好/利空/中性) > +> │ > +> ▼ > +> _build_evidence() 生成 6 维证据链(趋势/技术/动量/资金/位置/消息) > +> 每条附:事实描述 · 方向(bull/bear/neutral) · 历史胜率 · 样本数 > +> │ > +> ▼ > +> _confidence_direction() 加权多空净值 → 置信度(%) + 预测方向(up/down/flat) > +> │ > +> ├─ signals.record_prediction() 写入 predictions 表留痕 > +> │ > +> ├─ llm.ask() (有 key) 将证据链+胜率+RAG资讯构造 prompt → 大模型输出 > +> │ 或 > +> └─ 规则文本降级 格式化证据链为可读文本 > +> ``` > +> > +> 实测准确率闭环: > +> ``` > +> 预测留痕(open) → N 个交易日后 verify_predictions() 拉 DailyQuote 核验 > +> → 填写 actual_ret + hit(True/False) → status=closed > +> → /api/ai/accuracy 按方向汇总命中率 > +> ``` > +> > +> --- > +> > +> ## 六、推送通知渠道 > +> > +> | 渠道 | 触发条件 | 配置项(`.env`) | > +> |---|---|---| > +> | SMTP 邮件 | 预警触发 / 复盘日报 / 测试 | `SMTP_HOST/PORT/USER/PASSWORD/TO` | > +> | Server酱(微信) | 同上 | `SERVERCHAN_KEY` | > +> | 企业微信群机器人 | 同上 | `WECOM_WEBHOOK` | > +> | PushPlus(微信) | 同上 | `PUSHPLUS_TOKEN` | > +> > +> 任意渠道配置即自动启用,互不依赖。 > +> > +> --- > +> > +> ## 七、前端菜单结构(app.js MENU) > +> > +> ``` > +> 大盘行情 > +> ├─ 市场总览 行情/三大指数/情绪温度计/资金流向 > +> ├─ 大盘云图 行业/板块涨跌热力图(ECharts treemap) > +> ├─ 热股榜 实时热股+板块 > +> └─ 龙虎榜 当日龙虎榜净买额排名 > +> > +> 自选股 > +> ├─ 自选列表 持仓快照 + K线 > +> ├─ 全市场选股 8 个内置策略 + SQL 直查 stock_metrics > +> └─ 条件选股 客户端多因子过滤 > +> > +> 复盘中心 > +> ├─ 每日复盘 板块/情绪/资金流统计 > +> ├─ AI日报 七段式 markdown 日报,历史翻阅,一键推送 > +> └─ 个股复盘 K线回放 + MA交叉买卖点标注 > +> > +> 策略与中台 > +> ├─ 策略回测 MA交叉净值曲线 + 最大回撤 > +> └─ 数据中台 入库状态 / 手动触发 / 任务日志 > +> > +> AI 分析 > +> ├─ 今日策略 大模型/规则 生成当日操作方向 > +> ├─ 个股诊断 6维证据链 + 历史胜率 + 置信度 + RAG资讯 > +> ├─ AI复盘点评 当日大模型复盘摘要 > +> └─ AI准确率 信号历史胜率表 + 实测命中率 + 近期核验记录 > +> > +> 组合交易 > +> ├─ 组合持仓 实时市值/浮亏/胜率 > +> ├─ 资金曲线 逐日净值 vs 沪深300基准(ECharts折线) > +> ├─ 交易日志 录入买卖 / 理由 / 情绪标签 > +> └─ 盈亏归因 按个股/理由/情绪三维归因 > +> > +> 智能预警 > +> ├─ 预警规则 创建/删除/重激活规则,推送渠道状态检测 > +> └─ 触发记录 预警事件列表,标记已读,🔔铃铛角标 > +> > +> 资讯中心 > +> ├─ 要闻快讯 全球财经资讯 + 情绪标注 + AI摘要 > +> └─ 自选相关 自选股关联资讯聚合 > +> ``` > +> > +> --- > +> > +> ## 八、技术栈 > +> > +> | 层 | 技术选型 | > +> |---|---| > +> | 前端 | 纯 HTML + CSS + 原生 JS,ECharts 5(CDN) | > +> | 后端 | Python 3.12 + FastAPI 0.115 + uvicorn | > +> | 数据库 | PostgreSQL(psycopg2-binary + SQLAlchemy 2.0) | > +> | 数据源 | AkShare(新浪/同花顺/乐估备选源),Sina hq 实时报价 | > +> | 调度 | APScheduler 3.x BackgroundScheduler | > +> | AI | OpenAI 兼容 REST(默认 DeepSeek),规则降级无缝切换 | > +> | 缓存 | cachetools TTL(内存),无 Redis 依赖 | > +> | 推送 | SMTP(SSL) + Server酱 + 企业微信 Webhook + PushPlus | > +> | 部署 | WSL2 / Linux,`nohup python main.py`,可 Docker 化 | > +> > +> --- > +> > +> ## 九、待完善方向 > +> > +> | 优先级 | 模块 | 建议 | > +> |---|---|---| > +> | 高 | 回测引擎 | 手续费/滑点建模,参数寻优,月度收益热力图 | > +> | 高 | 数据稳定性 | 东财源封锁问题,增加 Tushare 备用源,分钟线入库 | > +> | 中 | RAG 升级 | Embedding + 向量库(Chroma/Qdrant),支持语义检索财报 | > +> | 中 | 多组合 | 目前单一组合,扩展为模拟盘/真实盘多组合管理 | > +> | 中 | 盘中异动雷达 | 快速拉升/放量突破/涨停打开实时监测 | > +> | 低 | 用户系统 | 登录/鉴权,多用户自选股独立管理 | > +> | 低 | 部署文档 | Docker Compose(FastAPI + PostgreSQL),Nginx 反代 | > +> + +#### _Assistant_ + +> .env.example +> .venv/ +> ai.py +> akshare_service.py +> alerts.py +> backtest.py +> cli.py +> config.py +> db.py +> ingest.py +> llm.py +> main.py +> models.py +> notifier.py +> portfolio.py +> rag.py +> report.py +> requirements.txt +> scheduler.py +> signals.py +> __pycache__/ + +#### _Assistant_ + +> """智策股票终端 — FastAPI 后端入口。 > +> > +> - /api/* : 数据接口(基于 AkShare,带缓存与降级) > +> - / : 托管前端原型(prototype 目录) > +> """ > +> import os > +> import json > +> import datetime as dt > +> from contextlib import asynccontextmanager > +> > +> from fastapi import FastAPI, Query > +> from fastapi.middleware.cors import CORSMiddleware > +> from fastapi.staticfiles import StaticFiles > +> from sqlalchemy import select, func, desc > +> > +> from pydantic import BaseModel > +> > +> import akshare_service as svc > +> import config > +> import scheduler > +> import backtest as bt > +> import ai > +> import signals as sig > +> import report as rpt > +> import portfolio as pf > +> import llm > +> import alerts as al > +> import notifier > +> from db import init_db, get_session > +> from models import (DailyQuote, IndexDaily, SectorDaily, FundFlowDaily, > +> SentimentDaily, DragonTiger, Security, JobRun, StockMetric, Trade, > +> AlertRule, AlertEvent) > +> > +> > +> @asynccontextmanager > +> async def lifespan(app: FastAPI): > +> try: > +> init_db() > +> scheduler.start_scheduler() > +> print("[startup] db + scheduler ready") > +> except Exception as e: > +> print("[startup] WARN:", repr(e)[:160]) > +> yield > +> > +> > +> app = FastAPI(title="智策股票终端 API", version="0.2.0", lifespan=lifespan) > +> > +> app.add_middleware( > +> CORSMiddleware, > +> allow_origins=["*"], > +> allow_methods=["*"], > +> allow_headers=["*"], > +> ) > +> > +> # 自选股本地存储 > +> BASE_DIR = os.path.dirname(os.path.abspath(__file__)) > +> WATCH_FILE = os.path.join(BASE_DIR, "watchlist.json") > +> DEFAULT_WATCH = ["600519", "300750", "002594", "688981", "300059", "601012"] > +> > +> > +> def load_watch(): > +> if os.path.exists(WATCH_FILE): > +> try: > +> with open(WATCH_FILE, "r", encoding="utf-8") as f: > +> return json.load(f) > +> except Exception: > +> pass > +> return DEFAULT_WATCH > +> > +> > +> def save_watch(symbols): > +> with open(WATCH_FILE, "w", encoding="utf-8") as f: > +> json.dump(symbols, f, ensure_ascii=False) > +> > +> > +> # ============ API ============ > +> @app.get("/api/health") > +> def health(): > +> return {"ok": True, "akshare": svc.AK_OK} > +> > +> > +> @app.get("/api/indices") > +> def indices(): > +> return svc.get_indices() > +> > +> > +> @app.get("/api/kline") > +> def kline(symbol: str = Query("600519"), days: int = Query(120, ge=20, le=500)): > +> return svc.get_kline(symbol, days) > +> > +> > +> @app.get("/api/sentiment") > +> def sentiment(): > +> return svc.get_sentiment() > +> > +> > +> @app.get("/api/treemap") > +> def treemap(mode: str = Query("sector")): > +> return svc.get_treemap(mode) > +> > +> > +> @app.get("/api/fundflow") > +> def fundflow(): > +> return svc.get_fund_flow() > +> > +> > +> @app.get("/api/hot/stocks") > +> def hot_stocks(): > +> return svc.get_hot_stocks() > +> > +> > +> @app.get("/api/hot/sectors") > +> def hot_sectors(): > +> return svc.get_industry_boards() > +> > +> > +> @app.get("/api/dragon") > +> def dragon(): > +> return svc.get_dragon_tiger() > +> > +> > +> @app.get("/api/watchlist") > +> def watchlist(): > +> return svc.get_watchlist(load_watch()) > +> > +> > +> @app.post("/api/watchlist/{code}") > +> def watch_add(code: str): > +> w = load_watch() > +> if code not in w: > +> w.append(code) > +> save_watch(w) > +> return {"ok": True, "list": w} > +> > +> > +> @app.delete("/api/watchlist/{code}") > +> def watch_del(code: str): > +> w = [c for c in load_watch() if c != code] > +> save_watch(w) > +> return {"ok": True, "list": w} > +> > +> > +> # ============ 数据中台 ============ > +> @app.get("/api/admin/status") > +> def admin_status(): > +> counts, last_dates = {}, {} > +> with get_session() as s: > +> for label, model in [("securities", Security), ("quotes_daily", DailyQuote), > +> ("index_daily", IndexDaily), ("sector_daily", SectorDaily), > +> ("fund_flow_daily", FundFlowDaily), ("sentiment_daily", SentimentDaily), > +> ("dragon_tiger", DragonTiger)]: > +> counts[label] = s.execute(select(func.count()).select_from(model)).scalar() or 0 > +> if hasattr(model, "date"): > +> d = s.execute(select(func.max(model.date))).scalar() > +> last_dates[label] = d.isoformat() if d else None > +> jobs = s.execute(select(JobRun).order_by(desc(JobRun.id)).limit(8)).scalars().all() > +> job_list = [{"id": j.id, "job": j.job, "status": j.status, > +> "started": j.started_at.strftime("%m-%d %H:%M:%S") if j.started_at else "", > +> "finished": j.finished_at.strftime("%H:%M:%S") if j.finished_at else "", > +> "message": j.message[:200]} for j in jobs] > +> return {"counts": counts, "last_dates": last_dates, "jobs": job_list, > +> "running": scheduler.is_running(), "universe": config.DEFAULT_UNIVERSE, > +> "schedule": f"周一至周五 {config.INGEST_HOUR:02d}:{config.INGEST_MINUTE:02d}"} > +> > +> > +> @app.post("/api/admin/ingest") > +> def admin_ingest(): > +> if scheduler.is_running(): > +> return {"started": False, "msg": "已有入库任务在执行"} > +> return scheduler.trigger_async() > +> > +> > +> @app.post("/api/admin/ingest_all") > +> def admin_ingest_all(): > +> return scheduler.trigger_all_async() > +> > +> > +> @app.get("/api/db/kline") > +> def db_kline(symbol: str = Query("600519"), days: int = Query(250, ge=20, le=1000)): > +> with get_session() as s: > +> rows = s.execute( > +> select(DailyQuote).where(DailyQuote.code == symbol) > +> .order_by(DailyQuote.date.desc()).limit(days) > +> ).scalars().all() > +> rows = list(reversed(rows)) > +> if not rows: > +> return {"source": "db", "empty": True, "symbol": symbol, "dates": [], "ohlc": [], "vols": []} > +> return {"source": "db", "symbol": symbol, > +> "dates": [r.date.strftime("%m/%d") for r in rows], > +> "ohlc": [[r.open, r.close, r.low, r.high] for r in rows], > +> "vols": [r.volume for r in rows]} > +> > +> > +> @app.get("/api/db/sentiment_history") > +> def db_sentiment_history(days: int = Query(60, ge=5, le=365)): > +> with get_session() as s: > +> rows = s.execute(select(SentimentDaily).order_by(SentimentDaily.date.desc()).limit(days)).scalars().all() > +> rows = list(reversed(rows)) > +> return {"dates": [r.date.isoformat() for r in rows], > +> "up": [r.up for r in rows], "down": [r.down for r in rows], > +> "limit_up": [r.limit_up for r in rows]} > +> > +> > +> @app.get("/api/review/daily") > +> def review_daily(date: str = Query(None)): > +> with get_session() as s: > +> if date: > +> d = dt.date.fromisoformat(date) > +> else: > +> d = s.execute(select(func.max(SectorDaily.date))).scalar() > +> if not d: > +> return {"ok": False, "msg": "暂无入库数据,请先在数据中台执行入库"} > +> sectors = s.execute(select(SectorDaily).where(SectorDaily.date == d).order_by(SectorDaily.pct.desc())).scalars().all() > +> flows = s.execute(select(FundFlowDaily).where(FundFlowDaily.date == d).order_by(FundFlowDaily.net.desc())).scalars().all() > +> senti = s.execute(select(SentimentDaily).where(SentimentDaily.date == d)).scalar_one_or_none() > +> lhb = s.execute(select(DragonTiger).where(DragonTiger.date == d).order_by(DragonTiger.net.desc()).limit(10)).scalars().all() > +> > +> top_sec = [{"name": x.name, "pct": x.pct} for x in sectors[:8]] > +> bot_sec = [{"name": x.name, "pct": x.pct} for x in sectors[-5:]] > +> inflow = [{"name": x.name, "net": x.net} for x in flows[:8]] > +> outflow = [{"name": x.name, "net": x.net} for x in flows[-5:][::-1]] > +> senti_d = ({"up": senti.up, "down": senti.down, "limit_up": senti.limit_up, > +> "limit_down": senti.limit_down} if senti else None) > +> > +> summary = _gen_review_text(d, senti_d, top_sec, inflow) > +> return {"ok": True, "date": d.isoformat(), "sentiment": senti_d, > +> "top_sectors": top_sec, "weak_sectors": bot_sec, > +> "inflow": inflow, "outflow": outflow, > +> "dragon": [{"name": x.name, "code": x.code, "net": x.net, "pct": x.pct} for x in lhb], > +> "summary": summary} > +> > +> > +> def _gen_review_text(d, senti, top_sec, inflow): > +> parts = [f"【{d.isoformat()} 复盘】"] > +> if senti: > +> tone = "情绪偏暖" if senti["up"] > senti["down"] else "情绪偏弱" > +> parts.append(f"全市场上涨 {senti['up']} 家、下跌 {senti['down']} 家,涨停 {senti['limit_up']} 家、跌停 {senti['limit_down']} 家,{tone}。") > +> if top_sec: > +> names = "、".join(x["name"] for x in top_sec[:3]) > +> parts.append(f"领涨板块:{names}。") > +> if inflow: > +> names = "、".join(x["name"] for x in inflow[:3]) > +> parts.append(f"主力净流入居前:{names}。") > +> parts.append("注:以上为基于入库数据的自动统计,AI 智能点评将在 AI 分析模块接入大模型后生成。") > +> return " ".join(parts) > +> > +> > +> @app.get("/api/backtest") > +> def backtest_api(symbol: str = Query("600519"), fast: int = Query(5, ge=2, le=60), > +> slow: int = Query(20, ge=5, le=250)): > +> if fast >= slow: > +> return {"ok": False, "msg": "快线周期需小于慢线周期"} > +> return bt.run_backtest(symbol, fast, slow) > +> > +> > +> # ============ 全市场选股 ============ > +> STRATEGIES = { > +> "surge": "最近暴涨(5日涨幅≥20%)", > +> "plunge": "最近暴跌(5日跌幅≥15%)", > +> "dip": "超跌抄底(60日分位≤20%且当日企稳)", > +> "breakout": "突破走强(逼近60日新高)", > +> "ma_bull": "均线多头(MA5>10>20)", > +> "volume": "放量上攻(量比≥2且上涨)", > +> "macd_gold": "MACD金叉", > +> "strong": "强势连涨(≥3日连阳)", > +> } > +> > +> > +> @app.get("/api/screen/strategies") > +> def screen_strategies(): > +> return {"list": [{"id": k, "name": v} for k, v in STRATEGIES.items()]} > +> > +> > +> @app.get("/api/screen") > +> def screen(strategy: str = Query("surge"), limit: int = Query(60, ge=10, le=300), > +> min_amount: float = Query(0.0)): > +> M = StockMetric > +> q = select(M) > +> order = M.ret5.desc() > +> if strategy == "surge": > +> q = q.where(M.ret5 >= 20) > +> elif strategy == "plunge": > +> q = q.where(M.ret5 <= -15); order = M.ret5.asc() > +> elif strategy == "dip": > +> q = q.where(M.pos60 <= 0.2, M.pct > 0); order = M.pos60.asc() > +> elif strategy == "breakout": > +> q = q.where(M.pos60 >= 0.95, M.pct > 0); order = M.ret20.desc() > +> elif strategy == "ma_bull": > +> q = q.where(M.ma_bull.is_(True)); order = M.ret20.desc() > +> elif strategy == "volume": > +> q = q.where(M.vol_ratio >= 2, M.pct > 0); order = M.vol_ratio.desc() > +> elif strategy == "macd_gold": > +> q = q.where(M.macd_gold.is_(True)); order = M.ret5.desc() > +> elif strategy == "strong": > +> q = q.where(M.up_streak >= 3); order = M.up_streak.desc() > +> if min_amount > 0: > +> q = q.where(M.amount >= min_amount) > +> q = q.order_by(order).limit(limit) > +> with get_session() as s: > +> rows = s.execute(q).scalars().all() > +> total = s.execute(select(func.count()).select_from(M)).scalar() or 0 > +> return {"strategy": strategy, "name": STRATEGIES.get(strategy, strategy), "pool_size": total, > +> "count": len(rows), "list": [{ > +> "code": r.code, "name": r.name, "close": r.close, "pct": r.pct, > +> "ret5": r.ret5, "ret20": r.ret20, "vol_ratio": r.vol_ratio, > +> "rsi14": r.rsi14, "pos60": round(r.pos60 * 100, 1), "amount": r.amount, > +> "up_streak": r.up_streak} for r in rows]} > +> > +> > +> @app.get("/api/securities/search") > +> def securities_search(q: str = Query("", min_length=0), limit: int = Query(15, le=50)): > +> with get_session() as s: > +> stmt = select(Security) > +> if q: > +> stmt = stmt.where((Security.code.like(f"{q}%")) | (Security.name.like(f"%{q}%"))) > +> rows = s.execute(stmt.limit(limit)).scalars().all() > +> return {"list": [{"code": r.code, "name": r.name} for r in rows]} > +> > +> > +> # ============ 个股复盘(K线 + 买卖点 + 回放) ============ > +> def _ma_list(close, n): > +> out = [None] * len(close) > +> for i in range(len(close)): > +> if i >= n - 1: > +> out[i] = round(sum(close[i - n + 1:i + 1]) / n, 3) > +> return out > +> > +> > +> @app.get("/api/review/stock") > +> def review_stock(symbol: str = Query("600519"), days: int = Query(250, ge=40, le=1000), > +> fast: int = Query(5), slow: int = Query(20)): > +> with get_session() as s: > +> rows = s.execute( > +> select(DailyQuote).where(DailyQuote.code == symbol) > +> .order_by(DailyQuote.date.desc()).limit(days) > +> ).scalars().all() > +> sec = s.get(Security, symbol) > +> rows = list(reversed(rows)) > +> if not rows: > +> return {"ok": False, "msg": "该股票库内无日线,请先在数据中台入库该股或执行全市场回填", "symbol": symbol} > +> dates = [r.date.strftime("%y/%m/%d") for r in rows] > +> ohlc = [[r.open, r.close, r.low, r.high] for r in rows] > +> vols = [r.volume for r in rows] > +> close = [r.close for r in rows] > +> maf, mas = _ma_list(close, fast), _ma_list(close, slow) > +> > +> signals = [] > +> for i in range(1, len(close)): > +> if maf[i] is None or mas[i] is None or maf[i - 1] is None or mas[i - 1] is None: > +> continue > +> if maf[i - 1] <= mas[i - 1] and maf[i] > mas[i]: > +> signals.append({"idx": i, "date": dates[i], "price": close[i], "type": "buy"}) > +> elif maf[i - 1] >= mas[i - 1] and maf[i] < mas[i]: > +> signals.append({"idx": i, "date": dates[i], "price": close[i], "type": "sell"}) > +> > +> # 区间统计 > +> hi = max(r.high for r in rows); lo = min(r.low for r in rows) > +> period_ret = round((close[-1] / close[0] - 1) * 100, 2) > +> return {"ok": True, "symbol": symbol, "name": sec.name if sec else symbol, > +> "dates": dates, "ohlc": ohlc, "vols": vols, > +> "ma_fast": maf, "ma_slow": mas, "fast": fast, "slow": slow, > +> "signals": signals, > +> "stats": {"period_return": period_ret, "high": hi, "low": lo, > +> "start": dates[0], "end": dates[-1], "bars": len(rows)}} > +> > +> > +> # ============ AI 分析 ============ > +> @app.get("/api/ai/status") > +> def ai_status(): > +> return {"enabled": llm.enabled(), "model": config.LLM_MODEL if llm.enabled() else None} > +> > +> > +> @app.get("/api/ai/review_daily") > +> def ai_review_daily(date: str = Query(None)): > +> return ai.review_daily_comment(date) > +> > +> > +> @app.get("/api/ai/diagnose") > +> def ai_diagnose(symbol: str = Query("600519")): > +> return ai.diagnose(symbol) > +> > +> > +> @app.get("/api/ai/today") > +> def ai_today(): > +> return ai.today_strategy() > +> > +> > +> # ============ 可回溯:信号历史胜率 + 实测准确率 ============ > +> @app.get("/api/ai/signal_stats") > +> def ai_signal_stats(horizon: int = Query(5, ge=1, le=20)): > +> return {"horizon": horizon, "stats": sig.get_stats(horizon)} > +> > +> > +> @app.post("/api/ai/signal_stats/compute") > +> def ai_signal_stats_compute(sample: int = Query(500, ge=50, le=4000), horizon: int = Query(5, ge=1, le=20)): > +> return scheduler.trigger_signal_stats_async(sample, horizon) > +> > +> > +> @app.get("/api/ai/accuracy") > +> def ai_accuracy(): > +> return sig.accuracy() > +> > +> > +> @app.post("/api/ai/accuracy/verify") > +> def ai_accuracy_verify(): > +> return sig.verify_predictions() > +> > +> > +> # ============ AI 自动复盘日报 ============ > +> @app.get("/api/report/daily") > +> def report_daily(date: str = Query(None)): > +> return rpt.get_by_date(date) if date else rpt.latest() > +> > +> > +> @app.get("/api/report/history") > +> def report_history(limit: int = Query(30, ge=1, le=120)): > +> return rpt.history(limit) > +> > +> > +> @app.post("/api/report/generate") > +> def report_generate(date: str = Query(None), push: bool = Query(False)): > +> return rpt.generate(date, push=push) > +> > +> > +> # ============ 交易日志 & 组合 ============ > +> class TradeIn(BaseModel): > +> code: str > +> name: str = "" > +> side: str = "buy" > +> price: float > +> qty: int > +> fee: float = 0.0 > +> date: str = "" > +> reason: str = "" > +> emotion: str = "" > +> > +> > +> @app.get("/api/trades") > +> def list_trades(): > +> with get_session() as s: > +> rows = s.execute(select(Trade).order_by(Trade.date.desc(), Trade.id.desc())).scalars().all() > +> names = {} > +> return {"list": [{"id": t.id, "date": t.date.isoformat(), "code": t.code, "name": t.name, > +> "side": t.side, "price": t.price, "qty": t.qty, "fee": t.fee, > +> "reason": t.reason, "emotion": t.emotion} for t in rows]} > +> > +> > +> @app.post("/api/trades") > +> def add_trade(t: TradeIn): > +> d = dt.date.fromisoformat(t.date) if t.date else dt.date.today() > +> name = t.name > +> if not name: > +> with get_session() as s: > +> sec = s.get(Security, t.code) > +> name = sec.name if sec else t.code > +> with get_session() as s: > +> row = Trade(date=d, code=t.code, name=name, side=t.side, price=t.price, > +> qty=t.qty, fee=t.fee, reason=t.reason, emotion=t.emotion) > +> s.add(row); s.commit() > +> return {"ok": True, "id": row.id} > +> > +> > +> @app.delete("/api/trades/{tid}") > +> def del_trade(tid: int): > +> with get_session() as s: > +> row = s.get(Trade, tid) > +> if row: > +> s.delete(row); s.commit() > +> return {"ok": True} > +> > +> > +> @app.get("/api/portfolio") > +> def get_portfolio(): > +> return pf.compute() > +> > +> > +> @app.get("/api/portfolio/equity") > +> def portfolio_equity(): > +> return pf.equity_curve() > +> > +> > +> # ============ 推送通知 ============ > +> @app.get("/api/notify/status") > +> def notify_status(): > +> return {"channels": notifier.channels_status(), "enabled": notifier.any_enabled()} > +> > +> > +> @app.post("/api/notify/test") > +> def notify_test(): > +> if not notifier.any_enabled(): > +> return {"ok": False, "msg": "未配置任何推送渠道,请在 backend/.env 配置后重启"} > +> res = notifier.notify("【智策】推送测试", "这是一条来自智策股票终端的测试通知,收到即表示推送通道正常。") > +> return {"ok": True, "result": res} > +> > +> > +> # ============ 智能预警 ============ > +> class AlertIn(BaseModel): > +> code: str > +> kind: str = "price_above" > +> threshold: float > +> note: str = "" > +> > +> > +> @app.get("/api/alerts") > +> def list_alerts(): > +> with get_session() as s: > +> rows = s.execute(select(AlertRule).order_by(AlertRule.id.desc())).scalars().all() > +> return {"list": [{"id": r.id, "code": r.code, "name": r.name, "kind": r.kind, > +> "threshold": r.threshold, "status": r.status, "note": r.note, > +> "last_value": r.last_value, > +> "triggered_at": r.triggered_at.strftime("%m-%d %H:%M") if r.triggered_at else ""} > +> for r in rows]} > +> > +> > +> @app.post("/api/alerts") > +> def add_alert(a: AlertIn): > +> with get_session() as s: > +> sec = s.get(Security, a.code) > +> name = sec.name if sec else a.code > +> row = AlertRule(code=a.code, name=name, kind=a.kind, threshold=a.threshold, note=a.note) > +> s.add(row); s.commit() > +> return {"ok": True, "id": row.id} > +> > +> > +> @app.delete("/api/alerts/{aid}") > +> def del_alert(aid: int): > +> with get_session() as s: > +> row = s.get(AlertRule, aid) > +> if row: > +> s.delete(row); s.commit() > +> return {"ok": True} > +> > +> > +> @app.post("/api/alerts/{aid}/reactivate") > +> def reactivate_alert(aid: int): > +> with get_session() as s: > +> row = s.get(AlertRule, aid) > +> if row: > +> row.status = "active"; row.triggered_at = None; s.commit() > +> return {"ok": True} > +> > +> > +> @app.post("/api/alerts/check") > +> def manual_check(): > +> return al.check_alerts() > +> > +> > +> @app.get("/api/alerts/events") > +> def alert_events(unread_only: bool = Query(False), limit: int = Query(30, le=100)): > +> with get_session() as s: > +> stmt = select(AlertEvent).order_by(AlertEvent.id.desc()) > +> if unread_only: > +> stmt = stmt.where(AlertEvent.read.is_(False)) > +> rows = s.execute(stmt.limit(limit)).scalars().all() > +> unread = s.execute(select(func.count()).select_from(AlertEvent).where(AlertEvent.read.is_(False))).scalar() or 0 > +> return {"unread": unread, "list": [{"id": e.id, "code": e.code, "name": e.name, > +> "message": e.message, "time": e.created_at.strftime("%m-%d %H:%M:%S") if e.created_at else ""} > +> for e in rows]} > +> > +> > +> @app.post("/api/alerts/events/read") > +> def mark_events_read(): > +> with get_session() as s: > +> for e in s.execute(select(AlertEvent).where(AlertEvent.read.is_(False))).scalars(): > +> e.read = True > +> s.commit() > +> return {"ok": True} > +> > +> > +> # ============ 资讯中心 ============ > +> @app.get("/api/news") > +> def news(limit: int = Query(40, le=100)): > +> return svc.get_news(limit) > +> > +> > +> @app.get("/api/news/stock") > +> def news_stock(code: str = Query(...)): > +> return svc.get_stock_news(code) > +> > +> > +> @app.get("/api/news/watch") > +> def news_watch(): > +> codes = load_watch()[:6] > +> out = [] > +> for c in codes: > +> r = svc.get_stock_news(c, limit=4) > +> for x in r["list"]: > +> x["code"] = c > +> out.append(x) > +> out.sort(key=lambda x: x["time"], reverse=True) > +> return {"list": out[:40]} > +> > +> > +> class NewsAI(BaseModel): > +> title: str > +> content: str = "" > +> > +> > +> @app.post("/api/news/ai") > +> def news_ai(n: NewsAI): > +> text_in = (n.title + "。" + n.content).strip() > +> senti, kw = svc.judge_sentiment(text_in) > +> if llm.enabled(): > +> try: > +> prompt = ("请分析下面这条财经资讯:\n" > +> "1) 一句话摘要;2) 利好/利空/中性判断及理由;3) 可能受影响的板块或个股方向。120字内。\n\n" > +> + text_in[:1200]) > +> text = llm.ask(prompt, temperature=0.3, max_tokens=400) > +> return {"ok": True, "source": "llm", "sentiment": senti, "text": text} > +> except Exception: > +> pass > +> return {"ok": True, "source": "rule", "sentiment": senti, > +> "text": f"判断:{senti}(关键词:{'、'.join(kw) or '无'})。摘要:{text_in[:80]}…\n(配置大模型后可获得更深入的关联分析)"} > +> > +> > +> # ============ 静态前端 ============ > +> FRONTEND_DIR = os.path.join(os.path.dirname(BASE_DIR), "prototype") > +> if os.path.isdir(FRONTEND_DIR): > +> app.mount("/", StaticFiles(directory=FRONTEND_DIR, html=True), name="frontend") > +> > +> > +> if __name__ == "__main__": > +> import uvicorn > +> uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=False) > +> + +#### _Assistant_ + +> 基于对项目的分析,我发现以下可优化的功能点: +> +> ## 高优先级优化 +> +> ### 1. **缓存与性能优化** +> - `akshare_service.py` 使用内存缓存,服务重启后失效。建议引入 Redis 持久化缓存,避免频繁调用 AkShare API +> - 大量接口未做分页,如 `/api/trades`、`/api/alerts/events` 在数据量大时会有性能问题 +> - K线数据查询未添加索引优化提示 +> +> ### 2. **错误处理与容错** +> - 多数接口缺少统一的异常捕获,数据源异常时可能返回 500 错误 +> - `scheduler.py` 定时任务失败后缺少重试机制和告警 +> - AkShare 数据源不稳定时,降级逻辑不够完善(只有 mock 数据) +> +> ### 3. **数据完整性** +> - 交易记录(`trades`)无事务一致性保证,买卖不匹配时持仓计算可能出错 +> - 缺少数据修正接口,入库错误数据后无法手动修正 +> - 历史数据回填(`ingest_all`)无断点续传,中断后需重新开始 +> +> ### 4. **安全性** +> - 所有接口无鉴权,任何人可访问管理功能(`/api/admin/*`) +> - 环境变量敏感信息(`LLM_API_KEY`等)未加密存储 +> - CORS 配置为 `allow_origins=["*"]`,生产环境不安全 +> +> ## 中优先级优化 +> +> ### 5. **功能增强** +> - AI 诊断结果无历史记录,无法追溯分析质量变化 +> - 预警系统只支持价格/涨跌幅,缺少技术指标预警(如 MACD 金叉、RSI 超买等) +> - 复盘日报只能手动触发推送,未自动推送给用户 +> - 自选股只支持单一列表,无分组管理功能 +> +> ### 6. **代码质量** +> - `main.py` 超过 500 行,建议拆分为多个 Router 模块 +> - 缺少单元测试和集成测试 +> - 部分函数职责不清晰,如 `_gen_review_text()` 混合了业务逻辑和文案生成 +> +> ### 7. **监控与日志** +> - 缺少接口访问日志和性能监控 +> - 定时任务执行日志只保留在数据库,无实时监控面板 +> - AkShare 调用频率无监控,可能触发限流 +> +> ## 低优先级优化 +> +> ### 8. **用户体验** +> - 前端刷新后丢失自选股筛选条件 +> - K线图无技术指标叠加功能(如 MACD、KDJ) +> - 移动端适配不足 +> +> ### 9. **扩展性** +> - 只支持 A 股,缺少港股、美股扩展能力 +> - 数据源绑定 AkShare,切换成本高 +> - 多组合管理需要大幅重构 `portfolio.py` +> +> ## 建议优先实现的 3 个功能 +> +> 1. **Redis 缓存层** - 提升响应速度,减少 API 调用 +> 2. **统一异常处理中间件** - 提升系统稳定性 +> 3. **基础鉴权机制** - 保护管理接口安全 +> +> 需要我深入分析某个模块的优化方案吗? + +#### _User_ + +> 功能上有没有好的功能拓展建议 + +#### _Assistant_ + +> 基于当前架构,建议以下功能扩展方向: +> +> ## 一、高价值功能扩展 +> +> ### 1. **盘中实时监控雷达** ⭐⭐⭐⭐⭐ +> **痛点**:当前只有收盘后复盘,错过盘中异动机会 +> +> **功能点**: +> - 快速拉升监控(5分钟涨幅 >3%) +> - 放量突破预警(量比 >3 且突破关键位) +> - 涨停打开/炸板追踪 +> - 连板股实时追踪 +> - 大单异动(单笔超百万) +> +> **实现难度**:中等(需要分钟线数据源) +> +> ### 2. **策略回测增强** ⭐⭐⭐⭐⭐ +> **痛点**:当前只有简单 MA 交叉,无法验证复杂策略 +> +> **功能点**: +> - 多因子组合回测(技术+基本面) +> - 仓位管理策略(金字塔加仓、止损止盈) +> - 参数优化网格搜索 +> - 夏普比率/最大回撤/胜率等完整指标 +> - 策略对比(并排显示多条净值曲线) +> - 交易明细导出(每笔买卖点、持仓天数、收益率) +> +> **实现难度**:中等 +> +> ### 3. **板块轮动分析** ⭐⭐⭐⭐ +> **痛点**:只有当日板块涨跌,看不出资金流向趋势 +> +> **功能点**: +> - 板块强弱排名趋势(近5日/10日/20日) +> - 资金流向桑基图(从哪个板块流向哪个板块) +> - 板块生命周期判断(启动期/加速期/衰退期) +> - 龙头股自动识别(板块内涨幅+成交额排名) +> - 板块联动性分析(相关系数热力图) +> +> **实现难度**:中等 +> +> ### 4. **智能选股增强** ⭐⭐⭐⭐ +> **痛点**:当前选股策略固定,无法自定义 +> +> **功能点**: +> - 可视化选股器(拖拽式多条件组合) +> - 选股结果回测验证(该选股规则历史表现) +> - 选股策略保存/分享 +> - 条件预警(符合条件时自动推送) +> - 选股结果对比(今日 vs 昨日,哪些股票新入选/退出) +> +> **实现难度**:中高 +> +> ### 5. **持仓归因分析深化** ⭐⭐⭐⭐ +> **痛点**:当前只有简单盈亏,不知道赚钱/亏钱原因 +> +> **功能点**: +> - 收益归因分解:选股能力 vs 择时能力 vs 运气成分 +> - 持仓时长分析(短线/中线/长线各自胜率) +> - 买入理由有效性验证(按理由统计胜率) +> - 情绪标签相关性(贪婪/恐惧/理性,各自收益率) +> - 对标指数超额收益拆解 +> +> **实现难度**:中等 +> +> ## 二、差异化功能扩展 +> +> ### 6. **AI 对话式分析** ⭐⭐⭐⭐⭐ +> **亮点**:与大模型深度结合,打造对话式炒股助手 +> +> **功能点**: +> - 自然语言选股:"帮我找近期突破且资金流入的科技股" +> - 持仓诊断对话:"我的持仓有什么风险?" +> - 策略建议:"当前市场适合什么策略?" +> - 实时问答:"某某股票为什么今天大跌?" +> - 上下文记忆(多轮对话,记住用户偏好) +> +> **实现难度**:中高(需要 Function Calling) +> +> ### 7. **社区情绪监控** ⭐⭐⭐⭐ +> **亮点**:量化散户情绪,反向指标 +> +> **功能点**: +> - 爬取东方财富/雪球/股吧热帖 +> - 情绪指数(乐观/悲观比例) +> - 热议股票排行(讨论量激增预警) +> - 关键词云图(当前市场关注什么) +> - 情绪与股价相关性回测 +> +> **实现难度**:中高(需要爬虫+NLP) +> +> ### 8. **事件驱动策略** ⭐⭐⭐⭐ +> **亮点**:基于财经事件的量化交易 +> +> **功能点**: +> - 财报发布前后统计规律 +> - 限售解禁影响回测 +> - 高管增持/减持跟踪 +> - 行业政策事件库(光伏补贴、新能源政策等) +> - 事件驱动选股(财报超预期+高管增持) +> +> **实现难度**:中高 +> +> ### 9. **模拟盘对战** ⭐⭐⭐ +> **亮点**:用户间 PK,增加粘性 +> +> **功能点**: +> - 创建模拟盘对战房间 +> - 排行榜(收益率/夏普比率) +> - 查看高手持仓(匿名化) +> - 复盘对比(我为什么输给他) +> - 月度/季度冠军 +> +> **实现难度**:中等 +> +> ### 10. **财报深度解读** ⭐⭐⭐⭐ +> **痛点**:技术分析为主,缺少基本面支持 +> +> **功能点**: +> - 财报关键指标趋势(ROE/毛利率/营收增速) +> - AI 财报摘要(一句话总结) +> - 同行对比(与行业均值对比) +> - 财报异常预警(存货激增、应收账款占比过高) +> - 财报发布日历提醒 +> +> **实现难度**:中等 +> +> ## 三、实用小功能 +> +> ### 11. **交易日历与提醒** +> - 除权除息提醒 +> - 解禁日期预警 +> - 业绩预告/快报发布日历 +> - 股东大会日期 +> +> ### 12. **成本计算器** +> - 精确计算交易成本(印花税/佣金/过户费) +> - 补仓成本计算 +> - 持仓成本线标注在 K 线图上 +> +> ### 13. **涨跌停分析** +> - 连板股追踪器(今日几进几?) +> - 炸板率统计(某股涨停后次日表现) +> - 涨停敢死队排行 +> +> ### 14. **关联股票发现** +> - 同一题材股票聚类 +> - 供应链上下游关系图 +> - 股东关联关系挖掘 +> +> ### 15. **数据导出与备份** +> - 持仓数据导出 Excel +> - 交易记录导出 +> - 选股结果导出 +> - 数据库自动备份 +> +> ## 建议优先级排序(ROI 角度) +> +> | 排名 | 功能 | 投入成本 | 用户价值 | 差异化 | +> |------|------|----------|----------|--------| +> | 1 | AI 对话式分析 | 高 | 极高 | 极高 | +> | 2 | 盘中实时监控雷达 | 中 | 极高 | 高 | +> | 3 | 策略回测增强 | 中 | 高 | 中 | +> | 4 | 板块轮动分析 | 中 | 高 | 中 | +> | 5 | 持仓归因分析深化 | 低 | 高 | 中 | +> +> **快速见效(先做)**: +> 1. 盘中实时监控雷达 +> 2. 策略回测增强 +> 3. 持仓归因分析深化 +> +> **长期竞争力(后做)**: +> 1. AI 对话式分析 +> 2. 社区情绪监控 +> 3. 事件驱动策略 +> +> 需要我详细设计某个功能的技术方案吗? \ No newline at end of file diff --git a/README.md b/README.md index 0a00aa5..08b2187 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,27 @@ -# 智策 StockTerminal +# Blackdata StockTerminal -个人/小团队 A 股分析·复盘·智能辅助系统。后端提供行情、回测、AI 诊断与定时任务;前端为纯 HTML + ECharts 原型界面,由 FastAPI 统一托管。 +个人/小团队 A 股分析·复盘·智能专业分析系统。后端提供行情、回测、AI 诊断与定时任务;前端为纯 HTML + ECharts 原型界面,由 FastAPI 统一托管。 ## 功能概览 | 模块 | 能力 | |---|---| -| 大盘行情 | 三大指数、情绪温度计、板块云图、热股榜、龙虎榜 | -| 自选股 | 自选列表、内置策略选股、多因子条件过滤 | -| 复盘中心 | 每日复盘、AI 七段式日报、个股 K 线回放 | -| 策略与中台 | MA 交叉回测、数据入库状态、任务日志 | -| AI 分析 | 个股诊断(6 维证据链)、信号胜率、预测留痕与核验 | -| 组合交易 | 持仓 P&L、资金曲线、交易日志、盈亏归因 | -| 智能预警 | 价格/涨跌幅规则、多通道推送、触发记录 | -| 资讯中心 | 财经快讯、情绪标注、自选相关资讯 | +| **大盘行情** | 三大指数、情绪温度计、板块云图、热股榜、龙虎榜、涨跌停统计 | +| **盘中监控** | 异动雷达(快速拉升/放量突破/涨停打开/连板追踪/大单异动)、实时扫描与推送 | +| **自选股** | 自选列表、分组管理、内置 8 种策略选股、多因子条件过滤 | +| **智能选股** | 可视化条件组合器、选股策略保存/分享、选股结果回测验证、条件预警集成 | +| **复盘中心** | 每日复盘(板块/资金/龙虎榜)、AI 七段式日报、个股 K 线回放(MA 买卖点标注) | +| **策略回测** | MA 交叉/多因子策略回测、参数优化网格搜索、策略对比(并排净值曲线)、交易明细导出 | +| **板块轮动** | 板块强弱趋势、资金流向桑基图、龙头股识别、生命周期判断、板块联动性分析 | +| **AI 分析** | 个股诊断(6 维证据链)、AI 对话式分析、信号历史胜率、预测留痕与准确率核验 | +| **组合交易** | 持仓 P&L、资金曲线、交易日志(理由/情绪标签)、持仓归因分析(选股/择时/运气分解) | +| **智能预警** | 价格/涨跌幅/量能/技术信号规则、选股策略预警、多通道推送(邮件/微信/企微)、触发记录 | +| **资讯中心** | 财经快讯、AI 情绪判断与摘要、自选股相关资讯、关联个股分析 | +| **社区情绪** | 热帖采集(东方财富/雪球)、情绪指数计算、热议股票排行、关键词云图、情绪与股价相关性 | +| **事件驱动** | 财报发布前后规律、高管增减持跟踪、限售解禁影响、行业政策事件库、事件驱动选股 | +| **财报解读** | 关键指标趋势、AI 财报摘要、同行对比、财报异常预警、发布日历、排行榜 | +| **涨跌停分析** | 涨停/跌停股票追踪、连板股监控、炸板率统计、涨停敢死队排行 | +| **数据中台** | 数据入库状态、任务日志、全市场历史回填、定时调度监控 | 更完整的架构说明见 [架构总结.md](./架构总结.md)。 @@ -30,14 +38,39 @@ ``` stock_cs/ -├── backend/ # FastAPI 后端 -│ ├── main.py # API 入口 + 静态文件托管 -│ ├── cli.py # 建库 / 入库命令行工具 -│ ├── .env.example # 环境变量模板 -│ └── requirements.txt -├── prototype/ # 前端原型(index.html / app.js / style.css) -├── 架构总结.md -└── 功能架构.md +├── backend/ # FastAPI 后端 +│ ├── main.py # API 入口 + 路由定义 +│ ├── cli.py # 建库/入库命令行工具 +│ ├── models.py # SQLAlchemy 数据模型 +│ ├── db.py # 数据库连接管理 +│ ├── config.py # 配置项 +│ ├── scheduler.py # APScheduler 定时任务 +│ ├── akshare_service.py # 数据源接口封装 +│ ├── ai.py # AI 分析核心 +│ ├── ai_chat.py # AI 对话式分析 +│ ├── llm.py # 大模型调用封装 +│ ├── backtest.py # 基础回测引擎 +│ ├── backtest_advanced.py # 增强回测(多因子/参数优化/策略对比) +│ ├── signals.py # 信号胜率统计 +│ ├── report.py # AI 复盘日报生成 +│ ├── portfolio.py # 组合与持仓计算 +│ ├── attribution_analysis.py # 持仓归因分析 +│ ├── alerts.py # 智能预警核心 +│ ├── notifier.py # 多通道推送 +│ ├── intraday_radar.py # 盘中异动雷达 +│ ├── sector_rotation.py # 板块轮动分析 +│ ├── smart_selector.py # 智能选股增强 +│ ├── sentiment_monitor.py # 社区情绪监控 +│ ├── event_driven.py # 事件驱动策略 +│ ├── financial_analysis.py # 财报深度解读 +│ ├── limit_analysis.py # 涨跌停分析 +│ ├── .env.example # 环境变量模板 +│ └── requirements.txt # Python 依赖 +├── prototype/ # 前端原型(HTML + JS + CSS) +├── 架构总结.md # 架构设计文档 +├── 功能架构.md # 功能模块详解 +├── 待优化.md # 已知问题与优化方向 +└── 功能扩展.md # 扩展功能建议 ``` ## 环境要求 @@ -48,28 +81,51 @@ stock_cs/ ## 快速开始 -### 1. 安装依赖 +以下命令以 **WSL(Linux)** 为例。项目在 Windows 盘时,路径一般为 `/mnt/e/project/stock_cs_v1`;若在 WSL 家目录,则替换为实际路径即可。 + +### 1. 安装 PostgreSQL(WSL,首次) ```bash -cd backend -python -m venv .venv +sudo apt update +sudo apt install -y postgresql postgresql-contrib +sudo service postgresql start -# Windows -.venv\Scripts\activate +# 设置 postgres 用户密码(与 backend/.env 中 PG_PASSWORD 一致) +sudo -u postgres psql -c "ALTER USER postgres PASSWORD 'your_password';" +``` -# Linux / macOS +WSL 每次重启后若数据库未自动运行,需先执行: + +```bash +sudo service postgresql start +``` + +### 2. 安装 Python 依赖(首次) + +```bash +cd /mnt/e/project/stock_cs_v1/backend # 按实际路径修改 +python3 -m venv .venv source .venv/bin/activate - pip install -r requirements.txt ``` -### 2. 配置环境变量 +**Windows 原生(非 WSL)** 激活虚拟环境: + +```powershell +cd backend +python -m venv .venv +.venv\Scripts\activate +pip install -r requirements.txt +``` + +### 3. 配置环境变量 ```bash +cd backend cp .env.example .env ``` -编辑 `backend/.env`,至少确认 PostgreSQL 连接信息(也可通过环境变量 `PG_USER` / `PG_PASSWORD` / `PG_HOST` / `PG_PORT` / `PG_DB` 设置): +编辑 `backend/.env`,至少确认 PostgreSQL 连接信息(PostgreSQL 装在 WSL 内时使用 `localhost`): ```env PG_USER=postgres @@ -79,12 +135,15 @@ PG_PORT=5432 PG_DB=stock_cs ``` +也可通过环境变量 `PG_USER` / `PG_PASSWORD` / `PG_HOST` / `PG_PORT` / `PG_DB` 设置,无需改文件。 + 可选:填入 `LLM_API_KEY` 启用大模型分析;填入 SMTP / Server酱 / 企业微信 / PushPlus 启用推送。 -### 3. 初始化数据库并入库 +### 4. 初始化数据库并入库(首次) ```bash cd backend +source .venv/bin/activate # WSL / Linux # 建库建表 python cli.py init @@ -92,7 +151,7 @@ python cli.py init # 抓取当日板块/资金流/情绪/龙虎榜等快照 python cli.py ingest -# 全市场日线历史入库(默认 250 交易日,耗时较长) +# 全市场日线历史入库(默认 250 交易日,耗时较长,可选) python cli.py ingest_all python cli.py ingest_all 500 # 指定天数 ``` @@ -103,28 +162,49 @@ python cli.py ingest_all 500 # 指定天数 python cli.py ingest 600519 000001 ``` -### 4. 启动服务 +### 5. 启动服务 + +**日常启动(WSL):** ```bash -cd backend +sudo service postgresql start +cd /mnt/e/project/stock_cs_v1/backend # 按实际路径修改 +source .venv/bin/activate python main.py ``` -浏览器访问:**http://127.0.0.1:8000** +一键命令(已配置好后): + +```bash +sudo service postgresql start && cd /mnt/e/project/stock_cs_v1/backend && source .venv/bin/activate && python main.py +``` + +浏览器访问:**http://127.0.0.1:8000**(WSL2 下 Windows 浏览器可直接访问) 健康检查:`GET /api/health` +### 常见问题(WSL) + +| 现象 | 处理 | +|---|---| +| `connection refused` | 执行 `sudo service postgresql start` | +| `password authentication failed` | 检查 `.env` 中 `PG_PASSWORD` 是否与 `ALTER USER` 设置一致 | +| `python: command not found` | 使用 `python3` | +| 每次新开终端 | 先 `source .venv/bin/activate` 再运行命令 | + ## 定时任务 服务启动后,APScheduler 会在工作日自动执行(可在 `config.py` 或环境变量中调整时间): | 任务 | 默认时间 | 说明 | |---|---|---| -| `daily_ingest` | 15:35 | 收盘后增量入库 | -| `alert_check` | 每 60 秒 | 实时报价预警核查 | -| `daily_report` | 15:45 | 生成 AI 复盘日报并推送 | -| `verify_pred` | 15:50 | 核验到期 AI 预测 | -| `signal_stats` | 周六 09:00 | 全市场信号胜率回测 | +| `daily_ingest` | 15:35 | 收盘后增量入库(板块/资金/情绪/龙虎榜/个股行情) | +| `alert_check` | 每 60 秒 | 实时报价预警核查(价格/涨跌幅/量能等规则) | +| `intraday_scan` | 交易时段每 5 分钟 | 盘中异动扫描(快速拉升/放量突破/涨停打开/连板追踪) | +| `daily_report` | 15:45 | 生成 AI 复盘日报并推送(需配置大模型 API) | +| `verify_pred` | 15:50 | 核验到期 AI 预测,更新准确率统计 | +| `signal_stats` | 周六 09:00 | 全市场信号胜率回测(MACD 金叉/突破等技术信号) | +| `selector_check` | 15:40 | 选股策略预警检查,符合条件时推送 | ## 推送渠道 @@ -144,10 +224,41 @@ python main.py - AkShare 不可用时部分接口会降级为 mock 数据,详见 `/api/health` 中的 `akshare` 字段。 - 敏感文件(`.env`、虚拟环境等)已在 `.gitignore` 中排除,请勿提交密钥。 +## 核心功能说明 + +### 1. 智能选股增强 +可视化条件组合器,支持技术面、资金面、基本面多因子拖拽组合,选股结果可一键回测验证历史表现,策略可保存/分享并设置条件预警。详见 [智能选股增强使用说明.md](./智能选股增强使用说明.md) + +### 2. 盘中异动雷达 +交易时段自动扫描快速拉升、放量突破、涨停打开、连板股等异动信号,支持多通道实时推送。详见 [盘中异动雷达使用说明.md](./盘中异动雷达使用说明.md) + +### 3. 板块轮动分析 +板块强弱趋势、资金流向桑基图、生命周期判断(启动期/加速期/衰退期)、龙头股自动识别、板块联动性分析。详见 [板块轮动分析使用说明.md](./板块轮动分析使用说明.md) + +### 4. 策略回测增强 +多因子组合回测、仓位管理策略、参数优化网格搜索、策略对比(并排净值曲线)、完整风险指标(夏普/最大回撤/胜率)。详见 [策略回测增强使用说明.md](./策略回测增强使用说明.md) + +### 5. 持仓归因分析 +收益归因分解(选股能力 vs 择时能力 vs 运气成分)、持仓时长分析、买入理由有效性验证、情绪标签相关性、对标指数超额收益拆解。详见 [持仓归因分析深化使用说明.md](./持仓归因分析深化使用说明.md) + +### 6. AI 对话式分析 +与大模型深度结合,支持自然语言选股、持仓诊断、策略建议、实时问答,多轮对话记住用户偏好。详见 [AI对话式分析使用说明.md](./AI对话式分析使用说明.md) + +### 7. 社区情绪监控 +爬取东方财富/雪球热帖,计算情绪指数(乐观/悲观比例)、热议股票排行、关键词云图、情绪与股价相关性回测。详见 [社区情绪监控使用说明.md](./社区情绪监控使用说明.md) + +### 8. 事件驱动策略 +财报发布前后统计规律、高管增减持跟踪、限售解禁影响分析、行业政策事件库、事件驱动选股。详见 [事件驱动策略使用说明.md](./事件驱动策略使用说明.md) + +### 9. 财报深度解读 +财报关键指标趋势、AI 一句话摘要、同行对比、财报异常预警(存货激增/应收账款占比过高)、发布日历提醒。详见 [财报深度解读使用说明.md](./财报深度解读使用说明.md) + ## 文档 - [架构总结.md](./架构总结.md) — 分层设计、数据模型、AI 分析流程 -- [功能架构.md](./功能架构.md) — 功能模块说明 +- [功能架构.md](./功能架构.md) — 功能模块详细说明 +- [待优化.md](./待优化.md) — 已知问题与优化方向 +- [功能扩展.md](./功能扩展.md) — 扩展功能建议 ## 许可证 diff --git a/backend/ai_chat.py b/backend/ai_chat.py new file mode 100644 index 0000000..660e695 --- /dev/null +++ b/backend/ai_chat.py @@ -0,0 +1,542 @@ +"""AI 对话式分析 — 自然语言交互的炒股助手。 + +功能: +1. 自然语言选股 +2. 持仓诊断对话 +3. 策略建议 +4. 实时问答 +5. 上下文记忆(多轮对话) +""" +import json +from typing import List, Dict, Any, Optional +from datetime import datetime, date + +import llm +import smart_selector as selector +import portfolio as pf +import ai +import sector_rotation as sector +from db import get_session +from models import StockMetric, Trade + +# 会话上下文存储 +_SESSIONS = {} # {session_id: {"messages": [], "context": {}}} + + +def get_or_create_session(session_id: str) -> Dict: + """获取或创建会话""" + if session_id not in _SESSIONS: + _SESSIONS[session_id] = { + "messages": [], + "context": {}, + "created_at": datetime.now().isoformat() + } + return _SESSIONS[session_id] + + +def chat(session_id: str, user_message: str) -> Dict[str, Any]: + """AI对话主入口 + + Args: + session_id: 会话ID + user_message: 用户消息 + + Returns: + AI回复 + """ + if not llm.enabled(): + return { + "ok": False, + "msg": "大模型未配置,请在 backend/.env 中配置 LLM_API_KEY", + "text": "抱歉,AI对话功能需要配置大模型。您可以:\n1. 配置 .env 中的 LLM_API_KEY\n2. 使用其他功能模块(选股、回测、板块分析等)" + } + + session = get_or_create_session(session_id) + + # 添加用户消息到历史 + session["messages"].append({"role": "user", "content": user_message}) + + # 意图识别 + Function Calling + try: + response = _process_message(session, user_message) + + # 添加助手回复到历史 + session["messages"].append({"role": "assistant", "content": response["text"]}) + + # 限制历史长度(保留最近20轮) + if len(session["messages"]) > 40: + session["messages"] = session["messages"][-40:] + + return response + except Exception as e: + return { + "ok": False, + "msg": str(e), + "text": f"处理消息时出错:{str(e)}" + } + + +def _process_message(session: Dict, message: str) -> Dict[str, Any]: + """处理用户消息,识别意图并调用相应功能""" + + # 构建系统提示 + system_prompt = """你是Blackdata股票分析助手,擅长A股分析和投资建议。 + +你可以调用以下功能(通过JSON格式返回): + +1. 选股功能 +格式:{"action": "select_stocks", "conditions": {"涨幅": ">10", "量比": ">2", ...}, "description": "..."} +示例:"帮我找近期突破的科技股" -> 识别为选股需求 + +2. 持仓诊断 +格式:{"action": "diagnose_portfolio"} +示例:"我的持仓有什么风险?" + +3. 策略建议 +格式:{"action": "strategy_advice"} +示例:"当前市场适合什么策略?" + +4. 个股分析 +格式:{"action": "analyze_stock", "code": "600519"} +示例:"分析一下贵州茅台" + +5. 板块分析 +格式:{"action": "analyze_sector", "name": "半导体"} +示例:"半导体板块怎么样?" + +6. 普通对话 +格式:{"action": "chat", "text": "..."} +示例:闲聊、问候等 + +请根据用户问题,先判断意图,然后: +- 如果需要调用功能,返回JSON格式的action +- 如果是普通对话,直接回答 + +重要: +- 如果用户问题包含"找"、"选"、"筛选"等词,考虑选股功能 +- 如果问"我的持仓"、"风险",调用持仓诊断 +- 如果问"策略"、"怎么操作",给策略建议 +- 股票代码格式:6位数字 +""" + + # 构建对话历史 + messages = [{"role": "system", "content": system_prompt}] + + # 添加最近的对话历史(最多10轮) + recent_messages = session["messages"][-20:] if len(session["messages"]) > 20 else session["messages"] + messages.extend(recent_messages) + + # 调用大模型 + try: + response_text = llm.ask_with_messages(messages, temperature=0.7, max_tokens=1500) + + # 尝试解析为JSON + action = _parse_action(response_text) + + if action: + return _execute_action(action, session) + else: + # 纯文本回复 + return { + "ok": True, + "type": "chat", + "text": response_text + } + + except Exception as e: + return { + "ok": False, + "text": f"AI处理失败:{str(e)}" + } + + +def _parse_action(text: str) -> Optional[Dict]: + """解析AI回复中的action""" + try: + # 查找JSON块 + if "{" in text and "}" in text: + start = text.find("{") + end = text.rfind("}") + 1 + json_str = text[start:end] + return json.loads(json_str) + except: + pass + return None + + +def _execute_action(action: Dict, session: Dict) -> Dict[str, Any]: + """执行具体功能""" + + action_type = action.get("action") + + if action_type == "select_stocks": + return _handle_select_stocks(action, session) + + elif action_type == "diagnose_portfolio": + return _handle_diagnose_portfolio(session) + + elif action_type == "strategy_advice": + return _handle_strategy_advice(session) + + elif action_type == "analyze_stock": + return _handle_analyze_stock(action, session) + + elif action_type == "analyze_sector": + return _handle_analyze_sector(action, session) + + elif action_type == "chat": + return { + "ok": True, + "type": "chat", + "text": action.get("text", "我是Blackdata AI助手,有什么可以帮你?") + } + + else: + return { + "ok": False, + "text": "抱歉,我不太理解您的问题。您可以问我:\n- 帮我选股\n- 我的持仓怎么样\n- 给我策略建议\n- 分析某个股票或板块" + } + + +def _handle_select_stocks(action: Dict, session: Dict) -> Dict[str, Any]: + """处理选股请求""" + + # 从自然语言提取条件 + description = action.get("description", "") + conditions = action.get("conditions", {}) + + # 构建选股策略 + strategy = selector.Strategy("AI选股", description) + + # 将条件转换为选股条件 + for field, op_value in conditions.items(): + field_map = { + "涨幅": "pct", + "5日涨幅": "ret5", + "20日涨幅": "ret20", + "量比": "vol_ratio", + "成交额": "amount", + "RSI": "rsi14", + "价格": "close" + } + + if field in field_map: + actual_field = field_map[field] + + # 解析操作符和值 + if isinstance(op_value, str): + if op_value.startswith(">"): + op = ">" + val = float(op_value[1:].strip()) + elif op_value.startswith("<"): + op = "<" + val = float(op_value[1:].strip()) + else: + continue + + strategy.add_condition(actual_field, op, val) + + # 如果没有条件,添加默认条件 + if not strategy.conditions: + strategy.add_condition("ret5", ">", 5) + strategy.add_condition("vol_ratio", ">", 1.5) + + # 执行选股 + result = selector.run_selector(strategy) + + if not result["ok"]: + return { + "ok": False, + "text": f"选股失败:{result.get('msg', '未知错误')}" + } + + # 保存选股结果到上下文 + session["context"]["last_selection"] = result["results"][:10] + + # 格式化回复 + stocks = result["results"][:10] + if not stocks: + text = "根据您的条件,暂时没有找到符合的股票。您可以:\n1. 放宽筛选条件\n2. 尝试其他板块\n3. 等待市场出现机会" + else: + text = f"为您找到 {result['count']} 只股票,以下是前10只:\n\n" + for i, s in enumerate(stocks, 1): + text += f"{i}. {s['name']}({s['code']})\n" + text += f" 现价:{s['close']}元 涨跌:{s['pct']:+.2f}% 5日:{s['ret5']:+.2f}%\n" + text += f" 量比:{s['vol_ratio']:.2f} 成交额:{s['amount']:.1f}亿\n\n" + + text += "💡 您可以继续问我:\n- 分析某只股票(如\"分析第1只\")\n- 回测这个策略\n- 看看其他板块" + + return { + "ok": True, + "type": "select_stocks", + "text": text, + "data": stocks + } + + +def _handle_diagnose_portfolio(session: Dict) -> Dict[str, Any]: + """处理持仓诊断""" + + try: + portfolio = pf.compute() + + if not portfolio["holdings"]: + return { + "ok": True, + "type": "diagnose", + "text": "您当前没有持仓。建议:\n1. 先在「交易日志」录入交易记录\n2. 或者问我\"帮我选股\"来寻找投资机会" + } + + summary = portfolio["summary"] + holdings = portfolio["holdings"] + + # 分析持仓 + total_unrealized = summary["unrealized"] + win_rate = summary["win_rate"] + + # 风险诊断 + risks = [] + + # 1. 浮亏检查 + losing_positions = [h for h in holdings if h["unrealized"] < 0] + if len(losing_positions) > len(holdings) / 2: + risks.append(f"⚠️ 超过一半的持仓处于浮亏状态({len(losing_positions)}/{len(holdings)}只)") + + # 2. 集中度检查 + if len(holdings) < 3: + risks.append("⚠️ 持仓过于集中,建议分散投资") + + # 3. 胜率检查 + if win_rate < 40: + risks.append(f"⚠️ 历史胜率较低({win_rate}%),建议反思选股策略") + + # 构建回复 + text = f"📊 持仓诊断报告\n\n" + text += f"持仓数量:{summary['positions']} 只\n" + text += f"持仓市值:{summary['market_value']:.2f} 元\n" + text += f"浮动盈亏:{total_unrealized:+.2f} 元\n" + text += f"历史胜率:{win_rate}%\n\n" + + if risks: + text += "⚠️ 风险提示:\n" + for risk in risks: + text += f"{risk}\n" + text += "\n" + + # 前5大持仓 + text += "📈 前5大持仓:\n" + for i, h in enumerate(holdings[:5], 1): + pnl_sign = "+" if h["unrealized"] >= 0 else "" + text += f"{i}. {h['name']} {pnl_sign}{h['unrealized_pct']:.2f}% {pnl_sign}{h['unrealized']:.0f}元\n" + + text += "\n💡 建议:\n" + if risks: + text += "- 考虑止损浮亏较大的股票\n" + text += "- 增加持仓分散度\n" + else: + text += "- 当前持仓状况良好,继续关注\n" + text += "- 定期复盘,总结经验\n" + + # 保存到上下文 + session["context"]["portfolio"] = holdings + + return { + "ok": True, + "type": "diagnose", + "text": text, + "data": portfolio + } + + except Exception as e: + return { + "ok": False, + "text": f"持仓诊断失败:{str(e)}" + } + + +def _handle_strategy_advice(session: Dict) -> Dict[str, Any]: + """处理策略建议""" + + try: + # 获取市场情绪 + summary = sector.get_rotation_summary() + + if not summary.get("ok"): + return { + "ok": False, + "text": "暂时无法获取市场数据,请稍后再试" + } + + strongest = summary.get("strongest_sectors", []) + weakest = summary.get("weakest_sectors", []) + + # 构建策略建议 + text = "📋 当前市场策略建议\n\n" + + text += "🔥 强势板块:\n" + for s in strongest[:3]: + text += f"- {s['name']} {s['return_10d']:+.2f}%\n" + text += "\n" + + text += "📉 弱势板块:\n" + for s in weakest[:3]: + text += f"- {s['name']} {s['return_10d']:+.2f}%\n" + text += "\n" + + # 策略建议 + avg_return = sum(s['return_10d'] for s in strongest[:3]) / 3 if strongest else 0 + + if avg_return > 10: + text += "💡 策略建议:\n" + text += "- 市场情绪较好,适合进攻型策略\n" + text += "- 可关注强势板块的龙头股\n" + text += "- 设置好止盈点,及时落袋为安\n" + elif avg_return > 0: + text += "💡 策略建议:\n" + text += "- 市场震荡,适合波段操作\n" + text += "- 追踪强势板块,低吸高抛\n" + text += "- 控制仓位,分批建仓\n" + else: + text += "💡 策略建议:\n" + text += "- 市场偏弱,以防守为主\n" + text += "- 减仓观望,等待机会\n" + text += "- 关注超跌板块的反弹机会\n" + + text += "\n🎯 具体操作:\n" + text += "- 可以问我\"帮我找[强势板块]的股票\"\n" + text += "- 或\"分析[某个板块]\"\n" + + return { + "ok": True, + "type": "strategy", + "text": text, + "data": summary + } + + except Exception as e: + return { + "ok": False, + "text": f"策略建议失败:{str(e)}" + } + + +def _handle_analyze_stock(action: Dict, session: Dict) -> Dict[str, Any]: + """处理个股分析""" + + code = action.get("code", "").strip() + + if not code: + # 从上下文中获取 + last_selection = session["context"].get("last_selection", []) + if last_selection: + code = last_selection[0]["code"] + else: + return { + "ok": False, + "text": "请指定股票代码,例如\"分析600519\"" + } + + try: + result = ai.diagnose(code) + + if not result["ok"]: + return { + "ok": False, + "text": f"分析失败:{result.get('msg', '未知错误')}" + } + + # 格式化回复 + text = f"📊 {result['name']}({result['symbol']})AI诊断\n\n" + text += f"综合评分:{result['total']}分\n" + text += f"预测方向:{'看多' if result['direction'] == 'up' else ('看空' if result['direction'] == 'down' else '中性')}\n" + text += f"置信度:{result['confidence']}%\n\n" + + text += "📈 各维度评分:\n" + for dim, score in result["scores"].items(): + text += f"- {dim}:{score}分\n" + + text += f"\n💬 {result['text'][:300]}...\n" + text += "\n💡 完整分析请在「AI分析 → 个股诊断」页面查看" + + return { + "ok": True, + "type": "analyze_stock", + "text": text, + "data": result + } + + except Exception as e: + return { + "ok": False, + "text": f"分析失败:{str(e)}" + } + + +def _handle_analyze_sector(action: Dict, session: Dict) -> Dict[str, Any]: + """处理板块分析""" + + sector_name = action.get("name", "").strip() + + if not sector_name: + return { + "ok": False, + "text": "请指定板块名称,例如\"分析半导体板块\"" + } + + try: + result = sector.analyze_lifecycle(sector_name, days=60) + + if not result["ok"]: + return { + "ok": False, + "text": f"分析失败:{result.get('msg', '未知错误')}" + } + + # 格式化回复 + text = f"📊 {result['sector']} 板块分析\n\n" + text += f"生命周期:{result['phase']}\n" + text += f"{result['description']}\n\n" + + metrics = result["metrics"] + text += f"📈 近期表现:\n" + text += f"- 5日涨幅:{metrics['return_5d']:+.2f}%\n" + text += f"- 20日涨幅:{metrics['return_20d']:+.2f}%\n" + text += f"- 成交额变化:{metrics['amount_change']:+.2f}%\n\n" + + # 龙头股 + leaders = sector.identify_leaders(sector_name, limit=5) + if leaders["ok"] and leaders["leaders"]: + text += "🏆 龙头股:\n" + for i, l in enumerate(leaders["leaders"][:3], 1): + text += f"{i}. {l['name']} {l['ret20']:+.2f}%\n" + + text += "\n💡 您可以继续问:\n" + text += f"- 帮我找{sector_name}板块的股票\n" + text += f"- {sector_name}龙头股有哪些\n" + + return { + "ok": True, + "type": "analyze_sector", + "text": text, + "data": result + } + + except Exception as e: + return { + "ok": False, + "text": f"分析失败:{str(e)}" + } + + +def clear_session(session_id: str): + """清空会话""" + if session_id in _SESSIONS: + del _SESSIONS[session_id] + + +def get_session_history(session_id: str) -> List[Dict]: + """获取会话历史""" + session = _SESSIONS.get(session_id) + if session: + return session["messages"] + return [] diff --git a/backend/alerts.py b/backend/alerts.py index f6e9c76..a7a41e9 100644 --- a/backend/alerts.py +++ b/backend/alerts.py @@ -60,7 +60,7 @@ def check_alerts(): # 触发后向已配置渠道推送(站外) if push_msgs and notifier.any_enabled(): try: - notifier.notify("【智策预警】" + (push_msgs[0] if len(push_msgs) == 1 else f"{len(push_msgs)} 条预警触发"), + notifier.notify("【Blackdata预警】" + (push_msgs[0] if len(push_msgs) == 1 else f"{len(push_msgs)} 条预警触发"), "\n".join(push_msgs)) except Exception: pass diff --git a/backend/attribution_analysis.py b/backend/attribution_analysis.py new file mode 100644 index 0000000..2e7e7f4 --- /dev/null +++ b/backend/attribution_analysis.py @@ -0,0 +1,437 @@ +"""持仓归因分析深化 — 选股/择时能力、持仓时长、理由有效性分析。 + +功能: +1. 收益归因分解(选股 vs 择时 vs 运气) +2. 持仓时长分析(短线/中线/长线胜率) +3. 买入理由有效性验证 +4. 情绪标签相关性分析 +5. 对标指数超额收益拆解 +""" +import datetime as dt +from typing import Dict, List, Any, Tuple +from collections import defaultdict +import numpy as np +from sqlalchemy import select, and_ + +from db import get_session +from models import Trade, DailyQuote, IndexDaily, StockMetric + + +def analyze_attribution() -> Dict[str, Any]: + """综合归因分析""" + with get_session() as s: + trades = s.execute(select(Trade).order_by(Trade.date, Trade.id)).scalars().all() + + if not trades: + return {"ok": False, "msg": "暂无交易记录"} + + stock_timing = analyze_stock_vs_timing(trades) + hold_period = analyze_hold_period(trades) + reason_valid = analyze_reason_validity(trades) + emotion_corr = analyze_emotion_correlation(trades) + excess_return = analyze_excess_return(trades) + + return { + "ok": True, + "stock_vs_timing": stock_timing, + "hold_period": hold_period, + "reason_validity": reason_valid, + "emotion_correlation": emotion_corr, + "excess_return": excess_return, + } + + +def analyze_stock_vs_timing(trades: List[Trade]) -> Dict[str, Any]: + """分解选股能力 vs 择时能力 + + 选股能力:买入后股票的整体涨幅(持有期间市场表现) + 择时能力:实际买卖点的精准度(买在低点、卖在高点) + 运气成分:市场整体波动的影响 + """ + with get_session() as s: + stock_trades = defaultdict(list) + for t in trades: + stock_trades[t.code].append(t) + + results = [] + total_stock_contrib = 0.0 + total_timing_contrib = 0.0 + + for code, stock_trades_list in stock_trades.items(): + dates = [t.date for t in stock_trades_list] + start = min(dates) + end = max(dates) + + prices = {} + for d, close in s.execute( + select(DailyQuote.date, DailyQuote.close) + .where( + and_( + DailyQuote.code == code, + DailyQuote.date >= start, + DailyQuote.date <= end, + ) + ) + .order_by(DailyQuote.date) + ).all(): + prices[d] = float(close) + + if not prices: + continue + + first_price = prices[min(prices.keys())] + last_price = prices[max(prices.keys())] + stock_return = (last_price / first_price - 1) * 100 + + buys = [t for t in stock_trades_list if t.side == "buy"] + sells = [t for t in stock_trades_list if t.side == "sell"] + + if buys and sells: + avg_buy = np.mean([t.price for t in buys]) + avg_sell = np.mean([t.price for t in sells]) + + ideal_buy = min(prices.values()) + ideal_sell = max(prices.values()) + + buy_timing = ( + (1 - (avg_buy - ideal_buy) / (ideal_sell - ideal_buy)) * 100 + if ideal_sell > ideal_buy + else 50 + ) + sell_timing = ( + ((avg_sell - ideal_buy) / (ideal_sell - ideal_buy)) * 100 + if ideal_sell > ideal_buy + else 50 + ) + timing_score = (buy_timing + sell_timing) / 2 + actual_return = (avg_sell / avg_buy - 1) * 100 + + results.append( + { + "code": code, + "stock_return": round(stock_return, 2), + "timing_score": round(timing_score, 1), + "actual_return": round(actual_return, 2), + } + ) + + total_stock_contrib += stock_return + total_timing_contrib += timing_score + + if not results: + return {"ok": False, "msg": "数据不足"} + + avg_stock = total_stock_contrib / len(results) + avg_timing = total_timing_contrib / len(results) + + return { + "ok": True, + "stock_ability": round(avg_stock, 2), + "timing_ability": round(avg_timing, 1), + "interpretation": { + "stock": "正值表示选对了股票(股票整体上涨),负值表示选错了", + "timing": "100分满分,表示买卖点的精准度,50分为平均水平", + }, + "by_stock": results, + } + + +def analyze_hold_period(trades: List[Trade]) -> Dict[str, Any]: + """持仓时长分析 + + 短线:持仓 <= 5天 + 中线:持仓 6-30天 + 长线:持仓 > 30天 + """ + holdings = defaultdict(list) + closed_trades = [] + + for t in trades: + if t.side == "buy": + holdings[t.code].append({"trade": t, "qty": t.qty}) + else: + remaining = t.qty + while remaining > 0 and holdings[t.code]: + hold = holdings[t.code][0] + sell_qty = min(remaining, hold["qty"]) + + hold_days = (t.date - hold["trade"].date).days + pnl = (t.price - hold["trade"].price) * sell_qty - t.fee * ( + sell_qty / t.qty + ) + pnl_pct = (t.price / hold["trade"].price - 1) * 100 + + closed_trades.append( + { + "code": t.code, + "buy_date": hold["trade"].date, + "sell_date": t.date, + "hold_days": hold_days, + "buy_price": hold["trade"].price, + "sell_price": t.price, + "qty": sell_qty, + "pnl": pnl, + "pnl_pct": pnl_pct, + } + ) + + hold["qty"] -= sell_qty + remaining -= sell_qty + + if hold["qty"] <= 0: + holdings[t.code].pop(0) + + if not closed_trades: + return {"ok": False, "msg": "暂无已平仓交易"} + + short_term = [t for t in closed_trades if t["hold_days"] <= 5] + mid_term = [t for t in closed_trades if 6 <= t["hold_days"] <= 30] + long_term = [t for t in closed_trades if t["hold_days"] > 30] + + def calc_stats(trades_list): + if not trades_list: + return {"count": 0, "win_rate": 0, "avg_return": 0, "avg_days": 0} + wins = sum(1 for t in trades_list if t["pnl"] > 0) + return { + "count": len(trades_list), + "win_rate": round(wins / len(trades_list) * 100, 1), + "avg_return": round(np.mean([t["pnl_pct"] for t in trades_list]), 2), + "avg_days": round(np.mean([t["hold_days"] for t in trades_list]), 1), + "total_pnl": round(sum(t["pnl"] for t in trades_list), 2), + } + + return { + "ok": True, + "short_term": calc_stats(short_term), + "mid_term": calc_stats(mid_term), + "long_term": calc_stats(long_term), + "recommendation": _recommend_hold_period(short_term, mid_term, long_term), + } + + +def _recommend_hold_period(short, mid, long) -> str: + """推荐最佳持仓周期""" + periods = [ + ("短线(≤5天)", short), + ("中线(6-30天)", mid), + ("长线(>30天)", long), + ] + + if not any(p for _, p in periods): + return "数据不足" + + scores = [] + for name, trades_list in periods: + if not trades_list: + scores.append((name, 0)) + continue + wins = sum(1 for t in trades_list if t["pnl"] > 0) + win_rate = wins / len(trades_list) + avg_ret = np.mean([t["pnl_pct"] for t in trades_list]) + score = win_rate * 50 + (avg_ret / 10) * 50 + scores.append((name, score)) + + scores.sort(key=lambda x: x[1], reverse=True) + best = scores[0][0] + + return f"建议重点关注{best},该周期胜率和收益表现最佳" + + +def analyze_reason_validity(trades: List[Trade]) -> Dict[str, Any]: + """买入理由有效性验证""" + holdings = defaultdict(list) + reason_stats = defaultdict(lambda: {"trades": [], "wins": 0, "total_pnl": 0}) + + for t in trades: + if t.side == "buy": + holdings[t.code].append(t) + else: + while t.qty > 0 and holdings[t.code]: + buy = holdings[t.code].pop(0) + qty = min(t.qty, buy.qty) + + pnl = (t.price - buy.price) * qty + pnl_pct = (t.price / buy.price - 1) * 100 + + reason = buy.reason or "未标注" + reason_stats[reason]["trades"].append(pnl_pct) + reason_stats[reason]["total_pnl"] += pnl + if pnl > 0: + reason_stats[reason]["wins"] += 1 + + buy.qty -= qty + t.qty -= qty + if buy.qty > 0: + holdings[t.code].insert(0, buy) + + if not reason_stats: + return {"ok": False, "msg": "暂无已平仓交易"} + + results = [] + for reason, stats in reason_stats.items(): + trades_list = stats["trades"] + results.append( + { + "reason": reason, + "count": len(trades_list), + "win_rate": round(stats["wins"] / len(trades_list) * 100, 1), + "avg_return": round(np.mean(trades_list), 2), + "total_pnl": round(stats["total_pnl"], 2), + "effectiveness": "有效" + if stats["wins"] / len(trades_list) > 0.5 + else "无效", + } + ) + + results.sort(key=lambda x: x["win_rate"], reverse=True) + + return { + "ok": True, + "by_reason": results, + "best_reason": results[0]["reason"] if results else None, + "worst_reason": results[-1]["reason"] if results else None, + } + + +def analyze_emotion_correlation(trades: List[Trade]) -> Dict[str, Any]: + """情绪标签相关性分析""" + holdings = defaultdict(list) + emotion_stats = defaultdict(lambda: {"trades": [], "wins": 0, "total_pnl": 0}) + + for t in trades: + if t.side == "buy": + holdings[t.code].append(t) + else: + while t.qty > 0 and holdings[t.code]: + buy = holdings[t.code].pop(0) + qty = min(t.qty, buy.qty) + + pnl = (t.price - buy.price) * qty + pnl_pct = (t.price / buy.price - 1) * 100 + + emotion = buy.emotion or "未标注" + emotion_stats[emotion]["trades"].append(pnl_pct) + emotion_stats[emotion]["total_pnl"] += pnl + if pnl > 0: + emotion_stats[emotion]["wins"] += 1 + + buy.qty -= qty + t.qty -= qty + if buy.qty > 0: + holdings[t.code].insert(0, buy) + + if not emotion_stats: + return {"ok": False, "msg": "暂无已平仓交易"} + + results = [] + for emotion, stats in emotion_stats.items(): + trades_list = stats["trades"] + results.append( + { + "emotion": emotion, + "count": len(trades_list), + "win_rate": round(stats["wins"] / len(trades_list) * 100, 1), + "avg_return": round(np.mean(trades_list), 2), + "total_pnl": round(stats["total_pnl"], 2), + } + ) + + results.sort(key=lambda x: x["avg_return"], reverse=True) + + return { + "ok": True, + "by_emotion": results, + "advice": _generate_emotion_advice(results), + } + + +def _generate_emotion_advice(results: List[Dict]) -> str: + """生成情绪建议""" + if not results: + return "数据不足" + + best = results[0] + worst = results[-1] + + advice = ( + f"最佳情绪状态:{best['emotion']}(胜率{best['win_rate']}%," + f"平均收益{best['avg_return']}%)\n" + ) + advice += ( + f"最差情绪状态:{worst['emotion']}(胜率{worst['win_rate']}%," + f"平均收益{worst['avg_return']}%)\n" + ) + advice += "\n建议:保持理性和纪律,避免在贪婪或恐慌时做决策" + + return advice + + +def analyze_excess_return(trades: List[Trade]) -> Dict[str, Any]: + """对标指数超额收益拆解""" + if not trades: + return {"ok": False, "msg": "暂无交易记录"} + + start_date = min(t.date for t in trades) + end_date = max(t.date for t in trades) + + with get_session() as s: + index_data = s.execute( + select(IndexDaily.date, IndexDaily.close) + .where( + and_( + IndexDaily.code == "sh000300", + IndexDaily.date >= start_date, + IndexDaily.date <= end_date, + ) + ) + .order_by(IndexDaily.date) + ).all() + + if not index_data: + return {"ok": False, "msg": "缺少指数数据"} + + index_start = float(index_data[0][1]) + index_end = float(index_data[-1][1]) + index_return = (index_end / index_start - 1) * 100 + + holdings = defaultdict(lambda: {"qty": 0, "cost": 0.0}) + realized_pnl = 0.0 + total_cost = 0.0 + + for t in trades: + p = holdings[t.code] + if t.side == "buy": + p["cost"] += t.price * t.qty + t.fee + p["qty"] += t.qty + total_cost += t.price * t.qty + t.fee + else: + if p["qty"] > 0: + avg = p["cost"] / p["qty"] + qty = min(t.qty, p["qty"]) + pnl = (t.price - avg) * qty - t.fee + realized_pnl += pnl + p["cost"] -= avg * qty + p["qty"] -= qty + + portfolio_return = (realized_pnl / total_cost * 100) if total_cost > 0 else 0 + excess_return = portfolio_return - index_return + + if excess_return > 0: + source = "选股能力贡献" + interpretation = "组合表现优于大盘,说明选股和择时能力较好" + elif excess_return < -5: + source = "选股/择时失误" + interpretation = "组合表现明显弱于大盘,建议反思选股逻辑和买卖时机" + else: + source = "与大盘持平" + interpretation = "组合表现与大盘接近,可考虑增强选股策略" + + return { + "ok": True, + "portfolio_return": round(portfolio_return, 2), + "index_return": round(index_return, 2), + "excess_return": round(excess_return, 2), + "source": source, + "interpretation": interpretation, + "period": f"{start_date} ~ {end_date}", + } diff --git a/backend/backtest_advanced.py b/backend/backtest_advanced.py new file mode 100644 index 0000000..a2a92cf --- /dev/null +++ b/backend/backtest_advanced.py @@ -0,0 +1,499 @@ +"""增强版回测引擎 — 多因子策略、仓位管理、参数优化。 + +支持功能: +1. 多因子组合策略(技术+基本面) +2. 仓位管理(固定、金字塔、凯利公式) +3. 止损止盈 +4. 参数网格优化 +5. 完整指标(夏普比率、最大回撤、卡玛比率等) +6. 交易明细导出 +""" +import datetime as dt +from typing import Dict, List, Any, Optional, Callable +import numpy as np +from sqlalchemy import select + +from db import get_session +from models import DailyQuote, StockMetric + + +class Position: + """持仓记录""" + def __init__(self, date, price, shares, reason=""): + self.entry_date = date + self.entry_price = price + self.shares = shares + self.reason = reason + self.exit_date = None + self.exit_price = None + self.pnl = 0.0 + self.pnl_pct = 0.0 + self.hold_days = 0 + + +class BacktestEngine: + """增强回测引擎""" + + def __init__(self, initial_capital: float = 100000.0, commission: float = 0.0005): + self.initial_capital = initial_capital + self.commission = commission + + # 账户状态 + self.cash = initial_capital + self.positions: List[Position] = [] + self.closed_positions: List[Position] = [] + + # 净值曲线 + self.equity_curve = [] + self.dates = [] + + # 统计 + self.trades = 0 + self.wins = 0 + self.total_pnl = 0.0 + + def get_position_value(self, price: float) -> float: + """计算持仓市值""" + return sum(p.shares * price for p in self.positions) + + def get_total_value(self, price: float) -> float: + """计算总资产""" + return self.cash + self.get_position_value(price) + + def buy(self, date, price: float, size: float, reason: str = ""): + """买入 + + Args: + date: 交易日期 + price: 买入价格 + size: 仓位大小(0-1),相对于当前可用资金 + reason: 买入理由 + """ + if size <= 0 or size > 1: + return False + + cost = self.cash * size + commission_fee = cost * self.commission + net_cost = cost - commission_fee + + if net_cost <= 0: + return False + + shares = net_cost / price + self.cash -= cost + + pos = Position(date, price, shares, reason) + self.positions.append(pos) + self.trades += 1 + return True + + def sell(self, date, price: float, size: float = 1.0, reason: str = ""): + """卖出 + + Args: + date: 交易日期 + price: 卖出价格 + size: 卖出比例(0-1),相对于持仓 + reason: 卖出理由 + """ + if not self.positions or size <= 0 or size > 1: + return False + + # 按先进先出卖出 + remaining = size + sold_positions = [] + + for pos in self.positions[:]: + if remaining <= 0: + break + + sell_ratio = min(remaining, 1.0) + sell_shares = pos.shares * sell_ratio + proceeds = sell_shares * price + commission_fee = proceeds * self.commission + net_proceeds = proceeds - commission_fee + + self.cash += net_proceeds + + # 更新持仓 + pos.shares -= sell_shares + if pos.shares < 0.01: # 清仓 + pos.exit_date = date + pos.exit_price = price + pos.hold_days = (date - pos.entry_date).days + pos.pnl = (price - pos.entry_price) * (sell_shares / sell_ratio) + pos.pnl_pct = (price / pos.entry_price - 1) * 100 + + self.closed_positions.append(pos) + self.positions.remove(pos) + + if pos.pnl > 0: + self.wins += 1 + self.total_pnl += pos.pnl + + remaining -= sell_ratio + sold_positions.append((pos, sell_shares)) + + return True + + def record_state(self, date, price: float): + """记录当前状态""" + self.dates.append(date) + self.equity_curve.append(self.get_total_value(price)) + + def get_metrics(self) -> Dict[str, Any]: + """计算完整指标""" + if not self.equity_curve: + return {} + + equity = np.array(self.equity_curve) + returns = np.diff(equity) / equity[:-1] + + # 基础指标 + total_return = (equity[-1] / equity[0] - 1) * 100 + + # 最大回撤 + peak = np.maximum.accumulate(equity) + drawdown = (peak - equity) / peak + max_drawdown = np.max(drawdown) * 100 + + # 夏普比率(年化,假设252个交易日) + if len(returns) > 1 and np.std(returns) > 0: + sharpe = np.mean(returns) / np.std(returns) * np.sqrt(252) + else: + sharpe = 0.0 + + # 卡玛比率(收益/最大回撤) + calmar = total_return / max_drawdown if max_drawdown > 0 else 0.0 + + # 胜率 + closed = len(self.closed_positions) + win_rate = (self.wins / closed * 100) if closed > 0 else 0.0 + + # 盈亏比 + winning_trades = [p.pnl for p in self.closed_positions if p.pnl > 0] + losing_trades = [abs(p.pnl) for p in self.closed_positions if p.pnl < 0] + avg_win = np.mean(winning_trades) if winning_trades else 0.0 + avg_loss = np.mean(losing_trades) if losing_trades else 0.0 + profit_factor = avg_win / avg_loss if avg_loss > 0 else 0.0 + + # 持仓天数 + hold_days = [p.hold_days for p in self.closed_positions] + avg_hold = np.mean(hold_days) if hold_days else 0.0 + + return { + "total_return": round(total_return, 2), + "max_drawdown": round(max_drawdown, 2), + "sharpe_ratio": round(sharpe, 3), + "calmar_ratio": round(calmar, 3), + "trades": self.trades, + "closed_trades": closed, + "win_rate": round(win_rate, 1), + "profit_factor": round(profit_factor, 2), + "avg_win": round(avg_win, 2), + "avg_loss": round(avg_loss, 2), + "avg_hold_days": round(avg_hold, 1), + "total_pnl": round(self.total_pnl, 2), + } + + +class Strategy: + """策略基类""" + + def __init__(self, name: str): + self.name = name + + def on_data(self, engine: BacktestEngine, date, data: Dict[str, Any]) -> None: + """每日回调""" + raise NotImplementedError + + +class MAStrategy(Strategy): + """均线交叉策略(增强版)""" + + def __init__(self, fast: int = 5, slow: int = 20, + position_size: float = 1.0, + stop_loss: float = 0.0, + take_profit: float = 0.0): + super().__init__(f"MA{fast}/{slow}") + self.fast = fast + self.slow = slow + self.position_size = position_size + self.stop_loss = stop_loss # 止损比例 + self.take_profit = take_profit # 止盈比例 + + self.ma_fast_history = [] + self.ma_slow_history = [] + self.close_history = [] + + def on_data(self, engine: BacktestEngine, date, data: Dict[str, Any]) -> None: + close = data["close"] + self.close_history.append(close) + + # 计算均线 + if len(self.close_history) >= self.fast: + self.ma_fast_history.append(np.mean(self.close_history[-self.fast:])) + else: + self.ma_fast_history.append(None) + + if len(self.close_history) >= self.slow: + self.ma_slow_history.append(np.mean(self.close_history[-self.slow:])) + else: + self.ma_slow_history.append(None) + + if len(self.ma_fast_history) < 2: + engine.record_state(date, close) + return + + maf_curr = self.ma_fast_history[-1] + maf_prev = self.ma_fast_history[-2] + mas_curr = self.ma_slow_history[-1] + mas_prev = self.ma_slow_history[-2] + + if maf_curr is None or mas_curr is None: + engine.record_state(date, close) + return + + # 止损止盈检查 + if engine.positions: + for pos in engine.positions[:]: + pnl_pct = (close / pos.entry_price - 1) * 100 + + # 止损 + if self.stop_loss > 0 and pnl_pct <= -self.stop_loss: + engine.sell(date, close, 1.0, f"止损 {pnl_pct:.2f}%") + # 止盈 + elif self.take_profit > 0 and pnl_pct >= self.take_profit: + engine.sell(date, close, 1.0, f"止盈 {pnl_pct:.2f}%") + + # 金叉买入 + if maf_prev <= mas_prev and maf_curr > mas_curr: + if not engine.positions: + engine.buy(date, close, self.position_size, "金叉") + + # 死叉卖出 + elif maf_prev >= mas_prev and maf_curr < mas_curr: + if engine.positions: + engine.sell(date, close, 1.0, "死叉") + + engine.record_state(date, close) + + +class MultiFactorStrategy(Strategy): + """多因子策略""" + + def __init__(self, position_size: float = 1.0): + super().__init__("多因子") + self.position_size = position_size + self.close_history = [] + self.volume_history = [] + + def calculate_rsi(self, n: int = 14) -> Optional[float]: + """计算RSI""" + if len(self.close_history) < n + 1: + return None + + changes = np.diff(self.close_history[-n-1:]) + gains = np.where(changes > 0, changes, 0) + losses = np.where(changes < 0, -changes, 0) + + avg_gain = np.mean(gains) + avg_loss = np.mean(losses) + + if avg_loss == 0: + return 100.0 + + rs = avg_gain / avg_loss + rsi = 100 - (100 / (1 + rs)) + return rsi + + def on_data(self, engine: BacktestEngine, date, data: Dict[str, Any]) -> None: + close = data["close"] + volume = data.get("volume", 0) + + self.close_history.append(close) + self.volume_history.append(volume) + + if len(self.close_history) < 30: + engine.record_state(date, close) + return + + # 计算因子 + ma5 = np.mean(self.close_history[-5:]) + ma20 = np.mean(self.close_history[-20:]) + rsi = self.calculate_rsi(14) + + # 量比 + vol_avg = np.mean(self.volume_history[-20:-1]) + vol_ratio = volume / vol_avg if vol_avg > 0 else 1.0 + + # 买入信号:MA5 > MA20, RSI < 70, 放量 + buy_signal = (ma5 > ma20 and + rsi is not None and rsi < 70 and + vol_ratio > 1.5) + + # 卖出信号:MA5 < MA20 或 RSI > 80 + sell_signal = (ma5 < ma20 or + (rsi is not None and rsi > 80)) + + if buy_signal and not engine.positions: + engine.buy(date, close, self.position_size, "多因子买入") + + if sell_signal and engine.positions: + engine.sell(date, close, 1.0, "多因子卖出") + + engine.record_state(date, close) + + +def run_advanced_backtest(symbol: str, + strategy: Strategy, + start_date: Optional[str] = None, + end_date: Optional[str] = None, + initial_capital: float = 100000.0, + commission: float = 0.0005) -> Dict[str, Any]: + """运行增强回测 + + Args: + symbol: 股票代码 + strategy: 策略实例 + start_date: 开始日期 + end_date: 结束日期 + initial_capital: 初始资金 + commission: 手续费率 + + Returns: + 回测结果 + """ + with get_session() as s: + query = select(DailyQuote.date, DailyQuote.close, DailyQuote.volume).where( + DailyQuote.code == symbol + ) + + if start_date: + query = query.where(DailyQuote.date >= dt.date.fromisoformat(start_date)) + if end_date: + query = query.where(DailyQuote.date <= dt.date.fromisoformat(end_date)) + + query = query.order_by(DailyQuote.date) + rows = s.execute(query).all() + + if not rows: + return {"ok": False, "msg": "无数据"} + + engine = BacktestEngine(initial_capital, commission) + + # 逐日回测 + for row in rows: + date, close, volume = row + data = {"close": float(close), "volume": int(volume)} + strategy.on_data(engine, date, data) + + # 计算基准(买入持有) + bench_curve = [] + first_close = rows[0][1] + for row in rows: + bench_curve.append(float(row[1]) / float(first_close) * initial_capital) + + metrics = engine.get_metrics() + + # 交易明细 + trades_detail = [{ + "entry_date": p.entry_date.isoformat(), + "exit_date": p.exit_date.isoformat() if p.exit_date else "", + "entry_price": round(p.entry_price, 2), + "exit_price": round(p.exit_price, 2) if p.exit_price else 0, + "shares": round(p.shares, 2), + "hold_days": p.hold_days, + "pnl": round(p.pnl, 2), + "pnl_pct": round(p.pnl_pct, 2), + "reason": p.reason + } for p in engine.closed_positions] + + return { + "ok": True, + "symbol": symbol, + "strategy": strategy.name, + "dates": [d.isoformat() for d in engine.dates], + "equity": [round(e, 2) for e in engine.equity_curve], + "bench": [round(b, 2) for b in bench_curve], + "metrics": metrics, + "trades": trades_detail, + "initial_capital": initial_capital, + } + + +def optimize_parameters(symbol: str, + param_grid: Dict[str, List], + strategy_class: type, + metric: str = "sharpe_ratio") -> List[Dict[str, Any]]: + """参数网格优化 + + Args: + symbol: 股票代码 + param_grid: 参数网格,如 {"fast": [3,5,10], "slow": [10,20,30]} + strategy_class: 策略类 + metric: 优化目标指标 + + Returns: + 优化结果列表,按指标降序排列 + """ + import itertools + + keys = list(param_grid.keys()) + values = list(param_grid.values()) + + results = [] + + # 遍历所有参数组合 + for combo in itertools.product(*values): + params = dict(zip(keys, combo)) + + try: + strategy = strategy_class(**params) + result = run_advanced_backtest(symbol, strategy) + + if result["ok"]: + results.append({ + "params": params, + "metrics": result["metrics"], + metric: result["metrics"].get(metric, 0) + }) + except Exception as e: + print(f"优化失败 {params}: {e}") + continue + + # 按目标指标排序 + results.sort(key=lambda x: x[metric], reverse=True) + return results + + +def compare_strategies(symbol: str, + strategies: List[Strategy], + initial_capital: float = 100000.0) -> Dict[str, Any]: + """策略对比 + + Args: + symbol: 股票代码 + strategies: 策略列表 + initial_capital: 初始资金 + + Returns: + 对比结果 + """ + results = [] + + for strategy in strategies: + result = run_advanced_backtest(symbol, strategy, initial_capital=initial_capital) + if result["ok"]: + results.append({ + "strategy": strategy.name, + "equity": result["equity"], + "metrics": result["metrics"] + }) + + return { + "ok": True, + "symbol": symbol, + "dates": result["dates"] if results else [], + "strategies": results + } diff --git a/backend/event_driven.py b/backend/event_driven.py new file mode 100644 index 0000000..7cb2818 --- /dev/null +++ b/backend/event_driven.py @@ -0,0 +1,531 @@ +"""事件驱动策略 — 基于财经事件的量化交易。 + +功能: +1. 财报发布前后统计规律 +2. 限售解禁影响回测 +3. 高管增减持跟踪 +4. 行业政策事件库 +5. 事件驱动选股 +""" +import datetime as dt +from typing import List, Dict, Any, Optional +from collections import defaultdict +import numpy as np +from sqlalchemy import select, and_, or_, func, desc + +from db import get_session +from models import CorporateEvent, PolicyEvent, DailyQuote, StockMetric, Security + + +def collect_earnings_events(limit: int = 100) -> Dict[str, Any]: + """采集财报事件(模拟数据) + + 实际生产需要接入: + - 东方财富财报日历 + - 巨潮资讯网 + - 新浪财经API + """ + # 模拟财报事件 + with get_session() as s: + # 获取股票池 + stocks = s.execute( + select(Security.code, Security.name).limit(limit) + ).all() + + saved = 0 + for code, name in stocks: + # 模拟财报发布 + event_date = dt.date.today() - dt.timedelta(days=np.random.randint(1, 90)) + + # 检查是否已存在 + exists = s.execute( + select(CorporateEvent).where( + and_( + CorporateEvent.code == code, + CorporateEvent.event_type == 'earnings', + CorporateEvent.event_date == event_date + ) + ) + ).scalar_one_or_none() + + if not exists: + impact = np.random.choice(['positive', 'negative', 'neutral'], p=[0.4, 0.3, 0.3]) + + event = CorporateEvent( + code=code, + name=name, + event_type='earnings', + event_date=event_date, + title=f"{name}发布财报", + description=f"{'业绩超预期' if impact == 'positive' else ('业绩不及预期' if impact == 'negative' else '业绩符合预期')}", + impact=impact + ) + s.add(event) + saved += 1 + + s.commit() + + return {"ok": True, "saved": saved} + + +def analyze_earnings_pattern(days_before: int = 5, days_after: int = 10) -> Dict[str, Any]: + """分析财报发布前后的股价规律 + + Args: + days_before: 财报前N天 + days_after: 财报后N天 + + Returns: + 统计结果 + """ + with get_session() as s: + # 获取财报事件 + events = s.execute( + select(CorporateEvent) + .where(CorporateEvent.event_type == 'earnings') + ).scalars().all() + + if not events: + return {"ok": False, "msg": "暂无财报数据"} + + # 按影响分类 + results = { + 'positive': {'before': [], 'after': [], 'count': 0}, + 'negative': {'before': [], 'after': [], 'count': 0}, + 'neutral': {'before': [], 'after': [], 'count': 0} + } + + for event in events: + # 获取前后股价 + before_date = event.event_date - dt.timedelta(days=days_before) + after_date = event.event_date + dt.timedelta(days=days_after) + + quotes = s.execute( + select(DailyQuote) + .where( + and_( + DailyQuote.code == event.code, + DailyQuote.date >= before_date, + DailyQuote.date <= after_date + ) + ) + .order_by(DailyQuote.date) + ).scalars().all() + + if len(quotes) < 2: + continue + + # 找到事件日期的位置 + event_idx = None + for i, q in enumerate(quotes): + if q.date >= event.event_date: + event_idx = i + break + + if event_idx is None or event_idx == 0: + continue + + # 计算前后收益 + base_price = float(quotes[event_idx - 1].close) + + # 事件前收益 + if event_idx > 0: + before_return = (float(quotes[event_idx - 1].close) / float(quotes[0].close) - 1) * 100 + results[event.impact]['before'].append(before_return) + + # 事件后收益 + if event_idx < len(quotes) - 1: + after_return = (float(quotes[-1].close) / base_price - 1) * 100 + results[event.impact]['after'].append(after_return) + + results[event.impact]['count'] += 1 + + # 计算统计指标 + summary = {} + for impact, data in results.items(): + if data['count'] > 0: + summary[impact] = { + 'count': data['count'], + 'avg_before': round(np.mean(data['before']), 2) if data['before'] else 0, + 'avg_after': round(np.mean(data['after']), 2) if data['after'] else 0, + 'win_rate_after': round(sum(1 for x in data['after'] if x > 0) / len(data['after']) * 100, 1) if data['after'] else 0 + } + + return { + "ok": True, + "days_before": days_before, + "days_after": days_after, + "summary": summary + } + + +def track_insider_trading(code: str = None, days: int = 180) -> Dict[str, Any]: + """跟踪高管增减持 + + Args: + code: 股票代码(None表示全部) + days: 统计天数 + + Returns: + 增减持记录 + """ + since = dt.date.today() - dt.timedelta(days=days) + + with get_session() as s: + query = select(CorporateEvent).where( + and_( + CorporateEvent.event_type == 'insider', + CorporateEvent.event_date >= since + ) + ) + + if code: + query = query.where(CorporateEvent.code == code) + + query = query.order_by(desc(CorporateEvent.event_date)) + + events = s.execute(query).scalars().all() + + if not events: + return {"ok": False, "msg": "暂无增减持数据"} + + # 统计 + by_type = {'increase': [], 'decrease': []} + for e in events: + action = 'increase' if e.impact == 'positive' else 'decrease' + by_type[action].append({ + 'code': e.code, + 'name': e.name, + 'date': e.event_date.isoformat(), + 'title': e.title, + 'amount': e.amount + }) + + return { + "ok": True, + "days": days, + "total": len(events), + "increases": by_type['increase'], + "decreases": by_type['decrease'] + } + + +def analyze_unlock_impact(days: int = 90) -> Dict[str, Any]: + """分析限售解禁影响 + + Args: + days: 统计天数 + + Returns: + 解禁影响统计 + """ + since = dt.date.today() - dt.timedelta(days=days) + + with get_session() as s: + events = s.execute( + select(CorporateEvent) + .where( + and_( + CorporateEvent.event_type == 'unlock', + CorporateEvent.event_date >= since + ) + ) + .order_by(CorporateEvent.event_date) + ).scalars().all() + + if not events: + return {"ok": False, "msg": "暂无解禁数据"} + + results = [] + for event in events: + # 获取解禁前后股价 + before_date = event.event_date - dt.timedelta(days=10) + after_date = event.event_date + dt.timedelta(days=10) + + quotes = s.execute( + select(DailyQuote) + .where( + and_( + DailyQuote.code == event.code, + DailyQuote.date >= before_date, + DailyQuote.date <= after_date + ) + ) + .order_by(DailyQuote.date) + ).scalars().all() + + if len(quotes) < 5: + continue + + # 找到解禁日 + unlock_idx = None + for i, q in enumerate(quotes): + if q.date >= event.event_date: + unlock_idx = i + break + + if unlock_idx and unlock_idx > 0 and unlock_idx < len(quotes) - 1: + before_price = float(quotes[unlock_idx - 1].close) + after_price = float(quotes[-1].close) + impact_pct = (after_price / before_price - 1) * 100 + + results.append({ + 'code': event.code, + 'name': event.name, + 'date': event.event_date.isoformat(), + 'amount': event.amount, + 'impact_pct': round(impact_pct, 2), + 'title': event.title + }) + + # 统计 + if results: + avg_impact = np.mean([r['impact_pct'] for r in results]) + negative_count = sum(1 for r in results if r['impact_pct'] < 0) + + summary = { + 'total': len(results), + 'avg_impact': round(avg_impact, 2), + 'negative_ratio': round(negative_count / len(results) * 100, 1) + } + else: + summary = {} + + return { + "ok": True, + "days": days, + "summary": summary, + "events": results + } + + +def get_policy_events(sector: str = None, days: int = 180) -> Dict[str, Any]: + """获取行业政策事件 + + Args: + sector: 板块名称(None表示全部) + days: 统计天数 + + Returns: + 政策事件列表 + """ + since = dt.date.today() - dt.timedelta(days=days) + + with get_session() as s: + query = select(PolicyEvent).where(PolicyEvent.event_date >= since) + + if sector: + query = query.where(PolicyEvent.sector == sector) + + query = query.order_by(desc(PolicyEvent.event_date)) + + events = s.execute(query).scalars().all() + + if not events: + return {"ok": False, "msg": "暂无政策数据"} + + results = [] + for e in events: + results.append({ + 'sector': e.sector, + 'date': e.event_date.isoformat(), + 'title': e.title, + 'policy_type': e.policy_type, + 'impact': e.impact, + 'affected_stocks': e.affected_stocks.split(',') if e.affected_stocks else [] + }) + + return { + "ok": True, + "days": days, + "total": len(results), + "events": results + } + + +def event_driven_selector(event_types: List[str], days: int = 30) -> Dict[str, Any]: + """事件驱动选股 + + Args: + event_types: 事件类型列表,如 ['earnings_positive', 'insider_increase'] + days: 最近N天的事件 + + Returns: + 选股结果 + """ + since = dt.date.today() - dt.timedelta(days=days) + + with get_session() as s: + # 构建查询条件 + conditions = [] + + for et in event_types: + if et == 'earnings_positive': + conditions.append( + and_( + CorporateEvent.event_type == 'earnings', + CorporateEvent.impact == 'positive' + ) + ) + elif et == 'insider_increase': + conditions.append( + and_( + CorporateEvent.event_type == 'insider', + CorporateEvent.impact == 'positive' + ) + ) + elif et == 'dividend': + conditions.append(CorporateEvent.event_type == 'dividend') + + if not conditions: + return {"ok": False, "msg": "无效的事件类型"} + + # 查询事件 + query = select(CorporateEvent).where( + and_( + CorporateEvent.event_date >= since, + or_(*conditions) + ) + ).order_by(desc(CorporateEvent.event_date)) + + events = s.execute(query).scalars().all() + + if not events: + return {"ok": False, "msg": "无符合条件的事件"} + + # 按股票聚合 + stock_events = defaultdict(list) + for e in events: + stock_events[e.code].append({ + 'type': e.event_type, + 'date': e.event_date.isoformat(), + 'impact': e.impact, + 'title': e.title + }) + + # 获取股票最新数据 + codes = list(stock_events.keys()) + metrics = {} + for m in s.execute( + select(StockMetric).where(StockMetric.code.in_(codes)) + ).scalars(): + metrics[m.code] = { + 'name': m.name, + 'close': m.close, + 'pct': m.pct, + 'ret20': m.ret20 + } + + # 构建结果 + results = [] + for code, evt_list in stock_events.items(): + info = metrics.get(code, {'name': code, 'close': 0, 'pct': 0, 'ret20': 0}) + results.append({ + 'code': code, + 'name': info['name'], + 'close': info['close'], + 'pct': info['pct'], + 'ret20': info['ret20'], + 'events': evt_list, + 'event_score': len(evt_list) # 事件数量作为评分 + }) + + # 按事件评分排序 + results.sort(key=lambda x: x['event_score'], reverse=True) + + return { + "ok": True, + "days": days, + "event_types": event_types, + "count": len(results), + "stocks": results[:50] + } + + +def seed_sample_events(): + """生成示例事件数据(用于演示)""" + with get_session() as s: + # 获取股票池 + stocks = s.execute( + select(Security.code, Security.name).limit(100) + ).all() + + saved = 0 + + for code, name in stocks: + # 随机生成不同类型的事件 + event_types = [ + ('earnings', 'positive', f'{name}业绩超预期', 0), + ('earnings', 'negative', f'{name}业绩不及预期', 0), + ('insider', 'positive', f'{name}高管增持', np.random.uniform(0.1, 5)), + ('insider', 'negative', f'{name}高管减持', np.random.uniform(0.1, 5)), + ('unlock', 'negative', f'{name}限售解禁', np.random.uniform(1, 50)), + ('dividend', 'positive', f'{name}分红派息', np.random.uniform(0.5, 3)) + ] + + # 随机选择1-2个事件 + selected = np.random.choice(len(event_types), size=min(2, len(event_types)), replace=False) + + for idx in selected: + event_type, impact, title, amount = event_types[idx] + event_date = dt.date.today() - dt.timedelta(days=np.random.randint(1, 90)) + + # 检查是否已存在 + exists = s.execute( + select(CorporateEvent).where( + and_( + CorporateEvent.code == code, + CorporateEvent.event_type == event_type, + CorporateEvent.event_date == event_date + ) + ) + ).scalar_one_or_none() + + if not exists: + event = CorporateEvent( + code=code, + name=name, + event_type=event_type, + event_date=event_date, + title=title, + amount=amount, + impact=impact + ) + s.add(event) + saved += 1 + + # 生成政策事件 + policies = [ + ('新能源', '新能源汽车补贴政策延续', 'subsidy', 'positive'), + ('半导体', '芯片产业扶持政策出台', 'support', 'positive'), + ('医药', '药品集采政策调整', 'regulation', 'negative'), + ('光伏', '光伏补贴退坡', 'subsidy', 'negative'), + ('人工智能', 'AI产业发展规划发布', 'support', 'positive'), + ] + + for sector, title, policy_type, impact in policies: + event_date = dt.date.today() - dt.timedelta(days=np.random.randint(1, 90)) + + exists = s.execute( + select(PolicyEvent).where( + and_( + PolicyEvent.sector == sector, + PolicyEvent.title == title + ) + ) + ).scalar_one_or_none() + + if not exists: + policy = PolicyEvent( + sector=sector, + event_date=event_date, + title=title, + policy_type=policy_type, + impact=impact + ) + s.add(policy) + saved += 1 + + s.commit() + + return {"ok": True, "saved": saved} diff --git a/backend/financial_analysis.py b/backend/financial_analysis.py new file mode 100644 index 0000000..8f80afd --- /dev/null +++ b/backend/financial_analysis.py @@ -0,0 +1,495 @@ +"""财报深度解读 — 关键指标趋势、AI摘要、同行对比、异常预警。 + +功能: +1. 财报关键指标趋势 +2. AI财报摘要 +3. 同行对比 +4. 财报异常预警 +5. 财报发布日历 +""" +import datetime as dt +import json +from typing import List, Dict, Any, Optional +from collections import defaultdict +import numpy as np +from sqlalchemy import select, and_, func, desc + +from db import get_session +from models import FinancialReport, Security, StockMetric +import llm + + +def seed_sample_reports(): + """生成示例财报数据(用于演示)""" + with get_session() as s: + stocks = s.execute( + select(Security.code, Security.name).limit(50) + ).all() + + saved = 0 + for code, name in stocks: + # 生成最近4个季度的财报 + base_date = dt.date(2023, 12, 31) + + for i in range(4): + report_date = base_date - dt.timedelta(days=i * 90) + publish_date = report_date + dt.timedelta(days=30) + + # 检查是否已存在 + exists = s.execute( + select(FinancialReport).where( + and_( + FinancialReport.code == code, + FinancialReport.report_date == report_date + ) + ) + ).scalar_one_or_none() + + if exists: + continue + + # 模拟财务数据 + base_revenue = np.random.uniform(10, 500) + growth = np.random.uniform(-20, 50) + + report = FinancialReport( + code=code, + name=name, + report_date=report_date, + publish_date=publish_date, + report_type='Q' + str((report_date.month // 3) or 4), + revenue=round(base_revenue * (1 + i * 0.1), 2), + net_profit=round(base_revenue * np.random.uniform(0.05, 0.2), 2), + roe=round(np.random.uniform(5, 25), 2), + gross_margin=round(np.random.uniform(20, 60), 2), + revenue_growth=round(growth, 2), + profit_growth=round(growth + np.random.uniform(-10, 10), 2), + inventory=round(base_revenue * np.random.uniform(0.1, 0.3), 2), + receivable=round(base_revenue * np.random.uniform(0.15, 0.4), 2), + debt_ratio=round(np.random.uniform(30, 70), 2) + ) + s.add(report) + saved += 1 + + s.commit() + + return {"ok": True, "saved": saved} + + +def get_report_trend(code: str, periods: int = 8) -> Dict[str, Any]: + """获取财报关键指标趋势 + + Args: + code: 股票代码 + periods: 统计期数 + + Returns: + 趋势数据 + """ + with get_session() as s: + reports = s.execute( + select(FinancialReport) + .where(FinancialReport.code == code) + .order_by(desc(FinancialReport.report_date)) + .limit(periods) + ).scalars().all() + + if not reports: + return {"ok": False, "msg": "暂无财报数据"} + + # 反转顺序(从旧到新) + reports = list(reversed(reports)) + + # 提取趋势数据 + dates = [r.report_date.isoformat() for r in reports] + + trend_data = { + "revenue": [r.revenue for r in reports], + "net_profit": [r.net_profit for r in reports], + "roe": [r.roe for r in reports], + "gross_margin": [r.gross_margin for r in reports], + "revenue_growth": [r.revenue_growth for r in reports], + "profit_growth": [r.profit_growth for r in reports], + "debt_ratio": [r.debt_ratio for r in reports] + } + + # 计算趋势(上升/下降/平稳) + def calc_trend(values): + if len(values) < 2: + return "平稳" + recent = np.mean(values[-2:]) + previous = np.mean(values[:2]) if len(values) >= 4 else values[0] + change = (recent - previous) / previous if previous != 0 else 0 + if change > 0.1: + return "上升" + elif change < -0.1: + return "下降" + else: + return "平稳" + + trends = { + key: calc_trend(values) + for key, values in trend_data.items() + } + + return { + "ok": True, + "code": code, + "name": reports[0].name, + "dates": dates, + "data": trend_data, + "trends": trends, + "latest": { + "revenue": reports[-1].revenue, + "net_profit": reports[-1].net_profit, + "roe": reports[-1].roe, + "gross_margin": reports[-1].gross_margin, + "revenue_growth": reports[-1].revenue_growth, + "profit_growth": reports[-1].profit_growth + } + } + + +def generate_ai_summary(code: str) -> Dict[str, Any]: + """生成AI财报摘要 + + Args: + code: 股票代码 + + Returns: + AI摘要 + """ + with get_session() as s: + # 获取最新财报 + report = s.execute( + select(FinancialReport) + .where(FinancialReport.code == code) + .order_by(desc(FinancialReport.report_date)) + .limit(1) + ).scalar_one_or_none() + + if not report: + return {"ok": False, "msg": "暂无财报数据"} + + # 如果已有摘要,直接返回 + if report.ai_summary: + return { + "ok": True, + "summary": report.ai_summary, + "report_date": report.report_date.isoformat() + } + + # 构建提示词 + prompt = f"""请用一句话总结以下财报数据(40字以内): + +公司:{report.name}({report.code}) +报告期:{report.report_date} +营业收入:{report.revenue}亿元,同比增长{report.revenue_growth:+.1f}% +净利润:{report.net_profit}亿元,同比增长{report.profit_growth:+.1f}% +ROE:{report.roe}% +毛利率:{report.gross_margin}% + +要求: +1. 一句话说明业绩是增长还是下降 +2. 提及最亮眼或最担忧的指标 +3. 给出简短评价(优秀/良好/一般/较差) +4. 不超过40字 + +示例:业绩稳步增长,ROE达20%创新高,盈利能力优秀。 +""" + + # 调用AI + if llm.enabled(): + try: + summary = llm.ask(prompt, max_tokens=100) + # 保存摘要 + report.ai_summary = summary + s.commit() + except Exception as e: + summary = f"营收{report.revenue}亿元({report.revenue_growth:+.1f}%),净利润{report.net_profit}亿元({report.profit_growth:+.1f}%)" + else: + summary = f"营收{report.revenue}亿元({report.revenue_growth:+.1f}%),净利润{report.net_profit}亿元({report.profit_growth:+.1f}%)" + + return { + "ok": True, + "summary": summary, + "report_date": report.report_date.isoformat() + } + + +def compare_with_peers(code: str, sector: str = None) -> Dict[str, Any]: + """同行对比 + + Args: + code: 股票代码 + sector: 行业(可选) + + Returns: + 对比结果 + """ + with get_session() as s: + # 获取目标股票最新财报 + target = s.execute( + select(FinancialReport) + .where(FinancialReport.code == code) + .order_by(desc(FinancialReport.report_date)) + .limit(1) + ).scalar_one_or_none() + + if not target: + return {"ok": False, "msg": "暂无财报数据"} + + # 获取同行业股票(简化:随机选取) + peers = s.execute( + select(FinancialReport) + .where( + and_( + FinancialReport.code != code, + FinancialReport.report_date == target.report_date + ) + ) + .limit(20) + ).scalars().all() + + if not peers: + return {"ok": False, "msg": "暂无同行数据"} + + # 计算行业均值 + industry_avg = { + "roe": np.mean([p.roe for p in peers]), + "gross_margin": np.mean([p.gross_margin for p in peers]), + "revenue_growth": np.mean([p.revenue_growth for p in peers]), + "profit_growth": np.mean([p.profit_growth for p in peers]), + "debt_ratio": np.mean([p.debt_ratio for p in peers]) + } + + # 计算差异 + comparison = { + "roe": { + "value": target.roe, + "industry_avg": round(industry_avg["roe"], 2), + "diff": round(target.roe - industry_avg["roe"], 2), + "better": target.roe > industry_avg["roe"] + }, + "gross_margin": { + "value": target.gross_margin, + "industry_avg": round(industry_avg["gross_margin"], 2), + "diff": round(target.gross_margin - industry_avg["gross_margin"], 2), + "better": target.gross_margin > industry_avg["gross_margin"] + }, + "revenue_growth": { + "value": target.revenue_growth, + "industry_avg": round(industry_avg["revenue_growth"], 2), + "diff": round(target.revenue_growth - industry_avg["revenue_growth"], 2), + "better": target.revenue_growth > industry_avg["revenue_growth"] + }, + "profit_growth": { + "value": target.profit_growth, + "industry_avg": round(industry_avg["profit_growth"], 2), + "diff": round(target.profit_growth - industry_avg["profit_growth"], 2), + "better": target.profit_growth > industry_avg["profit_growth"] + }, + "debt_ratio": { + "value": target.debt_ratio, + "industry_avg": round(industry_avg["debt_ratio"], 2), + "diff": round(target.debt_ratio - industry_avg["debt_ratio"], 2), + "better": target.debt_ratio < industry_avg["debt_ratio"] # 负债率越低越好 + } + } + + # 综合评分 + better_count = sum(1 for v in comparison.values() if v["better"]) + + return { + "ok": True, + "code": code, + "name": target.name, + "report_date": target.report_date.isoformat(), + "comparison": comparison, + "better_count": better_count, + "total_metrics": len(comparison), + "conclusion": "优于行业" if better_count >= 3 else ("持平行业" if better_count == 2 else "弱于行业") + } + + +def detect_abnormalities(code: str) -> Dict[str, Any]: + """财报异常预警 + + Args: + code: 股票代码 + + Returns: + 异常预警 + """ + with get_session() as s: + # 获取最近2期财报 + reports = s.execute( + select(FinancialReport) + .where(FinancialReport.code == code) + .order_by(desc(FinancialReport.report_date)) + .limit(2) + ).scalars().all() + + if len(reports) < 2: + return {"ok": False, "msg": "数据不足"} + + current, previous = reports[0], reports[1] + + warnings = [] + + # 1. 存货激增 + if current.inventory > 0 and previous.inventory > 0: + inventory_growth = (current.inventory / previous.inventory - 1) * 100 + if inventory_growth > 50: + warnings.append({ + "type": "存货激增", + "severity": "high", + "description": f"存货增长{inventory_growth:.1f}%,可能存在滞销风险", + "current": current.inventory, + "previous": previous.inventory + }) + + # 2. 应收账款占比过高 + receivable_ratio = current.receivable / current.revenue * 100 if current.revenue > 0 else 0 + if receivable_ratio > 50: + warnings.append({ + "type": "应收账款占比过高", + "severity": "medium", + "description": f"应收账款占营收{receivable_ratio:.1f}%,回款压力较大", + "ratio": round(receivable_ratio, 2) + }) + + # 3. 毛利率大幅下降 + if current.gross_margin > 0 and previous.gross_margin > 0: + margin_change = current.gross_margin - previous.gross_margin + if margin_change < -5: + warnings.append({ + "type": "毛利率大幅下降", + "severity": "high", + "description": f"毛利率下降{abs(margin_change):.1f}个百分点,盈利能力恶化", + "current": current.gross_margin, + "previous": previous.gross_margin + }) + + # 4. 资产负债率过高 + if current.debt_ratio > 70: + warnings.append({ + "type": "资产负债率过高", + "severity": "medium", + "description": f"资产负债率{current.debt_ratio}%,财务风险较高", + "value": current.debt_ratio + }) + + # 5. 增收不增利 + if current.revenue_growth > 10 and current.profit_growth < 0: + warnings.append({ + "type": "增收不增利", + "severity": "high", + "description": f"营收增长{current.revenue_growth:.1f}%,但净利润下降{abs(current.profit_growth):.1f}%", + "revenue_growth": current.revenue_growth, + "profit_growth": current.profit_growth + }) + + return { + "ok": True, + "code": code, + "name": current.name, + "report_date": current.report_date.isoformat(), + "warnings": warnings, + "risk_level": "高" if any(w["severity"] == "high" for w in warnings) else ("中" if warnings else "低") + } + + +def get_report_calendar(days: int = 30) -> Dict[str, Any]: + """财报发布日历 + + Args: + days: 未来N天 + + Returns: + 日历数据 + """ + today = dt.date.today() + end_date = today + dt.timedelta(days=days) + + with get_session() as s: + reports = s.execute( + select(FinancialReport) + .where( + and_( + FinancialReport.publish_date >= today, + FinancialReport.publish_date <= end_date + ) + ) + .order_by(FinancialReport.publish_date) + ).scalars().all() + + if not reports: + return {"ok": False, "msg": "暂无即将发布的财报"} + + # 按日期分组 + calendar = defaultdict(list) + for r in reports: + calendar[r.publish_date.isoformat()].append({ + "code": r.code, + "name": r.name, + "report_date": r.report_date.isoformat(), + "report_type": r.report_type + }) + + return { + "ok": True, + "days": days, + "total": len(reports), + "calendar": dict(calendar) + } + + +def get_top_reports(metric: str = "roe", limit: int = 20) -> Dict[str, Any]: + """获取财报排行榜 + + Args: + metric: 排序指标(roe/gross_margin/revenue_growth) + limit: 返回数量 + + Returns: + 排行榜 + """ + with get_session() as s: + # 获取最新一期的所有财报 + latest_date = s.execute( + select(func.max(FinancialReport.report_date)) + ).scalar() + + if not latest_date: + return {"ok": False, "msg": "暂无数据"} + + # 根据指标排序 + order_field = getattr(FinancialReport, metric) + + reports = s.execute( + select(FinancialReport) + .where(FinancialReport.report_date == latest_date) + .order_by(desc(order_field)) + .limit(limit) + ).scalars().all() + + results = [] + for r in reports: + results.append({ + "code": r.code, + "name": r.name, + "roe": r.roe, + "gross_margin": r.gross_margin, + "revenue_growth": r.revenue_growth, + "profit_growth": r.profit_growth, + metric: getattr(r, metric) + }) + + return { + "ok": True, + "metric": metric, + "report_date": latest_date.isoformat(), + "rankings": results + } + diff --git a/backend/intraday_radar.py b/backend/intraday_radar.py new file mode 100644 index 0000000..e56acb4 --- /dev/null +++ b/backend/intraday_radar.py @@ -0,0 +1,416 @@ +"""盘中实时监控雷达 — 异动检测与推送。 + +监控类型: +1. 快速拉升(5分钟涨幅 >3%) +2. 放量突破(量比 >3 且突破关键位) +3. 涨停打开/炸板 +4. 连板股追踪 +5. 大单异动(单笔超百万) +""" +import datetime as dt +from typing import List, Dict, Any +from sqlalchemy import select, desc, func +from cachetools import TTLCache + +import akshare_service as svc +import notifier +from db import get_session +from models import IntradayEvent, StockMetric, Security, DailyQuote + +# 缓存最近检测到的事件,避免短时间内重复推送 +_event_cache = TTLCache(maxsize=1000, ttl=300) # 5分钟缓存 + + +def _is_trading_time() -> bool: + """判断是否为交易时间(9:30-11:30, 13:00-15:00)。""" + now = dt.datetime.now() + if now.weekday() >= 5: # 周末 + return False + t = now.time() + morning = dt.time(9, 30) <= t <= dt.time(11, 30) + afternoon = dt.time(13, 0) <= t <= dt.time(15, 0) + return morning or afternoon + + +def _cache_key(code: str, event_type: str) -> str: + """生成事件缓存键。""" + return f"{code}:{event_type}" + + +def detect_surge(threshold: float = 3.0) -> List[Dict[str, Any]]: + """快速拉升检测(基于实时报价,模拟5分钟涨幅)。 + + Args: + threshold: 涨幅阈值(%) + + Returns: + 检测到的异动列表 + """ + if not _is_trading_time(): + return [] + + events = [] + try: + # 获取涨幅榜前50(模拟快速拉升) + data = svc.get_hot_stocks() + if not data.get("list"): + return [] + + for stock in data["list"][:50]: + pct = stock.get("pct", 0) + if pct >= threshold: + code = stock["code"] + key = _cache_key(code, "surge") + if key in _event_cache: + continue + + _event_cache[key] = True + events.append({ + "code": code, + "name": stock.get("name", code), + "event_type": "surge", + "price": stock.get("price", 0), + "pct": pct, + "description": f"快速拉升 {pct:.2f}%" + }) + except Exception as e: + print(f"[surge] error: {e}") + + return events + + +def detect_volume_break(vol_ratio_threshold: float = 3.0) -> List[Dict[str, Any]]: + """放量突破检测(量比 >3 且价格突破)。 + + Args: + vol_ratio_threshold: 量比阈值 + + Returns: + 检测到的异动列表 + """ + if not _is_trading_time(): + return [] + + events = [] + with get_session() as s: + # 查询高量比且上涨的股票 + rows = s.execute( + select(StockMetric) + .where(StockMetric.vol_ratio >= vol_ratio_threshold, StockMetric.pct > 0) + .order_by(StockMetric.vol_ratio.desc()) + .limit(20) + ).scalars().all() + + for r in rows: + key = _cache_key(r.code, "volume_break") + if key in _event_cache: + continue + + # 判断是否突破关键位(60日新高或MA20) + is_break = r.pos60 >= 0.95 or (r.close > r.ma20 and r.ma20 > 0) + if is_break: + _event_cache[key] = True + events.append({ + "code": r.code, + "name": r.name, + "event_type": "volume_break", + "price": r.close, + "pct": r.pct, + "volume_ratio": r.vol_ratio, + "description": f"放量突破 量比{r.vol_ratio:.1f}" + }) + + return events + + +def detect_limit_open() -> List[Dict[str, Any]]: + """涨停打开/炸板检测。 + + Returns: + 检测到的异动列表 + """ + if not _is_trading_time(): + return [] + + events = [] + try: + # 获取涨停股 + data = svc.get_hot_stocks() + if not data.get("list"): + return [] + + with get_session() as s: + for stock in data["list"]: + pct = stock.get("pct", 0) + # 涨停附近但未封死(9.5%-9.99%) + if 9.5 <= pct < 9.99: + code = stock["code"] + key = _cache_key(code, "limit_open") + if key in _event_cache: + continue + + _event_cache[key] = True + events.append({ + "code": code, + "name": stock.get("name", code), + "event_type": "limit_open", + "price": stock.get("price", 0), + "pct": pct, + "description": f"涨停打开 {pct:.2f}%" + }) + except Exception as e: + print(f"[limit_open] error: {e}") + + return events + + +def detect_consecutive_limit() -> List[Dict[str, Any]]: + """连板股追踪(2连板及以上)。 + + Returns: + 检测到的异动列表 + """ + if not _is_trading_time(): + return [] + + events = [] + try: + data = svc.get_hot_stocks() + if not data.get("list"): + return [] + + with get_session() as s: + for stock in data["list"]: + pct = stock.get("pct", 0) + if pct >= 9.9: # 涨停 + code = stock["code"] + + # 查询历史连板数 + recent = s.execute( + select(DailyQuote) + .where(DailyQuote.code == code) + .order_by(DailyQuote.date.desc()) + .limit(5) + ).scalars().all() + + if not recent: + continue + + # 统计连续涨停天数 + consecutive = 1 # 今天涨停 + for q in recent[1:]: + if q.close / q.open >= 1.095: # 近似判断涨停 + consecutive += 1 + else: + break + + if consecutive >= 2: + key = _cache_key(code, "consecutive") + if key in _event_cache: + continue + + _event_cache[key] = True + events.append({ + "code": code, + "name": stock.get("name", code), + "event_type": "consecutive", + "price": stock.get("price", 0), + "pct": pct, + "description": f"{consecutive}连板" + }) + except Exception as e: + print(f"[consecutive] error: {e}") + + return events + + +def detect_big_order(threshold: float = 1000000.0) -> List[Dict[str, Any]]: + """大单异动检测(单笔超百万)。 + + 注意:AkShare 免费接口无实时逐笔数据,此处返回空列表,可接入付费数据源。 + + Args: + threshold: 单笔金额阈值(元) + + Returns: + 检测到的异动列表 + """ + # 需要付费数据源支持,暂不实现 + return [] + + +def scan_all() -> Dict[str, Any]: + """执行全部异动扫描。 + + Returns: + 扫描结果,包含各类异动事件 + """ + if not _is_trading_time(): + return {"ok": False, "msg": "非交易时间", "events": []} + + all_events = [] + + # 执行各类检测 + all_events.extend(detect_surge()) + all_events.extend(detect_volume_break()) + all_events.extend(detect_limit_open()) + all_events.extend(detect_consecutive_limit()) + all_events.extend(detect_big_order()) + + # 写入数据库 + if all_events: + with get_session() as s: + for evt in all_events: + record = IntradayEvent( + code=evt["code"], + name=evt["name"], + event_type=evt["event_type"], + price=evt.get("price", 0), + pct=evt.get("pct", 0), + volume_ratio=evt.get("volume_ratio", 0), + amount=evt.get("amount", 0), + description=evt["description"] + ) + s.add(record) + s.commit() + + return { + "ok": True, + "count": len(all_events), + "events": all_events, + "scanned_at": dt.datetime.now().isoformat() + } + + +def get_recent_events(hours: int = 2, limit: int = 50) -> List[Dict[str, Any]]: + """获取最近N小时的异动事件。 + + Args: + hours: 时间范围(小时) + limit: 最大返回数量 + + Returns: + 异动事件列表 + """ + since = dt.datetime.now() - dt.timedelta(hours=hours) + with get_session() as s: + rows = s.execute( + select(IntradayEvent) + .where(IntradayEvent.detected_at >= since) + .order_by(desc(IntradayEvent.detected_at)) + .limit(limit) + ).scalars().all() + + return [{ + "id": r.id, + "code": r.code, + "name": r.name, + "event_type": r.event_type, + "price": r.price, + "pct": r.pct, + "volume_ratio": r.volume_ratio, + "amount": r.amount, + "description": r.description, + "detected_at": r.detected_at.strftime("%H:%M:%S"), + "notified": r.notified + } for r in rows] + + +def notify_events(event_types: List[str] = None) -> Dict[str, Any]: + """推送未通知的异动事件。 + + Args: + event_types: 需要推送的事件类型列表,None表示全部 + + Returns: + 推送结果 + """ + with get_session() as s: + stmt = select(IntradayEvent).where(IntradayEvent.notified.is_(False)) + if event_types: + stmt = stmt.where(IntradayEvent.event_type.in_(event_types)) + + rows = s.execute(stmt.order_by(desc(IntradayEvent.detected_at)).limit(10)).scalars().all() + + if not rows: + return {"ok": True, "count": 0, "msg": "无待推送事件"} + + # 按事件类型分组 + grouped = {} + for r in rows: + if r.event_type not in grouped: + grouped[r.event_type] = [] + grouped[r.event_type].append(r) + + # 构造推送消息 + type_names = { + "surge": "快速拉升", + "volume_break": "放量突破", + "limit_open": "涨停打开", + "consecutive": "连板追踪", + "big_order": "大单异动" + } + + msg_parts = ["【盘中异动雷达】\n"] + for etype, events in grouped.items(): + msg_parts.append(f"\n{type_names.get(etype, etype)}:") + for e in events[:5]: # 每类最多5条 + msg_parts.append(f"• {e.name}({e.code}) {e.description}") + + msg = "\n".join(msg_parts) + + # 推送 + if notifier.any_enabled(): + notifier.notify("盘中异动提醒", msg) + + # 标记已推送 + for r in rows: + r.notified = True + s.commit() + + return {"ok": True, "count": len(rows), "msg": f"已推送 {len(rows)} 条异动"} + + +def get_statistics(date: dt.date = None) -> Dict[str, Any]: + """获取异动统计数据。 + + Args: + date: 统计日期,None表示今天 + + Returns: + 统计结果 + """ + if date is None: + date = dt.date.today() + + start = dt.datetime.combine(date, dt.time.min) + end = dt.datetime.combine(date, dt.time.max) + + with get_session() as s: + # 按事件类型统计 + stmt = ( + select(IntradayEvent.event_type, func.count().label("count")) + .where(IntradayEvent.detected_at >= start, IntradayEvent.detected_at <= end) + .group_by(IntradayEvent.event_type) + ) + rows = s.execute(stmt).all() + + stats = {row.event_type: row.count for row in rows} + total = sum(stats.values()) + + # 最活跃股票 + stmt = ( + select(IntradayEvent.code, IntradayEvent.name, func.count().label("count")) + .where(IntradayEvent.detected_at >= start, IntradayEvent.detected_at <= end) + .group_by(IntradayEvent.code, IntradayEvent.name) + .order_by(desc("count")) + .limit(10) + ) + top_stocks = s.execute(stmt).all() + + return { + "date": date.isoformat(), + "total": total, + "by_type": stats, + "top_stocks": [{"code": r.code, "name": r.name, "count": r.count} for r in top_stocks] + } \ No newline at end of file diff --git a/backend/limit_analysis.py b/backend/limit_analysis.py new file mode 100644 index 0000000..7e45bf4 --- /dev/null +++ b/backend/limit_analysis.py @@ -0,0 +1,394 @@ +"""涨跌停分析 — 连板股追踪、炸板率统计、敢死队排行。 + +功能: +1. 连板股追踪器 +2. 炸板率统计 +3. 涨停敢死队排行 +""" +import datetime as dt +from typing import List, Dict, Any, Optional +from collections import defaultdict, Counter +import numpy as np +from sqlalchemy import select, and_, func, desc + +from db import get_session +from models import DailyQuote, StockMetric + + +def get_limit_stocks(date: Optional[dt.date] = None, limit_type: str = "up") -> Dict[str, Any]: + """获取涨停/跌停股票 + + Args: + date: 日期(None表示最新) + limit_type: up涨停/down跌停 + + Returns: + 涨跌停股票列表 + """ + with get_session() as s: + if date is None: + date = s.execute(select(func.max(DailyQuote.date))).scalar() + + if not date: + return {"ok": False, "msg": "暂无数据"} + + # 查询当日股票 + quotes = s.execute( + select(DailyQuote) + .where(DailyQuote.date == date) + ).scalars().all() + + if not quotes: + return {"ok": False, "msg": "暂无数据"} + + # 筛选涨停/跌停股(涨跌幅接近±10%) + threshold = 9.8 # 考虑精度问题,用9.8%作为阈值 + + results = [] + for q in quotes: + if q.open == 0: + continue + + pct = (float(q.close) - float(q.open)) / float(q.open) * 100 + + if limit_type == "up" and pct >= threshold: + results.append({ + "code": q.code, + "name": q.name, + "close": float(q.close), + "pct": round(pct, 2), + "volume": float(q.volume), + "amount": float(q.amount) + }) + elif limit_type == "down" and pct <= -threshold: + results.append({ + "code": q.code, + "name": q.name, + "close": float(q.close), + "pct": round(pct, 2), + "volume": float(q.volume), + "amount": float(q.amount) + }) + + return { + "ok": True, + "date": date.isoformat(), + "type": limit_type, + "count": len(results), + "stocks": sorted(results, key=lambda x: x["pct"], reverse=(limit_type == "up")) + } + + +def track_consecutive_limits(days: int = 10) -> Dict[str, Any]: + """连板股追踪器 + + Args: + days: 追踪天数 + + Returns: + 连板股列表 + """ + with get_session() as s: + latest_date = s.execute(select(func.max(DailyQuote.date))).scalar() + if not latest_date: + return {"ok": False, "msg": "暂无数据"} + + start_date = latest_date - dt.timedelta(days=days) + + # 获取期间所有股票的日线数据 + quotes = s.execute( + select(DailyQuote) + .where(DailyQuote.date >= start_date) + .order_by(DailyQuote.code, DailyQuote.date) + ).scalars().all() + + # 按股票分组 + stock_data = defaultdict(list) + for q in quotes: + stock_data[q.code].append(q) + + # 统计连板 + consecutive_limits = [] + + for code, data in stock_data.items(): + if not data: + continue + + # 倒序遍历,统计从最新日期开始的连续涨停天数 + data_sorted = sorted(data, key=lambda x: x.date, reverse=True) + + consecutive_days = 0 + for q in data_sorted: + if q.open == 0: + break + + pct = (float(q.close) - float(q.open)) / float(q.open) * 100 + + if pct >= 9.8: # 涨停 + consecutive_days += 1 + else: + break + + if consecutive_days >= 2: # 至少2连板 + latest = data_sorted[0] + consecutive_limits.append({ + "code": code, + "name": latest.name, + "consecutive_days": consecutive_days, + "close": float(latest.close), + "amount": float(latest.amount), + "status": f"{consecutive_days}连板" + }) + + # 按连板天数排序 + consecutive_limits.sort(key=lambda x: x["consecutive_days"], reverse=True) + + return { + "ok": True, + "date": latest_date.isoformat(), + "days": days, + "count": len(consecutive_limits), + "stocks": consecutive_limits + } + + +def analyze_limit_break_rate(days: int = 60) -> Dict[str, Any]: + """炸板率统计 + + 分析涨停后次日的表现(继续涨停/上涨/下跌/跌停) + + Args: + days: 统计天数 + + Returns: + 炸板率统计 + """ + with get_session() as s: + latest_date = s.execute(select(func.max(DailyQuote.date))).scalar() + if not latest_date: + return {"ok": False, "msg": "暂无数据"} + + start_date = latest_date - dt.timedelta(days=days) + + # 获取期间所有股票的日线数据 + quotes = s.execute( + select(DailyQuote) + .where(DailyQuote.date >= start_date) + .order_by(DailyQuote.code, DailyQuote.date) + ).scalars().all() + + # 按股票和日期组织数据 + stock_data = defaultdict(dict) + for q in quotes: + stock_data[q.code][q.date] = q + + # 统计涨停后次日表现 + next_day_stats = { + "limit_up": 0, # 继续涨停 + "up": 0, # 上涨但未涨停 + "down": 0, # 下跌但未跌停 + "limit_down": 0, # 跌停 + } + + stock_break_rates = {} # 个股炸板率 + + for code, data in stock_data.items(): + dates = sorted(data.keys()) + stock_limits = 0 + stock_breaks = 0 + + for i in range(len(dates) - 1): + today = dates[i] + tomorrow = dates[i + 1] + + today_q = data[today] + tomorrow_q = data[tomorrow] + + # 判断今日是否涨停 + if today_q.open == 0: + continue + + today_pct = (float(today_q.close) - float(today_q.open)) / float(today_q.open) * 100 + + if today_pct >= 9.8: # 今日涨停 + stock_limits += 1 + + # 判断次日表现 + if tomorrow_q.open == 0: + continue + + tomorrow_pct = (float(tomorrow_q.close) - float(tomorrow_q.open)) / float(tomorrow_q.open) * 100 + + if tomorrow_pct >= 9.8: + next_day_stats["limit_up"] += 1 + elif tomorrow_pct > 0: + next_day_stats["up"] += 1 + stock_breaks += 1 # 炸板 + elif tomorrow_pct > -9.8: + next_day_stats["down"] += 1 + stock_breaks += 1 # 炸板 + else: + next_day_stats["limit_down"] += 1 + stock_breaks += 1 # 炸板 + + # 计算个股炸板率 + if stock_limits > 0: + break_rate = stock_breaks / stock_limits * 100 + if stock_limits >= 3: # 至少3次涨停才有统计意义 + stock_break_rates[code] = { + "name": list(data.values())[0].name, + "limits": stock_limits, + "breaks": stock_breaks, + "break_rate": round(break_rate, 2) + } + + total = sum(next_day_stats.values()) + + if total == 0: + return {"ok": False, "msg": "统计样本不足"} + + # 计算比例 + stats_with_pct = { + "limit_up": { + "count": next_day_stats["limit_up"], + "pct": round(next_day_stats["limit_up"] / total * 100, 2) + }, + "up": { + "count": next_day_stats["up"], + "pct": round(next_day_stats["up"] / total * 100, 2) + }, + "down": { + "count": next_day_stats["down"], + "pct": round(next_day_stats["down"] / total * 100, 2) + }, + "limit_down": { + "count": next_day_stats["limit_down"], + "pct": round(next_day_stats["limit_down"] / total * 100, 2) + } + } + + # 炸板率 = (上涨未涨停 + 下跌 + 跌停) / 总数 + break_rate = (next_day_stats["up"] + next_day_stats["down"] + next_day_stats["limit_down"]) / total * 100 + + # 个股炸板率排行(从高到低) + stock_rankings = sorted( + [(code, data) for code, data in stock_break_rates.items()], + key=lambda x: x[1]["break_rate"], + reverse=True + )[:30] + + return { + "ok": True, + "days": days, + "total_samples": total, + "overall_break_rate": round(break_rate, 2), + "next_day_stats": stats_with_pct, + "stock_rankings": [{ + "code": code, + "name": data["name"], + "limits": data["limits"], + "breaks": data["breaks"], + "break_rate": data["break_rate"] + } for code, data in stock_rankings] + } + + +def get_limit_squad_rankings(days: int = 30, min_limits: int = 5) -> Dict[str, Any]: + """涨停敢死队排行 + + 统计期间内涨停次数最多的股票(俗称"妖股") + + Args: + days: 统计天数 + min_limits: 最少涨停次数 + + Returns: + 敢死队排行 + """ + with get_session() as s: + latest_date = s.execute(select(func.max(DailyQuote.date))).scalar() + if not latest_date: + return {"ok": False, "msg": "暂无数据"} + + start_date = latest_date - dt.timedelta(days=days) + + # 获取期间所有股票的日线数据 + quotes = s.execute( + select(DailyQuote) + .where(DailyQuote.date >= start_date) + .order_by(DailyQuote.code, DailyQuote.date) + ).scalars().all() + + # 统计每只股票的涨停次数 + limit_counts = defaultdict(lambda: { + "name": "", + "count": 0, + "dates": [], + "total_days": 0, + "max_consecutive": 0 + }) + + # 按股票分组 + stock_data = defaultdict(list) + for q in quotes: + stock_data[q.code].append(q) + + for code, data in stock_data.items(): + if not data: + continue + + data_sorted = sorted(data, key=lambda x: x.date) + + limit_days = [] + for q in data_sorted: + if q.open == 0: + continue + + pct = (float(q.close) - float(q.open)) / float(q.open) * 100 + + if pct >= 9.8: # 涨停 + limit_days.append(q.date) + + if len(limit_days) >= min_limits: + # 计算最大连板数 + max_consecutive = 1 + current_consecutive = 1 + + for i in range(1, len(limit_days)): + if (limit_days[i] - limit_days[i-1]).days == 1: + current_consecutive += 1 + max_consecutive = max(max_consecutive, current_consecutive) + else: + current_consecutive = 1 + + limit_counts[code] = { + "name": data_sorted[0].name, + "count": len(limit_days), + "dates": [d.isoformat() for d in limit_days], + "total_days": len(data_sorted), + "max_consecutive": max_consecutive, + "frequency": round(len(limit_days) / len(data_sorted) * 100, 2) + } + + # 排序 + rankings = sorted( + [(code, data) for code, data in limit_counts.items()], + key=lambda x: (x[1]["count"], x[1]["max_consecutive"]), + reverse=True + )[:50] + + return { + "ok": True, + "days": days, + "start_date": start_date.isoformat(), + "end_date": latest_date.isoformat(), + "count": len(rankings), + "rankings": [{ + "code": code, + "name": data["name"], + "limit_count": data["count"], + "max_consecutive": data["max_consecutive"], + "frequency": data["frequency"], + "dates": data["dates"] + } for code, data in rankings] + } diff --git a/backend/llm.py b/backend/llm.py index 717d30e..edf6ac6 100644 --- a/backend/llm.py +++ b/backend/llm.py @@ -40,3 +40,8 @@ def ask(user_content: str, temperature: float = 0.5, max_tokens: int = 900) -> s {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": user_content}, ], temperature=temperature, max_tokens=max_tokens) + + +def ask_with_messages(messages: list, temperature: float = 0.5, max_tokens: int = 900) -> str: + """使用完整消息列表调用(支持多轮对话)""" + return chat(messages, temperature=temperature, max_tokens=max_tokens) diff --git a/backend/main.py b/backend/main.py index b30882c..d28e351 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,4 +1,4 @@ -"""智策股票终端 — FastAPI 后端入口。 +"""Blackdata股票终端 — FastAPI 后端入口。 - /api/* : 数据接口(基于 AkShare,带缓存与降级) - / : 托管前端原型(prototype 目录) @@ -7,6 +7,7 @@ import os import json import datetime as dt from contextlib import asynccontextmanager +from typing import List, Dict, Any, Optional from fastapi import FastAPI, Query from fastapi.middleware.cors import CORSMiddleware @@ -19,6 +20,7 @@ import akshare_service as svc import config import scheduler import backtest as bt +import backtest_advanced as bta import ai import signals as sig import report as rpt @@ -26,10 +28,19 @@ import portfolio as pf import llm import alerts as al import notifier +import intraday_radar as radar +import sector_rotation as sector +import smart_selector as selector +import attribution_analysis as attrib +import ai_chat +import sentiment_monitor as sentiment +import event_driven as events +import financial_analysis as fin +import limit_analysis as limit_up from db import init_db, get_session from models import (DailyQuote, IndexDaily, SectorDaily, FundFlowDaily, SentimentDaily, DragonTiger, Security, JobRun, StockMetric, Trade, - AlertRule, AlertEvent) + AlertRule, AlertEvent, SelectorStrategy, SelectorAlert) @asynccontextmanager @@ -43,7 +54,7 @@ async def lifespan(app: FastAPI): yield -app = FastAPI(title="智策股票终端 API", version="0.2.0", lifespan=lifespan) +app = FastAPI(title="Blackdata股票终端 API", version="0.2.0", lifespan=lifespan) app.add_middleware( CORSMiddleware, @@ -253,6 +264,98 @@ def backtest_api(symbol: str = Query("600519"), fast: int = Query(5, ge=2, le=60 return bt.run_backtest(symbol, fast, slow) +# ============ 增强回测 ============ +class BacktestParams(BaseModel): + symbol: str + strategy: str = "ma" # ma, multi_factor + fast: int = 5 + slow: int = 20 + position_size: float = 1.0 + stop_loss: float = 0.0 + take_profit: float = 0.0 + initial_capital: float = 100000.0 + commission: float = 0.0005 + + +@app.post("/api/backtest/advanced") +def backtest_advanced(params: BacktestParams): + """增强回测""" + if params.strategy == "ma": + strategy = bta.MAStrategy( + fast=params.fast, + slow=params.slow, + position_size=params.position_size, + stop_loss=params.stop_loss, + take_profit=params.take_profit + ) + elif params.strategy == "multi_factor": + strategy = bta.MultiFactorStrategy(position_size=params.position_size) + else: + return {"ok": False, "msg": "不支持的策略类型"} + + return bta.run_advanced_backtest( + symbol=params.symbol, + strategy=strategy, + initial_capital=params.initial_capital, + commission=params.commission + ) + + +class OptimizeParams(BaseModel): + symbol: str + strategy: str = "ma" + fast_range: List[int] = [3, 5, 10, 15] + slow_range: List[int] = [20, 30, 60] + metric: str = "sharpe_ratio" + + +@app.post("/api/backtest/optimize") +def backtest_optimize(params: OptimizeParams): + """参数优化""" + param_grid = { + "fast": params.fast_range, + "slow": params.slow_range + } + + results = bta.optimize_parameters( + symbol=params.symbol, + param_grid=param_grid, + strategy_class=bta.MAStrategy, + metric=params.metric + ) + + return { + "ok": True, + "symbol": params.symbol, + "metric": params.metric, + "results": results[:20] # 返回前20个最优结果 + } + + +class CompareParams(BaseModel): + symbol: str + strategies: List[Dict[str, Any]] + + +@app.post("/api/backtest/compare") +def backtest_compare(params: CompareParams): + """策略对比""" + strategies = [] + + for s in params.strategies: + if s["type"] == "ma": + strategies.append(bta.MAStrategy( + fast=s.get("fast", 5), + slow=s.get("slow", 20), + stop_loss=s.get("stop_loss", 0), + take_profit=s.get("take_profit", 0) + )) + elif s["type"] == "multi_factor": + strategies.append(bta.MultiFactorStrategy()) + + return bta.compare_strategies(params.symbol, strategies) + + # ============ 全市场选股 ============ STRATEGIES = { "surge": "最近暴涨(5日涨幅≥20%)", @@ -479,6 +582,186 @@ def portfolio_equity(): return pf.equity_curve() +@app.get("/api/portfolio/attribution") +def portfolio_attribution(): + """持仓归因分析""" + return attrib.analyze_attribution() + + +# ============ AI 对话式分析 ============ +class ChatRequest(BaseModel): + session_id: str + message: str + + +@app.post("/api/chat") +def chat(req: ChatRequest): + """AI对话""" + return ai_chat.chat(req.session_id, req.message) + + +@app.delete("/api/chat/{session_id}") +def clear_chat(session_id: str): + """清空会话""" + ai_chat.clear_session(session_id) + return {"ok": True} + + +@app.get("/api/chat/{session_id}/history") +def chat_history(session_id: str): + """获取会话历史""" + return {"ok": True, "messages": ai_chat.get_session_history(session_id)} + + +# ============ 社区情绪监控 ============ +@app.post("/api/sentiment/collect") +def sentiment_collect(limit: int = Query(50, ge=10, le=200)): + """采集社区帖子""" + return sentiment.collect_posts(limit) + + +@app.get("/api/sentiment/index") +def sentiment_index(date: Optional[str] = None): + """获取情绪指数""" + d = dt.date.fromisoformat(date) if date else None + return sentiment.calculate_sentiment_index(d) + + +@app.get("/api/sentiment/hot_stocks") +def sentiment_hot_stocks(days: int = Query(1, ge=1, le=7), limit: int = Query(20, le=50)): + """热议股票排行""" + return sentiment.get_hot_stocks(days, limit) + + +@app.get("/api/sentiment/history") +def sentiment_history(days: int = Query(30, ge=7, le=90)): + """情绪指数历史""" + return sentiment.get_sentiment_history(days) + + +@app.get("/api/sentiment/correlation") +def sentiment_correlation(code: str = Query(...), days: int = Query(60, ge=20, le=180)): + """情绪与股价相关性""" + return sentiment.analyze_sentiment_correlation(code, days) + + +@app.get("/api/sentiment/wordcloud") +def sentiment_wordcloud(days: int = Query(7, ge=1, le=30), top_n: int = Query(50, le=100)): + """关键词云""" + return sentiment.get_keyword_cloud(days, top_n) + + +# ============ 事件驱动策略 ============ +@app.post("/api/events/seed") +def events_seed(): + """生成示例事件数据""" + return events.seed_sample_events() + + +@app.get("/api/events/earnings/pattern") +def earnings_pattern(days_before: int = Query(5, ge=1, le=10), days_after: int = Query(10, ge=5, le=30)): + """财报发布前后统计规律""" + return events.analyze_earnings_pattern(days_before, days_after) + + +@app.get("/api/events/insider") +def insider_trading(code: Optional[str] = None, days: int = Query(180, ge=30, le=365)): + """高管增减持跟踪""" + return events.track_insider_trading(code, days) + + +@app.get("/api/events/unlock") +def unlock_impact(days: int = Query(90, ge=30, le=180)): + """限售解禁影响分析""" + return events.analyze_unlock_impact(days) + + +@app.get("/api/events/policy") +def policy_events(sector: Optional[str] = None, days: int = Query(180, ge=30, le=365)): + """行业政策事件""" + return events.get_policy_events(sector, days) + + +class EventSelectorRequest(BaseModel): + event_types: List[str] + days: int = 30 + + +@app.post("/api/events/selector") +def event_selector(req: EventSelectorRequest): + """事件驱动选股""" + return events.event_driven_selector(req.event_types, req.days) + + +# ============ 财报深度解读 ============ +@app.post("/api/financial/seed") +def financial_seed(): + """生成示例财报数据""" + return fin.seed_sample_reports() + + +@app.get("/api/financial/trend") +def financial_trend(code: str = Query(...), periods: int = Query(8, ge=4, le=16)): + """财报关键指标趋势""" + return fin.get_report_trend(code, periods) + + +@app.get("/api/financial/summary") +def financial_summary(code: str = Query(...)): + """AI财报摘要""" + return fin.generate_ai_summary(code) + + +@app.get("/api/financial/compare") +def financial_compare(code: str = Query(...), sector: Optional[str] = None): + """同行对比""" + return fin.compare_with_peers(code, sector) + + +@app.get("/api/financial/warnings") +def financial_warnings(code: str = Query(...)): + """财报异常预警""" + return fin.detect_abnormalities(code) + + +@app.get("/api/financial/calendar") +def financial_calendar(days: int = Query(30, ge=7, le=90)): + """财报发布日历""" + return fin.get_report_calendar(days) + + +@app.get("/api/financial/rankings") +def financial_rankings(metric: str = Query("roe"), limit: int = Query(20, le=50)): + """财报排行榜""" + return fin.get_top_reports(metric, limit) + + +# ============ 涨跌停分析 ============ +@app.get("/api/limit/stocks") +def limit_stocks(date: Optional[str] = None, limit_type: str = Query("up")): + """获取涨停/跌停股票""" + d = dt.date.fromisoformat(date) if date else None + return limit_up.get_limit_stocks(d, limit_type) + + +@app.get("/api/limit/consecutive") +def consecutive_limits(days: int = Query(10, ge=5, le=30)): + """连板股追踪""" + return limit_up.track_consecutive_limits(days) + + +@app.get("/api/limit/break_rate") +def limit_break_rate(days: int = Query(60, ge=30, le=180)): + """炸板率统计""" + return limit_up.analyze_limit_break_rate(days) + + +@app.get("/api/limit/squad") +def limit_squad(days: int = Query(30, ge=10, le=90), min_limits: int = Query(5, ge=3, le=10)): + """涨停敢死队排行""" + return limit_up.get_limit_squad_rankings(days, min_limits) + + # ============ 推送通知 ============ @app.get("/api/notify/status") def notify_status(): @@ -489,7 +772,7 @@ def notify_status(): def notify_test(): if not notifier.any_enabled(): return {"ok": False, "msg": "未配置任何推送渠道,请在 backend/.env 配置后重启"} - res = notifier.notify("【智策】推送测试", "这是一条来自智策股票终端的测试通知,收到即表示推送通道正常。") + res = notifier.notify("【Blackdata】推送测试", "这是一条来自Blackdata股票终端的测试通知,收到即表示推送通道正常。") return {"ok": True, "result": res} @@ -613,6 +896,252 @@ def news_ai(n: NewsAI): "text": f"判断:{senti}(关键词:{'、'.join(kw) or '无'})。摘要:{text_in[:80]}…\n(配置大模型后可获得更深入的关联分析)"} +# ============ 盘中实时监控雷达 ============ +@app.get("/api/radar/status") +def radar_status(): + """雷达状态。""" + return {"trading_time": radar._is_trading_time()} + + +@app.post("/api/radar/scan") +def radar_scan(): + """手动触发异动扫描。""" + return radar.scan_all() + + +@app.get("/api/radar/events") +def radar_events(hours: int = Query(2, ge=1, le=24), limit: int = Query(50, le=200)): + """获取最近的异动事件。""" + return {"list": radar.get_recent_events(hours, limit)} + + +@app.post("/api/radar/notify") +def radar_notify(): + """推送未通知的异动。""" + return radar.notify_events() + + +@app.get("/api/radar/stats") +def radar_stats(date: str = Query(None)): + """异动统计。""" + d = dt.date.fromisoformat(date) if date else None + return radar.get_statistics(d) + + +# ============ 板块轮动分析 ============ +@app.get("/api/sector/trend") +def sector_trend(days: int = Query(20, ge=5, le=60), top_n: int = Query(15, le=30)): + """板块强弱趋势""" + return sector.get_sector_trend(days, top_n) + + +@app.get("/api/sector/flow") +def sector_flow(days: int = Query(5, ge=1, le=20)): + """资金流向分析""" + return sector.analyze_fund_flow(days) + + +@app.get("/api/sector/lifecycle") +def sector_lifecycle(name: str = Query(...), days: int = Query(60, ge=20, le=120)): + """板块生命周期""" + return sector.analyze_lifecycle(name, days) + + +@app.get("/api/sector/leaders") +def sector_leaders(name: str = Query(...), days: int = Query(20, ge=5, le=60), limit: int = Query(10, le=30)): + """龙头股识别""" + return sector.identify_leaders(name, days, limit) + + +@app.get("/api/sector/correlation") +def sector_correlation(days: int = Query(60, ge=20, le=120), top_n: int = Query(20, le=30)): + """板块联动性分析""" + return sector.analyze_correlation(days, top_n) + + +@app.get("/api/sector/summary") +def sector_summary(): + """板块轮动摘要""" + return sector.get_rotation_summary() + + +# ============ 智能选股增强 ============ +@app.get("/api/selector/fields") +def selector_fields(): + """获取可用字段""" + return {"ok": True, "fields": selector.get_available_fields()} + + +@app.get("/api/selector/presets") +def selector_presets(): + """获取预设策略""" + return {"ok": True, "presets": selector.get_preset_strategies()} + + +class SelectorRequest(BaseModel): + strategy: Dict[str, Any] + date: Optional[str] = None + + +@app.post("/api/selector/run") +def selector_run(req: SelectorRequest): + """执行选股""" + try: + strategy = selector.Strategy.from_dict(req.strategy) + date = dt.date.fromisoformat(req.date) if req.date else None + return selector.run_selector(strategy, date) + except Exception as e: + return {"ok": False, "msg": str(e)} + + +@app.post("/api/selector/backtest") +def selector_backtest(req: SelectorRequest, days: int = Query(60, ge=20, le=250)): + """选股策略回测""" + try: + strategy = selector.Strategy.from_dict(req.strategy) + return selector.backtest_selector(strategy, days) + except Exception as e: + return {"ok": False, "msg": str(e)} + + +class CompareRequest(BaseModel): + strategy: Dict[str, Any] + date1: str + date2: str + + +@app.post("/api/selector/compare") +def selector_compare(req: CompareRequest): + """对比选股结果""" + try: + strategy = selector.Strategy.from_dict(req.strategy) + date1 = dt.date.fromisoformat(req.date1) + date2 = dt.date.fromisoformat(req.date2) + return selector.compare_results(date1, date2, strategy) + except Exception as e: + return {"ok": False, "msg": str(e)} + + +@app.get("/api/selector/strategies") +def list_strategies(): + """获取保存的策略列表""" + with get_session() as s: + rows = s.execute( + select(SelectorStrategy).order_by(SelectorStrategy.updated_at.desc()) + ).scalars().all() + return { + "ok": True, + "strategies": [{ + "id": r.id, + "name": r.name, + "description": r.description, + "is_preset": r.is_preset, + "created_at": r.created_at.strftime("%Y-%m-%d %H:%M:%S"), + "updated_at": r.updated_at.strftime("%Y-%m-%d %H:%M:%S") + } for r in rows] + } + + +class SaveStrategyRequest(BaseModel): + name: str + description: str = "" + strategy: Dict[str, Any] + + +@app.post("/api/selector/strategies") +def save_strategy(req: SaveStrategyRequest): + """保存策略""" + try: + strategy = selector.Strategy.from_dict(req.strategy) + with get_session() as s: + record = SelectorStrategy( + name=req.name, + description=req.description, + strategy_json=strategy.to_json() + ) + s.add(record) + s.commit() + return {"ok": True, "id": record.id} + except Exception as e: + return {"ok": False, "msg": str(e)} + + +@app.get("/api/selector/strategies/{sid}") +def get_strategy(sid: int): + """获取策略详情""" + with get_session() as s: + record = s.get(SelectorStrategy, sid) + if not record: + return {"ok": False, "msg": "策略不存在"} + return { + "ok": True, + "id": record.id, + "name": record.name, + "description": record.description, + "strategy": json.loads(record.strategy_json) + } + + +@app.delete("/api/selector/strategies/{sid}") +def delete_strategy(sid: int): + """删除策略""" + with get_session() as s: + record = s.get(SelectorStrategy, sid) + if record: + s.delete(record) + s.commit() + return {"ok": True} + + +@app.get("/api/selector/alerts") +def list_selector_alerts(): + """获取选股预警列表""" + with get_session() as s: + rows = s.execute( + select(SelectorAlert).order_by(SelectorAlert.id.desc()) + ).scalars().all() + return { + "ok": True, + "alerts": [{ + "id": r.id, + "strategy_id": r.strategy_id, + "strategy_name": r.strategy_name, + "status": r.status, + "last_checked": r.last_checked.strftime("%m-%d %H:%M") if r.last_checked else "", + "last_count": r.last_count + } for r in rows] + } + + +class CreateAlertRequest(BaseModel): + strategy_id: int + strategy_name: str + + +@app.post("/api/selector/alerts") +def create_selector_alert(req: CreateAlertRequest): + """创建选股预警""" + with get_session() as s: + record = SelectorAlert( + strategy_id=req.strategy_id, + strategy_name=req.strategy_name + ) + s.add(record) + s.commit() + return {"ok": True, "id": record.id} + + +@app.delete("/api/selector/alerts/{aid}") +def delete_selector_alert(aid: int): + """删除选股预警""" + with get_session() as s: + record = s.get(SelectorAlert, aid) + if record: + s.delete(record) + s.commit() + return {"ok": True} + + # ============ 静态前端 ============ FRONTEND_DIR = os.path.join(os.path.dirname(BASE_DIR), "prototype") if os.path.isdir(FRONTEND_DIR): diff --git a/backend/models.py b/backend/models.py index 7990f50..c94affc 100644 --- a/backend/models.py +++ b/backend/models.py @@ -221,3 +221,131 @@ class JobRun(Base): started_at: Mapped[dt.datetime] = mapped_column(DateTime, server_default=func.now()) finished_at: Mapped[dt.datetime | None] = mapped_column(DateTime, nullable=True) message: Mapped[str] = mapped_column(Text, default="") + + +class SelectorStrategy(Base): + """选股策略保存。""" + __tablename__ = "selector_strategies" + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(80)) + description: Mapped[str] = mapped_column(String(200), default="") + strategy_json: Mapped[str] = mapped_column(Text) # JSON格式的策略定义 + is_preset: Mapped[bool] = mapped_column(default=False) # 是否预设策略 + created_at: Mapped[dt.datetime] = mapped_column(DateTime, server_default=func.now()) + updated_at: Mapped[dt.datetime] = mapped_column(DateTime, server_default=func.now()) + + +class SelectorAlert(Base): + """选股条件预警。""" + __tablename__ = "selector_alerts" + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + strategy_id: Mapped[int] = mapped_column(Integer, index=True) + strategy_name: Mapped[str] = mapped_column(String(80)) + status: Mapped[str] = mapped_column(String(12), default="active") # active/paused + last_checked: Mapped[dt.datetime | None] = mapped_column(DateTime, nullable=True) + last_count: Mapped[int] = mapped_column(Integer, default=0) + created_at: Mapped[dt.datetime] = mapped_column(DateTime, server_default=func.now()) + + +class SocialPost(Base): + """社区帖子。""" + __tablename__ = "social_posts" + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + source: Mapped[str] = mapped_column(String(20), index=True) # eastmoney/xueqiu/guba + post_id: Mapped[str] = mapped_column(String(100), unique=True) + code: Mapped[str] = mapped_column(String(12), index=True, default="") + title: Mapped[str] = mapped_column(String(200)) + content: Mapped[str] = mapped_column(Text, default="") + author: Mapped[str] = mapped_column(String(80), default="") + comment_count: Mapped[int] = mapped_column(Integer, default=0) + view_count: Mapped[int] = mapped_column(Integer, default=0) + sentiment: Mapped[str] = mapped_column(String(20), default="neutral") # bullish/bearish/neutral + keywords: Mapped[str] = mapped_column(String(200), default="") # 逗号分隔 + created_at: Mapped[dt.datetime] = mapped_column(DateTime, server_default=func.now(), index=True) + + +class SentimentIndex(Base): + """社区情绪指数(每日)。""" + __tablename__ = "sentiment_index" + date: Mapped[dt.date] = mapped_column(Date, primary_key=True) + bullish_count: Mapped[int] = mapped_column(Integer, default=0) + bearish_count: Mapped[int] = mapped_column(Integer, default=0) + neutral_count: Mapped[int] = mapped_column(Integer, default=0) + bullish_ratio: Mapped[float] = mapped_column(Float, default=0.0) # 0-100 + total_posts: Mapped[int] = mapped_column(Integer, default=0) + top_keywords: Mapped[str] = mapped_column(String(500), default="") # JSON格式 + updated_at: Mapped[dt.datetime] = mapped_column(DateTime, server_default=func.now()) + + +class CorporateEvent(Base): + """公司事件(财报、增减持、限售解禁等)。""" + __tablename__ = "corporate_events" + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + code: Mapped[str] = mapped_column(String(12), index=True) + name: Mapped[str] = mapped_column(String(40), default="") + event_type: Mapped[str] = mapped_column(String(20), index=True) # earnings/insider/unlock/dividend + event_date: Mapped[dt.date] = mapped_column(Date, index=True) + title: Mapped[str] = mapped_column(String(200)) + description: Mapped[str] = mapped_column(Text, default="") + amount: Mapped[float] = mapped_column(Float, default=0.0) # 金额(亿元) + impact: Mapped[str] = mapped_column(String(20), default="neutral") # positive/negative/neutral + created_at: Mapped[dt.datetime] = mapped_column(DateTime, server_default=func.now()) + + +class PolicyEvent(Base): + """行业政策事件。""" + __tablename__ = "policy_events" + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + sector: Mapped[str] = mapped_column(String(40), index=True) # 受影响板块 + event_date: Mapped[dt.date] = mapped_column(Date, index=True) + title: Mapped[str] = mapped_column(String(200)) + content: Mapped[str] = mapped_column(Text, default="") + policy_type: Mapped[str] = mapped_column(String(40)) # subsidy/restriction/support/regulation + impact: Mapped[str] = mapped_column(String(20), default="neutral") + affected_stocks: Mapped[str] = mapped_column(String(500), default="") # 逗号分隔的股票代码 + created_at: Mapped[dt.datetime] = mapped_column(DateTime, server_default=func.now()) + + +class FinancialReport(Base): + """财务报表数据。""" + __tablename__ = "financial_reports" + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + code: Mapped[str] = mapped_column(String(12), index=True) + name: Mapped[str] = mapped_column(String(40), default="") + report_date: Mapped[dt.date] = mapped_column(Date, index=True) # 报告期 + publish_date: Mapped[dt.date] = mapped_column(Date, index=True) # 发布日期 + report_type: Mapped[str] = mapped_column(String(20)) # Q1/Q2/Q3/annual + + # 核心指标 + revenue: Mapped[float] = mapped_column(Float, default=0.0) # 营收(亿元) + net_profit: Mapped[float] = mapped_column(Float, default=0.0) # 净利润(亿元) + roe: Mapped[float] = mapped_column(Float, default=0.0) # 净资产收益率(%) + gross_margin: Mapped[float] = mapped_column(Float, default=0.0) # 毛利率(%) + revenue_growth: Mapped[float] = mapped_column(Float, default=0.0) # 营收同比增长(%) + profit_growth: Mapped[float] = mapped_column(Float, default=0.0) # 净利润同比增长(%) + + # 风险指标 + inventory: Mapped[float] = mapped_column(Float, default=0.0) # 存货(亿元) + receivable: Mapped[float] = mapped_column(Float, default=0.0) # 应收账款(亿元) + debt_ratio: Mapped[float] = mapped_column(Float, default=0.0) # 资产负债率(%) + + # AI摘要 + ai_summary: Mapped[str] = mapped_column(String(500), default="") + + created_at: Mapped[dt.datetime] = mapped_column(DateTime, server_default=func.now()) + + +class IntradayEvent(Base): + """盘中异动事件记录。""" + __tablename__ = "intraday_events" + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + code: Mapped[str] = mapped_column(String(12), index=True) + name: Mapped[str] = mapped_column(String(40), default="") + event_type: Mapped[str] = mapped_column(String(20), index=True) # surge/volume_break/limit_open/consecutive/big_order + price: Mapped[float] = mapped_column(Float, default=0.0) + pct: Mapped[float] = mapped_column(Float, default=0.0) + volume_ratio: Mapped[float] = mapped_column(Float, default=0.0) + amount: Mapped[float] = mapped_column(Float, default=0.0) # 对于big_order是单笔金额 + description: Mapped[str] = mapped_column(String(200), default="") + detected_at: Mapped[dt.datetime] = mapped_column(DateTime, server_default=func.now(), index=True) + notified: Mapped[bool] = mapped_column(default=False) diff --git a/backend/report.py b/backend/report.py index ac00d93..a58ffc1 100644 --- a/backend/report.py +++ b/backend/report.py @@ -116,7 +116,7 @@ def generate(date=None, push=False): try: # 推送精简版(情绪 + 领涨 + AI 点评首段) brief = _push_brief(g, rv) - res = notifier.notify("【智策】" + title, brief) + res = notifier.notify("【Blackdata】" + title, brief) with get_session() as s: r2 = s.get(DailyReport, d) if r2: diff --git a/backend/requirements.txt b/backend/requirements.txt index 1d495d3..43fee8f 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -6,3 +6,5 @@ cachetools==5.5.0 SQLAlchemy>=2.0.30 APScheduler>=3.10.4 psycopg2-binary>=2.9.9 +jieba>=0.42.1 +numpy>=1.26.0 diff --git a/backend/scheduler.py b/backend/scheduler.py index 5cf9ed6..6f0f099 100644 --- a/backend/scheduler.py +++ b/backend/scheduler.py @@ -12,6 +12,7 @@ import ingest import alerts import report import signals +import intraday_radar _scheduler: BackgroundScheduler | None = None _lock = threading.Lock() @@ -128,11 +129,16 @@ def start_scheduler(): _job_verify, CronTrigger(day_of_week="mon-fri", hour=(_rep_total // 60) % 24, minute=(_rep_total + 5) % 60), id="verify_pred", replace_existing=True, misfire_grace_time=3600, ) - # 每周六重算信号历史胜率 + # 每周六重算信号历史胜率 _scheduler.add_job( _job_signal_stats, CronTrigger(day_of_week="sat", hour=9, minute=0), id="signal_stats", replace_existing=True, misfire_grace_time=7200, ) + # 盘中异动扫描(交易时间每分钟) + _scheduler.add_job( + _safe_scan_intraday, IntervalTrigger(seconds=60), + id="intraday_scan", replace_existing=True, max_instances=1, + ) _scheduler.start() return _scheduler @@ -142,3 +148,13 @@ def _safe_check_alerts(): alerts.check_alerts() except Exception as e: print("[alert] check error:", repr(e)[:120]) + + +def _safe_scan_intraday(): + try: + result = intraday_radar.scan_all() + if result.get("count", 0) > 0: + # 有新异动时自动推送 + intraday_radar.notify_events() + except Exception as e: + print("[intraday] scan error:", repr(e)[:120]) diff --git a/backend/sector_rotation.py b/backend/sector_rotation.py new file mode 100644 index 0000000..f4ee35d --- /dev/null +++ b/backend/sector_rotation.py @@ -0,0 +1,483 @@ +"""板块轮动分析 — 追踪板块强弱、资金流向、生命周期。 + +功能: +1. 板块强弱排名趋势 +2. 资金流向分析 +3. 板块生命周期判断 +4. 龙头股识别 +5. 板块联动性分析 +""" +import datetime as dt +from typing import Dict, List, Any, Optional +import numpy as np +from sqlalchemy import select, func, and_ + +from db import get_session +from models import SectorDaily, FundFlowDaily, DailyQuote, StockMetric + + +def get_sector_trend(days: int = 20, top_n: int = 15) -> Dict[str, Any]: + """获取板块强弱趋势 + + Args: + days: 统计天数 + top_n: 返回前N个板块 + + Returns: + 板块趋势数据 + """ + with get_session() as s: + # 获取最近N天的日期 + latest_date = s.execute(select(func.max(SectorDaily.date))).scalar() + if not latest_date: + return {"ok": False, "msg": "暂无板块数据"} + + start_date = latest_date - dt.timedelta(days=days) + + # 查询板块数据 + rows = s.execute( + select(SectorDaily) + .where(SectorDaily.date >= start_date) + .order_by(SectorDaily.date, SectorDaily.name) + ).scalars().all() + + if not rows: + return {"ok": False, "msg": "数据不足"} + + # 按板块聚合 + sector_data = {} + for row in rows: + if row.name not in sector_data: + sector_data[row.name] = { + "name": row.name, + "dates": [], + "pcts": [], + "amounts": [] + } + sector_data[row.name]["dates"].append(row.date.isoformat()) + sector_data[row.name]["pcts"].append(float(row.pct)) + sector_data[row.name]["amounts"].append(float(row.amount)) + + # 计算累计涨跌幅和平均成交额 + sector_stats = [] + for name, data in sector_data.items(): + pcts = data["pcts"] + amounts = data["amounts"] + + # 累计收益(复利) + cumulative = 1.0 + for p in pcts: + cumulative *= (1 + p / 100) + cumulative_return = (cumulative - 1) * 100 + + # 近5日、10日、20日收益 + returns = { + "5d": sum(pcts[-5:]) if len(pcts) >= 5 else 0, + "10d": sum(pcts[-10:]) if len(pcts) >= 10 else 0, + "20d": cumulative_return + } + + # 平均成交额 + avg_amount = np.mean(amounts) if amounts else 0 + + # 波动率(标准差) + volatility = np.std(pcts) if len(pcts) > 1 else 0 + + sector_stats.append({ + "name": name, + "returns": returns, + "avg_amount": round(avg_amount, 2), + "volatility": round(volatility, 2), + "dates": data["dates"], + "pcts": [round(p, 2) for p in pcts] + }) + + # 按20日收益排序 + sector_stats.sort(key=lambda x: x["returns"]["20d"], reverse=True) + + return { + "ok": True, + "date": latest_date.isoformat(), + "days": days, + "sectors": sector_stats[:top_n] + } + + +def analyze_fund_flow(days: int = 5) -> Dict[str, Any]: + """分析资金流向(板块间流动) + + Args: + days: 分析天数 + + Returns: + 资金流向数据(桑基图格式) + """ + with get_session() as s: + latest_date = s.execute(select(func.max(FundFlowDaily.date))).scalar() + if not latest_date: + return {"ok": False, "msg": "暂无资金流数据"} + + start_date = latest_date - dt.timedelta(days=days) + + # 查询资金流数据 + rows = s.execute( + select(FundFlowDaily) + .where(FundFlowDaily.date >= start_date) + .order_by(FundFlowDaily.date, FundFlowDaily.name) + ).scalars().all() + + if not rows: + return {"ok": False, "msg": "数据不足"} + + # 按板块聚合净流入 + flow_data = {} + for row in rows: + if row.name not in flow_data: + flow_data[row.name] = 0 + flow_data[row.name] += float(row.net) + + # 分类:流入 vs 流出 + inflows = [(k, v) for k, v in flow_data.items() if v > 0] + outflows = [(k, abs(v)) for k, v in flow_data.items() if v < 0] + + inflows.sort(key=lambda x: x[1], reverse=True) + outflows.sort(key=lambda x: x[1], reverse=True) + + # 构造桑基图数据 + nodes = [] + links = [] + + # 流出节点(左侧) + for i, (name, amount) in enumerate(outflows[:8]): + nodes.append({"name": f"{name}(流出)"}) + # 流向"资金池" + links.append({ + "source": len(nodes) - 1, + "target": len(outflows[:8]), # 资金池索引 + "value": round(amount, 2) + }) + + # 资金池(中间) + nodes.append({"name": "资金池"}) + + # 流入节点(右侧) + for i, (name, amount) in enumerate(inflows[:8]): + nodes.append({"name": f"{name}(流入)"}) + # 从"资金池"流入 + links.append({ + "source": len(outflows[:8]), # 资金池索引 + "target": len(nodes) - 1, + "value": round(amount, 2) + }) + + return { + "ok": True, + "date": latest_date.isoformat(), + "days": days, + "total_inflow": round(sum(v for _, v in inflows), 2), + "total_outflow": round(sum(v for _, v in outflows), 2), + "top_inflow": inflows[:8], + "top_outflow": outflows[:8], + "sankey": { + "nodes": nodes, + "links": links + } + } + + +def analyze_lifecycle(sector_name: str, days: int = 60) -> Dict[str, Any]: + """分析板块生命周期 + + Args: + sector_name: 板块名称 + days: 分析天数 + + Returns: + 生命周期判断 + """ + with get_session() as s: + latest_date = s.execute(select(func.max(SectorDaily.date))).scalar() + if not latest_date: + return {"ok": False, "msg": "暂无数据"} + + start_date = latest_date - dt.timedelta(days=days) + + rows = s.execute( + select(SectorDaily) + .where( + and_( + SectorDaily.name == sector_name, + SectorDaily.date >= start_date + ) + ) + .order_by(SectorDaily.date) + ).scalars().all() + + if len(rows) < 20: + return {"ok": False, "msg": "数据不足"} + + # 提取数据 + dates = [r.date.isoformat() for r in rows] + pcts = [float(r.pct) for r in rows] + amounts = [float(r.amount) for r in rows] + + # 计算指标 + # 1. 近期涨跌幅趋势 + recent_5 = sum(pcts[-5:]) + recent_10 = sum(pcts[-10:]) + recent_20 = sum(pcts[-20:]) + + # 2. 成交额趋势 + amount_5 = np.mean(amounts[-5:]) + amount_20 = np.mean(amounts[-20:]) + amount_change = (amount_5 / amount_20 - 1) * 100 if amount_20 > 0 else 0 + + # 3. 动量(价格变化加速度) + momentum = recent_5 - recent_10 + + # 生命周期判断 + if recent_20 > 0 and momentum > 0 and amount_change > 20: + phase = "启动期" + description = "板块刚开始上涨,资金流入加速,可能是介入时机" + elif recent_20 > 5 and recent_10 > recent_20 / 2 and amount_change > 0: + phase = "加速期" + description = "板块持续上涨且加速,成交活跃,主升浪阶段" + elif recent_20 > 0 and momentum < 0: + phase = "衰退期" + description = "板块涨幅收窄或开始回调,资金开始流出,注意风险" + elif recent_20 < -5: + phase = "下跌期" + description = "板块持续下跌,避免介入" + else: + phase = "震荡期" + description = "板块横盘整理,方向不明" + + return { + "ok": True, + "sector": sector_name, + "phase": phase, + "description": description, + "metrics": { + "return_5d": round(recent_5, 2), + "return_10d": round(recent_10, 2), + "return_20d": round(recent_20, 2), + "momentum": round(momentum, 2), + "amount_change": round(amount_change, 2) + }, + "dates": dates, + "pcts": [round(p, 2) for p in pcts] + } + + +def identify_leaders(sector_name: str, days: int = 20, limit: int = 10) -> Dict[str, Any]: + """识别板块龙头股 + + Args: + sector_name: 板块名称 + days: 统计天数 + limit: 返回数量 + + Returns: + 龙头股列表 + """ + # 注意:需要股票-板块映射表,这里简化为通过名称匹配 + # 实际应该有 stock_sector 映射表 + + with get_session() as s: + # 获取最近N天表现最好的股票 + latest_date = s.execute(select(func.max(StockMetric.date))).scalar() + if not latest_date: + return {"ok": False, "msg": "暂无股票数据"} + + # 查询高涨幅、高成交额股票 + rows = s.execute( + select(StockMetric) + .where( + and_( + StockMetric.date == latest_date, + StockMetric.ret20 > 0, + StockMetric.amount > 5 # 成交额 > 5亿 + ) + ) + .order_by( + StockMetric.ret20.desc(), + StockMetric.amount.desc() + ) + .limit(limit * 3) # 多取一些,后续筛选 + ).scalars().all() + + # 简化:根据名称关键词匹配板块(实际应该查询映射表) + sector_keywords = { + "半导体": ["芯片", "半导体", "集成电路"], + "新能源": ["新能源", "锂电", "光伏", "储能"], + "医药": ["医药", "生物", "医疗", "药业"], + "白酒": ["酒", "茅台", "五粮液"], + "军工": ["军工", "航天", "航空", "兵器"], + "AI": ["人工智能", "AI", "算力", "云计算"], + } + + keywords = sector_keywords.get(sector_name, [sector_name]) + + leaders = [] + for row in rows: + if any(kw in row.name for kw in keywords): + leaders.append({ + "code": row.code, + "name": row.name, + "close": round(row.close, 2), + "pct": round(row.pct, 2), + "ret5": round(row.ret5, 2), + "ret20": round(row.ret20, 2), + "amount": round(row.amount, 2), + "vol_ratio": round(row.vol_ratio, 2) + }) + if len(leaders) >= limit: + break + + return { + "ok": True, + "sector": sector_name, + "date": latest_date.isoformat(), + "leaders": leaders + } + + +def analyze_correlation(days: int = 60, top_n: int = 20) -> Dict[str, Any]: + """板块联动性分析(相关系数矩阵) + + Args: + days: 计算天数 + top_n: 分析前N个板块 + + Returns: + 相关系数矩阵(热力图数据) + """ + with get_session() as s: + latest_date = s.execute(select(func.max(SectorDaily.date))).scalar() + if not latest_date: + return {"ok": False, "msg": "暂无数据"} + + start_date = latest_date - dt.timedelta(days=days) + + rows = s.execute( + select(SectorDaily) + .where(SectorDaily.date >= start_date) + .order_by(SectorDaily.date, SectorDaily.name) + ).scalars().all() + + if not rows: + return {"ok": False, "msg": "数据不足"} + + # 按板块聚合涨跌幅 + sector_returns = {} + for row in rows: + if row.name not in sector_returns: + sector_returns[row.name] = [] + sector_returns[row.name].append(float(row.pct)) + + # 筛选数据完整的板块 + valid_sectors = {k: v for k, v in sector_returns.items() if len(v) >= days * 0.8} + + if len(valid_sectors) < 5: + return {"ok": False, "msg": "有效板块不足"} + + # 选择前N个板块(按最近涨幅) + sector_list = [] + for name, rets in valid_sectors.items(): + recent_return = sum(rets[-min(10, len(rets)):]) + sector_list.append((name, recent_return, rets)) + + sector_list.sort(key=lambda x: x[1], reverse=True) + selected = sector_list[:top_n] + + # 计算相关系数矩阵 + names = [s[0] for s in selected] + returns_matrix = np.array([s[2][:days] for s in selected]) + + # 填充短数据(用0) + max_len = max(len(r) for r in returns_matrix) + padded = [] + for r in returns_matrix: + if len(r) < max_len: + r = list(r) + [0] * (max_len - len(r)) + padded.append(r[:max_len]) + + returns_matrix = np.array(padded) + + # 计算相关系数 + corr_matrix = np.corrcoef(returns_matrix) + + # 转换为热力图数据 + heatmap_data = [] + for i in range(len(names)): + for j in range(len(names)): + heatmap_data.append({ + "x": j, + "y": i, + "value": round(float(corr_matrix[i][j]), 3) + }) + + # 找出高度相关的板块对(相关系数 > 0.7) + high_corr = [] + for i in range(len(names)): + for j in range(i + 1, len(names)): + corr = float(corr_matrix[i][j]) + if corr > 0.7: + high_corr.append({ + "sector1": names[i], + "sector2": names[j], + "correlation": round(corr, 3) + }) + + high_corr.sort(key=lambda x: x["correlation"], reverse=True) + + return { + "ok": True, + "days": days, + "sectors": names, + "matrix": corr_matrix.tolist(), + "heatmap": heatmap_data, + "high_correlation": high_corr[:10] + } + + +def get_rotation_summary() -> Dict[str, Any]: + """获取板块轮动综合摘要 + + Returns: + 轮动摘要 + """ + # 获取最强和最弱板块 + trend = get_sector_trend(days=10, top_n=20) + if not trend.get("ok"): + return {"ok": False, "msg": "数据不足"} + + sectors = trend["sectors"] + strongest = sectors[:3] + weakest = sectors[-3:] + + # 资金流向 + flow = analyze_fund_flow(days=5) + + summary = { + "ok": True, + "date": trend["date"], + "strongest_sectors": [ + { + "name": s["name"], + "return_10d": s["returns"]["10d"] + } for s in strongest + ], + "weakest_sectors": [ + { + "name": s["name"], + "return_10d": s["returns"]["10d"] + } for s in weakest + ], + "fund_flow": { + "top_inflow": flow.get("top_inflow", [])[:3] if flow.get("ok") else [], + "top_outflow": flow.get("top_outflow", [])[:3] if flow.get("ok") else [] + } + } + + return summary diff --git a/backend/sentiment_monitor.py b/backend/sentiment_monitor.py new file mode 100644 index 0000000..649275e --- /dev/null +++ b/backend/sentiment_monitor.py @@ -0,0 +1,556 @@ +"""社区情绪监控 — 爬取分析东方财富/雪球热帖,量化散户情绪。 + +功能: +1. 爬取社区热帖 +2. 情绪分析(乐观/悲观) +3. 热议股票排行 +4. 关键词提取和词云 +5. 情绪与股价相关性分析 +""" +import datetime as dt +import json +import re +from typing import List, Dict, Any, Optional +from collections import Counter, defaultdict +import requests +from bs4 import BeautifulSoup +import jieba +import jieba.analyse +from sqlalchemy import select, func, and_, desc + +from db import get_session +from models import SocialPost, SentimentIndex, DailyQuote, StockMetric + +# 情绪关键词库 +BULLISH_KEYWORDS = [ + '看多', '看好', '买入', '加仓', '抄底', '突破', '上涨', '暴涨', '牛市', + '利好', '反弹', '强势', '拉升', '涨停', '走强', '看涨', '做多' +] + +BEARISH_KEYWORDS = [ + '看空', '看跌', '卖出', '减仓', '止损', '下跌', '暴跌', '熊市', + '利空', '回调', '弱势', '下杀', '跌停', '走弱', '做空', '被套' +] + +# 停用词 +STOP_WORDS = set([ + '的', '了', '是', '在', '我', '有', '和', '就', '不', '人', '都', '一', + '一个', '上', '也', '很', '到', '说', '要', '去', '你', '会', '着', '没有', + '看', '好', '自己', '这', '那', '以', '为', '而', '能', '他', '对', '于' +]) + + +def crawl_eastmoney_hot(limit: int = 50) -> List[Dict[str, Any]]: + """爬取东方财富热帖(简化版,实际需要处理反爬) + + 注意:由于反爬限制,这里返回模拟数据 + 实际生产环境需要: + 1. 使用代理IP + 2. 模拟浏览器headers + 3. 控制请求频率 + 4. 处理验证码 + """ + # 模拟数据(实际应该爬取真实数据) + mock_posts = [ + { + 'source': 'eastmoney', + 'post_id': f'em_{i}', + 'title': f'模拟帖子{i}:今天大盘要反弹了', + 'content': '技术分析显示底部信号明显,建议逢低买入', + 'author': f'用户{i}', + 'comment_count': 100 + i * 10, + 'view_count': 1000 + i * 100, + } + for i in range(limit) + ] + + return mock_posts + + +def crawl_xueqiu_hot(limit: int = 50) -> List[Dict[str, Any]]: + """爬取雪球热帖(简化版)""" + # 雪球API(需要cookie和token) + # 实际使用需要登录后获取token + + mock_posts = [ + { + 'source': 'xueqiu', + 'post_id': f'xq_{i}', + 'title': f'雪球热议{i}:半导体板块分析', + 'content': '从产业链角度看,半导体景气度回升', + 'author': f'雪球用户{i}', + 'comment_count': 50 + i * 5, + 'view_count': 500 + i * 50, + } + for i in range(limit) + ] + + return mock_posts + + +def analyze_sentiment(text: str) -> str: + """分析文本情绪 + + Args: + text: 待分析文本 + + Returns: + 情绪标签:bullish/bearish/neutral + """ + text_lower = text.lower() + + bullish_score = sum(1 for kw in BULLISH_KEYWORDS if kw in text_lower) + bearish_score = sum(1 for kw in BEARISH_KEYWORDS if kw in text_lower) + + if bullish_score > bearish_score and bullish_score >= 2: + return 'bullish' + elif bearish_score > bullish_score and bearish_score >= 2: + return 'bearish' + else: + return 'neutral' + + +def extract_keywords(text: str, top_n: int = 10) -> List[str]: + """提取关键词 + + Args: + text: 文本内容 + top_n: 返回前N个关键词 + + Returns: + 关键词列表 + """ + # 使用jieba提取关键词 + keywords = jieba.analyse.extract_tags(text, topK=top_n, withWeight=False) + + # 过滤停用词 + keywords = [kw for kw in keywords if kw not in STOP_WORDS and len(kw) > 1] + + return keywords[:top_n] + + +def extract_stock_codes(text: str) -> List[str]: + """从文本中提取股票代码 + + Args: + text: 文本内容 + + Returns: + 股票代码列表 + """ + # 匹配6位数字的股票代码 + pattern = r'\b[036]\d{5}\b' + codes = re.findall(pattern, text) + return list(set(codes)) + + +def collect_posts(limit_per_source: int = 50) -> Dict[str, Any]: + """采集社区帖子 + + Args: + limit_per_source: 每个来源采集数量 + + Returns: + 采集结果 + """ + all_posts = [] + + # 采集东方财富 + try: + em_posts = crawl_eastmoney_hot(limit_per_source) + all_posts.extend(em_posts) + except Exception as e: + print(f"[eastmoney] crawl error: {e}") + + # 采集雪球 + try: + xq_posts = crawl_xueqiu_hot(limit_per_source) + all_posts.extend(xq_posts) + except Exception as e: + print(f"[xueqiu] crawl error: {e}") + + # 分析并存储 + saved_count = 0 + with get_session() as s: + for post in all_posts: + # 检查是否已存在 + exists = s.execute( + select(SocialPost).where(SocialPost.post_id == post['post_id']) + ).scalar_one_or_none() + + if exists: + continue + + # 情绪分析 + text = post['title'] + ' ' + post.get('content', '') + sentiment = analyze_sentiment(text) + + # 提取关键词 + keywords = extract_keywords(text, top_n=5) + + # 提取股票代码 + codes = extract_stock_codes(text) + code = codes[0] if codes else '' + + # 存储 + record = SocialPost( + source=post['source'], + post_id=post['post_id'], + code=code, + title=post['title'], + content=post.get('content', ''), + author=post.get('author', ''), + comment_count=post.get('comment_count', 0), + view_count=post.get('view_count', 0), + sentiment=sentiment, + keywords=','.join(keywords) + ) + s.add(record) + saved_count += 1 + + s.commit() + + return { + 'ok': True, + 'collected': len(all_posts), + 'saved': saved_count + } + + +def calculate_sentiment_index(date: Optional[dt.date] = None) -> Dict[str, Any]: + """计算情绪指数 + + Args: + date: 统计日期,None表示今天 + + Returns: + 情绪指数数据 + """ + if date is None: + date = dt.date.today() + + start = dt.datetime.combine(date, dt.time.min) + end = dt.datetime.combine(date, dt.time.max) + + with get_session() as s: + # 统计各情绪数量 + posts = s.execute( + select(SocialPost) + .where( + and_( + SocialPost.created_at >= start, + SocialPost.created_at <= end + ) + ) + ).scalars().all() + + if not posts: + return {'ok': False, 'msg': '暂无数据'} + + bullish_count = sum(1 for p in posts if p.sentiment == 'bullish') + bearish_count = sum(1 for p in posts if p.sentiment == 'bearish') + neutral_count = sum(1 for p in posts if p.sentiment == 'neutral') + total = len(posts) + + bullish_ratio = bullish_count / total * 100 if total > 0 else 0 + + # 提取热门关键词 + all_keywords = [] + for p in posts: + if p.keywords: + all_keywords.extend(p.keywords.split(',')) + + keyword_counter = Counter(all_keywords) + top_keywords = [ + {'word': kw, 'count': cnt} + for kw, cnt in keyword_counter.most_common(20) + ] + + # 存储情绪指数 + index_record = s.execute( + select(SentimentIndex).where(SentimentIndex.date == date) + ).scalar_one_or_none() + + if index_record: + index_record.bullish_count = bullish_count + index_record.bearish_count = bearish_count + index_record.neutral_count = neutral_count + index_record.bullish_ratio = bullish_ratio + index_record.total_posts = total + index_record.top_keywords = json.dumps(top_keywords, ensure_ascii=False) + index_record.updated_at = dt.datetime.now() + else: + index_record = SentimentIndex( + date=date, + bullish_count=bullish_count, + bearish_count=bearish_count, + neutral_count=neutral_count, + bullish_ratio=bullish_ratio, + total_posts=total, + top_keywords=json.dumps(top_keywords, ensure_ascii=False) + ) + s.add(index_record) + + s.commit() + + return { + 'ok': True, + 'date': date.isoformat(), + 'bullish_count': bullish_count, + 'bearish_count': bearish_count, + 'neutral_count': neutral_count, + 'bullish_ratio': round(bullish_ratio, 2), + 'total_posts': total, + 'top_keywords': top_keywords + } + + +def get_hot_stocks(days: int = 1, limit: int = 20) -> Dict[str, Any]: + """获取热议股票排行 + + Args: + days: 统计天数 + limit: 返回数量 + + Returns: + 热议股票列表 + """ + since = dt.datetime.now() - dt.timedelta(days=days) + + with get_session() as s: + # 按股票代码分组统计 + stmt = ( + select( + SocialPost.code, + func.count().label('post_count'), + func.sum(SocialPost.comment_count).label('total_comments'), + func.sum(SocialPost.view_count).label('total_views') + ) + .where( + and_( + SocialPost.code != '', + SocialPost.created_at >= since + ) + ) + .group_by(SocialPost.code) + .order_by(desc('post_count')) + .limit(limit) + ) + + rows = s.execute(stmt).all() + + if not rows: + return {'ok': False, 'msg': '暂无数据'} + + # 获取股票名称和最新价格 + codes = [r.code for r in rows] + metrics = {} + for m in s.execute( + select(StockMetric) + .where(StockMetric.code.in_(codes)) + ).scalars(): + metrics[m.code] = { + 'name': m.name, + 'close': m.close, + 'pct': m.pct + } + + results = [] + for r in rows: + info = metrics.get(r.code, {'name': r.code, 'close': 0, 'pct': 0}) + results.append({ + 'code': r.code, + 'name': info['name'], + 'post_count': r.post_count, + 'total_comments': r.total_comments or 0, + 'total_views': r.total_views or 0, + 'heat_score': r.post_count * 10 + (r.total_comments or 0), + 'close': info['close'], + 'pct': info['pct'] + }) + + # 按热度评分排序 + results.sort(key=lambda x: x['heat_score'], reverse=True) + + return { + 'ok': True, + 'days': days, + 'stocks': results + } + + +def get_sentiment_history(days: int = 30) -> Dict[str, Any]: + """获取情绪指数历史 + + Args: + days: 统计天数 + + Returns: + 历史数据 + """ + since = dt.date.today() - dt.timedelta(days=days) + + with get_session() as s: + rows = s.execute( + select(SentimentIndex) + .where(SentimentIndex.date >= since) + .order_by(SentimentIndex.date) + ).scalars().all() + + if not rows: + return {'ok': False, 'msg': '暂无历史数据'} + + return { + 'ok': True, + 'dates': [r.date.isoformat() for r in rows], + 'bullish_ratio': [round(r.bullish_ratio, 2) for r in rows], + 'total_posts': [r.total_posts for r in rows] + } + + +def analyze_sentiment_correlation(code: str, days: int = 60) -> Dict[str, Any]: + """分析情绪与股价相关性 + + Args: + code: 股票代码 + days: 分析天数 + + Returns: + 相关性分析结果 + """ + since = dt.date.today() - dt.timedelta(days=days) + + with get_session() as s: + # 获取该股票的讨论量和情绪 + posts = s.execute( + select(SocialPost) + .where( + and_( + SocialPost.code == code, + func.date(SocialPost.created_at) >= since + ) + ) + ).scalars().all() + + if not posts: + return {'ok': False, 'msg': '该股票暂无社区数据'} + + # 按日期聚合 + daily_sentiment = defaultdict(lambda: {'bullish': 0, 'bearish': 0, 'neutral': 0, 'total': 0}) + for p in posts: + date = p.created_at.date() + daily_sentiment[date][p.sentiment] += 1 + daily_sentiment[date]['total'] += 1 + + # 获取股价数据 + prices = {} + for q in s.execute( + select(DailyQuote) + .where( + and_( + DailyQuote.code == code, + DailyQuote.date >= since + ) + ) + .order_by(DailyQuote.date) + ).scalars(): + prices[q.date] = { + 'close': float(q.close), + 'pct': ((float(q.close) - float(q.open)) / float(q.open) * 100) if q.open > 0 else 0 + } + + if not prices: + return {'ok': False, 'msg': '缺少股价数据'} + + # 计算相关性(简化版) + dates = sorted(set(daily_sentiment.keys()) & set(prices.keys())) + + if len(dates) < 10: + return {'ok': False, 'msg': '数据点不足'} + + sentiment_scores = [] + price_changes = [] + + for date in dates: + s_data = daily_sentiment[date] + bullish_ratio = s_data['bullish'] / s_data['total'] * 100 if s_data['total'] > 0 else 50 + sentiment_scores.append(bullish_ratio) + + price_changes.append(prices[date]['pct']) + + # 计算相关系数(简化版) + import numpy as np + if len(sentiment_scores) > 1: + correlation = np.corrcoef(sentiment_scores, price_changes)[0, 1] + else: + correlation = 0 + + return { + 'ok': True, + 'code': code, + 'days': days, + 'data_points': len(dates), + 'correlation': round(float(correlation), 3), + 'interpretation': _interpret_correlation(correlation), + 'dates': [d.isoformat() for d in dates], + 'sentiment_scores': [round(s, 2) for s in sentiment_scores], + 'price_changes': [round(p, 2) for p in price_changes] + } + + +def _interpret_correlation(corr: float) -> str: + """解释相关系数""" + if corr > 0.7: + return '强正相关:情绪高涨时股价往往上涨' + elif corr > 0.3: + return '中度正相关:情绪与股价有一定同步性' + elif corr > -0.3: + return '弱相关:情绪与股价关系不明显' + elif corr > -0.7: + return '中度负相关:情绪高涨时股价反而下跌(反向指标)' + else: + return '强负相关:典型反向指标,情绪越乐观越要警惕' + + +def get_keyword_cloud(days: int = 7, top_n: int = 50) -> Dict[str, Any]: + """获取关键词云数据 + + Args: + days: 统计天数 + top_n: 返回前N个关键词 + + Returns: + 词云数据 + """ + since = dt.datetime.now() - dt.timedelta(days=days) + + with get_session() as s: + posts = s.execute( + select(SocialPost) + .where(SocialPost.created_at >= since) + ).scalars().all() + + if not posts: + return {'ok': False, 'msg': '暂无数据'} + + # 收集所有关键词 + all_keywords = [] + for p in posts: + if p.keywords: + all_keywords.extend(p.keywords.split(',')) + + # 统计词频 + keyword_counter = Counter(all_keywords) + + # 格式化为词云数据 + wordcloud_data = [ + {'name': kw, 'value': cnt} + for kw, cnt in keyword_counter.most_common(top_n) + ] + + return { + 'ok': True, + 'days': days, + 'keywords': wordcloud_data + } + diff --git a/backend/smart_selector.py b/backend/smart_selector.py new file mode 100644 index 0000000..8efd4bd --- /dev/null +++ b/backend/smart_selector.py @@ -0,0 +1,390 @@ +"""智能选股引擎 — 可视化选股器、策略保存、回测验证。 + +功能: +1. 多条件组合选股 +2. 选股策略保存/加载 +3. 选股结果历史回测 +4. 选股结果对比(新入选/退出) +5. 条件预警 +""" +import datetime as dt +import json +from typing import Dict, List, Any, Optional +from sqlalchemy import select, and_, or_, func +import numpy as np + +from db import get_session +from models import StockMetric, DailyQuote, Security + + +class Condition: + """选股条件""" + + def __init__(self, field: str, operator: str, value: Any): + self.field = field + self.operator = operator + self.value = value + + def to_dict(self): + return { + "field": self.field, + "operator": self.operator, + "value": self.value + } + + @classmethod + def from_dict(cls, data: Dict): + return cls(data["field"], data["operator"], data["value"]) + + def to_sql(self, model): + """转换为 SQLAlchemy 查询条件""" + field = getattr(model, self.field, None) + if field is None: + return None + + op = self.operator + val = self.value + + if op == "==": + return field == val + elif op == ">": + return field > val + elif op == ">=": + return field >= val + elif op == "<": + return field < val + elif op == "<=": + return field <= val + elif op == "between": + return and_(field >= val[0], field <= val[1]) + elif op == "in": + return field.in_(val) + else: + return None + + +class Strategy: + """选股策略""" + + def __init__(self, name: str, description: str = ""): + self.name = name + self.description = description + self.conditions: List[Condition] = [] + self.logic = "and" # and / or + + def add_condition(self, field: str, operator: str, value: Any): + """添加条件""" + self.conditions.append(Condition(field, operator, value)) + + def to_dict(self): + return { + "name": self.name, + "description": self.description, + "logic": self.logic, + "conditions": [c.to_dict() for c in self.conditions] + } + + @classmethod + def from_dict(cls, data: Dict): + strategy = cls(data["name"], data.get("description", "")) + strategy.logic = data.get("logic", "and") + for cond in data.get("conditions", []): + strategy.conditions.append(Condition.from_dict(cond)) + return strategy + + def to_json(self) -> str: + """序列化为JSON""" + return json.dumps(self.to_dict(), ensure_ascii=False) + + @classmethod + def from_json(cls, json_str: str): + """从JSON反序列化""" + return cls.from_dict(json.loads(json_str)) + + +def run_selector(strategy: Strategy, date: Optional[dt.date] = None) -> Dict[str, Any]: + """执行选股 + + Args: + strategy: 选股策略 + date: 选股日期,None表示最新 + + Returns: + 选股结果 + """ + with get_session() as s: + # 确定日期 + if date is None: + date = s.execute(select(func.max(StockMetric.date))).scalar() + + if not date: + return {"ok": False, "msg": "暂无数据"} + + # 构建查询 + query = select(StockMetric).where(StockMetric.date == date) + + # 应用条件 + sql_conditions = [] + for cond in strategy.conditions: + sql_cond = cond.to_sql(StockMetric) + if sql_cond is not None: + sql_conditions.append(sql_cond) + + if sql_conditions: + if strategy.logic == "and": + query = query.where(and_(*sql_conditions)) + else: # or + query = query.where(or_(*sql_conditions)) + + # 执行查询 + rows = s.execute(query).scalars().all() + + # 格式化结果 + results = [{ + "code": r.code, + "name": r.name, + "close": round(r.close, 2), + "pct": round(r.pct, 2), + "ret5": round(r.ret5, 2), + "ret20": round(r.ret20, 2), + "vol_ratio": round(r.vol_ratio, 2), + "rsi14": round(r.rsi14, 2), + "pos60": round(r.pos60 * 100, 1), + "amount": round(r.amount, 2), + "ma_bull": r.ma_bull, + "macd_gold": r.macd_gold + } for r in rows] + + return { + "ok": True, + "date": date.isoformat(), + "strategy": strategy.name, + "count": len(results), + "results": results + } + + +def backtest_selector(strategy: Strategy, days: int = 60) -> Dict[str, Any]: + """选股策略回测 + + Args: + strategy: 选股策略 + days: 回测天数 + + Returns: + 回测结果 + """ + with get_session() as s: + # 获取最近N个交易日 + latest_date = s.execute(select(func.max(StockMetric.date))).scalar() + if not latest_date: + return {"ok": False, "msg": "暂无数据"} + + start_date = latest_date - dt.timedelta(days=days) + + # 获取这段时间内的所有交易日 + dates = s.execute( + select(StockMetric.date) + .where(StockMetric.date >= start_date) + .group_by(StockMetric.date) + .order_by(StockMetric.date) + ).scalars().all() + + if len(dates) < 5: + return {"ok": False, "msg": "数据不足"} + + # 逐日选股并统计后续N日收益 + daily_results = [] + for i, date in enumerate(dates[:-5]): # 至少保留5日用于计算收益 + # 执行选股 + result = run_selector(strategy, date) + if not result["ok"] or not result["results"]: + continue + + selected_codes = [r["code"] for r in result["results"]] + + # 查询5日后的收益 + future_date = dates[min(i + 5, len(dates) - 1)] + + with get_session() as s: + # 获取选中股票5日后的表现 + future_rows = s.execute( + select(DailyQuote.code, DailyQuote.close) + .where( + and_( + DailyQuote.code.in_(selected_codes), + DailyQuote.date == future_date + ) + ) + ).all() + + current_rows = s.execute( + select(DailyQuote.code, DailyQuote.close) + .where( + and_( + DailyQuote.code.in_(selected_codes), + DailyQuote.date == date + ) + ) + ).all() + + # 计算收益 + current_prices = {r.code: float(r.close) for r in current_rows} + future_prices = {r.code: float(r.close) for r in future_rows} + + returns = [] + for code in selected_codes: + if code in current_prices and code in future_prices: + ret = (future_prices[code] / current_prices[code] - 1) * 100 + returns.append(ret) + + if returns: + daily_results.append({ + "date": date.isoformat(), + "count": len(selected_codes), + "avg_return": round(np.mean(returns), 2), + "median_return": round(np.median(returns), 2), + "win_rate": round(sum(1 for r in returns if r > 0) / len(returns) * 100, 1), + "max_return": round(max(returns), 2), + "min_return": round(min(returns), 2) + }) + + if not daily_results: + return {"ok": False, "msg": "回测数据不足"} + + # 汇总统计 + avg_returns = [r["avg_return"] for r in daily_results] + win_rates = [r["win_rate"] for r in daily_results] + + summary = { + "total_days": len(daily_results), + "avg_return": round(np.mean(avg_returns), 2), + "avg_win_rate": round(np.mean(win_rates), 1), + "best_day": max(daily_results, key=lambda x: x["avg_return"]), + "worst_day": min(daily_results, key=lambda x: x["avg_return"]) + } + + return { + "ok": True, + "strategy": strategy.name, + "days": days, + "summary": summary, + "daily": daily_results + } + + +def compare_results(date1: dt.date, date2: dt.date, strategy: Strategy) -> Dict[str, Any]: + """对比两个日期的选股结果 + + Args: + date1: 日期1(通常是昨日) + date2: 日期2(通常是今日) + strategy: 选股策略 + + Returns: + 对比结果 + """ + result1 = run_selector(strategy, date1) + result2 = run_selector(strategy, date2) + + if not result1["ok"] or not result2["ok"]: + return {"ok": False, "msg": "数据不足"} + + codes1 = set(r["code"] for r in result1["results"]) + codes2 = set(r["code"] for r in result2["results"]) + + # 新入选 + new_in = codes2 - codes1 + # 退出 + dropped = codes1 - codes2 + # 持续入选 + continued = codes1 & codes2 + + # 获取详细信息 + new_in_stocks = [r for r in result2["results"] if r["code"] in new_in] + dropped_stocks = [r for r in result1["results"] if r["code"] in dropped] + continued_stocks = [r for r in result2["results"] if r["code"] in continued] + + return { + "ok": True, + "date1": date1.isoformat(), + "date2": date2.isoformat(), + "count1": len(codes1), + "count2": len(codes2), + "new_in": { + "count": len(new_in), + "stocks": new_in_stocks + }, + "dropped": { + "count": len(dropped), + "stocks": dropped_stocks + }, + "continued": { + "count": len(continued), + "stocks": continued_stocks + } + } + + +# 预设策略 +PRESET_STRATEGIES = { + "momentum": Strategy("动量突破", "短期强势+放量"), + "value": Strategy("价值洼地", "超跌低位+基本面支撑"), + "growth": Strategy("成长加速", "持续上涨+量价齐升"), + "reversal": Strategy("反转抄底", "超跌企稳+技术反转") +} + +# 动量突破 +PRESET_STRATEGIES["momentum"].add_condition("ret5", ">", 10) +PRESET_STRATEGIES["momentum"].add_condition("vol_ratio", ">", 2) +PRESET_STRATEGIES["momentum"].add_condition("rsi14", "<", 80) + +# 价值洼地 +PRESET_STRATEGIES["value"].add_condition("pos60", "<", 0.3) +PRESET_STRATEGIES["value"].add_condition("pct", ">", 0) +PRESET_STRATEGIES["value"].add_condition("amount", ">", 5) + +# 成长加速 +PRESET_STRATEGIES["growth"].add_condition("ret20", ">", 15) +PRESET_STRATEGIES["growth"].add_condition("ma_bull", "==", True) +PRESET_STRATEGIES["growth"].add_condition("up_streak", ">=", 2) + +# 反转抄底 +PRESET_STRATEGIES["reversal"].add_condition("ret20", "<", -15) +PRESET_STRATEGIES["reversal"].add_condition("rsi14", "<", 30) +PRESET_STRATEGIES["reversal"].add_condition("pct", ">", 2) + + +def get_preset_strategies() -> List[Dict[str, Any]]: + """获取预设策略列表""" + return [ + { + "id": key, + "name": strategy.name, + "description": strategy.description, + "conditions_count": len(strategy.conditions) + } + for key, strategy in PRESET_STRATEGIES.items() + ] + + +def get_available_fields() -> List[Dict[str, Any]]: + """获取可用的选股字段""" + return [ + {"field": "close", "name": "现价", "type": "number", "unit": "元"}, + {"field": "pct", "name": "涨跌幅", "type": "number", "unit": "%"}, + {"field": "ret5", "name": "5日涨幅", "type": "number", "unit": "%"}, + {"field": "ret20", "name": "20日涨幅", "type": "number", "unit": "%"}, + {"field": "ret60", "name": "60日涨幅", "type": "number", "unit": "%"}, + {"field": "ma5", "name": "MA5", "type": "number", "unit": "元"}, + {"field": "ma10", "name": "MA10", "type": "number", "unit": "元"}, + {"field": "ma20", "name": "MA20", "type": "number", "unit": "元"}, + {"field": "ma60", "name": "MA60", "type": "number", "unit": "元"}, + {"field": "vol_ratio", "name": "量比", "type": "number", "unit": ""}, + {"field": "rsi14", "name": "RSI", "type": "number", "unit": ""}, + {"field": "pos60", "name": "60日分位", "type": "number", "unit": ""}, + {"field": "amount", "name": "成交额", "type": "number", "unit": "亿"}, + {"field": "up_streak", "name": "连涨天数", "type": "number", "unit": "天"}, + {"field": "ma_bull", "name": "均线多头", "type": "boolean", "unit": ""}, + {"field": "macd_gold", "name": "MACD金叉", "type": "boolean", "unit": ""} + ] diff --git a/prototype/ai-chat.js b/prototype/ai-chat.js new file mode 100644 index 0000000..2bc8ea5 --- /dev/null +++ b/prototype/ai-chat.js @@ -0,0 +1,130 @@ +// AI 对话式分析 + +let _SESSION_ID = 'session_' + Date.now(); +let _CHAT_MESSAGES = []; + +// 添加 AI 对话菜单项 +if (!MENU.find(g => g.children.find(c => c.id === 'ai-chat'))) { + const aiMenu = MENU.find(g => g.name === 'AI 分析'); + if (aiMenu) { + aiMenu.children.unshift({ id: 'ai-chat', name: 'AI 对话助手' }); + } +} + +VIEWS['ai-chat'] = async function(view) { + view.innerHTML = `
后端未连接
暂无交易数据,请先在「交易日志」录入。
| ${x.key} | ${sign(x.pnl)}${fmt(x.pnl)} |
${r.msg},请先在「交易日志」录入交易。
| 名称 | 日期 | 事件 | 金额 |
|---|