first commit

This commit is contained in:
zlt 2026-07-27 09:05:52 +08:00
commit c62599a8f2
23 changed files with 2473 additions and 0 deletions

134
ARCHITECTURE.md Normal file
View File

@ -0,0 +1,134 @@
# A股大事记录 / 大周期择时看板 · 架构设计
> 目标:把 **指数日成交量、ETF流入、股民情绪、重大事件** 四类信息按时间轴叠加到
> **上证综指 / 创业板指 / 科创50** 的指数K线上形成一份 A 股「大事记录」,
> 用于辅助判断 A 股 **大周期的顶与底**
>
> 本文件是设计基线,随迭代更新。数据字段口径参考同目录 `DATA_MODEL.md`
---
## 1. 设计原则与关键决策
| 决策点 | 结论 | 说明 |
|---|---|---|
| 数据来源 | **直连现有内网库** + 自有库 | 指数收盘/成交额直读 `zs_day_data`(MySQL-A 18.199),情绪直读 `gp_market_sentiment`(PG 16.150);本系统另建**自有 Postgres** 存事件、ETF、派生指标与本地快照。 |
| 指数 OHLC | **可插拔 Provider暂缺** | 现有 `zs_day_data` 只有 `close`,无开/高/低画不了完整蜡烛图。OHLC 源由你后续提供,系统预留 `OHLCProvider` 接口;未接入时**退化为收盘线**`ohlc_source='close_only'`),接入后自动转蜡烛图。 |
| ETF & 事件 | **人工录入为主 + 自动化接口预留** | 提供 Web 表单录入 + CSV 导入模板;`importers.py` 预留 `EventImporter`/`EtfFlowImporter` 接口,未来可挂新闻/公告/资金流抓取。 |
| 前端 | **FastAPI + ECharts** | 单页看板:蜡烛图 + 成交量副图 + 情绪/温度副图 + 事件打点;切换指数、缩放、看事件详情。 |
| 部署 | **Docker Compose** | `db`(自有 Postgres) + `backend`(FastAPI含静态前端)。外部内网库 DSN 走环境变量,可缺省降级。 |
| 数据落地 | **读→同步→自有库→看板** | ETL 从内网库拉取并落一份到自有库;看板只读自有库,保证「大事记录」自包含、可离线回看、事件可长期叠加。 |
### 为什么不直接在看板里跨库实时查?
「大事记录」要长期留存、可离线回看、和事件长期叠加,且要跨 MySQL + PG 联合出图。
因此采用 **ETL 同步进自有库** 的读写分离:内网库只在同步时被读,看板永远读自有库,
既尊重「直连现有库」的选择,又让系统自包含、易 Docker 化、查询快。
---
## 2. 系统拓扑
```
外部(现有内网库,只读)
┌──────────────────────────────┐ ┌──────────────────────────────┐
│ MySQL-A 192.168.18.199 │ │ PostgreSQL 192.168.16.150 │
│ zs_day_data(指数 close/额) │ │ gp_market_sentiment(情绪) │
└───────────────┬──────────────┘ └───────────────┬──────────────┘
│ (LEGACY_MYSQL_DSN) │ (LEGACY_PG_DSN)
▼ ▼
┌───────────────────────────────────────────────────────────┐
│ backend (FastAPI 容器) │
│ sources.py —— 现有库读适配 + 可插拔 OHLCProvider(预留) │
│ etl.py —— 同步 index_daily / sentiment_daily │
│ importers.py—— ETF/事件 CSV导入 + 自动化接口(预留) │
│ signals.py —— 顶底「市场温度」透明启发式 │
│ api.py —— REST 接口 │
│ frontend/index.html —— ECharts 看板(静态挂载) │
└───────────────────────────┬───────────────────────────────┘
│ (OWN_DB_DSN)
┌──────────────────────────────┐
│ db: 自有 Postgres(容器) │
│ index_daily / sentiment_daily│
│ etf_flow / market_event │
│ cycle_annotation │
└──────────────────────────────┘
```
---
## 3. 指数范围
| 名称 | 代码(dot式) | zs_day_data 有 | 说明 |
|---|---|---|---|
| 上证综指 | `000001.SH` | ✅ | 主看盘 |
| 创业板指 | `399006.SZ` | ✅ | 成长/科技情绪 |
| 科创50 | `000688.SH` | ✅ | 科创板代表指数科创板本身无指数用科创50代理 |
| 深证成指 | `399001.SZ` | ✅ | 可选,默认关闭 |
代码统一用 **dot 式**`000001.SH`),与 `zs_day_data.symbol` 一致,避免格式互转。
---
## 4. 数据模型(自有库)
详见 `ddl/own_db_init.sql``backend/app/db.py`。摘要:
- **`index_daily`** — 指数日线(同步落地):`index_code, trade_date, open/high/low/close, volume, amount, pct_chg, turnover_rate, ohlc_source`。唯一键 `(index_code, trade_date)`
- **`sentiment_daily`** — 情绪日度:`trade_date, up_down_ratio, median_pct_chg, pct_chg_gt_5_count, limit_up/down_count, margin_balance, new_accounts, sentiment_score`。
- **`etf_flow`** — ETF 流入(人工/导入/自动):`trade_date, etf_code, etf_name, category, related_index, net_inflow(亿元), shares_change, source, note`。
- **`market_event`** — 重大事件:`event_date, title, category(监管/IPO/政策/资金/外部/其他), impact_direction(bullish/bearish/neutral), severity(1-5), related_indices, cycle_tag(top/bottom/none), description, source_url, source(manual/import/auto)`。
- **`cycle_annotation`** — 人工顶底标注(复盘用):`index_code, anno_date, kind(top/bottom/watch), note`。
---
## 5. 顶底信号方法论(`signals.py`,透明可调)
不做黑盒。综合几个 A 股经典的顶底极值信号,产出每日 **市场温度 0100** 与离散标记:
| 分量 | 顶部含义 | 底部含义 | 计算 |
|---|---|---|---|
| 量能分位 `vol_pct` | 天量见天价 | 地量见地价 | `amount` 在滚动 N 日(默认250)的百分位 |
| 价格分位 `price_pct` | 高位 | 低位 | `close` 在滚动 N 日的百分位 |
| 情绪分 `sentiment` | 涨停潮/普涨亢奋 | 跌停潮/普跌冰点 | `up_down_ratio`、`pct_chg_gt_5_count` 归一 |
| ETF资金 `etf_z` | 大额净流出 | 大额净流入(国家队) | 净流入 z-score |
**市场温度** = 各分量加权(默认权重写在 `signals.py` 顶部,便于你用自己数据回测调参)。
温度 >80 记 **过热/顶部风险**<20 **冰点/底部机会**并在看板上以**热力带 + 标记**呈现
> 权重与阈值是初值,需你在实机用历史数据回测校准;方法论刻意保持透明,不追求复杂模型。
---
## 6. 接口一览REST
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/api/health` | 健康检查(含各库连通性) |
| GET | `/api/index/list` | 可用指数列表 |
| GET | `/api/index/{code}/kline?start=&end=` | K线(OHLC/收盘)+成交量+温度 |
| GET | `/api/sentiment?start=&end=` | 情绪日度序列 |
| GET | `/api/etf?start=&end=&index=` | ETF 流入序列 |
| GET/POST/PUT/DELETE | `/api/events` | 事件 CRUD人工录入 |
| POST | `/api/events/import` `/api/etf/import` | CSV 导入 |
| GET | `/api/signals?code=&start=&end=` | 顶底温度与离散标记 |
| POST | `/api/sync/index` `/api/sync/sentiment` | 触发 ETL也可 CLI/定时) |
---
## 7. 部署与运维
- `docker compose up -d``db` + `backend`;前端由 backend 静态挂载,浏览器访问 `:8000`
- 内网库 DSN`LEGACY_MYSQL_DSN` / `LEGACY_PG_DSN`)经 `.env` 注入;**留空则该同步自动跳过**(降级不报错)。
- ETL`docker compose exec backend python -m app.etl sync-index --start 20240101`;后续可挂 cron / APScheduler。
- 开发机 ↔ 服务器:**git 同步代码**;数据不入库随代码走。每个里程碑提示提交。
---
## 8. 里程碑
1. ✅ 架构 + 骨架 + Docker + 自有库DDL
2. ✅ 数据源适配 + ETL + API + 顶底信号
3. ✅ ECharts 看板 + 事件录入/导入
4. ⏳ 实机联调(你跑测试回传)→ OHLC 源接入 → 顶底权重校准
5. ⏳ 自动化接入ETF/事件抓取)按需开启

323
DATA_MODEL.md Normal file
View File

@ -0,0 +1,323 @@
# Bionic Trader 数据模型
> 全部库表、Milvus 集合、Redis 键与 MongoDB 集合的 Schema 与上下游关系。存储拓扑与连接配置见 `ARCHITECTURE.md` §2.2。
约定:
- 股票代码在不同库里有两种格式 —— **交易所前缀式**`SH600000` / `SZ000001``strategy_daily_results`、`gp_day_data`、**因子分表 `gp_stock_factor_pro_*``symbol`** 用)与 **Tushare 式**`600000.SH` / `000001.SZ`PG、行情 Redis、盘中告警流用。代码里通过 `parts[1]+parts[0]` 互转。
> ⚠️ 2026-07-07 实测更正:因子分表 `symbol` 为**前缀式**`SH603501` 命中、`603501.SH` 查空),早期文档误记为 Tushare 式。全链取数一律传前缀式给 `DataLoader`
- 日期同样有两种:`INT YYYYMMDD``strategy_daily_results`、`trade_date`)与 `DATE`/`DATETIME`。
---
## 1. MySQL-A · 本地库 `db_gp_cj`192.168.18.199
原始行情与筹码,主要供旧形态线与宏观情绪使用。
### 1.1 `gp_day_data` — 原始日线(模型 `DayData`
⚠️ 价格字段是 **VARCHAR**,读出后必须 `pd.to_numeric` 转换。
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | BIGINT PK | 自增 |
| `symbol` | VARCHAR(255) idx | 个股代码 |
| `timestamp` | DATETIME idx | 交易时间 |
| `volume` | BIGINT | 成交量 |
| `open` / `high` / `low` / `close` | VARCHAR(255) | 价格(字符串存储) |
| `chg` | VARCHAR | 涨跌额 |
| `percent` | DECIMAL(10,2) | 涨跌幅 % |
| `turnoverrate` | DECIMAL(10,2) | 换手率 |
| `amount` | BIGINT | 成交额 |
| `pb` / `pe` / `ps` | DECIMAL(10,2) | 估值 |
| `pre_close` | DECIMAL(10,2) | 前收 |
费者:`slicer.py`(旧)、`backtester.py`/`tasks_backtest.py`(旧)、`visualizer.py`(旧)、`curve_algo._fetch_future_prices`、**`DataLoader._append_raw_daily_fallback`V7.2 日线兜底因子分表尾部缺行时按重叠日对账后补尾2026-07-07 起)**。
### 1.2 `gp_chip_data` — 筹码分布
| 字段 | 说明 |
|---|---|
| `symbol`, `trade_date` | 主键维度 |
| `winner_rate` | 获利盘比例 |
| `cost_5pct` / `cost_50pct` / `cost_95pct` | 成本分位 |
消费者:`data_loader.fetch_chip_data` → `get_resistance_support_map`(算支撑压力)。
### 1.3 `zs_day_data` — 大盘指数日线
| 字段 | 说明 |
|---|---|
| `symbol` | 指数代码000001.SH / 399001.SZ / 000688.SH / 399006.SZ |
| `timestamp` | 日期 |
| `close` | 收盘 |
| `percent` | 涨跌幅 |
| `amount` | 成交额 |
消费者:`MarketSentimentAnalyzer`(大脑第 3 步「水温」)、`GlobalIndexLoader`Miner预留
> **注意**`intraday_watcher` 读 `strategy_daily_results``PROXY_DB_URL`ShardingSphere 代理 192.168.16.153:3307已前置 16.150 主业务库),与日终主链路写入库(`SOURCE_DB_EXT_DSN`/16.150)一致,**不读本库**。早期文档误记为本库(`DB_MYSQL_URL`/18.199),已订正。
---
## 2. MySQL-B · 外部主业务库 `factordb_mysql`192.168.16.150
系统的运行主库。`tasks_brain.db_engine` 与 `daily_scan_v2.engine` 都指向这里。
### 2.1 `gp_stock_factor_pro_YYYYMM` — 按月分表的前复权因子(核心特征源)
每月一张表(如 `gp_stock_factor_pro_202405`)。`data_loader._get_sharded_table_names` 按日期范围拼 `UNION ALL` 查询。
关键字段(`data_loader.fetch_technical_factors` 选取):
| 字段 | 说明 |
|---|---|
| `symbol`(→ts_code), `trade_date` | 维度 |
| `close_qfq` / `open_qfq` / `high_qfq` / `low_qfq` | 前复权 OHLC |
| `pct_chg`, `vol`, `amount`, `turnover_rate`, `volume_ratio` | 量价 |
| `pe_ttm`, `pb`, `total_mv`, `circ_mv` | 估值/市值 |
| `macd_dif_qfq` / `macd_dea_qfq` / `macd_qfq` | MACD |
| `kdj_k_qfq` / `kdj_d_qfq` / `kdj_qfq` | KDJ |
| `rsi_qfq_12`, `atr_qfq`, `cci_qfq` | RSI/ATR/CCI |
| `boll_upper_qfq` / `boll_lower_qfq` / `boll_mid_qfq` | 布林带 |
| `obv_qfq` | **OBV向量「资金」通道的核心缺它则降级用 vol** |
消费者:`DataLoader.get_daily_data`(几乎所有模块的行情入口)、`FactorCalculator`(在线向量)、`MinerController`(离线向量)。
### 2.2 `strategy_daily_results` — 核心产出表
系统最终结论落库于此。**无随代码提供的建表 DDL需手工建立**(建表语句见 `DEPLOYMENT.md` §4。写入方 `tasks_brain.save_to_database``INSERT ... ON DUPLICATE KEY UPDATE`,唯一键应为 `(stock_code, trade_date)`)。
| 字段 | 类型 | 说明 |
|---|---|---|
| `stock_code` | VARCHAR | 交易所前缀式(`SZ000001` |
| `trade_date` | INT | YYYYMMDD |
| `signal_type` | VARCHAR | `BUY` / `WATCH` / `SELL` / `DROPPED``AVOID` 入库时映射为 `SELL` |
| `confidence_score` | INT | 0100 |
| `support_level` / `pressure_level` | DECIMAL | 支撑/压力位 |
| `analysis_summary` | TEXT | 中文研报Markdown |
| `raw_logic_json` | TEXT/JSON | 完整 decision JSON`forecast` / `forecast_script` |
| `visual_pattern` | VARCHAR | 视觉识别形态 |
| `smart_score` | FLOAT | 资金评分 |
| `updated_at` | DATETIME | 更新时间 |
消费者:`/api/pool`、`/api/stock/{code}`、`/api/v1/reports/export_csv`、`DailyInspector`(对账)、`ReviewerAgent`(取上次结论)、`intraday_watcher`(取昨日底牌)。
### 2.3 `strategy_audit_log` — 审计日志(自动建表)
`daily_scan_v2._init_audit_table` 启动时自动 `CREATE TABLE IF NOT EXISTS`
| 字段 | 说明 |
|---|---|
| `id` | PK 自增 |
| `stock_code` | 代码 |
| `audit_date` | INT 审计日 |
| `strategy_date` | INT 被审策略日 |
| `verdict` | `MAINTAIN` / `ADAPT` / `FAIL` |
| `reason` | TEXT 审计理由(截断 2000 |
| `created_at` | DATETIME |
| INDEX | `idx_code_date(stock_code, audit_date)` |
### 2.4 `gp_stock_category` — 行业归属
| 字段 | 说明 |
|---|---|
| `ts_code`, `trade_date` | 维度(按日期变化,支持历史行业切换) |
| `industry` | 行业名称 |
消费者:`SectorMapper`(全内存加载 + 二分查找历史行业;行业名 CRC32 哈希成 int ID、`data_loader.fetch_sector_info`。
### 2.5 `trading_position` — 持仓快照(下游维护,经 153 代理读取)
由下游交易系统维护的**当前持仓**快照表,完整 DDL 不在本项目。本系统一律经 `PROXY_DB_URL`153 代理)做**单表 SELECT**(代理禁多表联查),且**仅消费 `stock_code` 一列**。
消费者:`periodic.position_intraday_check`(持仓体检轮询,见 PIPELINES ⑥)、`alert_query_service.query_alerts_by_positions``by_positions` 告警聚合)。
> 表内 `stock_code` 已实测确认为**点式**(如 `000636.SZ`2026-07-03 查证)。持仓体检的 TP_BRAIN 锁键继承表内原文 = 点式,与告警侧锁互认成立;告警聚合侧另有双格式兼容兜底。
---
## 3. PostgreSQL · `factordb`192.168.16.150
量化评分、资金流、市场情绪。
### 3.1 `t_signal_daily_results` — 量化评分(选股淘金源)
| 字段 | 说明 |
|---|---|
| `ts_code`, `trade_date`(DATE) | 维度 |
| `total_score` | 综合评分(**小数 0~1代码里 ×100 转百分制** |
消费者:
- `daily_scan_v2.get_quant_score`:取当日评分作为 Smart Score 传给大脑。
- `daily_scan_v2.get_discovery_queue`:淘金池筛选(最新日 `total_score > 0.9` 且较 5 日前上升)。
### 3.2 资金流系列(模型 `MoneyFlow` / `ConceptMoneyFlow` / `IndustryMoneyFlow`
- `gp_moneyflow_ths` — 个股资金流:`trade_date, symbol, ts_code, name, pct_change, latest, net_amount, net_d5_amount, buy_lg/md/sm_amount(+_rate)`。
- `gp_concept_moneyflow_ths` — 概念资金流:`concept_code, concept_name, lead_stock, net_buy/sell/net_amount` 等。
- `gp_industry_moneyflow_ths` — 行业资金流:结构同概念。
> 这些表已建模,但在当前 V6 主链路中未见直接消费(资金维度主要通过 PG 的 `total_score` 与因子表的 `obv_qfq` 体现)。
### 3.3 其它(`data_loader` 读取)
- `gp_market_sentiment``trade_date, up_down_ratio, median_pct_chg, pct_chg_gt_5_count`。
- `gp_sector_daily``trade_date, sector_name, avg_pct_chg, relative_strength, leader_stock`。
### 3.4 `gp_pattern_analysis` — 旧形态线(模型 `PatternAnalysis`
⚠️ 属已废弃管线(见 `ARCHITECTURE.md` §7。字段`symbol, start_date, end_date, pattern_name, is_bullish, confidence, analysis_json(JSONB), theoretical_curve(JSONB), context_snapshot(JSONB), backtest_status, similarity_score, actual_return, exit_date, holding_days`。`backtest_status` 流转:`PENDING_RENDERING → PENDING → FINISHED/INVALID/CANCELLED/WIN/LOSS/TIMEOUT`。
---
## 4. MongoDB · `stock_predictions`192.168.16.222
### `stock_groups` — 核心选股池
每个文档含一个 `stock_codes` 数组字段。`daily_scan_v2.get_mongo_stock_pool` 汇总所有文档的 `stock_codes`,过滤出以 `SH`/`SZ` 开头的代码去重后作为核心池。
---
## 5. Redis-B · 行情/信号实例192.168.18.208
> 注意Celery 总线(`REDIS_URL`16.150 db7不在此列。库号经 `settings.SIGNAL_REDIS_DB_*` 配置。
### 5.1 db 2`SIGNAL_REDIS_DB_INTRADAY`)— 盘中告警与信号
| 键 | 类型 | 方向 | 说明 |
|---|---|---|---|
| `intraday_alerts:{YYYY-MM-DD}` | Stream | 上游写 / watcher 读 | 离散告警consumer group `bionic_audit_group` |
| `mtf:intraday:stream:metrics` | Stream | 上游写 / watcher 读 | 事件式资金异动告警2026-06 协议升级,字段 `direction/z/mode/window_net/cum_large_amount`;旧 `smart_score` 协议过渡兼容) |
| `intraday_signals:{YYYY-MM-DD}` | Stream | `tasks_intraday.broadcast_signal` 写 | ENTRY/EXIT 反转/止盈信号(见 5.4 |
| `bionic:signal_timeline:{date}:{ts_code}` | List | watcher 读写 | 当日该股信号时间线喂给风控仲裁TTL 12h |
| `bionic_lock:{type}:{date}:{ts_code}` | String | watcher / tasks_risk / tasks_periodic | 防抖锁。碰撞类 BREAK_*/BULLISH_BRAIN/COLD_START_RESCAN/TP_BRAIN TTL 2h**TP_BRAIN 由告警止盈评估与持仓体检共用同一把锁**,体检写入值 `holding_check`;无价/异常时体检会释放锁供下轮重试METRICS_RISK 30min**ENTRY_GATE 30min建仓仲裁同股重判节流context 构造失败时释放供下轮重试)****RESCAN_GLOBAL 5min统一重算节流跨碰撞/风控/止盈/冷启动去重)** |
| `bionic:internal_alerts_state:{date}` | Hash | watcher | 收件箱去重 + 处理状态TTL 48h |
| `bionic:risk_rescan:{ymd}:{ts_code}` | Hash | tasks_risk | SELL→重算高水位`hwm_conf/last_ts`TTL 1 天 |
> **资金告警**`capital_accumulation`放量吸筹UP/ `capital_distribution`放量出货DOWN为上游告警流新增源`metadata` 含 `main_net_ratio/divergence/purified_volume`watcher 经 `_enrich_capital_semantics` 拼成带数字的证据描述喂大脑。`Massive_Outflow` / `Massive_Inflow` 则是 watcher 对 metrics 流mode=z的合成告警记录`value=z`、`metadata` 携 direction/z/mode/window_net/cum压入时间线供风控仲裁消费。
> **收件箱状态码**`bionic:internal_alerts_state:{date}` 的 `{uid}:status` 取值):
> - 看多:`BULLISH_DISPATCHED` / `BULLISH_IMMUNE`昨日已BUY/MAINTAIN且未达DANGER止盈门槛/ `BULLISH_COLDSTART` / `BULLISH_LOCKED`
> - 止盈:`TP_DISPATCHED`(持仓+DANGER派发止盈研判/ `TP_LOCKED` / `TP_NO_BASELINE`
> - 碰撞:`COLLIDE_DISPATCHED` / `COLLIDE_IMMUNE` / `COLLIDE_NO_BREAK`(未破位拦截)/ `COLLIDE_NO_PRICE` / `COLLIDE_LOCKED` / `COLLIDE_COLDSTART`
> - 风控DOWN/metrics流出`RISK_TRIGGERED` / `RISK_COOLDOWN`30min锁命中
> - metrics 暖机:`OUTFLOW_COLD_LOGGED` / `INFLOW_COLD_LOGGED`mode=cold 仅留痕)
> - 组合形式DOWN/流出写 `{风控状态}|{碰撞状态}`(如 `RISK_TRIGGERED|COLLIDE_NO_BREAK`);流入写 `INFLOW|{TP_*或COLLIDE_*}`
### 5.2 db 3`SIGNAL_REDIS_DB_ACTIONS`)— 风控卖出指令
| 键 | 类型 | 说明 |
|---|---|---|
| `bionic:signals:llm_sell_actions` | Stream | `tasks_risk` 写 SELL见 5.5`/api/v1/risk/sell_signals` 读 |
### 5.3 db13`SIGNAL_REDIS_DB_QUOTES`)— 实时行情
| 键 | 类型 | 说明 |
|---|---|---|
| `tushare:rt_min:1MIN:{ts_code}` | String | 每股一个 key`ts_code` 为 Tushare 式,拼在键名内)。值为**当日分钟 K 线 JSON 数组**,每根含 `open/close/high/low/vol/amount`,时间正序。取现价 = `json.loads(get(key))[-1]['close']`(用 `get` 取整个 key顶层是 list取最后一根 `[-1]`)。`intraday_watcher` 与 `tasks_risk` 均按此读取。 |
### 5.4 ENTRY/EXIT 信号 Payload`intraday_signals` 流,扁平结构)
`schema_version, signal_id, ts_code, trade_date, trigger_time, producer_id="bionic_brain_intraday_v2.0", action(BUY/SELL), signal_type(ENTRY/EXIT), verdict(REVERSAL_SELL/REVERSAL_BUY/TAKE_PROFIT统一区分止损/入场/止盈离场;下游不识别可忽略), suggested_price, confidence, in_candidate_pool, signal_validity(JSON, 含 expires_at/ttl_seconds=300), audit_reason, component_scores, pred_upside, pred_downside`
> **同股同日下游消费规则B1**:同一 `ts_code` 多条信号按 stream 消息 ID 时序**后写覆盖先写**;大脑审计信号(`producer_id="bionic_brain_intraday_v2.0"` 且 `verdict` 非空)为对上游原始信号的知情二审,语义上覆盖上游 ENTRY/EXIT。解决上游 `intraday_buy_emitted` 自写 ENTRY 与我方 TAKE_PROFIT 同流并存的方向冲突。
### 5.5 SELL 指令 Payload`llm_sell_actions` 流)
包在 `{"data": <json>}` 中:`ts_code, action="SELL", confidence, dominant_signal, llm_reason, is_fallback, timestamp(ms)`。`dominant_signal` 为风控仲裁的主导信号枚举(如 `daily_qrs_symmetric_down/Massive_Outflow/...`**新增 `take_profit`**——由持仓止盈(`tasks_intraday._emit_sell_action`)写入,下游据此区分"风控止损卖出"与"止盈离场"。
### 5.6 上游单股信号查询 API192.168.16.188:28000非 Redis
上游提供的**纯 HTTP 按需计算**接口(单股冷算,无需预热),响应条目格式对齐 db2 告警流。当前唯一消费者:持仓体检 `workers/holding_check.py`(每次体检即时拉取,超时 4s
| 端点GET参数 `ts_code` 点式) | 内容 | 体检 context 中的角色 |
|---|---|---|
| `/api/v1/qrs/minute` | 分钟级 QRS盘中择时核心分量需 ~30 根分钟窗口,约 10:00 起可用) | 第一部分·盘中实时信号现状 |
| `/api/v1/qrs/daily` | 日线 QRS | 同上 |
| `/api/v1/capital/distribution` | 资金分布(`main_net_ratio` / `divergence` / `purified_volume`,早盘提纯量可能不足) | 同上 |
响应包络(本系统消费的字段面,完整 schema 以上游为准):
```json
{"available": true, "reason": "...", "value": ..., "level": "WARNING|DANGER|...",
"metadata": {"direction": "...", "main_net_ratio": ..., "divergence": ..., "purified_volume": ...}}
```
降级语义:`available=false` / 超时 / HTTP 非 200 / 异常 → 取数函数一律返回 `None`context 中该维度渲染为"未获取到(该维度当前无异常或数据未就绪)"**绝不中断体检**。
---
## 6. Milvus`trader_milvus`:19530
### 6.1 `market_memories_v2` — 当前记忆库(活跃)
向量口径:`z-score(close 64) + z-score(obv 64) = 128 维`(训练/推理一致,见 `ARCHITECTURE.md` §5。索引 `L2 / IVF_FLAT`
| 字段 | 类型 | 说明 |
|---|---|---|
| `stock_code` | VARCHAR | Tushare 式代码 |
| `trade_date` | INT64 | YYYYMMDD |
| `industry` | INT64 | 行业 CRC32 ID |
| `vector` | FLOAT_VECTOR(128) | 特征向量 |
| `score_smart` / `score_trend` / `score_chip` / `score_heat` | FLOAT | 影子分数rolling rank 百分位) |
| `label_profit` | FLOAT | 后验:未来 20 日最高价收益率 |
写:`MinerController._insert_to_milvus`(字段顺序须与上表一致)。读:`tasks_brain``anns_field="vector"``output_fields=[stock_code, trade_date, label_profit]`)。
> ⚠️ Miner 假设该集合**已存在**`Collection("market_memories_v2")`),不会自动建表。首次部署需手工创建集合 + 索引DDL/脚本见 `DEPLOYMENT.md` §5。
### 6.2 `market_failures_v1` — 失败记忆
完整 Schema2026-07-09 实测穷举):`id`(主键) / `vector`(FLOAT_VECTOR 128) / `stock_code` / `fail_date`(INT64) / `reason`(VARCHAR) / `original_signal`(VARCHAR)。
读:`tasks_brain` 检索(`output_fields=[stock_code, reason]`),命中则在 Prompt 注入「痛苦记忆」。写:**2026-07-09 起由二期 L2 自动沉淀**`meta_reflection.sink_failures`,每晚 ≤20 条schema 自适应插入 + `decision_outcome.l2_sunk` 防重;早期为人工维护)。⚠️ 本环境 pymilvus 的 `str(dtype)` 返回枚举数字101=FLOAT_VECTOR判型须用 DataType 数字码。
### 6.3 `market_memories_v1` — 旧记忆库(已弃用)
`memory_service.py` + `vectorizer.HolographicVectorizer`60 日线 + 40 周线 + 20 量比 + 8 状态 = 128 维)维护,字段 `memory_id, stock_code, event_date, embedding(128), outcome_label, profit_20d, raw_data(json)`。已被 v2 取代,见 `ARCHITECTURE.md` §7。
---
## 7. 数据流向速查
| 数据 | 来源 | 去向 / 消费者 |
|---|---|---|
| 选股池 | Mongo `stock_groups` + PG `t_signal_daily_results` | `DailyCognitiveLoop` |
| 行情/因子 | MySQL-B `gp_stock_factor_pro_*` | `DataLoader` → 大脑/Miner |
| 特征向量 | `FactorCalculator`/`Miner` | Milvus `market_memories_v2` |
| 策略结论 | 大脑 `save_to_database` | MySQL-B `strategy_daily_results` → API/前端 |
| 盘中告警 | 上游 → Redis-B db2 | `intraday_watcher` → 风控/大脑 |
| 实时行情 | 上游 → Redis-B db13 | `intraday_watcher` |
| SELL 指令 | `tasks_risk` / 持仓止盈·体检 → Redis-B db3 | `/api/v1/risk/sell_signals` |
| ENTRY/EXIT | `tasks_intraday` → Redis-B db2 | 下游交易系统 |
| 持仓快照 | 下游交易系统 → `trading_position`153 代理读) | `position_intraday_check`(体检)、`by_positions` 告警聚合、ENTRY_GATE持仓数、watch 扫描(持仓态) |
| 单股盘中信号 | 上游单股 API188:28000§5.6 | `holding_check` 体检 context |
| 建仓意向单 | 上游 → `trading_order` 状态 '7'153 代理§8.1 | `entry_gate_poll` 仲裁 → 置 '6' / 留 '7' |
| 决策账本 | 盘中裁决出口 → `decision_ledger`§8.2 | ENTRY_GATE 交易史注入、L1 判分 |
| 关注条件 | 大脑布防 → `watch_conditions`§8.4 | `watch_condition_scan` → CONDITION_HIT |
| 判分结果 | `outcome_scorer``decision_outcome`§8.3 | 滚动摘要、L2/L3二期 |
---
## 8. 153 代理侧 · 决策闭环 V7 表
> 一律经 `PROXY_DB_URL` 访问,**严格单表**(代理禁多表联查;判分器用"两次单表查询 + 内存比对"替代 JOIN。建表 DDL 见仓库根 `ddl_decision_loop_v7.sql`2026-07-03 已在 153 侧手工建立)。设计详见 `DECISION_LOOP_DESIGN.md` §5。
### 8.1 `trading_buy_plan` — 买入计划表仲裁状态机所在2026-07-07 更正)+ `trading_order` — 成交执行表
> ⚠️ **表更正2026-07-07 实盘发现)**:置 7 的状态机在 **`trading_buy_plan.is_active`**,不在 `trading_order.order_status`(设计对账时曾误按后者的 DDL 建轮询上游切换上线首日暴露fail-closed 期间无错误买入)。`trading_order` 仍是成交执行表,作 ENTRY_GATE **真实交易史**来源completed/filled 双向单,严格模式/连败判定依据)。
`trading_buy_plan.is_active` 状态词汇(**2026-07-09 上游代码实证版**`3`=盘中评估池(上游 orchestrator/intake **只认 3**)→ 上游评估触发时 `mark_intraday_triggered`**3→7**(幂等守卫 `WHERE is_active=3`,同时经其 publisher 发带 `entry_score/price_band/target_price` 的买入信号)→ `7`=已触发待我方仲裁(**不进上游评估池、不产信号**;但仍在 M16 风控监控池)→ 我方 APPROVE 置 `6`=待挂单(下游取走置 `1``5`=上游盘中拒、`2`=盘前审。REJECT 留 7上游不会再碰。**V7.3(已启用)**:我方 REVERSAL_BUY 变盘裁决可将今日 `5` 翻案回 **`3`(回评估池,绝不直置 7——那是永无信号的死单**`approved_by='bionic_revive'` + `change_reason` 署名,上游重评估触发后经 3→7 进我方 gate 二审。我方写入词汇 = **{6, 3}**,其余状态不写。
本系统消费字段:`id`(仲裁回写锚,账本 ref=`plan_{id}`) / `stock_code`(点式) / `target_price`(**实为计划买入限价**) / `tp_ratio`/`sl_ratio`(换算上望位=买价×(1+tp)、止损位=买价×(1sl),直接供 RR) / `buy_amount`(估算股数) / `strategy_id` / `factor_code`(来源池 TEMP_POOL/event_driven/TRADING_POOL) / `prob_thresh`/`hold_days` / `update_time`(置7时刻FIFO)。仅取 `trading_time >= 当日` 的计划(隔日 7 单由上游过期机制处置)。回写仅一种:`UPDATE trading_buy_plan SET is_active=6, approved_by='bionic_gate' WHERE id=? AND is_active=7`。
### 8.2 `decision_ledger` — 决策账本(共用基座)
每笔盘中裁决的结构化记账。写入方ENTRY_GATE 出口ENTRY_APPROVE/ENTRY_REJECT`price_at` 必填=反事实判分锚)+ `process_intraday_audit` 通用落笔REVERSAL_*/TAKE_PROFIT/MAINTAIN_HOLD/MAINTAIN全 direction 含 CONDITION_HIT/HOLD_CHECK。关键字段`ts_code`(点式) / `kind` / `direction` / `price_at` / `ref_id`(order_id 或 cond_id) / `strategy_id` / `gate_mode`(normal/strict) / `extra_json`(严格模式 checklist) / `outcome_scored`(L1 位图: 1=T+1, 2=T+5, 4=T+20)。消费方ENTRY_GATE 交易史注入、L1 判分。与 `strategy_audit_log` 分工audit_log 记思考文本人查ledger 记结构化动作(机读)。
### 8.3 `decision_outcome` — L1 判分结果
维度键 `(ref_type, ref_id, horizon)` 唯一INSERT IGNORE 幂等)。`ref_type` ∈ `strategy`ref_id=`{stock_code}_{trade_date}`/ `ledger`(账本 id/ `watch`cond_id`horizon` ∈ 1/5/20。指标`ret_pct / excess_pct`(对 000001.SH) `/ dir_hit / mdd_pct / post_high_pct`(卖飞度) `/ support_tested/held / pressure_tested/broken / conf_bucket / visual_pattern / fund_structure / gate_mode`。写入方 `outcome_scorer`(每晚 23:45从最老开始回填水位 800 行/晚)。
### 8.4 `watch_conditions` — 大脑声明的关注条件
状态机 `armed → hit / expired / superseded`。生成端 `tasks_brain._persist_watch_conditions`(校验+布防,新策略落库时该股旧 armed 全部 superseded扫描端 `watch_scanner`(每 5 分钟机检hit 用条件 UPDATE 防双派);`verdict` 由 CONDITION_HIT 裁决回写。字段:`cond_id`(`{ymd}_{prefix}_{seq}`) / `cond_type`(break_above/break_below/volume_surge/pullback_to/time_stop) / `level / volume_gate / vol_base`(生成时算好的近5日均量) / `horizon_days / expires_date`(交易日口径) / `then_action`(UPGRADE/EXIT_WARN/RECHECK) / `note`(声明逻辑,命中时喂回大脑) / `hit_at / hit_price / verdict`

26
Makefile Normal file
View File

@ -0,0 +1,26 @@
.PHONY: up down build logs sync sync-index sync-sentiment shell psql init-db
up: ## 启动
docker compose up -d --build
down: ## 停止
docker compose down
build:
docker compose build
logs:
docker compose logs -f backend
init-db:
docker compose exec backend python -m app.etl init-db
# 同步START 可覆盖make sync START=20200101
START ?= 20150101
sync:
docker compose exec backend python -m app.etl sync-all --start $(START)
sync-index:
docker compose exec backend python -m app.etl sync-index --start $(START)
sync-sentiment:
docker compose exec backend python -m app.etl sync-sentiment --start $(START)
shell:
docker compose exec backend bash
psql:
docker compose exec db psql -U asevent -d asevent

136
README.md Normal file
View File

@ -0,0 +1,136 @@
# A股大事记录 · 大周期择时看板
**指数日成交量、ETF流入、股民情绪、重大事件** 按时间轴叠加到
**上证综指 / 创业板指 / 科创50** 的指数K线上形成一份 A 股「大事记录」,
辅助判断 A 股 **大周期的顶与底**
> 架构设计见 [`ARCHITECTURE.md`](./ARCHITECTURE.md);数据字段口径见 [`DATA_MODEL.md`](./DATA_MODEL.md)。
---
## 一句话架构
`ETL 从现有内网库(zs_day_data / gp_market_sentiment)同步``自有 Postgres``FastAPI + ECharts 看板`
事件与 ETF **人工录入为主**,预留自动化接口;指数 OHLC 用**可插拔 Provider**(你后续提供,暂以收盘线兜底)。
---
## 快速开始Docker
```bash
cp .env.example .env # 按需填内网库 DSN不填也能起只是无数据
docker compose up -d --build # 起 db(自有Postgres) + backend(FastAPI+前端)
# 打开看板
open http://localhost:8000
```
首启会自动建表(`ddl/own_db_init.sql` + ORM 双保险)。此时自有库还没数据,需要同步 ↓
## 同步数据ETL
前提:在 `.env` 里配置了可达的 `LEGACY_MYSQL_DSN`(读 `zs_day_data`)与
`LEGACY_PG_DSN`(读 `gp_market_sentiment`)。**留空则对应同步自动跳过、不报错。**
```bash
# 指数(close/成交额) + 情绪 一起同步
docker compose exec backend python -m app.etl sync-all --start 20150101
# 或分开
docker compose exec backend python -m app.etl sync-index --start 20150101
docker compose exec backend python -m app.etl sync-sentiment --start 20150101
```
也可在看板点「同步数据」按钮,或 `POST /api/sync/index`、`/api/sync/sentiment`。
后续可把上面命令挂到宿主机 cron / 容器内 APScheduler 做每日增量。
---
## 接入指数 OHLC 源(你后续提供)
现有 `zs_day_data` 只有 `close`,画不了完整蜡烛图,看板默认显示**收盘线**。
你拿到 OHLC 源后:
1. 在 `backend/app/sources.py` 实现一个类,满足 `OHLCProvider` 协议:
```python
class MyOHLCProvider:
name = "myprovider"
def fetch(self, index_code, start, end):
# 返回 {date: IndexBar(open/high/low/close[/volume])}
...
```
2. 注册到同文件的 `_OHLC_REGISTRY`
3. `.env``OHLC_PROVIDER=myprovider`,重跑 `sync-index`
看板检测到 OHLC 后自动切换为蜡烛图。
---
## 事件 / ETF 录入
- **人工录入**看板右下角表单直接加事件ETF 用 `POST /api/etf`
- **CSV 批量导入**
```bash
curl -F file=@ddl/event_import_template.csv http://localhost:8000/api/events/import
curl -F file=@ddl/etf_import_template.csv http://localhost:8000/api/etf/import
```
模板与列说明见 `ddl/event_import_template.csv`、`ddl/etf_import_template.csv`。
- **自动化接入(预留)**`backend/app/importers.py` 里 `register_auto_event_source` /
`register_auto_etf_source`,实现后可挂新闻/公告/资金流抓取。
---
## 顶底「市场温度」
看板底部温度副图 0100越高越过热顶部风险越低越冰点底部机会
由 量能分位 / 价格分位 / 情绪 / ETF出货热度 加权得到,**权重与阈值在 `.env` 可调**
`W_VOL/W_PRICE/W_SENTIMENT/W_ETF`、`HOT/COLD_THRESHOLD`),方法论见 `ARCHITECTURE.md §5`
> 初值仅供起步,请用你自己的历史数据回测校准。
---
## 开发 / 部署 · Git 工作流
开发机改代码 → 提交 → 推送;服务器 `git pull``docker compose up -d --build`
数据在自有库卷里,不随代码走。**每个里程碑请及时 commit**(见下)。
```bash
git add .
git commit -m "milestone: xxx"
git push
```
## 目录结构
```
as-event/
├─ docker-compose.yml # db(Postgres) + backend
├─ .env.example # 配置样例
├─ ARCHITECTURE.md # 架构设计
├─ ddl/
│ ├─ own_db_init.sql # 自有库建表
│ ├─ event_import_template.csv
│ └─ etf_import_template.csv
├─ backend/
│ ├─ Dockerfile
│ ├─ requirements.txt
│ └─ app/
│ ├─ config.py # 配置/环境变量
│ ├─ db.py # 自有库 ORM 模型
│ ├─ sources.py # 现有库读适配 + 可插拔 OHLC Provider
│ ├─ etl.py # 同步 CLI
│ ├─ signals.py # 顶底温度启发式
│ ├─ importers.py # 录入/导入 + 自动化接口
│ ├─ schemas.py # API 模型
│ ├─ api.py # REST 路由
│ └─ main.py # FastAPI 入口
└─ frontend/
├─ index.html # ECharts 看板(单文件)
└─ vendor/echarts.min.js # 本地内置(适配内网离线)
```
## API 速览
`GET /api/health` · `GET /api/index/list` · `GET /api/index/{code}/kline` ·
`GET /api/sentiment` · `GET /api/etf` · `GET/POST/PUT/DELETE /api/events` ·
`POST /api/events/import` · `POST /api/etf/import` · `GET /api/annotations` ·
`POST /api/sync/index` · `POST /api/sync/sentiment`
(在线文档 `http://localhost:8000/docs`

BIN
as-event-v0.1.tar.gz Normal file

Binary file not shown.

17
backend/Dockerfile Normal file
View File

@ -0,0 +1,17 @@
FROM python:3.11-slim
WORKDIR /app
ENV PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PYTHONDONTWRITEBYTECODE=1
# 依赖
COPY backend/requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir -r requirements.txt
# 代码 + 前端
COPY backend/app /app/app
COPY frontend /app/frontend
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

0
backend/app/__init__.py Normal file
View File

382
backend/app/api.py Normal file
View File

@ -0,0 +1,382 @@
"""REST API 路由(全部挂在 /api 下)。看板只读自有库。"""
from __future__ import annotations
import logging
from datetime import date, datetime, timedelta
from typing import Dict, List, Optional
from fastapi import APIRouter, Body, Depends, HTTPException, Query, UploadFile
from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session
from .config import INDEX_NAMES, get_settings
from .db import (
CycleAnnotation,
EtfFlow,
IndexDaily,
MarketEvent,
SentimentDaily,
engine,
get_session,
)
from .importers import parse_etf_csv, parse_events_csv, list_auto_sources
from .schemas import (
AnnotationIn,
EtfFlowIn,
EtfFlowOut,
EventIn,
EventOut,
ImportResult,
)
from .signals import compute_signals
log = logging.getLogger("as-event.api")
settings = get_settings()
router = APIRouter(prefix="/api")
# ============================ 工具 ============================
def _parse_qdate(s: Optional[str], default: date) -> date:
if not s:
return default
for fmt in ("%Y%m%d", "%Y-%m-%d"):
try:
return datetime.strptime(s, fmt).date()
except ValueError:
continue
raise HTTPException(400, f"日期格式错误: {s}")
def _default_range() -> tuple[date, date]:
end = date.today()
start = end - timedelta(days=730) # 默认近两年
return start, end
# ============================ 健康 ============================
@router.get("/health")
def health() -> dict:
own_ok = False
try:
with engine.connect() as c:
c.execute(select(func.count()).select_from(IndexDaily))
own_ok = True
except Exception as e: # noqa: BLE001
log.warning("own db 健康检查失败: %s", e)
return {
"status": "ok" if own_ok else "degraded",
"own_db": own_ok,
"legacy_mysql_configured": bool(settings.legacy_mysql_dsn),
"legacy_pg_configured": bool(settings.legacy_pg_dsn),
"ohlc_provider": settings.ohlc_provider,
"auto_sources": list_auto_sources(),
}
# ============================ 指数 / K线 ============================
@router.get("/index/list")
def index_list(session: Session = Depends(get_session)) -> List[dict]:
out = []
for code in settings.index_code_list:
rng = session.execute(
select(func.min(IndexDaily.trade_date), func.max(IndexDaily.trade_date))
.where(IndexDaily.index_code == code)
).one()
out.append(
{
"code": code,
"name": INDEX_NAMES.get(code, code),
"data_start": rng[0].isoformat() if rng[0] else None,
"data_end": rng[1].isoformat() if rng[1] else None,
}
)
return out
@router.get("/index/{code}/kline")
def index_kline(
code: str,
start: Optional[str] = Query(None),
end: Optional[str] = Query(None),
session: Session = Depends(get_session),
) -> dict:
ds, de = _default_range()
ds = _parse_qdate(start, ds)
de = _parse_qdate(end, de)
# 为滚动分位预留前置缓冲,保证请求区间起点温度也有意义
buffer_start = ds - timedelta(days=int(settings.vol_window * 2))
rows = (
session.execute(
select(IndexDaily)
.where(IndexDaily.index_code == code, IndexDaily.trade_date >= buffer_start,
IndexDaily.trade_date <= de)
.order_by(IndexDaily.trade_date.asc())
)
.scalars()
.all()
)
if not rows:
return {"index_code": code, "index_name": INDEX_NAMES.get(code, code),
"ohlc_available": False, "dates": [], "ohlc": [], "amount": [],
"volume": [], "pct_chg": [], "temperature": [], "flags": []}
bars = [{"trade_date": r.trade_date, "close": _f(r.close), "amount": _f(r.amount)} for r in rows]
# 情绪、ETF 用于温度计算
senti = _sentiment_map(session, buffer_start, de)
etf_net = _etf_net_map(session, buffer_start, de, related_index=code)
sig = compute_signals(bars, senti, etf_net)
sig_by_date = {s["trade_date"]: s for s in sig}
# 只输出请求区间
dates, ohlc, amount, volume, pct, temp, flags = [], [], [], [], [], [], []
ohlc_available = False
for r in rows:
if r.trade_date < ds:
continue
dates.append(r.trade_date.isoformat())
o, h, l, c = _f(r.open), _f(r.high), _f(r.low), _f(r.close)
ohlc.append([o, h, l, c])
if r.ohlc_source and r.ohlc_source != "close_only":
ohlc_available = True
amount.append(_f(r.amount))
volume.append(r.volume)
pct.append(_f(r.pct_chg))
s = sig_by_date.get(r.trade_date, {})
temp.append(s.get("temperature"))
flags.append(s.get("flags", []))
return {
"index_code": code,
"index_name": INDEX_NAMES.get(code, code),
"ohlc_available": ohlc_available, # False → 前端用收盘线
"dates": dates,
"ohlc": ohlc,
"amount": amount,
"volume": volume,
"pct_chg": pct,
"temperature": temp,
"flags": flags,
"thresholds": {"hot": settings.hot_threshold, "cold": settings.cold_threshold},
}
# ============================ 情绪 ============================
@router.get("/sentiment")
def sentiment(
start: Optional[str] = Query(None),
end: Optional[str] = Query(None),
session: Session = Depends(get_session),
) -> List[dict]:
ds, de = _default_range()
ds, de = _parse_qdate(start, ds), _parse_qdate(end, de)
rows = (
session.execute(
select(SentimentDaily)
.where(SentimentDaily.trade_date >= ds, SentimentDaily.trade_date <= de)
.order_by(SentimentDaily.trade_date.asc())
).scalars().all()
)
return [
{
"trade_date": r.trade_date.isoformat(),
"up_down_ratio": _f(r.up_down_ratio),
"median_pct_chg": _f(r.median_pct_chg),
"pct_chg_gt_5_count": r.pct_chg_gt_5_count,
"limit_up_count": r.limit_up_count,
"limit_down_count": r.limit_down_count,
"margin_balance": _f(r.margin_balance),
"new_accounts": r.new_accounts,
}
for r in rows
]
# ============================ ETF ============================
@router.get("/etf")
def etf_list(
start: Optional[str] = Query(None),
end: Optional[str] = Query(None),
index: Optional[str] = Query(None),
session: Session = Depends(get_session),
) -> List[EtfFlowOut]:
ds, de = _default_range()
ds, de = _parse_qdate(start, ds), _parse_qdate(end, de)
q = select(EtfFlow).where(EtfFlow.trade_date >= ds, EtfFlow.trade_date <= de)
if index:
q = q.where(EtfFlow.related_index == index)
rows = session.execute(q.order_by(EtfFlow.trade_date.asc())).scalars().all()
return [EtfFlowOut.model_validate(r) for r in rows]
@router.post("/etf", response_model=EtfFlowOut)
def etf_create(payload: EtfFlowIn, session: Session = Depends(get_session)) -> EtfFlowOut:
obj = EtfFlow(**payload.model_dump(), source="manual")
session.add(obj)
session.commit()
session.refresh(obj)
return EtfFlowOut.model_validate(obj)
@router.post("/etf/import", response_model=ImportResult)
async def etf_import(file: UploadFile, session: Session = Depends(get_session)) -> ImportResult:
content = (await file.read()).decode("utf-8-sig")
try:
recs = parse_etf_csv(content)
except ValueError as e:
raise HTTPException(400, str(e))
ins = 0
for rec in recs:
session.add(EtfFlow(**rec))
ins += 1
session.commit()
return ImportResult(inserted=ins, updated=0, total=len(recs))
# ============================ 事件 CRUD ============================
@router.get("/events")
def events_list(
start: Optional[str] = Query(None),
end: Optional[str] = Query(None),
index: Optional[str] = Query(None),
category: Optional[str] = Query(None),
session: Session = Depends(get_session),
) -> List[EventOut]:
ds, de = _default_range()
ds, de = _parse_qdate(start, ds), _parse_qdate(end, de)
q = select(MarketEvent).where(MarketEvent.event_date >= ds, MarketEvent.event_date <= de)
if category:
q = q.where(MarketEvent.category == category)
rows = session.execute(q.order_by(MarketEvent.event_date.asc())).scalars().all()
# related_indices 过滤CSV 包含匹配,空视为全市场)
if index:
rows = [r for r in rows if (not r.related_indices) or (index in r.related_indices)]
return [EventOut.model_validate(r) for r in rows]
@router.post("/events", response_model=EventOut)
def event_create(payload: EventIn, session: Session = Depends(get_session)) -> EventOut:
obj = MarketEvent(**payload.model_dump(), source="manual")
session.add(obj)
session.commit()
session.refresh(obj)
return EventOut.model_validate(obj)
@router.put("/events/{event_id}", response_model=EventOut)
def event_update(event_id: int, payload: EventIn, session: Session = Depends(get_session)) -> EventOut:
obj = session.get(MarketEvent, event_id)
if not obj:
raise HTTPException(404, "事件不存在")
for k, v in payload.model_dump().items():
setattr(obj, k, v)
session.commit()
session.refresh(obj)
return EventOut.model_validate(obj)
@router.delete("/events/{event_id}")
def event_delete(event_id: int, session: Session = Depends(get_session)) -> dict:
obj = session.get(MarketEvent, event_id)
if not obj:
raise HTTPException(404, "事件不存在")
session.delete(obj)
session.commit()
return {"deleted": event_id}
@router.post("/events/import", response_model=ImportResult)
async def events_import(file: UploadFile, session: Session = Depends(get_session)) -> ImportResult:
content = (await file.read()).decode("utf-8-sig")
try:
evs = parse_events_csv(content)
except ValueError as e:
raise HTTPException(400, str(e))
for ev in evs:
session.add(MarketEvent(**ev))
session.commit()
return ImportResult(inserted=len(evs), updated=0, total=len(evs))
# ============================ 人工顶底标注 ============================
@router.get("/annotations")
def anno_list(index: Optional[str] = Query(None), session: Session = Depends(get_session)) -> List[dict]:
q = select(CycleAnnotation)
if index:
q = q.where(CycleAnnotation.index_code == index)
rows = session.execute(q.order_by(CycleAnnotation.anno_date.asc())).scalars().all()
return [
{"id": r.id, "index_code": r.index_code, "anno_date": r.anno_date.isoformat(),
"kind": r.kind, "note": r.note}
for r in rows
]
@router.post("/annotations")
def anno_create(payload: AnnotationIn, session: Session = Depends(get_session)) -> dict:
obj = CycleAnnotation(**payload.model_dump())
session.add(obj)
session.commit()
session.refresh(obj)
return {"id": obj.id}
@router.delete("/annotations/{anno_id}")
def anno_delete(anno_id: int, session: Session = Depends(get_session)) -> dict:
session.execute(delete(CycleAnnotation).where(CycleAnnotation.id == anno_id))
session.commit()
return {"deleted": anno_id}
# ============================ 触发 ETL ============================
@router.post("/sync/index")
def sync_index_ep(
start: str = Body(..., embed=True),
end: Optional[str] = Body(None, embed=True),
codes: Optional[str] = Body(None, embed=True),
) -> dict:
from .etl import sync_index, _parse_date # 延迟导入避免循环
de = _parse_date(end) if end else date.today()
code_list = codes.split(",") if codes else None
res = sync_index(_parse_date(start), de, code_list)
return {"synced": res}
@router.post("/sync/sentiment")
def sync_sentiment_ep(
start: str = Body(..., embed=True),
end: Optional[str] = Body(None, embed=True),
) -> dict:
from .etl import sync_sentiment, _parse_date
de = _parse_date(end) if end else date.today()
n = sync_sentiment(_parse_date(start), de)
return {"synced": n}
# ============================ helpers ============================
def _f(v) -> Optional[float]:
return float(v) if v is not None else None
def _sentiment_map(session: Session, ds: date, de: date) -> Dict[date, dict]:
rows = session.execute(
select(SentimentDaily).where(SentimentDaily.trade_date >= ds, SentimentDaily.trade_date <= de)
).scalars().all()
return {
r.trade_date: {
"up_down_ratio": _f(r.up_down_ratio),
"pct_chg_gt_5_count": r.pct_chg_gt_5_count,
}
for r in rows
}
def _etf_net_map(session: Session, ds: date, de: date, related_index: Optional[str] = None) -> Dict[date, float]:
q = select(EtfFlow.trade_date, func.sum(EtfFlow.net_inflow)).where(
EtfFlow.trade_date >= ds, EtfFlow.trade_date <= de
)
# 温度用全市场 ETF 净流入;如需按指数可加过滤,这里保留全市场以反映整体资金
q = q.group_by(EtfFlow.trade_date)
rows = session.execute(q).all()
return {d: float(s) for d, s in rows if s is not None}

71
backend/app/config.py Normal file
View File

@ -0,0 +1,71 @@
"""全局配置:从环境变量 / .env 读取。
关键点
- OWN_DB_DSN 自有 Postgres看板只读它ETL 写它 必填
- LEGACY_MYSQL_DSN 现有 MySQL-A(18.199) zs_day_data 选填留空则跳过指数同步
- LEGACY_PG_DSN 现有 PG(16.150) gp_market_sentiment 选填留空则跳过情绪同步
- OHLC_PROVIDER 指数 OHLC 数据源你后续提供默认 "none" 退化为收盘线
"""
from __future__ import annotations
from functools import lru_cache
from typing import List, Optional
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env", env_file_encoding="utf-8", extra="ignore"
)
# ---- 数据库 ----
own_db_dsn: str = Field(
default="postgresql+psycopg://asevent:asevent@db:5432/asevent",
alias="OWN_DB_DSN",
)
legacy_mysql_dsn: Optional[str] = Field(default=None, alias="LEGACY_MYSQL_DSN")
legacy_pg_dsn: Optional[str] = Field(default=None, alias="LEGACY_PG_DSN")
# ---- 指数范围dot 式,与 zs_day_data.symbol 一致)----
index_codes: str = Field(default="000001.SH,399006.SZ,000688.SH", alias="INDEX_CODES")
# ---- OHLC 源(预留插槽)----
# "none" = 无源open=high=low=closeohlc_source='close_only'(退化为收盘线)
# 你接入后在 sources.py:build_ohlc_provider 里注册自己的实现,并把此值改成对应名字
ohlc_provider: str = Field(default="none", alias="OHLC_PROVIDER")
# ---- 顶底信号参数(初值,实机回测后再调)----
vol_window: int = Field(default=250, alias="VOL_WINDOW") # 量能/价格分位滚动窗口
w_vol: float = Field(default=0.35, alias="W_VOL")
w_price: float = Field(default=0.25, alias="W_PRICE")
w_sentiment: float = Field(default=0.25, alias="W_SENTIMENT")
w_etf: float = Field(default=0.15, alias="W_ETF")
hot_threshold: float = Field(default=80.0, alias="HOT_THRESHOLD") # 过热/顶部
cold_threshold: float = Field(default=20.0, alias="COLD_THRESHOLD") # 冰点/底部
# ---- 其它 ----
cors_origins: str = Field(default="*", alias="CORS_ORIGINS")
@property
def index_code_list(self) -> List[str]:
return [c.strip() for c in self.index_codes.split(",") if c.strip()]
@property
def cors_origin_list(self) -> List[str]:
return [c.strip() for c in self.cors_origins.split(",") if c.strip()]
@lru_cache
def get_settings() -> Settings:
return Settings()
# 指数中文名映射(展示用)
INDEX_NAMES = {
"000001.SH": "上证综指",
"399006.SZ": "创业板指",
"000688.SH": "科创50",
"399001.SZ": "深证成指",
}

137
backend/app/db.py Normal file
View File

@ -0,0 +1,137 @@
"""自有库PostgresORM 模型与会话。
看板只读自有库ETL 把内网库数据同步进这里
建表正式部署用 ddl/own_db_init.sql本模块也提供 init_db() 供开发期 create_all
"""
from __future__ import annotations
from datetime import date, datetime
from typing import Iterator
from sqlalchemy import (
BigInteger,
Date,
DateTime,
Integer,
Numeric,
String,
Text,
UniqueConstraint,
create_engine,
func,
)
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column, sessionmaker
from .config import get_settings
settings = get_settings()
engine = create_engine(settings.own_db_dsn, pool_pre_ping=True, future=True)
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False, future=True)
class Base(DeclarativeBase):
pass
class IndexDaily(Base):
"""指数日线同步落地。OHLC 缺源时 open=high=low=closeohlc_source='close_only'"""
__tablename__ = "index_daily"
__table_args__ = (UniqueConstraint("index_code", "trade_date", name="uq_index_daily"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
index_code: Mapped[str] = mapped_column(String(16), index=True)
trade_date: Mapped[date] = mapped_column(Date, index=True)
open: Mapped[float | None] = mapped_column(Numeric(16, 4))
high: Mapped[float | None] = mapped_column(Numeric(16, 4))
low: Mapped[float | None] = mapped_column(Numeric(16, 4))
close: Mapped[float | None] = mapped_column(Numeric(16, 4))
volume: Mapped[int | None] = mapped_column(BigInteger) # 成交量(手),如源有
amount: Mapped[float | None] = mapped_column(Numeric(24, 4)) # 成交额(元)
pct_chg: Mapped[float | None] = mapped_column(Numeric(10, 4)) # 涨跌幅 %
turnover_rate: Mapped[float | None] = mapped_column(Numeric(10, 4))
ohlc_source: Mapped[str] = mapped_column(String(24), default="close_only")
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
class SentimentDaily(Base):
"""情绪日度(源自 gp_market_sentiment可扩展两融/开户等)。"""
__tablename__ = "sentiment_daily"
trade_date: Mapped[date] = mapped_column(Date, primary_key=True)
up_down_ratio: Mapped[float | None] = mapped_column(Numeric(10, 4)) # 涨跌家数比
median_pct_chg: Mapped[float | None] = mapped_column(Numeric(10, 4)) # 全市场涨跌幅中位数
pct_chg_gt_5_count: Mapped[int | None] = mapped_column(Integer) # 涨幅>5%家数(涨停潮代理)
limit_up_count: Mapped[int | None] = mapped_column(Integer)
limit_down_count: Mapped[int | None] = mapped_column(Integer)
margin_balance: Mapped[float | None] = mapped_column(Numeric(24, 4)) # 两融余额(元)
new_accounts: Mapped[int | None] = mapped_column(Integer) # 新增开户数
sentiment_score: Mapped[float | None] = mapped_column(Numeric(10, 4)) # 归一情绪分 0-100
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
class EtfFlow(Base):
"""ETF 流入(人工/导入/自动)。可按单只 ETF 或按类别聚合存。"""
__tablename__ = "etf_flow"
__table_args__ = (
UniqueConstraint("trade_date", "etf_code", name="uq_etf_flow"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
trade_date: Mapped[date] = mapped_column(Date, index=True)
etf_code: Mapped[str] = mapped_column(String(24), default="") # 空串=类别聚合行
etf_name: Mapped[str | None] = mapped_column(String(64))
category: Mapped[str | None] = mapped_column(String(32)) # broad/chinext/star/...
related_index: Mapped[str | None] = mapped_column(String(16)) # 关联指数 dot 式
net_inflow: Mapped[float | None] = mapped_column(Numeric(20, 4)) # 净流入(亿元)
shares_change: Mapped[float | None] = mapped_column(Numeric(20, 4)) # 份额变化(亿份)
source: Mapped[str] = mapped_column(String(16), default="manual") # manual/import/auto
note: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
class MarketEvent(Base):
"""重大事件证监会抓人、巨无霸IPO、政策等"""
__tablename__ = "market_event"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
event_date: Mapped[date] = mapped_column(Date, index=True)
title: Mapped[str] = mapped_column(String(255))
category: Mapped[str] = mapped_column(String(32), default="其他") # 监管/IPO/政策/资金/外部/其他
impact_direction: Mapped[str] = mapped_column(String(16), default="neutral") # bullish/bearish/neutral
severity: Mapped[int] = mapped_column(Integer, default=3) # 1-5
related_indices: Mapped[str | None] = mapped_column(String(128)) # CSV空=全市场
cycle_tag: Mapped[str] = mapped_column(String(16), default="none") # top/bottom/none 经典顶底标记
description: Mapped[str | None] = mapped_column(Text)
source_url: Mapped[str | None] = mapped_column(String(512))
source: Mapped[str] = mapped_column(String(16), default="manual") # manual/import/auto
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
class CycleAnnotation(Base):
"""人工顶底标注(复盘用,看板上手动打点)。"""
__tablename__ = "cycle_annotation"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
index_code: Mapped[str] = mapped_column(String(16), index=True)
anno_date: Mapped[date] = mapped_column(Date, index=True)
kind: Mapped[str] = mapped_column(String(16), default="watch") # top/bottom/watch
note: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
def init_db() -> None:
"""开发期便捷建表;正式部署建议用 ddl/own_db_init.sql。"""
Base.metadata.create_all(bind=engine)
def get_session() -> Iterator[Session]:
"""FastAPI 依赖注入用。"""
with SessionLocal() as s:
yield s

169
backend/app/etl.py Normal file
View File

@ -0,0 +1,169 @@
"""ETL把现有内网库数据同步进自有库。
python -m app.etl init-db
python -m app.etl sync-index --start 20150101 [--end 20260726] [--codes 000001.SH,399006.SZ]
python -m app.etl sync-sentiment --start 20150101
python -m app.etl sync-all --start 20150101
设计看板只读自有库此处是唯一读现有库写自有库的地方
内网库不可达时降级为空同步不报错
"""
from __future__ import annotations
import argparse
import logging
from datetime import date, datetime
from typing import Dict, List, Optional
from sqlalchemy.dialects.postgresql import insert as pg_insert
from .config import get_settings
from .db import IndexDaily, SentimentDaily, engine, init_db
from .sources import (
IndexBar,
LegacyIndexSource,
LegacySentimentSource,
build_ohlc_provider,
)
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
log = logging.getLogger("as-event.etl")
settings = get_settings()
def _parse_date(s: str) -> date:
s = s.strip()
for fmt in ("%Y%m%d", "%Y-%m-%d"):
try:
return datetime.strptime(s, fmt).date()
except ValueError:
continue
raise ValueError(f"无法解析日期: {s}(用 YYYYMMDD 或 YYYY-MM-DD")
# ============================ 指数同步 ============================
def sync_index(start: date, end: date, codes: Optional[List[str]] = None) -> Dict[str, int]:
codes = codes or settings.index_code_list
legacy = LegacyIndexSource()
ohlc = build_ohlc_provider()
result: Dict[str, int] = {}
for code in codes:
bars = legacy.fetch(code, start, end) # close/amount/pct
ohlc_map = ohlc.fetch(code, start, end) # 可能为空
by_date: Dict[date, IndexBar] = {b.trade_date: b for b in bars}
# 若 OHLC 源提供了 close 而现有库没有该日,也纳入
for d, ob in ohlc_map.items():
by_date.setdefault(d, IndexBar(trade_date=d, close=ob.close))
rows = []
for d, b in sorted(by_date.items()):
ob = ohlc_map.get(d)
close = b.close if b.close is not None else (ob.close if ob else None)
if ob and ob.open is not None:
o, h, l = ob.open, ob.high, ob.low
vol = ob.volume
src = ohlc.name
else:
o = h = l = close # 收盘兜底
vol = b.volume
src = "close_only"
rows.append(
dict(
index_code=code, trade_date=d, open=o, high=h, low=l, close=close,
volume=vol, amount=b.amount, pct_chg=b.pct_chg,
turnover_rate=None, ohlc_source=src,
)
)
_upsert_index(rows)
result[code] = len(rows)
log.info("指数同步 %s: %d 行 (ohlc=%s)", code, len(rows), ohlc.name)
return result
def _upsert_index(rows: List[dict]) -> None:
if not rows:
return
stmt = pg_insert(IndexDaily).values(rows)
stmt = stmt.on_conflict_do_update(
constraint="uq_index_daily",
set_={
"open": stmt.excluded.open, "high": stmt.excluded.high,
"low": stmt.excluded.low, "close": stmt.excluded.close,
"volume": stmt.excluded.volume, "amount": stmt.excluded.amount,
"pct_chg": stmt.excluded.pct_chg, "turnover_rate": stmt.excluded.turnover_rate,
"ohlc_source": stmt.excluded.ohlc_source,
},
)
with engine.begin() as conn:
conn.execute(stmt)
# ============================ 情绪同步 ============================
def sync_sentiment(start: date, end: date) -> int:
legacy = LegacySentimentSource()
rows = legacy.fetch(start, end)
payload = [
dict(
trade_date=r.trade_date,
up_down_ratio=r.up_down_ratio,
median_pct_chg=r.median_pct_chg,
pct_chg_gt_5_count=r.pct_chg_gt_5_count,
)
for r in rows
]
_upsert_sentiment(payload)
log.info("情绪同步: %d", len(payload))
return len(payload)
def _upsert_sentiment(rows: List[dict]) -> None:
if not rows:
return
stmt = pg_insert(SentimentDaily).values(rows)
stmt = stmt.on_conflict_do_update(
index_elements=["trade_date"],
set_={
"up_down_ratio": stmt.excluded.up_down_ratio,
"median_pct_chg": stmt.excluded.median_pct_chg,
"pct_chg_gt_5_count": stmt.excluded.pct_chg_gt_5_count,
},
)
with engine.begin() as conn:
conn.execute(stmt)
# ============================ CLI ============================
def main() -> None:
p = argparse.ArgumentParser(description="as-event ETL")
sub = p.add_subparsers(dest="cmd", required=True)
sub.add_parser("init-db", help="建表(等价 ddl/own_db_init.sql")
for name in ("sync-index", "sync-sentiment", "sync-all"):
sp = sub.add_parser(name)
sp.add_argument("--start", default="20150101")
sp.add_argument("--end", default=date.today().strftime("%Y%m%d"))
if name in ("sync-index", "sync-all"):
sp.add_argument("--codes", default=None, help="逗号分隔,缺省用配置")
args = p.parse_args()
if args.cmd == "init-db":
init_db()
log.info("建表完成")
return
start, end = _parse_date(args.start), _parse_date(args.end)
codes = args.codes.split(",") if getattr(args, "codes", None) else None
if args.cmd == "sync-index":
sync_index(start, end, codes)
elif args.cmd == "sync-sentiment":
sync_sentiment(start, end)
elif args.cmd == "sync-all":
sync_index(start, end, codes)
sync_sentiment(start, end)
if __name__ == "__main__":
main()

128
backend/app/importers.py Normal file
View File

@ -0,0 +1,128 @@
"""录入与导入:人工 CSV 导入 + 自动化接入接口(预留)。
- parse_events_csv / parse_etf_csv解析导入模板 ddl/*_template.csv
- AutoEventSource / AutoEtfSource自动化抓取接口未来挂新闻/公告/资金流
现在留空注册表实现后在 _AUTO_* 注册即可被定时任务调用
"""
from __future__ import annotations
import csv
import io
import logging
from datetime import date, datetime
from typing import Dict, List, Protocol
log = logging.getLogger("as-event.importers")
_VALID_DIRECTION = {"bullish", "bearish", "neutral"}
_VALID_CYCLE = {"top", "bottom", "none"}
def _parse_date(s: str) -> date:
s = (s or "").strip()
for fmt in ("%Y%m%d", "%Y-%m-%d", "%Y/%m/%d"):
try:
return datetime.strptime(s, fmt).date()
except ValueError:
continue
raise ValueError(f"日期格式错误: {s!r}")
def _clean(s) -> str:
return (s or "").strip()
def parse_events_csv(content: str) -> List[dict]:
"""event_date,title,category,impact_direction,severity,related_indices,cycle_tag,description,source_url"""
reader = csv.DictReader(io.StringIO(content))
out: List[dict] = []
for i, row in enumerate(reader, start=2): # 含表头数据从第2行
title = _clean(row.get("title"))
if not title:
continue
try:
ev = dict(
event_date=_parse_date(row.get("event_date", "")),
title=title,
category=_clean(row.get("category")) or "其他",
impact_direction=(_clean(row.get("impact_direction")).lower() or "neutral"),
severity=int(_clean(row.get("severity")) or 3),
related_indices=_clean(row.get("related_indices")) or None,
cycle_tag=(_clean(row.get("cycle_tag")).lower() or "none"),
description=_clean(row.get("description")) or None,
source_url=_clean(row.get("source_url")) or None,
source="import",
)
except ValueError as e:
raise ValueError(f"{i} 行解析失败: {e}") from e
if ev["impact_direction"] not in _VALID_DIRECTION:
ev["impact_direction"] = "neutral"
if ev["cycle_tag"] not in _VALID_CYCLE:
ev["cycle_tag"] = "none"
ev["severity"] = min(5, max(1, ev["severity"]))
out.append(ev)
return out
def parse_etf_csv(content: str) -> List[dict]:
"""trade_date,etf_code,etf_name,category,related_index,net_inflow,shares_change,note"""
reader = csv.DictReader(io.StringIO(content))
out: List[dict] = []
for i, row in enumerate(reader, start=2):
d = _clean(row.get("trade_date"))
if not d:
continue
try:
rec = dict(
trade_date=_parse_date(d),
etf_code=_clean(row.get("etf_code")),
etf_name=_clean(row.get("etf_name")) or None,
category=_clean(row.get("category")) or None,
related_index=_clean(row.get("related_index")) or None,
net_inflow=float(_clean(row.get("net_inflow")) or 0) if _clean(row.get("net_inflow")) else None,
shares_change=float(_clean(row.get("shares_change"))) if _clean(row.get("shares_change")) else None,
note=_clean(row.get("note")) or None,
source="import",
)
except ValueError as e:
raise ValueError(f"{i} 行解析失败: {e}") from e
out.append(rec)
return out
# ============================ 自动化接入接口(预留)============================
class AutoEventSource(Protocol):
"""未来自动抓取事件源的接口。实现后注册到 _AUTO_EVENT_SOURCES。"""
name: str
def pull(self, start: date, end: date) -> List[dict]: # 返回同 parse_events_csv 的 dict 列表
...
class AutoEtfSource(Protocol):
name: str
def pull(self, start: date, end: date) -> List[dict]:
...
_AUTO_EVENT_SOURCES: Dict[str, AutoEventSource] = {}
_AUTO_ETF_SOURCES: Dict[str, AutoEtfSource] = {}
def register_auto_event_source(src: AutoEventSource) -> None:
_AUTO_EVENT_SOURCES[src.name] = src
log.info("注册自动事件源: %s", src.name)
def register_auto_etf_source(src: AutoEtfSource) -> None:
_AUTO_ETF_SOURCES[src.name] = src
log.info("注册自动ETF源: %s", src.name)
def list_auto_sources() -> Dict[str, List[str]]:
return {
"event": list(_AUTO_EVENT_SOURCES.keys()),
"etf": list(_AUTO_ETF_SOURCES.keys()),
}

50
backend/app/main.py Normal file
View File

@ -0,0 +1,50 @@
"""FastAPI 入口:挂 API + 静态前端。"""
from __future__ import annotations
import logging
import os
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from .api import router
from .config import get_settings
from .db import init_db
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
log = logging.getLogger("as-event")
settings = get_settings()
@asynccontextmanager
async def lifespan(app: FastAPI):
# 首次启动自动建表create_all 只补缺表,安全);生产可用 ddl/own_db_init.sql
if os.getenv("AUTO_CREATE_TABLES", "1") == "1":
try:
init_db()
log.info("自有库建表检查完成")
except Exception as e: # noqa: BLE001
log.warning("建表失败DB 未就绪?):%s", e)
yield
app = FastAPI(title="A股大事记录 / 择时看板", version="0.1.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origin_list,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(router)
# 静态前端(放在 API 之后,作为兜底挂在根路径)
_frontend = Path(__file__).resolve().parent.parent / "frontend"
if _frontend.is_dir():
app.mount("/", StaticFiles(directory=str(_frontend), html=True), name="frontend")
else:
log.warning("未找到前端目录: %s", _frontend)

65
backend/app/schemas.py Normal file
View File

@ -0,0 +1,65 @@
"""API 请求/响应模型pydantic v2"""
from __future__ import annotations
from datetime import date, datetime
from typing import List, Optional
from pydantic import BaseModel, Field
# ---------------- 事件 ----------------
class EventIn(BaseModel):
event_date: date
title: str = Field(min_length=1, max_length=255)
category: str = "其他"
impact_direction: str = "neutral" # bullish/bearish/neutral
severity: int = Field(default=3, ge=1, le=5)
related_indices: Optional[str] = None # CSV如 "000001.SH,399006.SZ"
cycle_tag: str = "none" # top/bottom/none
description: Optional[str] = None
source_url: Optional[str] = None
class EventOut(EventIn):
id: int
source: str = "manual"
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
model_config = {"from_attributes": True}
# ---------------- ETF ----------------
class EtfFlowIn(BaseModel):
trade_date: date
etf_code: str = ""
etf_name: Optional[str] = None
category: Optional[str] = None
related_index: Optional[str] = None
net_inflow: Optional[float] = None # 亿元
shares_change: Optional[float] = None
note: Optional[str] = None
class EtfFlowOut(EtfFlowIn):
id: int
source: str = "manual"
created_at: Optional[datetime] = None
model_config = {"from_attributes": True}
# ---------------- 顶底人工标注 ----------------
class AnnotationIn(BaseModel):
index_code: str
anno_date: date
kind: str = "watch" # top/bottom/watch
note: Optional[str] = None
# ---------------- 导入结果 ----------------
class ImportResult(BaseModel):
inserted: int
updated: int
total: int
errors: List[str] = []

130
backend/app/signals.py Normal file
View File

@ -0,0 +1,130 @@
"""顶底「市场温度」透明启发式(可调,非黑盒)。
温度 0100越高越过热/顶部风险越低越冰点/底部机会
四个分量缺哪个自动剔除并重新归一权重
vol_pct 量能分位天量高温()地量低温()
price_pct 价格分位高位高温低位低温
sentiment 情绪普涨/涨停潮亢奋高温普跌/跌停潮恐慌低温
etf_heat ETF出货热度= (-净流入) 分位大额净流出高温()大额净流入(国家队)低温()
权重与阈值来自 configW_VOL/W_PRICE/W_SENTIMENT/W_ETFHOT/COLD_THRESHOLD
初值仅供起步请用你自己的历史数据回测校准
"""
from __future__ import annotations
from datetime import date
from typing import Dict, List, Optional
from .config import get_settings
settings = get_settings()
def _rolling_pct(values: List[Optional[float]], window: int) -> List[Optional[float]]:
"""滚动百分位排名(0-100):当前值在最近 window 个有效值中的分位。"""
out: List[Optional[float]] = []
for i in range(len(values)):
cur = values[i]
if cur is None:
out.append(None)
continue
lo = max(0, i - window + 1)
base = [v for v in values[lo : i + 1] if v is not None]
if len(base) < max(5, window // 10): # 样本太少不给分位,避免早期噪声
out.append(None)
continue
le = sum(1 for v in base if v <= cur)
out.append(round(le / len(base) * 100, 2))
return out
def _norm_sentiment(sr) -> Optional[float]:
"""把一条情绪原始值折算到 0-100 的「亢奋度」粗分(缺字段则 None交给分位再平滑
这里只做方向性折算涨跌家数比越高涨幅>5%家数越多 越亢奋
真正的相对高低由外层 _rolling_pct 处理
"""
if sr is None:
return None
parts = []
if sr.get("up_down_ratio") is not None:
parts.append(sr["up_down_ratio"])
if sr.get("pct_chg_gt_5_count") is not None:
parts.append(sr["pct_chg_gt_5_count"])
if not parts:
return None
return float(sum(parts)) # 量纲无所谓,后续走分位
def compute_signals(
bars: List[dict],
sentiment_by_date: Dict[date, dict],
etf_net_by_date: Dict[date, float],
window: Optional[int] = None,
) -> List[dict]:
"""bars: [{trade_date, close, amount}, ...] 升序。返回逐日温度与标记。"""
window = window or settings.vol_window
dates = [b["trade_date"] for b in bars]
closes = [b.get("close") for b in bars]
amounts = [b.get("amount") for b in bars]
# 情绪与 ETF 原始序列(对齐 bars 日期)
senti_raw = [_norm_sentiment(sentiment_by_date.get(d)) for d in dates]
etf_out_raw = [
(-etf_net_by_date[d]) if d in etf_net_by_date and etf_net_by_date[d] is not None else None
for d in dates
]
vol_pct = _rolling_pct(amounts, window)
price_pct = _rolling_pct(closes, window)
senti_pct = _rolling_pct(senti_raw, window)
etf_pct = _rolling_pct(etf_out_raw, window)
W = {
"vol": settings.w_vol,
"price": settings.w_price,
"sentiment": settings.w_sentiment,
"etf": settings.w_etf,
}
out: List[dict] = []
for i, d in enumerate(dates):
comps = {
"vol": vol_pct[i],
"price": price_pct[i],
"sentiment": senti_pct[i],
"etf": etf_pct[i],
}
avail = {k: v for k, v in comps.items() if v is not None}
if avail:
wsum = sum(W[k] for k in avail)
temp = round(sum(comps[k] * W[k] for k in avail) / wsum, 2) if wsum else None
else:
temp = None
flags = []
if vol_pct[i] is not None and price_pct[i] is not None:
if vol_pct[i] >= 95 and price_pct[i] >= 80:
flags.append("TOP_VOLUME_PRICE") # 天量见天价
if vol_pct[i] is not None and vol_pct[i] <= 5:
flags.append("BOTTOM_VOLUME_DRY") # 地量见地价
if senti_pct[i] is not None:
if senti_pct[i] >= 95:
flags.append("SENTIMENT_EUPHORIA")
elif senti_pct[i] <= 5:
flags.append("SENTIMENT_PANIC")
if temp is not None:
if temp >= settings.hot_threshold:
flags.append("OVERHEAT")
elif temp <= settings.cold_threshold:
flags.append("FREEZE")
out.append(
{
"trade_date": d,
"temperature": temp,
"components": comps,
"flags": flags,
}
)
return out

202
backend/app/sources.py Normal file
View File

@ -0,0 +1,202 @@
"""外部数据源适配(直连现有内网库,只读)+ 可插拔 OHLC Provider。
- LegacyIndexSource zs_day_data(MySQL-A 18.199) 的指数 close/percent/amount
- LegacySentimentSource gp_market_sentiment(PG 16.150) 的情绪
- OHLCProvider 指数开高低收源你后续提供默认 NullOHLCProvider = close_only 退化
所有源在 DSN 未配置或连接失败时**降级返回空**绝不让 ETL 崩掉
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import date
from typing import Dict, List, Optional, Protocol
from sqlalchemy import create_engine, text
from sqlalchemy.engine import Engine
from .config import get_settings
log = logging.getLogger("as-event.sources")
settings = get_settings()
# ---- 惰性引擎(只在配置了 DSN 时创建)----
_engines: Dict[str, Optional[Engine]] = {}
def _engine_for(dsn: Optional[str]) -> Optional[Engine]:
if not dsn:
return None
if dsn not in _engines:
try:
_engines[dsn] = create_engine(dsn, pool_pre_ping=True, future=True)
except Exception as e: # noqa: BLE001
log.warning("创建引擎失败 dsn=%s err=%s", _mask(dsn), e)
_engines[dsn] = None
return _engines[dsn]
def _mask(dsn: str) -> str:
"""隐藏 DSN 中的密码用于日志。"""
try:
head, tail = dsn.split("@", 1)
scheme_user = head.split("//", 1)
return f"{scheme_user[0]}//***@{tail}"
except Exception: # noqa: BLE001
return "<dsn>"
def _to_float(v) -> Optional[float]:
if v is None:
return None
try:
return float(v)
except (TypeError, ValueError):
return None
# ============================ 数据行 ============================
@dataclass
class IndexBar:
trade_date: date
close: Optional[float]
amount: Optional[float] = None
pct_chg: Optional[float] = None
open: Optional[float] = None
high: Optional[float] = None
low: Optional[float] = None
volume: Optional[int] = None
@dataclass
class SentimentRow:
trade_date: date
up_down_ratio: Optional[float] = None
median_pct_chg: Optional[float] = None
pct_chg_gt_5_count: Optional[int] = None
# ============================ 现有库读适配 ============================
class LegacyIndexSource:
"""读 zs_day_dataMySQL-A 18.199。字段symbol, timestamp, close, percent, amount。"""
def __init__(self) -> None:
self.engine = _engine_for(settings.legacy_mysql_dsn)
@property
def available(self) -> bool:
return self.engine is not None
def fetch(self, index_code: str, start: date, end: date) -> List[IndexBar]:
if not self.engine:
log.info("LEGACY_MYSQL_DSN 未配置,跳过指数拉取 %s", index_code)
return []
sql = text(
"""
SELECT DATE(`timestamp`) AS d, `close` AS c, `percent` AS p, `amount` AS a
FROM zs_day_data
WHERE symbol = :code AND DATE(`timestamp`) BETWEEN :s AND :e
ORDER BY d ASC
"""
)
try:
with self.engine.connect() as conn:
rows = conn.execute(sql, {"code": index_code, "s": start, "e": end}).all()
except Exception as e: # noqa: BLE001
log.warning("读 zs_day_data 失败 %s: %s", index_code, e)
return []
return [
IndexBar(
trade_date=r.d,
close=_to_float(r.c),
amount=_to_float(r.a),
pct_chg=_to_float(r.p),
)
for r in rows
]
class LegacySentimentSource:
"""读 gp_market_sentimentPG 16.150。字段trade_date, up_down_ratio, median_pct_chg, pct_chg_gt_5_count。"""
def __init__(self) -> None:
self.engine = _engine_for(settings.legacy_pg_dsn)
@property
def available(self) -> bool:
return self.engine is not None
def fetch(self, start: date, end: date) -> List[SentimentRow]:
if not self.engine:
log.info("LEGACY_PG_DSN 未配置,跳过情绪拉取")
return []
sql = text(
"""
SELECT trade_date, up_down_ratio, median_pct_chg, pct_chg_gt_5_count
FROM gp_market_sentiment
WHERE trade_date BETWEEN :s AND :e
ORDER BY trade_date ASC
"""
)
try:
with self.engine.connect() as conn:
rows = conn.execute(sql, {"s": start, "e": end}).all()
except Exception as e: # noqa: BLE001
log.warning("读 gp_market_sentiment 失败: %s", e)
return []
out: List[SentimentRow] = []
for r in rows:
cnt = r.pct_chg_gt_5_count
out.append(
SentimentRow(
trade_date=r.trade_date,
up_down_ratio=_to_float(r.up_down_ratio),
median_pct_chg=_to_float(r.median_pct_chg),
pct_chg_gt_5_count=int(cnt) if cnt is not None else None,
)
)
return out
# ============================ 可插拔 OHLC Provider ============================
class OHLCProvider(Protocol):
"""指数开高低收源接口。你接入自己的数据源时实现此协议。"""
name: str
def fetch(self, index_code: str, start: date, end: date) -> Dict[date, IndexBar]:
"""返回 {trade_date: IndexBar(带 open/high/low/close[/volume])}。"""
...
class NullOHLCProvider:
"""默认:无 OHLC 源。ETL 会用 close 兜底成 open=high=low=close标 ohlc_source='close_only'"""
name = "none"
def fetch(self, index_code: str, start: date, end: date) -> Dict[date, IndexBar]:
return {}
# ------- 你后续接入 OHLC 源的示例骨架(改名注册即可)-------
# class MyOHLCProvider:
# name = "myprovider"
# def fetch(self, index_code, start, end):
# # 调你自己的接口/库表,返回 {date: IndexBar(open/high/low/close/volume)}
# return {}
_OHLC_REGISTRY = {
NullOHLCProvider.name: NullOHLCProvider,
# MyOHLCProvider.name: MyOHLCProvider, # <-- 接入后取消注释
}
def build_ohlc_provider() -> OHLCProvider:
name = settings.ohlc_provider
cls = _OHLC_REGISTRY.get(name)
if cls is None:
log.warning("未知 OHLC_PROVIDER=%s,回退 none收盘线", name)
cls = NullOHLCProvider
return cls()

8
backend/requirements.txt Normal file
View File

@ -0,0 +1,8 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
pydantic==2.10.4
pydantic-settings==2.7.1
SQLAlchemy==2.0.36
psycopg[binary]==3.2.3
PyMySQL==1.1.1
python-multipart==0.0.20

View File

@ -0,0 +1,4 @@
trade_date,etf_code,etf_name,category,related_index,net_inflow,shares_change,note
20240924,510300.SH,沪深300ETF,broad,000001.SH,50.5,10.2,示例请替换(net_inflow单位亿元)
20240924,588000.SH,科创50ETF,star,000688.SH,20.1,5.0,示例请替换
20240924,159915.SZ,创业板ETF,chinext,399006.SZ,15.3,4.1,示例请替换
1 trade_date etf_code etf_name category related_index net_inflow shares_change note
2 20240924 510300.SH 沪深300ETF broad 000001.SH 50.5 10.2 示例请替换(net_inflow单位亿元)
3 20240924 588000.SH 科创50ETF star 000688.SH 20.1 5.0 示例请替换
4 20240924 159915.SZ 创业板ETF chinext 399006.SZ 15.3 4.1 示例请替换

View File

@ -0,0 +1,6 @@
event_date,title,category,impact_direction,severity,related_indices,cycle_tag,description,source_url
20150612,上证见顶5178点,情绪,bearish,5,000001.SH,top,杠杆牛见顶随后股灾(示例请替换),
20150708,国家队入场救市,政策,bullish,4,,bottom,汇金证金入场(示例请替换),
20160104,熔断机制首日,监管,bearish,4,,none,熔断触发提前收市(示例请替换),
20190104,上证2440见底,资金,bullish,5,000001.SH,bottom,政策底后的市场底(示例请替换),
20210218,核心资产抱团见顶,情绪,bearish,4,,top,春节后抱团股回落(示例请替换),
1 event_date title category impact_direction severity related_indices cycle_tag description source_url
2 20150612 上证见顶5178点 情绪 bearish 5 000001.SH top 杠杆牛见顶随后股灾(示例请替换)
3 20150708 国家队入场救市 政策 bullish 4 bottom 汇金证金入场(示例请替换)
4 20160104 熔断机制首日 监管 bearish 4 none 熔断触发提前收市(示例请替换)
5 20190104 上证2440见底 资金 bullish 5 000001.SH bottom 政策底后的市场底(示例请替换)
6 20210218 核心资产抱团见顶 情绪 bearish 4 top 春节后抱团股回落(示例请替换)

85
ddl/own_db_init.sql Normal file
View File

@ -0,0 +1,85 @@
-- 自有库Postgres建表脚本。
-- docker-compose 首次启动会自动执行;也可手工 psql -f 执行。
-- 与 backend/app/db.py 的 ORM 模型保持一致。
-- ============ 指数日线(同步落地)============
CREATE TABLE IF NOT EXISTS index_daily (
id SERIAL PRIMARY KEY,
index_code VARCHAR(16) NOT NULL,
trade_date DATE NOT NULL,
open NUMERIC(16,4),
high NUMERIC(16,4),
low NUMERIC(16,4),
close NUMERIC(16,4),
volume BIGINT,
amount NUMERIC(24,4),
pct_chg NUMERIC(10,4),
turnover_rate NUMERIC(10,4),
ohlc_source VARCHAR(24) DEFAULT 'close_only',
updated_at TIMESTAMP DEFAULT now(),
CONSTRAINT uq_index_daily UNIQUE (index_code, trade_date)
);
CREATE INDEX IF NOT EXISTS ix_index_daily_code ON index_daily (index_code);
CREATE INDEX IF NOT EXISTS ix_index_daily_date ON index_daily (trade_date);
-- ============ 情绪日度 ============
CREATE TABLE IF NOT EXISTS sentiment_daily (
trade_date DATE PRIMARY KEY,
up_down_ratio NUMERIC(10,4),
median_pct_chg NUMERIC(10,4),
pct_chg_gt_5_count INTEGER,
limit_up_count INTEGER,
limit_down_count INTEGER,
margin_balance NUMERIC(24,4),
new_accounts INTEGER,
sentiment_score NUMERIC(10,4),
updated_at TIMESTAMP DEFAULT now()
);
-- ============ ETF 流入(人工/导入/自动)============
-- 说明etf_code 每个交易日应唯一。类别聚合行请用形如 'AGG_broad' 的 code避免空串冲突。
CREATE TABLE IF NOT EXISTS etf_flow (
id SERIAL PRIMARY KEY,
trade_date DATE NOT NULL,
etf_code VARCHAR(24) DEFAULT '',
etf_name VARCHAR(64),
category VARCHAR(32),
related_index VARCHAR(16),
net_inflow NUMERIC(20,4), -- 亿元
shares_change NUMERIC(20,4), -- 亿份
source VARCHAR(16) DEFAULT 'manual',
note TEXT,
created_at TIMESTAMP DEFAULT now(),
CONSTRAINT uq_etf_flow UNIQUE (trade_date, etf_code)
);
CREATE INDEX IF NOT EXISTS ix_etf_flow_date ON etf_flow (trade_date);
-- ============ 重大事件 ============
CREATE TABLE IF NOT EXISTS market_event (
id SERIAL PRIMARY KEY,
event_date DATE NOT NULL,
title VARCHAR(255) NOT NULL,
category VARCHAR(32) DEFAULT '其他', -- 监管/IPO/政策/资金/外部/其他
impact_direction VARCHAR(16) DEFAULT 'neutral', -- bullish/bearish/neutral
severity INTEGER DEFAULT 3, -- 1-5
related_indices VARCHAR(128), -- CSV空=全市场
cycle_tag VARCHAR(16) DEFAULT 'none', -- top/bottom/none
description TEXT,
source_url VARCHAR(512),
source VARCHAR(16) DEFAULT 'manual',
created_at TIMESTAMP DEFAULT now(),
updated_at TIMESTAMP DEFAULT now()
);
CREATE INDEX IF NOT EXISTS ix_market_event_date ON market_event (event_date);
-- ============ 人工顶底标注 ============
CREATE TABLE IF NOT EXISTS cycle_annotation (
id SERIAL PRIMARY KEY,
index_code VARCHAR(16) NOT NULL,
anno_date DATE NOT NULL,
kind VARCHAR(16) DEFAULT 'watch', -- top/bottom/watch
note TEXT,
created_at TIMESTAMP DEFAULT now()
);
CREATE INDEX IF NOT EXISTS ix_cycle_anno_code ON cycle_annotation (index_code);
CREATE INDEX IF NOT EXISTS ix_cycle_anno_date ON cycle_annotation (anno_date);

34
docker-compose.yml Normal file
View File

@ -0,0 +1,34 @@
services:
db:
image: postgres:16-alpine
container_name: asevent-db
environment:
POSTGRES_USER: ${POSTGRES_USER:-asevent}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-asevent}
POSTGRES_DB: ${POSTGRES_DB:-asevent}
volumes:
- asevent_pgdata:/var/lib/postgresql/data
# 首次启动自动执行建表脚本
- ./ddl/own_db_init.sql:/docker-entrypoint-initdb.d/01_init.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-asevent}"]
interval: 5s
timeout: 3s
retries: 10
restart: unless-stopped
backend:
build:
context: .
dockerfile: backend/Dockerfile
container_name: asevent-backend
env_file: .env
depends_on:
db:
condition: service_healthy
ports:
- "${BACKEND_PORT:-8000}:8000"
restart: unless-stopped
volumes:
asevent_pgdata:

321
frontend/index.html Normal file
View File

@ -0,0 +1,321 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>A股大事记录 · 大周期择时看板</title>
<script src="./vendor/echarts.min.js"></script>
<style>
:root{
--bg:#0f1420; --panel:#161d2e; --panel2:#1c2740; --line:#2a3654;
--txt:#e6ecf7; --muted:#8ea0c0; --up:#ef4444; --down:#22c55e;
--accent:#4f8cff; --warn:#f59e0b;
}
*{box-sizing:border-box}
body{margin:0;font-family:-apple-system,"PingFang SC","Microsoft YaHei",Segoe UI,sans-serif;
background:var(--bg);color:var(--txt);font-size:14px}
header{display:flex;align-items:center;gap:16px;flex-wrap:wrap;
padding:12px 18px;background:var(--panel);border-bottom:1px solid var(--line)}
header h1{font-size:16px;margin:0;font-weight:600;letter-spacing:.5px}
.idx-btns{display:flex;gap:8px}
.idx-btns button{background:var(--panel2);border:1px solid var(--line);color:var(--muted);
padding:6px 12px;border-radius:6px;cursor:pointer;transition:.15s}
.idx-btns button.active{background:var(--accent);color:#fff;border-color:var(--accent)}
.idx-btns button:hover{color:var(--txt)}
.ctrls{display:flex;align-items:center;gap:8px;margin-left:auto;flex-wrap:wrap}
input,select,textarea{background:var(--panel2);border:1px solid var(--line);color:var(--txt);
padding:6px 8px;border-radius:6px;font-size:13px}
button.act{background:var(--accent);border:1px solid var(--accent);color:#fff;padding:6px 12px;
border-radius:6px;cursor:pointer}
button.act:hover{filter:brightness(1.1)}
button.ghost{background:transparent;border:1px solid var(--line);color:var(--muted);padding:6px 12px;
border-radius:6px;cursor:pointer}
#status{font-size:12px;color:var(--muted);padding:4px 18px;background:var(--panel)}
#chart{width:100%;height:640px}
.bottom{display:grid;grid-template-columns:1.3fr 1fr;gap:14px;padding:14px 18px}
.card{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:14px}
.card h3{margin:0 0 10px;font-size:14px;font-weight:600}
table{width:100%;border-collapse:collapse;font-size:12.5px}
th,td{text-align:left;padding:6px 8px;border-bottom:1px solid var(--line)}
th{color:var(--muted);font-weight:500}
.tag{padding:1px 7px;border-radius:10px;font-size:11px}
.bull{background:rgba(239,68,68,.15);color:#ff8080}
.bear{background:rgba(34,197,94,.15);color:#7ee6a0}
.neu{background:rgba(142,160,192,.15);color:var(--muted)}
.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}
.form-grid label{display:flex;flex-direction:column;gap:3px;font-size:12px;color:var(--muted)}
.form-grid .full{grid-column:1/3}
.del{color:#ff8080;cursor:pointer;background:none;border:none;font-size:12px}
.hint{color:var(--muted);font-size:11.5px;line-height:1.6;margin-top:8px}
.legend-dot{display:inline-block;width:9px;height:9px;border-radius:50%;margin-right:4px;vertical-align:middle}
</style>
</head>
<body>
<header>
<h1>📈 A股大事记录 · 大周期择时看板</h1>
<div class="idx-btns" id="idxBtns"></div>
<div class="ctrls">
<input type="date" id="start" />
<span style="color:var(--muted)"></span>
<input type="date" id="end" />
<button class="act" onclick="reload()">刷新</button>
<button class="ghost" onclick="doSync()" title="从现有内网库同步指数/情绪(需配置 DSN)">同步数据</button>
</div>
</header>
<div id="status">加载中…</div>
<div id="chart"></div>
<div class="bottom">
<div class="card">
<h3>重大事件</h3>
<table id="evtTable"><thead>
<tr><th>日期</th><th>标题</th><th>类别</th><th>方向</th><th>级别</th><th>顶底</th><th></th></tr>
</thead><tbody></tbody></table>
<div class="hint">点击图上事件标记或此表可定位。事件可 CSV 批量导入:<code>POST /api/events/import</code>(模板见 ddl/event_import_template.csv</div>
</div>
<div class="card">
<h3>新增事件(人工录入)</h3>
<div class="form-grid">
<label>日期<input type="date" id="f_date" /></label>
<label>类别
<select id="f_cat"><option>监管</option><option>IPO</option><option>政策</option>
<option>资金</option><option>外部</option><option selected>其他</option></select></label>
<label class="full">标题<input type="text" id="f_title" placeholder="如:证监会立案某券商 / 某巨无霸IPO上市" /></label>
<label>方向
<select id="f_dir"><option value="bullish">利多</option>
<option value="bearish">利空</option><option value="neutral" selected>中性</option></select></label>
<label>影响级别
<select id="f_sev"><option>1</option><option>2</option><option selected>3</option>
<option>4</option><option>5</option></select></label>
<label>顶底标记
<select id="f_cycle"><option value="none" selected></option>
<option value="top">顶部信号</option><option value="bottom">底部信号</option></select></label>
<label>关联指数(可空)
<input type="text" id="f_rel" placeholder="000001.SH,399006.SZ" /></label>
<label class="full">说明<textarea id="f_desc" rows="2"></textarea></label>
</div>
<div style="margin-top:10px"><button class="act" onclick="addEvent()">保存事件</button></div>
<div class="hint">
<span class="legend-dot" style="background:#ef4444"></span>利多
<span class="legend-dot" style="background:#22c55e"></span>利空
<span class="legend-dot" style="background:#8ea0c0"></span>中性 ·
温度副图:<b style="color:#ef4444">红=过热(顶部风险)</b><b style="color:#3b82f6">蓝=冰点(底部机会)</b>
</div>
</div>
</div>
<script>
const API = "";
const state = { code:null, list:[], k:null, events:[] };
const CAT_COLOR = { bullish:"#ef4444", bearish:"#22c55e", neutral:"#8ea0c0" };
const DIR_CN = { bullish:"利多", bearish:"利空", neutral:"中性" };
let chart;
function fmt(d){ return d.toISOString().slice(0,10); }
async function j(url, opts){
const r = await fetch(API+url, opts);
if(!r.ok){ throw new Error(await r.text()); }
return r.json();
}
async function init(){
chart = echarts.init(document.getElementById("chart"), "dark");
window.addEventListener("resize", ()=>chart.resize());
const end = new Date(); const start = new Date(); start.setFullYear(end.getFullYear()-3);
document.getElementById("start").value = fmt(start);
document.getElementById("end").value = fmt(end);
document.getElementById("f_date").value = fmt(end);
try{
state.list = await j("/api/index/list");
}catch(e){ setStatus("加载指数列表失败:"+e.message); return; }
renderIdxBtns();
if(state.list.length){ state.code = state.list[0].code; await reload(); }
else setStatus("自有库暂无指数数据,请先运行 ETL 同步(见 README。");
}
function renderIdxBtns(){
const box = document.getElementById("idxBtns"); box.innerHTML="";
state.list.forEach(it=>{
const b=document.createElement("button");
b.textContent = it.name + "("+it.code+")";
b.className = it.code===state.code ? "active":"";
b.onclick = ()=>{ state.code=it.code; renderIdxBtns(); reload(); };
box.appendChild(b);
});
}
function setStatus(s){ document.getElementById("status").textContent = s; }
async function reload(){
if(!state.code) return;
const s = document.getElementById("start").value, e = document.getElementById("end").value;
setStatus("加载中…");
try{
const [k, events] = await Promise.all([
j(`/api/index/${state.code}/kline?start=${s}&end=${e}`),
j(`/api/events?start=${s}&end=${e}`)
]);
state.k = k; state.events = events;
renderChart(); renderEventTable();
const srcTxt = k.ohlc_available ? "蜡烛图(已接入OHLC源)" : "收盘线(未接入OHLC源仅收盘价)";
setStatus(`${k.index_name} · ${k.dates.length} 交易日 · ${srcTxt} · 事件 ${events.length} 条`);
}catch(err){ setStatus("加载失败:"+err.message); }
}
function nearestIdx(dates, target){
// 最后一个 <= target 的下标
let lo=0, hi=dates.length-1, ans=-1;
while(lo<=hi){ const m=(lo+hi)>>1; if(dates[m]<=target){ans=m;lo=m+1;} else hi=m-1; }
return ans;
}
function renderChart(){
const k = state.k;
const dates = k.dates;
const priceLine = k.ohlc.map(x=>x[3]); // close
const cs = k.ohlc.map(x=>[x[0],x[3],x[2],x[1]]); // [open,close,low,high]
const volData = k.amount.map((a,i)=>({
value: a==null? null : +(a/1e8).toFixed(2),
itemStyle:{ color: (k.pct_chg[i]??0) >= 0 ? "#ef4444":"#22c55e" }
}));
const temp = k.temperature;
// 事件散点(吸附到最近交易日)
const evPts = [];
state.events.forEach(ev=>{
const idx = nearestIdx(dates, ev.event_date);
if(idx<0) return;
const y = k.ohlc[idx][1] ?? k.ohlc[idx][3]; // high or close
evPts.push({
value:[dates[idx], y],
symbol: ev.cycle_tag==="top"?"triangle": ev.cycle_tag==="bottom"?"triangle":"pin",
symbolRotate: ev.cycle_tag==="bottom"?180:0,
symbolSize: 8 + (ev.severity||3)*2,
itemStyle:{ color: CAT_COLOR[ev.impact_direction]||"#8ea0c0" },
_ev: ev
});
});
const priceSeries = k.ohlc_available ? {
name:"K线", type:"candlestick", data:cs,
itemStyle:{color:"#ef4444",color0:"#22c55e",borderColor:"#ef4444",borderColor0:"#22c55e"}
} : {
name:"收盘", type:"line", data:priceLine, showSymbol:false,
lineStyle:{width:1.5,color:"#e6ecf7"}, areaStyle:{color:"rgba(79,140,255,.08)"}
};
const option = {
backgroundColor:"transparent",
animation:false,
axisPointer:{link:[{xAxisIndex:"all"}]},
tooltip:{ trigger:"axis", axisPointer:{type:"cross"},
backgroundColor:"rgba(20,28,48,.95)", borderColor:"#2a3654", textStyle:{color:"#e6ecf7"} },
grid:[
{left:60,right:24,top:20,height:"52%"},
{left:60,right:24,top:"60%",height:"14%"},
{left:60,right:24,top:"78%",height:"16%"}
],
xAxis:[
{type:"category",data:dates,scale:true,boundaryGap:false,axisLine:{lineStyle:{color:"#2a3654"}},
axisLabel:{show:false},splitLine:{show:false}},
{type:"category",gridIndex:1,data:dates,axisLabel:{show:false},axisLine:{lineStyle:{color:"#2a3654"}},
splitLine:{show:false}},
{type:"category",gridIndex:2,data:dates,axisLine:{lineStyle:{color:"#2a3654"}},
axisLabel:{color:"#8ea0c0",fontSize:10},splitLine:{show:false}}
],
yAxis:[
{scale:true,axisLabel:{color:"#8ea0c0"},splitLine:{lineStyle:{color:"rgba(42,54,84,.5)"}}},
{scale:true,gridIndex:1,name:"成交额(亿)",nameTextStyle:{color:"#8ea0c0",fontSize:10},
axisLabel:{color:"#8ea0c0",fontSize:10},splitLine:{show:false}},
{scale:true,gridIndex:2,min:0,max:100,name:"温度",nameTextStyle:{color:"#8ea0c0",fontSize:10},
axisLabel:{color:"#8ea0c0",fontSize:10},splitLine:{show:false}}
],
dataZoom:[
{type:"inside",xAxisIndex:[0,1,2],start:60,end:100},
{type:"slider",xAxisIndex:[0,1,2],bottom:6,height:16,start:60,end:100,
borderColor:"#2a3654",textStyle:{color:"#8ea0c0"}}
],
visualMap:{ show:false, seriesIndex:4, dimension:1, min:0, max:100,
inRange:{color:["#3b82f6","#93c5fd","#f59e0b","#ef4444"]} },
series:[
Object.assign({xAxisIndex:0,yAxisIndex:0}, priceSeries),
{ name:"事件", type:"scatter", data:evPts, xAxisIndex:0, yAxisIndex:0, z:10,
tooltip:{trigger:"item", formatter:p=>{
const ev=p.data._ev; if(!ev) return "";
return `<b>${ev.event_date}</b> · ${ev.category}<br/><b>${ev.title}</b><br/>`+
`方向:<span style="color:${CAT_COLOR[ev.impact_direction]}">${DIR_CN[ev.impact_direction]||""}</span>`+
` · 级别 ${ev.severity} ${ev.cycle_tag!=="none"?("· "+(ev.cycle_tag==="top"?"顶部":"底部")):""}<br/>`+
`${ev.description? "<span style='color:#8ea0c0'>"+ev.description+"</span>":""}`;
}}
},
{ name:"标注", type:"scatter", data:[], xAxisIndex:0, yAxisIndex:0 },
{ name:"成交额", type:"bar", data:volData, xAxisIndex:1, yAxisIndex:1 },
{ name:"市场温度", type:"line", data:temp, xAxisIndex:2, yAxisIndex:2, showSymbol:false,
connectNulls:false, lineStyle:{width:1.5},
markLine:{ silent:true, symbol:"none", label:{color:"#8ea0c0",fontSize:10},
data:[
{yAxis:k.thresholds?.hot??80, lineStyle:{color:"#ef4444",type:"dashed"}, label:{formatter:"过热"}},
{yAxis:k.thresholds?.cold??20, lineStyle:{color:"#3b82f6",type:"dashed"}, label:{formatter:"冰点"}}
]}
}
]
};
chart.setOption(option, true);
}
function renderEventTable(){
const tb = document.querySelector("#evtTable tbody"); tb.innerHTML="";
state.events.slice().reverse().forEach(ev=>{
const cls = ev.impact_direction==="bullish"?"bull":ev.impact_direction==="bearish"?"bear":"neu";
const tr=document.createElement("tr");
tr.innerHTML =
`<td>${ev.event_date}</td><td>${ev.title}</td><td>${ev.category}</td>`+
`<td><span class="tag ${cls}">${DIR_CN[ev.impact_direction]||""}</span></td>`+
`<td>${ev.severity}</td>`+
`<td>${ev.cycle_tag==="top"?"顶":ev.cycle_tag==="bottom"?"底":"-"}</td>`+
`<td><button class="del" onclick="delEvent(${ev.id})">删除</button></td>`;
tb.appendChild(tr);
});
}
async function addEvent(){
const payload = {
event_date: document.getElementById("f_date").value,
title: document.getElementById("f_title").value.trim(),
category: document.getElementById("f_cat").value,
impact_direction: document.getElementById("f_dir").value,
severity: parseInt(document.getElementById("f_sev").value),
cycle_tag: document.getElementById("f_cycle").value,
related_indices: document.getElementById("f_rel").value.trim() || null,
description: document.getElementById("f_desc").value.trim() || null
};
if(!payload.title){ alert("请填写标题"); return; }
try{
await j("/api/events", {method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(payload)});
document.getElementById("f_title").value=""; document.getElementById("f_desc").value="";
await reload();
}catch(e){ alert("保存失败:"+e.message); }
}
async function delEvent(id){
if(!confirm("确认删除该事件?")) return;
try{ await j("/api/events/"+id, {method:"DELETE"}); await reload(); }
catch(e){ alert("删除失败:"+e.message); }
}
async function doSync(){
const s = document.getElementById("start").value;
if(!confirm("从现有内网库同步 "+s+" 至今 的指数与情绪数据?(需已配置 LEGACY_*_DSN)")) return;
setStatus("同步中…可能耗时,请稍候");
try{
const r1 = await j("/api/sync/index",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({start:s.replaceAll("-","")})});
const r2 = await j("/api/sync/sentiment",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({start:s.replaceAll("-","")})});
setStatus("同步完成:"+JSON.stringify(r1.synced)+" / 情绪 "+r2.synced+" 行。刷新中…");
state.list = await j("/api/index/list"); renderIdxBtns(); await reload();
}catch(e){ setStatus("同步失败:"+e.message+"(检查 LEGACY_*_DSN 是否配置且内网可达)"); }
}
init();
</script>
</body>
</html>

45
frontend/vendor/echarts.min.js vendored Normal file

File diff suppressed because one or more lines are too long