first commit
This commit is contained in:
commit
c62599a8f2
|
|
@ -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 股经典的顶底极值信号,产出每日 **市场温度 0–100** 与离散标记:
|
||||||
|
|
||||||
|
| 分量 | 顶部含义 | 底部含义 | 计算 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 量能分位 `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/事件抓取)按需开启
|
||||||
|
|
@ -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 | 0–100 |
|
||||||
|
| `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 上游单股信号查询 API(192.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` — 失败记忆
|
||||||
|
|
||||||
|
完整 Schema(2026-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 扫描(持仓态) |
|
||||||
|
| 单股盘中信号 | 上游单股 API(188: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)、止损位=买价×(1−sl),直接供 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`。
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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`,实现后可挂新闻/公告/资金流抓取。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 顶底「市场温度」
|
||||||
|
|
||||||
|
看板底部温度副图 0–100:越高越过热(顶部风险,红),越低越冰点(底部机会,蓝)。
|
||||||
|
由 量能分位 / 价格分位 / 情绪 / 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`)
|
||||||
Binary file not shown.
|
|
@ -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,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}
|
||||||
|
|
@ -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=close,ohlc_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": "深证成指",
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,137 @@
|
||||||
|
"""自有库(Postgres)ORM 模型与会话。
|
||||||
|
|
||||||
|
看板只读自有库;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=close,ohlc_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
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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()),
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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] = []
|
||||||
|
|
@ -0,0 +1,130 @@
|
||||||
|
"""顶底「市场温度」透明启发式(可调,非黑盒)。
|
||||||
|
|
||||||
|
温度 0–100:越高越「过热/顶部风险」,越低越「冰点/底部机会」。
|
||||||
|
四个分量(缺哪个自动剔除并重新归一权重):
|
||||||
|
vol_pct 量能分位:天量→高温(顶),地量→低温(底)
|
||||||
|
price_pct 价格分位:高位→高温,低位→低温
|
||||||
|
sentiment 情绪:普涨/涨停潮亢奋→高温;普跌/跌停潮恐慌→低温
|
||||||
|
etf_heat ETF「出货热度」= (-净流入) 分位:大额净流出→高温(顶),大额净流入(国家队)→低温(底)
|
||||||
|
|
||||||
|
权重与阈值来自 config(W_VOL/W_PRICE/W_SENTIMENT/W_ETF、HOT/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
|
||||||
|
|
@ -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_data(MySQL-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_sentiment(PG 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()
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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,示例请替换
|
||||||
|
|
|
@ -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,春节后抱团股回落(示例请替换),
|
||||||
|
|
|
@ -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);
|
||||||
|
|
@ -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:
|
||||||
|
|
@ -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>
|
||||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue