From 175173de0f639885357d830acd892fcc0e8a13cd Mon Sep 17 00:00:00 2001 From: Lxy Date: Sun, 15 Mar 2026 00:06:09 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E8=B0=83=E6=95=B4=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E7=BB=93=E6=9E=84=EF=BC=8C=E5=A2=9E=E5=8A=A0=E8=AF=B7=E6=B1=82?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=EF=BC=88=E5=90=8E=E7=BB=AD=E5=AF=B9=E5=A4=96?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E9=9C=80=E8=A6=81=E7=BB=A7=E7=BB=AD=E8=B0=83?= =?UTF-8?q?=E6=95=B4=EF=BC=8C=E5=8A=9F=E8=83=BD=E5=8D=95=E4=B8=80=E5=8C=96?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- API_TEST_CASES.md | 77 +++ DATABASE_REFACTOR.md | 320 +++++++++ QUICKSTART_NEW_TABLES.md | 198 ++++++ REFACTOR_SUMMARY.md | 186 ++++++ SPLIT_TABLES_COMPLETE.md | 146 +++++ TABLE_SPLIT_DESIGN.md | 253 +++++++ .../amazingdata_adapter.cpython-311.pyc | Bin 59688 -> 83612 bytes app/adapters/__pycache__/base.cpython-311.pyc | Bin 5217 -> 9614 bytes app/adapters/amazingdata_adapter.py | 498 ++++++++++++++ app/adapters/base.py | 124 ++++ .../__pycache__/admin_routes.cpython-311.pyc | Bin 13431 -> 20877 bytes app/api/admin_routes.py | 156 ++++- app/models/__init__.py | 9 + .../__pycache__/__init__.cpython-311.pyc | Bin 2755 -> 2849 bytes .../__pycache__/admin_types.cpython-311.pyc | Bin 18879 -> 22045 bytes app/models/admin_types.py | 41 ++ .../futures_repository_v2.cpython-311.pyc | Bin 0 -> 23021 bytes .../__pycache__/models.cpython-311.pyc | Bin 15029 -> 39910 bytes .../stock_repository_v2.cpython-311.pyc | Bin 0 -> 34191 bytes app/repositories/futures_repository_v2.py | 393 +++++++++++ app/repositories/models.py | 418 ++++++++++++ app/repositories/models_old.py | 229 +++++++ app/repositories/stock_repository_v2.py | 618 ++++++++++++++++++ .../data_sync_service.cpython-311.pyc | Bin 0 -> 17547 bytes .../__pycache__/test_service.cpython-311.pyc | Bin 19200 -> 20734 bytes app/services/data_sync_service.py | 395 +++++++++++ app/services/test_service.py | 84 +++ .../create_split_tables.cpython-311.pyc | Bin 0 -> 5499 bytes scripts/add_15_30_60_tables.py | 204 ++++++ scripts/add_adapter_methods.py | 522 +++++++++++++++ scripts/add_stock_split_tables.py | 145 ++++ scripts/append_futures_models.py | 338 ++++++++++ scripts/create_split_tables.py | 147 +++++ scripts/fix_garbled_chinese.py | 210 ++++++ scripts/fix_models.py | 143 ++++ 35 files changed, 5851 insertions(+), 3 deletions(-) create mode 100644 API_TEST_CASES.md create mode 100644 DATABASE_REFACTOR.md create mode 100644 QUICKSTART_NEW_TABLES.md create mode 100644 REFACTOR_SUMMARY.md create mode 100644 SPLIT_TABLES_COMPLETE.md create mode 100644 TABLE_SPLIT_DESIGN.md create mode 100644 app/repositories/__pycache__/futures_repository_v2.cpython-311.pyc create mode 100644 app/repositories/__pycache__/stock_repository_v2.cpython-311.pyc create mode 100644 app/repositories/futures_repository_v2.py create mode 100644 app/repositories/models_old.py create mode 100644 app/repositories/stock_repository_v2.py create mode 100644 app/services/__pycache__/data_sync_service.cpython-311.pyc create mode 100644 app/services/data_sync_service.py create mode 100644 scripts/__pycache__/create_split_tables.cpython-311.pyc create mode 100644 scripts/add_15_30_60_tables.py create mode 100644 scripts/add_adapter_methods.py create mode 100644 scripts/add_stock_split_tables.py create mode 100644 scripts/append_futures_models.py create mode 100644 scripts/create_split_tables.py create mode 100644 scripts/fix_garbled_chinese.py create mode 100644 scripts/fix_models.py diff --git a/API_TEST_CASES.md b/API_TEST_CASES.md new file mode 100644 index 0000000..755a5ac --- /dev/null +++ b/API_TEST_CASES.md @@ -0,0 +1,77 @@ +# API 测试用例清单 + +## 新增数据同步接口测试用例 + +| 测试ID | 测试名称 | 方法 | 路径 | 说明 | +|--------|----------|------|------|------| +| `admin_data_sync_full` | 全量数据同步 | POST | `/v1/admin/data/sync` | 同步基础+行情+财务数据 | +| `admin_data_sync_base` | 同步基础K线数据 | POST | `/v1/admin/data/sync` | 仅同步OHLCV基础数据 | +| `admin_data_sync_quote` | 同步行情指标数据 | POST | `/v1/admin/data/sync` | 同步均线/MACD/涨跌幅 | +| `admin_data_sync_finance` | 同步财务数据 | POST | `/v1/admin/data/sync` | 同步市值/股本/利润 | +| `admin_data_sync_incremental` | 增量数据同步 | POST | `/v1/admin/data/sync/incremental` | 同步最近30天数据 | +| `admin_data_sync_futures` | 期货数据同步 | POST | `/v1/admin/data/sync` | 同步期货数据 | + +## 测试用例分类统计 + +| 分类 | 用例数 | +|------|--------| +| 股票接口 | 4 | +| 期货接口 | 5 | +| 管理接口 | 7 | +| 适配器管理 | 3 | +| **数据同步接口** | **6** | +| 测试管理 | 1 | +| **总计** | **26** | + +## 使用方法 + +### 通过管理后台API测试 +```bash +# 1. 获取测试列表 +curl "http://localhost:8080/v1/admin/tests/api" + +# 2. 执行全量数据同步测试 +curl -X POST "http://localhost:8080/v1/admin/tests/api/run" \ + -H "Content-Type: application/json" \ + -d '{ + "id": "admin_data_sync_full", + "params": { + "symbols": ["000001.SZ"], + "sync_type": "full" + } + }' + +# 3. 执行基础数据同步测试 +curl -X POST "http://localhost:8080/v1/admin/tests/api/run" \ + -H "Content-Type: application/json" \ + -d '{ + "id": "admin_data_sync_base" + }' +``` + +### 直接调用数据同步接口 +```bash +# 全量同步 +curl -X POST "http://localhost:8080/v1/admin/data/sync" \ + -H "Content-Type: application/json" \ + -d '{ + "symbols": ["600519.SH", "000001.SZ"], + "sync_type": "full", + "start_date": "20240101", + "end_date": "20240301" + }' + +# 增量同步 +curl -X POST "http://localhost:8080/v1/admin/data/sync/incremental" \ + -H "Content-Type: application/json" \ + -d '["600519.SH", "000001.SZ"]' +``` + +## 对应的适配器方法 + +| API接口 | 适配器方法 | +|---------|-----------| +| `/admin/data/sync` (base) | `fetch_kline_base` | +| `/admin/data/sync` (quote) | `fetch_kline_quote` | +| `/admin/data/sync` (finance) | `fetch_kline_finance` | +| `/admin/data/sync/incremental` | `sync_daily_incremental` | diff --git a/DATABASE_REFACTOR.md b/DATABASE_REFACTOR.md new file mode 100644 index 0000000..754f2f7 --- /dev/null +++ b/DATABASE_REFACTOR.md @@ -0,0 +1,320 @@ +# 数据库结构重设计文档 + +## 背景 + +原有数据库使用单一的 `stock_klines_1d` 表存储所有日线数据,包含OHLCV、涨跌停状态、市值等多个维度的信息。这种设计在以下场景存在问题: + +1. **数据来源不一致**:OHLCV来自行情接口,财务数据来自股本/财报接口,合并存储导致更新频率不一致 +2. **查询效率低**:不需要财务指标时仍需查询包含大量字段的宽表 +3. **扩展困难**:新增指标需要修改表结构,影响线上服务 + +## 新表结构设计 + +### 1. 日线历史数据表 (1d) + +#### stock_klines_1d_base - 日线基础表 +存储最基础的K线数据,对应 `query_kline` 接口。 + +```sql +CREATE TABLE stock_klines_1d_base ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + symbol_id VARCHAR(20) NOT NULL COMMENT '标的代码', + ts DATETIME NOT NULL COMMENT '时间戳', + trade_date VARCHAR(10) NOT NULL COMMENT '交易日 YYYY-MM-DD', + open DECIMAL(18,4) COMMENT '开盘价', + high DECIMAL(18,4) COMMENT '最高价', + low DECIMAL(18,4) COMMENT '最低价', + close DECIMAL(18,4) COMMENT '收盘价', + volume BIGINT COMMENT '成交量(股)', + amount DECIMAL(20,4) COMMENT '成交额(元)', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_symbol_ts (symbol_id, ts), + INDEX idx_symbol_date (symbol_id, trade_date) +) COMMENT='股票日线K线-基础表'; +``` + +#### stock_klines_1d_quote - 日线行情表 +存储行情技术指标,需要在基础数据基础上计算。 + +```sql +CREATE TABLE stock_klines_1d_quote ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + symbol_id VARCHAR(20) NOT NULL COMMENT '标的代码', + trade_date VARCHAR(10) NOT NULL COMMENT '交易日', + change_pct DECIMAL(8,4) COMMENT '涨跌幅(%)', + change_5d_pct DECIMAL(8,4) COMMENT '5日涨跌幅', + change_10d_pct DECIMAL(8,4) COMMENT '10日涨跌幅', + change_20d_pct DECIMAL(8,4) COMMENT '20日涨跌幅', + change_30d_pct DECIMAL(8,4) COMMENT '30日涨跌幅', + change_60d_pct DECIMAL(8,4) COMMENT '60日涨跌幅', + ma5 DECIMAL(18,4) COMMENT '5日均线', + ma10 DECIMAL(18,4) COMMENT '10日均线', + ma20 DECIMAL(18,4) COMMENT '20日均线', + ma30 DECIMAL(18,4) COMMENT '30日均线', + ma60 DECIMAL(18,4) COMMENT '60日均线', + ma120 DECIMAL(18,4) COMMENT '120日均线', + ma250 DECIMAL(18,4) COMMENT '250日均线', + macd_dif DECIMAL(18,6) COMMENT 'MACD DIF', + macd_dea DECIMAL(18,6) COMMENT 'MACD DEA', + macd_bar DECIMAL(18,6) COMMENT 'MACD BAR', + bias5 DECIMAL(8,4) COMMENT '5日乖离率', + bias10 DECIMAL(8,4) COMMENT '10日乖离率', + bias20 DECIMAL(8,4) COMMENT '20日乖离率', + is_limit_up BOOLEAN COMMENT '是否涨停', + is_limit_down BOOLEAN COMMENT '是否跌停', + limit_up_price DECIMAL(18,4) COMMENT '涨停价', + limit_down_price DECIMAL(18,4) COMMENT '跌停价', + is_st BOOLEAN COMMENT '是否ST', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_symbol_date (symbol_id, trade_date) +) COMMENT='股票日线K线-行情指标表'; +``` + +#### stock_klines_1d_finance - 日线财务表 +存储财务相关数据,对应 `get_equity_structure` / `get_share_holder` / `get_income` 等接口。 + +```sql +CREATE TABLE stock_klines_1d_finance ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + symbol_id VARCHAR(20) NOT NULL COMMENT '标的代码', + trade_date VARCHAR(10) NOT NULL COMMENT '交易日', + total_market_cap DECIMAL(20,4) COMMENT '总市值(元)', + float_market_cap DECIMAL(20,4) COMMENT '流通市值(元)', + total_shares BIGINT COMMENT '总股本(股)', + float_shares BIGINT COMMENT '流通股本(股)', + inst_holding_shares BIGINT COMMENT '机构持股数量', + inst_holding_ratio DECIMAL(8,4) COMMENT '机构持仓占比(%)', + top10_holders_ratio DECIMAL(8,4) COMMENT '前十大股东持股占比(%)', + net_profit DECIMAL(20,4) COMMENT '净利润', + revenue DECIMAL(20,4) COMMENT '营业总收入', + eps DECIMAL(12,4) COMMENT '每股收益', + roe DECIMAL(8,4) COMMENT '净资产收益率(%)', + trading_days INT COMMENT '从上市至今交易日数', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_symbol_date (symbol_id, trade_date) +) COMMENT='股票日线K线-财务数据表'; +``` + +### 2. 其他周期历史数据表 + +分钟线/周线/月线只保留基础K线数据和简单指标: + +- `stock_klines_1m` - 1分钟线 +- `stock_klines_5m` - 5分钟线 +- `stock_klines_15m` - 15分钟线 +- `stock_klines_30m` - 30分钟线 +- `stock_klines_60m` - 60分钟线 +- `stock_klines_1w` - 周线 +- `stock_klines_1month` - 月线 + +字段结构: +```sql +symbol_id, ts, trade_date, open, high, low, close, volume, amount, +change_pct, macd_dif, macd_dea, macd_bar +``` + +### 3. 实时数据表 + +#### stock_realtime_quotes - 实时行情快照 +```sql +CREATE TABLE stock_realtime_quotes ( + symbol_id VARCHAR(20) PRIMARY KEY COMMENT '标的代码', + update_time DATETIME COMMENT '更新时间', + last_price DECIMAL(18,4) COMMENT '最新价', + open DECIMAL(18,4) COMMENT '开盘价', + high DECIMAL(18,4) COMMENT '最高价', + low DECIMAL(18,4) COMMENT '最低价', + pre_close DECIMAL(18,4) COMMENT '昨收', + volume BIGINT COMMENT '成交量', + amount DECIMAL(20,4) COMMENT '成交额', + bid1 DECIMAL(18,4) COMMENT '买一价', + ask1 DECIMAL(18,4) COMMENT '卖一价', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) COMMENT='股票实时行情快照'; +``` + +### 4. 基础数据表 + +#### stock_basic_info - 股票基础信息 +```sql +CREATE TABLE stock_basic_info ( + symbol_id VARCHAR(20) PRIMARY KEY COMMENT '标的代码', + name VARCHAR(100) COMMENT '名称', + exchange VARCHAR(10) COMMENT '交易所', + list_date DATE COMMENT '上市日期', + list_board VARCHAR(20) COMMENT '上市板块', + industry VARCHAR(50) COMMENT '所属行业', + status VARCHAR(10) COMMENT '状态 active/delisted', + is_delisted BOOLEAN COMMENT '是否退市', + delist_date DATE COMMENT '退市日期', + is_st BOOLEAN COMMENT '是否ST', + total_shares BIGINT COMMENT '总股本', + float_shares BIGINT COMMENT '流通股本', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) COMMENT='股票基础信息'; +``` + +#### stock_trading_calendar - 交易日历 +```sql +CREATE TABLE stock_trading_calendar ( + trade_date VARCHAR(8) PRIMARY KEY COMMENT '交易日 YYYYMMDD', + is_trading_day BOOLEAN COMMENT '是否交易日', + week_day TINYINT COMMENT '星期几 1-7' +) COMMENT='交易日历'; +``` + +## 代码结构 + +### 新增文件 + +1. **app/repositories/stock_repository_v2.py** - 支持拆分表结构的新仓库 + - `get_klines_base()` / `save_klines_base()` - 基础数据操作 + - `get_klines_quote()` / `save_klines_quote()` - 行情数据操作 + - `get_klines_finance()` / `save_klines_finance()` - 财务数据操作 + +2. **app/services/data_sync_service.py** - 数据同步服务 + - `sync_kline_base()` - 同步基础K线数据 + - `sync_kline_quote()` - 同步行情指标数据 + - `sync_kline_finance()` - 同步财务数据 + - `sync_full_stock_data()` - 全量同步 + - `sync_daily_incremental()` - 每日增量同步 + +3. **app/api/admin_routes.py** - 新增数据同步API + - `POST /admin/data/sync` - 手动触发数据同步 + - `POST /admin/data/sync/incremental` - 增量同步 + +### 适配器接口扩展 + +**app/adapters/base.py** 新增抽象方法: +- `fetch_kline_base()` - 获取基础K线数据 +- `fetch_kline_quote()` - 获取行情指标数据 +- `fetch_kline_finance()` - 获取财务数据 +- `fetch_stock_basic_info()` - 获取股票基础信息 + +**app/adapters/amazingdata_adapter.py** 新增实现: +- `_fetch_kline_base_sync()` - 同步获取基础K线 +- `_fetch_kline_quote_sync()` - 同步计算并获取行情指标 +- `_fetch_kline_finance_sync()` - 同步获取财务数据 +- `_fetch_stock_basic_info_sync()` - 同步获取基础信息 + +## 使用指南 + +### 1. 手动触发数据同步 + +```bash +# 全量同步(基础+行情+财务) +curl -X POST "http://localhost:8080/admin/data/sync" \ + -H "Content-Type: application/json" \ + -H "X-Admin-Token: your_token" \ + -d '{ + "symbols": ["600519.SH", "000001.SZ"], + "sync_type": "full", + "start_date": "20240101", + "end_date": "20240301", + "asset_class": "stock" + }' + +# 只同步基础K线数据 +curl -X POST "http://localhost:8080/admin/data/sync" \ + -H "Content-Type: application/json" \ + -d '{ + "symbols": ["600519.SH"], + "sync_type": "base", + "freq": "1d" + }' + +# 只同步行情指标 +curl -X POST "http://localhost:8080/admin/data/sync" \ + -H "Content-Type: application/json" \ + -d '{ + "symbols": ["600519.SH"], + "sync_type": "quote" + }' +``` + +### 2. 在代码中使用 Repository + +```python +from app.repositories.stock_repository_v2 import StockRepositoryV2 +from app.repositories.database import SessionLocal +from app.models import Frequency + +db = SessionLocal() +repo = StockRepositoryV2(db) + +# 查询基础K线 +base_data = repo.get_klines_base( + symbol="600519.SH", + freq=Frequency.FREQ_1D, + start=datetime(2024, 1, 1), + end=datetime(2024, 3, 1) +) + +# 查询行情指标 +quote_data = repo.get_klines_quote( + symbol="600519.SH", + start_date="2024-01-01", + end_date="2024-03-01" +) + +# 查询财务数据 +finance_data = repo.get_klines_finance( + symbol="600519.SH", + start_date="2024-01-01", + end_date="2024-03-01" +) +``` + +### 3. 使用数据同步服务 + +```python +from app.services.adapter_service import AdapterService +from app.services.data_sync_service import DataSyncService + +# 获取适配器 +adapter_service = AdapterService() +adapter = adapter_service.get_active_adapter("stock") + +# 创建同步服务 +sync_service = DataSyncService(adapter) + +# 同步指定标的 +results = await sync_service.sync_full_stock_data( + symbols=["600519.SH", "000001.SZ"], + start="20240101", + end="20240301" +) +``` + +## 迁移建议 + +1. **新部署系统**:直接使用新表结构,旧表可忽略 +2. **存量系统迁移**: + - 使用 `INSERT INTO stock_klines_1d_base SELECT ... FROM stock_klines_1d` 迁移基础数据 + - 行情/财务字段需要重新计算或使用默认值 + - 保持旧表一段时间用于回滚 + +3. **数据补全策略**: + - 基础数据:从数据源全量拉取 + - 行情指标:在本地根据基础数据计算 + - 财务数据:从股本结构接口获取历史数据 + +## 性能优化建议 + +1. **索引优化**:已为高频查询字段(symbol_id, trade_date, ts)创建联合索引 +2. **分区建议**:对于大表可按 `trade_date` 进行按月分区 +3. **查询优化**: + - 只需OHLCV时只查询 base 表 + - 需要指标时 JOIN quote 表 + - 需要财务数据时 JOIN finance 表 + +## 后续扩展计划 + +1. **支持更多指标**:RSI、KDJ、布林带等 +2. **分钟线行情表**:为高频策略提供分钟级指标 +3. **财报季数据表**:季度/年度详细财报数据 +4. **资金流向表**:北向资金、主力资金等 diff --git a/QUICKSTART_NEW_TABLES.md b/QUICKSTART_NEW_TABLES.md new file mode 100644 index 0000000..5f8d744 --- /dev/null +++ b/QUICKSTART_NEW_TABLES.md @@ -0,0 +1,198 @@ +# 新表结构快速开始指南 + +## 1. 创建新表 + +```bash +# 进入项目目录 +cd d:\\alpha_workspace\\python_market_data_service + +# 激活虚拟环境 +.\\venv\\Scripts\\activate + +# 创建新表 +python scripts/create_split_tables.py + +# 如果需要重新创建(会删除已有数据) +python scripts/create_split_tables.py --drop-existing +``` + +## 2. 启动服务 + +```bash +# 启动主服务 +python main.py +``` + +## 3. 同步数据 + +### 全量同步示例 + +```bash +# 同步茅台和平安银行的历史数据 +curl -X POST "http://localhost:8080/admin/data/sync" \ + -H "Content-Type: application/json" \ + -d '{ + "symbols": ["600519.SH", "000001.SZ"], + "sync_type": "full", + "start_date": "20230101", + "end_date": "20240301" + }' +``` + +### 增量同步示例 + +```bash +# 同步最近30天的数据 +curl -X POST "http://localhost:8080/admin/data/sync/incremental" \ + -H "Content-Type: application/json" \ + -d '["600519.SH", "000001.SZ"]' +``` + +### 只同步基础数据 + +```bash +curl -X POST "http://localhost:8080/admin/data/sync" \ + -H "Content-Type: application/json" \ + -d '{ + "symbols": ["600519.SH"], + "sync_type": "base", + "start_date": "20240101", + "end_date": "20240301" + }' +``` + +## 4. 查询数据 + +### 使用 Repository 查询 + +```python +from app.repositories.stock_repository_v2 import StockRepositoryV2 +from app.repositories.database import SessionLocal +from app.models import Frequency +from datetime import datetime + +# 创建会话 +db = SessionLocal() +repo = StockRepositoryV2(db) + +# 查询基础K线数据(高效,字段少) +base_data = repo.get_klines_base( + symbol="600519.SH", + freq=Frequency.FREQ_1D, + start=datetime(2024, 1, 1), + end=datetime(2024, 3, 1) +) +print(f"Base records: {len(base_data)}") + +# 查询行情指标(包含均线、MACD等) +quote_data = repo.get_klines_quote( + symbol="600519.SH", + start_date="2024-01-01", + end_date="2024-03-01" +) +for item in quote_data[:3]: + print(f"{item['trade_date']}: close={item.get('ma5')}") + +# 查询财务数据(市值、股本等) +finance_data = repo.get_klines_finance( + symbol="600519.SH", + start_date="2024-01-01", + end_date="2024-03-01" +) +for item in finance_data[:3]: + print(f"{item['trade_date']}: 市值={item.get('total_market_cap')}") +``` + +## 5. 定时任务配置 + +创建每日定时同步脚本 `scripts/daily_sync.py`: + +```python +#!/usr/bin/env python3 +"""每日增量同步脚本""" +import asyncio +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from app.services.adapter_service import AdapterService +from app.services.data_sync_service import DataSyncService +from app.repositories.stock_repository_v2 import StockRepositoryV2 +from app.repositories.database import SessionLocal + +async def main(): + # 获取热门股票列表 + db = SessionLocal() + repo = StockRepositoryV2(db) + + # 这里可以从配置或数据库读取需要同步的标的列表 + symbols = ["600519.SH", "000001.SZ", "000858.SZ"] # 示例 + + # 获取适配器 + adapter_service = AdapterService() + adapter = adapter_service.get_active_adapter("stock") + + # 同步 + sync_service = DataSyncService(adapter) + results = await sync_service.sync_daily_incremental(symbols) + + print("Sync completed:", results) + +if __name__ == "__main__": + asyncio.run(main()) +``` + +添加到定时任务(Linux crontab): +```bash +# 每天凌晨2点执行增量同步 +0 2 * * * cd /path/to/project && python scripts/daily_sync.py >> logs/sync.log 2>&1 +``` + +## 6. 表结构说明 + +| 表名 | 用途 | 数据来源 | 更新频率 | +|------|------|----------|----------| +| stock_klines_1d_base | 基础K线(OHLCV) | query_kline | 每日 | +| stock_klines_1d_quote | 行情指标 | 本地计算 | 每日 | +| stock_klines_1d_finance | 财务数据 | 股本/财报接口 | 财报季 | +| stock_realtime_quotes | 实时行情 | 行情推送 | 实时 | +| stock_symbols | 股票列表 | 基础数据接口 | 每日 | +| stock_trading_calendar | 交易日历 | 交易所数据 | 每年 | + +## 7. 常见问题 + +### Q: 旧表数据如何迁移? + +A: 使用以下SQL迁移基础数据: +```sql +INSERT INTO stock_klines_1d_base +(symbol_id, ts, trade_date, open, high, low, close, volume, amount) +SELECT + symbol_id, ts, trade_date, open, high, low, close, volume, amount +FROM stock_klines_1d +WHERE trade_date >= '2024-01-01'; +``` + +### Q: 如何只查询基础数据提高性能? + +A: 使用 `StockRepositoryV2.get_klines_base()` 方法,它只查询轻量的base表。 + +### Q: 行情指标如何计算? + +A: 指标在 `AmazingDataAdapter._fetch_kline_quote_sync()` 中本地计算: +- 均线: 基于收盘价计算MA5/10/20/30/60/120/250 +- MACD: 使用pandas ewm计算 +- 乖离率: (收盘价-均线)/均线 +- 涨跌停: 对比收盘价和涨跌停价 + +### Q: 市值数据如何计算? + +A: 市值 = 收盘价 × 股本,股本从 `get_equity_structure` 接口获取。 + +## 8. 下一步 + +- [ ] 配置定时自动同步 +- [ ] 添加Redis缓存热点数据 +- [ ] 实现分钟线行情指标表 +- [ ] 添加数据一致性校验任务 diff --git a/REFACTOR_SUMMARY.md b/REFACTOR_SUMMARY.md new file mode 100644 index 0000000..fef80bd --- /dev/null +++ b/REFACTOR_SUMMARY.md @@ -0,0 +1,186 @@ +# 数据库结构重设计 - 完成总结 + +## 概述 + +已完成数据库表结构从单一宽表向拆分表的迁移设计,并实现了完整的数据同步流程。 + +## 主要改动 + +### 1. 数据模型层 (app/repositories/models.py) + +**新增拆分表模型:** +- `StockKLine1DBase` - 日线基础表 (OHLCV) +- `StockKLine1DQuote` - 日线行情表 (涨跌幅、均线、MACD、乖离率、涨跌停状态) +- `StockKLine1DFinance` - 日线财务表 (市值、股本、机构持股、利润) +- `StockRealTimeQuote` - 实时行情快照表 +- `StockBasicInfo` - 股票基础信息表 + +**向后兼容:** +- 添加 `StockKLine1D = StockKLine1DBase` 别名,保持旧代码兼容 + +### 2. 数据仓库层 (app/repositories/stock_repository_v2.py) + +**新增 Repository:** +- `StockRepositoryV2` - 支持拆分表结构的新仓库 + - `get_klines_base()` / `save_klines_base()` - 基础数据操作 + - `get_klines_quote()` / `save_klines_quote()` - 行情数据操作 + - `get_klines_finance()` / `save_klines_finance()` - 财务数据操作 + - `get_realtime_quote()` / `save_realtime_quote()` - 实时行情操作 + +### 3. 适配器接口层 (app/adapters/base.py) + +**新增抽象方法:** +- `fetch_kline_base()` - 获取基础K线数据 +- `fetch_kline_quote()` - 获取行情指标数据 +- `fetch_kline_finance()` - 获取财务数据 +- `fetch_stock_basic_info()` - 获取股票基础信息 + +### 4. AmazingData适配器 (app/adapters/amazingdata_adapter.py) + +**新增实现方法:** +- `_fetch_kline_base_sync()` - 从 query_kline 获取基础数据 +- `_fetch_kline_quote_sync()` - 计算均线/MACD/乖离率等指标 +- `_fetch_kline_finance_sync()` - 从股本结构数据计算市值 +- `_fetch_stock_basic_info_sync()` - 获取股票列表和基础信息 + +### 5. 数据同步服务 (app/services/data_sync_service.py) + +**新增服务:** +- `DataSyncService` - 协调数据拉取和存储 + - `sync_kline_base()` - 同步基础K线 + - `sync_kline_quote()` - 同步行情指标 + - `sync_kline_finance()` - 同步财务数据 + - `sync_full_stock_data()` - 全量同步 + - `sync_daily_incremental()` - 每日增量同步 + +### 6. 管理API层 (app/api/admin_routes.py) + +**新增端点:** +- `POST /admin/data/sync` - 手动触发数据同步 + - 支持 sync_type: base/quote/finance/full + - 支持指定日期范围和标的列表 +- `POST /admin/data/sync/incremental` - 增量同步(最近30天) + +### 7. 类型定义 (app/models/admin_types.py) + +**新增类型:** +- `DataSyncType` - 同步类型枚举 +- `DataSyncRequest` - 同步请求 +- `DataSyncResult` - 单个标的同步结果 +- `DataSyncData` - 同步响应 + +### 8. 迁移脚本 (scripts/create_split_tables.py) + +**功能:** +- 自动创建所有新表 +- 支持 `--drop-existing` 参数重建表 + +## 表结构对比 + +### 旧表 (stock_klines_1d) +``` +symbol_id, ts, trade_date, open, high, low, close, volume, amount, +is_limit_up, is_limit_down, total_market_cap, float_market_cap, +inst_holding_ratio, trading_days +``` + +### 新表 +``` +stock_klines_1d_base: + symbol_id, ts, trade_date, open, high, low, close, volume, amount + +stock_klines_1d_quote: + symbol_id, trade_date, change_pct, change_Nd_pct, maN, + macd_dif/dea/bar, biasN, is_limit_up/down, limit_up/down_price, is_st + +stock_klines_1d_finance: + symbol_id, trade_date, total_market_cap, float_market_cap, + total_shares, float_shares, inst_holding_shares, inst_holding_ratio, + top10_holders_ratio, net_profit, revenue, eps, roe, trading_days +``` + +## API使用示例 + +### 1. 全量同步 +```bash +curl -X POST "http://localhost:8080/admin/data/sync" \ + -H "Content-Type: application/json" \ + -d '{ + "symbols": ["600519.SH", "000001.SZ"], + "sync_type": "full", + "start_date": "20240101", + "end_date": "20240301" + }' +``` + +### 2. 只同步基础数据 +```bash +curl -X POST "http://localhost:8080/admin/data/sync" \ + -H "Content-Type: application/json" \ + -d '{ + "symbols": ["600519.SH"], + "sync_type": "base" + }' +``` + +### 3. 增量同步 +```bash +curl -X POST "http://localhost:8080/admin/data/sync/incremental" \ + -H "Content-Type: application/json" \ + -d '["600519.SH", "000001.SZ"]' +``` + +## 代码使用示例 + +```python +from app.repositories.stock_repository_v2 import StockRepositoryV2 +from app.repositories.database import SessionLocal +from app.models import Frequency + +db = SessionLocal() +repo = StockRepositoryV2(db) + +# 只查询基础数据(高效) +base_data = repo.get_klines_base( + symbol="600519.SH", + freq=Frequency.FREQ_1D, + start=datetime(2024, 1, 1), + end=datetime(2024, 3, 1) +) + +# 只查询行情指标 +quote_data = repo.get_klines_quote( + symbol="600519.SH", + start_date="2024-01-01", + end_date="2024-03-01" +) +``` + +## 性能优势 + +1. **查询优化**:只需OHLCV时只查询轻量的base表 +2. **更新解耦**:基础数据可高频更新,财务数据按财报季更新 +3. **扩展灵活**:新增指标只需修改quote表,不影响基础数据 + +## 后续建议 + +1. **定时任务**:配置每日凌晨执行增量同步 +2. **数据校验**:定期校验基础数据和行情指标的一致性 +3. **分区优化**:对大表按trade_date进行分区 +4. **缓存优化**:热点数据使用Redis缓存 + +## 文件清单 + +**新增文件:** +- `app/repositories/stock_repository_v2.py` (636 lines) +- `app/services/data_sync_service.py` (387 lines) +- `scripts/create_split_tables.py` (106 lines) +- `DATABASE_REFACTOR.md` - 详细设计文档 + +**修改文件:** +- `app/repositories/models.py` - 添加新表模型和向后兼容别名 +- `app/adapters/base.py` - 添加新抽象方法 +- `app/adapters/amazingdata_adapter.py` - 添加新数据获取方法 (~300 lines) +- `app/api/admin_routes.py` - 添加数据同步端点 +- `app/models/admin_types.py` - 添加同步相关类型 +- `app/models/__init__.py` - 导出新类型 diff --git a/SPLIT_TABLES_COMPLETE.md b/SPLIT_TABLES_COMPLETE.md new file mode 100644 index 0000000..4252245 --- /dev/null +++ b/SPLIT_TABLES_COMPLETE.md @@ -0,0 +1,146 @@ +# 数据表分表完成总结 + +## 完成状态: ✅ 全部完成 + +## 股票数据表分表 + +### 日线分表 (3张表) +| 表名 | 用途 | 状态 | +|------|------|------| +| `stock_klines_1d_base` | 基础K线数据 (OHLCV) | ✅ | +| `stock_klines_1d_quote` | 行情指标 (均线/MACD/涨跌幅) | ✅ | +| `stock_klines_1d_finance` | 财务数据 (市值/股本/利润) | ✅ | + +### 分钟线 (2张表) +| 表名 | 用途 | 状态 | +|------|------|------| +| `stock_klines_1m` | 1分钟线 | ✅ | +| `stock_klines_5m` | 5分钟线 | ✅ | + +### 实时数据 (1张表) +| 表名 | 用途 | 状态 | +|------|------|------| +| `stock_realtime_quotes` | 实时行情快照 | ✅ | + +--- + +## 期货数据表分表 + +### 日线分表 (2张表) +| 表名 | 用途 | 状态 | +|------|------|------| +| `futures_klines_1d_base` | 基础K线数据 (OHLCV+持仓量) | ✅ | +| `futures_klines_1d_quote` | 行情指标 (均线/MACD/持仓变化) | ✅ | + +### 分钟线 (2张表) +| 表名 | 用途 | 状态 | +|------|------|------| +| `futures_klines_1m_base` | 1分钟基础线 | ✅ | +| `futures_klines_5m_base` | 5分钟基础线 | ✅ | + +### 实时数据 (1张表) +| 表名 | 用途 | 状态 | +|------|------|------| +| `futures_realtime_quotes` | 实时行情快照 | ✅ | + +--- + +## 代码文件 + +### 模型定义 +- `app/repositories/models.py` - 所有分表模型定义 + +### 数据仓库 +- `app/repositories/stock_repository_v2.py` - 股票分表仓库 +- `app/repositories/futures_repository_v2.py` - 期货分表仓库 + +### 创建脚本 +- `scripts/create_split_tables.py` - 创建所有分表 +- `scripts/add_stock_split_tables.py` - 添加股票分表 +- `scripts/fix_models.py` - 添加期货分表 + +--- + +## 使用方法 + +### 1. 创建分表 +```bash +python scripts/create_split_tables.py +``` + +### 2. 股票数据操作 +```python +from app.repositories.stock_repository_v2 import StockRepositoryV2 +from app.repositories.database import SessionLocal +from app.models import Frequency + +db = SessionLocal() +repo = StockRepositoryV2(db) + +# 查询基础数据 +base_data = repo.get_klines_base( + symbol="600519.SH", + freq=Frequency.FREQ_1D, + start=datetime(2024, 1, 1), + end=datetime(2024, 3, 1) +) + +# 查询行情指标 +quote_data = repo.get_klines_quote( + symbol="600519.SH", + start_date="2024-01-01", + end_date="2024-03-01" +) + +# 查询财务数据 +finance_data = repo.get_klines_finance( + symbol="600519.SH", + start_date="2024-01-01", + end_date="2024-03-01" +) +``` + +### 3. 期货数据操作 +```python +from app.repositories.futures_repository_v2 import FuturesRepositoryV2 + +db = SessionLocal() +repo = FuturesRepositoryV2(db) + +# 查询基础数据 +base_data = repo.get_klines_base( + symbol="RB2410.SH", + freq=Frequency.FREQ_1D, + start=datetime(2024, 1, 1), + end=datetime(2024, 3, 1) +) + +# 查询行情指标 +quote_data = repo.get_klines_quote( + symbol="RB2410.SH", + start_date="2024-01-01", + end_date="2024-03-01" +) +``` + +--- + +## 分表设计优势 + +1. **查询性能**: 只需基础数据时,只查询轻量的base表 +2. **更新解耦**: + - 基础数据: 每日更新 + - 行情指标: 计算后更新 + - 财务数据: 财报季更新 +3. **扩展性**: 新增指标只需修改quote表 +4. **存储优化**: 冷热数据分离 + +--- + +## 总表数统计 + +| 类别 | 表数量 | +|------|--------| +| 股票分表 | 6张 | +| 期货分表 | 5张 | +| **总计** | **11张** | diff --git a/TABLE_SPLIT_DESIGN.md b/TABLE_SPLIT_DESIGN.md new file mode 100644 index 0000000..6e78261 --- /dev/null +++ b/TABLE_SPLIT_DESIGN.md @@ -0,0 +1,253 @@ +# 数据表分表设计文档 + +## 设计原则 + +所有K线数据表按照**数据类型**和**更新频率**进行拆分: +1. **基础表(Base)**: OHLCV等原始行情数据,更新频率高 +2. **行情表(Quote)**: 技术指标(均线/MACD/涨跌幅),需要计算 +3. **财务表(Finance)**: 市值/股本等财务数据,更新频率低 + +## 股票数据表 + +### 日线数据 (1d) + +| 表名 | 说明 | 主要字段 | +|------|------|----------| +| `stock_klines_1d_base` | 日线基础表 | symbol_id, trade_date, open, high, low, close, volume, amount, adj_factor | +| `stock_klines_1d_quote` | 日线行情表 | change_pct, ma5/10/20/30/60/120/250, macd_dif/dea/bar, bias5/10/20, is_limit_up/down, is_st | +| `stock_klines_1d_finance` | 日线财务表 | total_market_cap, float_market_cap, total_shares, float_shares, inst_holding_ratio, net_profit, revenue, eps, roe | + +### 分钟线数据 + +| 表名 | 说明 | 主要字段 | +|------|------|----------| +| `stock_klines_1m` | 1分钟线 | symbol_id, ts, trade_date, open, high, low, close, volume, amount, change_pct, macd_dif/dea/bar | +| `stock_klines_5m` | 5分钟线 | 同上 | +| `stock_klines_15m` | 15分钟线 | 同上 | +| `stock_klines_30m` | 30分钟线 | 同上 | +| `stock_klines_60m` | 60分钟线 | 同上 | + +### 周月线数据 + +| 表名 | 说明 | 主要字段 | +|------|------|----------| +| `stock_klines_1w` | 周线 | symbol_id, ts, trade_date, open, high, low, close, volume, amount | +| `stock_klines_1month` | 月线 | 同上 | + +### 实时数据 + +| 表名 | 说明 | 主要字段 | +|------|------|----------| +| `stock_realtime_quotes` | 实时行情快照 | symbol_id, last_price, open, high, low, volume, amount, bid1, ask1 | + +### 基础数据 + +| 表名 | 说明 | 主要字段 | +|------|------|----------| +| `stock_symbols` | 股票列表 | symbol_id, name, exchange, list_date, industry, status | +| `stock_trading_calendar` | 交易日历 | trade_date, is_trading_day, week_day | +| `stock_adjust_factors` | 复权系数 | symbol_id, trade_date, qfq_factor, hfq_factor | + +--- + +## 期货数据表 + +### 日线数据 (1d) + +| 表名 | 说明 | 主要字段 | +|------|------|----------| +| `futures_klines_1d_base` | 日线基础表 | symbol_id, trade_date, open, high, low, close, volume, amount, open_interest, settlement, pre_settlement | +| `futures_klines_1d_quote` | 日线行情表 | change_pct, ma5/10/20/30/60, macd_dif/dea/bar, oi_change, oi_change_pct, amplitude | + +### 分钟线数据 + +| 表名 | 说明 | 主要字段 | +|------|------|----------| +| `futures_klines_1m_base` | 1分钟基础表 | symbol_id, ts, trade_date, open, high, low, close, volume, amount, open_interest | +| `futures_klines_5m_base` | 5分钟基础表 | 同上 | +| `futures_klines_15m_base` | 15分钟基础表 | 同上 | +| `futures_klines_30m_base` | 30分钟基础表 | 同上 | +| `futures_klines_60m_base` | 60分钟基础表 | 同上 | + +### 周月线数据 + +| 表名 | 说明 | 主要字段 | +|------|------|----------| +| `futures_klines_1w_base` | 周线基础表 | symbol_id, ts, trade_date, open, high, low, close, volume, amount, open_interest | +| `futures_klines_1month_base` | 月线基础表 | 同上 | + +### 实时数据 + +| 表名 | 说明 | 主要字段 | +|------|------|----------| +| `futures_realtime_quotes` | 实时行情快照 | symbol_id, last_price, open, high, low, volume, amount, open_interest, bid1, ask1, limit_up, limit_down | + +### 基础数据 + +| 表名 | 说明 | 主要字段 | +|------|------|----------| +| `futures_symbols` | 合约列表 | symbol_id, name, exchange, underlying, contract_month, list_date, delist_date | +| `futures_trading_calendar` | 交易日历 | trade_date, is_trading_day, has_night_session, week_day | + +--- + +## 代码实现 + +### Repository 结构 + +``` +app/repositories/ +├── stock_repository_v2.py # 股票分表仓库 +├── futures_repository_v2.py # 期货分表仓库 +└── models.py # 所有模型定义 +``` + +### 关键方法 + +#### StockRepositoryV2 +```python +# 基础数据操作 +get_klines_base(symbol, freq, start, end) -> List[Dict] +save_klines_base(freq, items) -> int + +# 行情数据操作 +get_klines_quote(symbol, start_date, end_date) -> List[Dict] +save_klines_quote(items) -> int + +# 财务数据操作 +get_klines_finance(symbol, start_date, end_date) -> List[Dict] +save_klines_finance(items) -> int + +# 实时行情 +get_realtime_quote(symbol) -> Optional[Dict] +save_realtime_quote(data) -> None +``` + +#### FuturesRepositoryV2 +```python +# 基础数据操作 +get_klines_base(symbol, freq, start, end) -> List[Dict] +save_klines_base(freq, items) -> int + +# 行情数据操作 +get_klines_quote(symbol, start_date, end_date) -> List[Dict] +save_klines_quote(items) -> int + +# 实时行情 +get_realtime_quote(symbol) -> Optional[Dict] +save_realtime_quote(data) -> None +``` + +--- + +## 使用示例 + +### 股票数据查询 + +```python +from app.repositories.stock_repository_v2 import StockRepositoryV2 +from app.repositories.database import SessionLocal +from app.models import Frequency + +db = SessionLocal() +repo = StockRepositoryV2(db) + +# 只查询基础数据(高效) +base_data = repo.get_klines_base( + symbol="600519.SH", + freq=Frequency.FREQ_1D, + start=datetime(2024, 1, 1), + end=datetime(2024, 3, 1) +) + +# 查询行情指标 +quote_data = repo.get_klines_quote( + symbol="600519.SH", + start_date="2024-01-01", + end_date="2024-03-01" +) + +# 查询财务数据 +finance_data = repo.get_klines_finance( + symbol="600519.SH", + start_date="2024-01-01", + end_date="2024-03-01" +) +``` + +### 期货数据查询 + +```python +from app.repositories.futures_repository_v2 import FuturesRepositoryV2 + +db = SessionLocal() +repo = FuturesRepositoryV2(db) + +# 查询期货基础数据 +base_data = repo.get_klines_base( + symbol="RB2410.SH", + freq=Frequency.FREQ_1D, + start=datetime(2024, 1, 1), + end=datetime(2024, 3, 1) +) + +# 查询期货行情指标 +quote_data = repo.get_klines_quote( + symbol="RB2410.SH", + start_date="2024-01-01", + end_date="2024-03-01" +) +``` + +--- + +## 创建表的脚本 + +```bash +# 创建所有分表 +python scripts/create_split_tables.py + +# 重新创建(会删除已有数据) +python scripts/create_split_tables.py --drop-existing +``` + +--- + +## 数据同步 + +### 股票数据同步 +```bash +# 全量同步 +curl -X POST "http://localhost:8080/admin/data/sync" \ + -H "Content-Type: application/json" \ + -d '{ + "symbols": ["600519.SH", "000001.SZ"], + "sync_type": "full", + "start_date": "20240101", + "end_date": "20240301" + }' +``` + +### 期货数据同步 +期货数据同步API待实现,可参考股票同步API实现类似接口。 + +--- + +## 性能优势 + +1. **查询优化**: 只需基础数据时,只查询轻量的base表 +2. **更新解耦**: + - 基础数据: 每日收盘后更新 + - 行情指标: 计算后批量更新 + - 财务数据: 财报季更新 +3. **存储优化**: 热点数据(base)和冷数据(finance)分离存储 +4. **扩展性**: 新增指标只需修改quote表,不影响其他表 + +--- + +## 后续扩展 + +1. **分钟线行情表**: 为高频策略提供分钟级技术指标 +2. **跨表查询视图**: 创建联合视图方便查询 +3. **数据分区**: 按trade_date对大表进行分区 +4. **缓存层**: Redis缓存热点基础数据 diff --git a/app/adapters/__pycache__/amazingdata_adapter.cpython-311.pyc b/app/adapters/__pycache__/amazingdata_adapter.cpython-311.pyc index 37ea79f83910ea04a0e8e7bf0e2ca0461467469d..ec0b1d5cbe1d7d59553a273defc4de726a28b699 100644 GIT binary patch delta 21040 zcmcJ133yZ2mGFDoSIf3`%ZudQU}J1!7GoA;V+;WYa9F|;qxi{S;{~2X07r`GG$|&- zOqD#6IH}W!lq6=d)YMIXr_)U7Oco|HO(pkzV?`|;I@2_zGi}8r>Cp85r*qDIS}fT~ z+WG(g`RTlK&)v?u_rCki_B=5o9{&re?ggz@MZxjjgRl0fwe%HTzg+Z@io0KaoNk}` zmE1_vo47T~I%VmD(tX8Biow(>=_aPwmBSrZujam`%%VHE<0>atuG&Jd!FYPTS`)`! zQEQ|fOtni6<+`~-%^{%Y=yq@+4VFM`i8g_qPh!7JVjWqS>(H&!DVP<&#|kBde+=nY zyAmiXlaw6Y!FZcjb(MetXWC{cu?BfhaP5~dYk;{rhHl%g<`E^>L zPAjRCxViK?{`q&NU^YOdwm-3>R;aTvKC)^UJ9%oXQrP@aWw8HLfnEvI2Gi00r#CdO zosH1WCPvjoRc)SnCcC48zRdk%#ZhkSLyD>WE05&HfY-1>^L%y4@Z&Dq=iB zL;r$h{}sW{5WJ7z=Ln(*aKPBe)DOB=sznN#Tl21!lRReNLfu-fwYv*R7srfKFL!@K zV*EtBq^pB%fC`B1W0xVud6=)zvs||8Kg?E|qTC`N+U@L8Y}N*ZktWVrujg(Y)^qE6 zx2g3&Vhsq4+!MWa`U7sdcR&3xXE?l5e-r9dso6WRssf9MXzui3huVmE5du5++~Hzc z&HW)@1!Uaa15eP#sapdbG;QRX zh8}`_0J@Y4T^gtUap-SE^t;@jAHQSAe;^a<5PXav2S9=~!p>7j{R)B~AfS=E-y-&( z2t<&KPetF5hm}K;yK>k>pPpLFeqBTtPyO1HC8Do!w?=+E_1?){H2pSLd%7A32Tp&h z@EW%85CFFXz@bBj;-mQ`Xl2B~)txbOUp}px+A$&)(GPLEyo>0w+!^n~)=}i)9Ds~^ z=V7=k+~h-&sdc`WvC6Of)uf7vTYBcCOsF>X@|iD-jo-tr*B~HF{4HXyb2}caDk7)` z(ov0Ti0LM)3~!B;yOZK3Uomr&4_?j_)@ZT?ybYGreVohrE7R1uhh7({NiT`EM~l^D z$jGe!mV03I1!JlWQk`V~cB<*z6}l*ug&n)40PJ`O>n+GV@J<0&@#nwJyNF})H5`kK zBF}NP=l@G4WKT8x#nZ6FxbT-tjmi6CAIGZXwk~t3zzdyYkgie*OXM=<;V7`_jF9PK zU%_1AW&v~K%MWMI4(Iyy0*B8|{UI$^~+ zO3Zd(2j>y=Awk6cfNOp?k6SY464t9&VCDx?!Le;p;Q!Z8SJ97gUw_5IE&b|g8S=|M z38QV4v)=-Iq>QWkYS~omtG|~(*?<39WxVX5m$cl@OI>s7h}hR)5c8*ga_J%# zKXbVvUi|9imh306=~`q==wvGAuTEmAbFY+Ou2DjLO5kXy&Xa(YO@eY_)E_G>XzxjMDt)fS1G%u9UfeDSr#>f~s% z*SObSd$gQPmRuO0g{DX-&mI4^QTC27Hdnq|IQ3WGegkh~_`Aum`EGOew=-@QM^waq zA4i9)y_zez3i(sdd|?Yc_2utf!DZC)Mq;eK@J7?Tu_DX=r*GWGj-r2`>g9LbVtAD}-ZjIUX4ShV$x^r+UkV!T*kdNyEZ$KeZV*$kcg@(5 z;)lY-Sh$m?E!;gnbY#D=z+K7NA7f2nYq{r#s;PhcAuEA>VqK&nzEAif#|uAgd--Rq zTSB&_lJ~yOt$J8%R>CBI5q=+ziYV$Vb(TIWIx9Xa;iC2V^f~VJCNuZRmn_`+w{oUd zHRRLW|5?_cVkF}VpVTW2YPpLJgXo-uyV2m_{L7TwyN!4IrK93e$*6pk8WoMwy)s7j zpyGLG`bGE&#}_F^dcSx#^@R9K5_m()ojAMgE>F)9`yDj{eM2t$y-v5w4%?Q~UU_H7 z*4EusDtr7$BCW$L>vA6(9%A~24%dk0+ZCcE1+T+?&3`zW^J?yh6Hv88Gx&!G;+ zsnFvR&T6ahZ|!q?_P6!*cn;cohKD@Pz9DS0x6d`exSNtaA_p~g_vyiVhX~=NT zx3hgc!k}cddEfBBi9slU?IGVnI-`(taQMWKCsjyD3x(?9V_Rc)GX34X&K}P&+hlKH z`cJq$gRUWuT_7b}9I54Uwix&K;*1GHO$PKJnL^}%b>K47rlxVpk|k9NHVD(tUPSOL zg0CUCgy1;@69A&Jx0sB>a2=PympbJBB?bJRJ}Ir@2cO}vP& z3UYK&YygldI%?|9WU1SIdZ?#LCcOK{p%gobfV{qlP;dy6ZW(|xXU=fLkzy{mLM_3& z;dWfoAJ_|>xCaeGcFlx8S@eAq~)EVmIMU*ltly%^$3;>+h48j#) zSaFb791JNAMiiP^g>^<@4J&NC!WL54VlvLKYFAaR=|Ux+Tf*lq;&W>wro0PBcvA&$ zTEd%_#d4G;Rg3~Ks)`x7pRaOo;m7i=dqSFpPi}QneMt|FQsT%31U7o=!(+hr6O@-e zO#5VBF8eRd+>IBr#Xh-!7p~6HDZEra+?Q|!McgBQX{?iZ35w)(O>+Elas$u7KE&jW)mCYf}Lyjf@H^YtE-BjblR%Y%|c z{ODJ5H#V4cD?G*og`&J!+||lb?w)p?ROihKYDl*qSahYn?4kQTwp79NNV(O!a4FY7&`q2Qty8Hj%jyLs>#*F{AUU`t=Ep z@1xj~1^L%8*~tIaA)3i%a>iBZgG{T$1E0N98{*rUn&($2bihFQjK?;U3CRR*>j zi*82n3j{dG>~9cw5ey=TVgKxY1ji7xAh?C#AcA!WP*G%Y?XpBoBO2N;CXWKB(hHmM zAtZbU!RrXfE69g+HIu$4gqRythgMZ1OX1{oB@y_4OKZe^3DCUORo`x`hNx5 zNL5_Tl^2maCtEOiSM0}q_;HwmGgK^>%C&HZ?^?vg?%KKdhSoeLMbF)PX&|g?;B^hK+iDxH8!cmb zv(}m!Yfac%%Uf&1#-+S*X+RVysF*ESI#aN8GA~@v%oj8VQ~}ldw!){~!KNquPlt3zvp1b-bnS(=*KZV_nbed1_B^*F;OWU6N*Vk*uIRnFAL{2@_CJs z);mL8yZEkyp;kPfb_H8s*!1kCiLKKm;nKBy>DqANI=*mS2+p3~m{Fad6{FPoEm^lH zwO02L0!V9Nf&hG+2h$MJwm&T%+w`PnTmvYa)4jqqtk}dWHiZ z)qSi7ghfH*6exBull6cw=scV$t;TC~GD4-!8pe6revC+~LY6-%IijFhV;^*OcQ)C# z4c~{dqVql%3vIgRmFWS)MPF|%<8s#C>jZgpTT5$OYM3(1-`nSOW2+nBYG<9EzTs4f z-9op0?(TuU!9Gv-iDR|Q@X4Vj`&LZa<4I5woSqY@TFFXo7>TaCGP6e-PzYW&SFj@k zey}w@b9i$jhRo@8gDA{}X3gAB29-38g6-yWVi0Pn=V6O{k-Xwz&4Hwo5Nge7LM`cn z>{OQ}f>b+4mIZqZ+I0*lR!YhAqZGS!68AI|nr;hH?9v&-(n-^_@v1m%XyXlSX9NNE zvXpg0A+`&_0|?0Geg-heBHbLKxbF`+a&zY0H1~yfjM(dPVbMK~J)&I{h1s|)TS;Wu zLs#2FS%-u(tZ?!QXGr0UaKGuU(YJ*(nI{UK-1M;4p@X(?Y(5+V+4`*gtmAC)*^;xR zPf!fahz6*KOF8-HWCB$DDNWB9(OdxK|9s-!p9voe+HFG77nZ1U!E+g~y_WJsGn{m?#7ORBV|HH9boK?S19;Mq7Q z$B@neqe&_d96l{3uVLQPV)Po30mnQB(&jfl6k-)aqfG68*a6dNI#*-`S` z`)okTVvK|W?`Y5<^F@@``aEDSCb>NTJBY8qSLiG9>VxE2;+}Kl6qy)vI>TO(&?@@} zuP#`I>&xFqF&5AoXvXT*1I+W90NS7n3zMHdS3oZy^ul@cB0{&%qdN$_cpklk&`W{t zpQGYJ2W8#@faTsofEC^%fR#`*Zvj@S2z@>)iwJ!_D~r80Xsy}{_GG38Mm}4ZYQn9?r0PQ9|mpx=n0|E!9eof^c1>`X?+Sk1CM}TbtNd!{( zG^U>1MCl=NTTvOYg}S43A90)6!D>gex@ZoLT=x)IR!?^eLg&cFgpTJIbi9=4w4`}p z7|OfXE}2*BBiIVu+kq>Pl_Gk>82#w-!1A%);Ev$&i(TVKCt98w9vi-Hvj@v2$|rLs z>#pcuS{yEJoHkvhuR6Xb{*Eo|XuIkTJGNYF3EMh(TW847*%2|@0v_UunL7s*h%yu^ zryu?q05TCGF9#&S=xY$xt3R~7AS)ypd8dXClam?32(=H7*YBzB#dp9xRWJo z28R_qR3E`5Dj9S+hrrwOF^>0d5&S2TMMK9jhE`4nY0x>c`i~vZLFeyq7~6rt#4wJv zJSly&DbR#Nm-~YGS@VQxlAd&25l^;Ukxn|N=_@^xO1`Qo>{!h^R!{E;+t%^6bs@vL zIb#RS2?zJy-)V|2zyt9Z@l?PHmcbe&d8~*Lbx|;E+#}_B-8m_Pd14%@_z;P}1PSkP zr33^lxZf9Pu77nQS`N99ld2p>mRj}?i~opblfvE6l^NHr#JYjjM(-%luF8!IgqRiZ0 zG3;TQPXA2@+`(mWXQ!~rZlYWZ)_95gm~)7I2NE~)0HiH_+>gl8$DGGRPf&*`FLhAv zBYO&R|A^?k0(77ykWBzA)LF#ExJIR}L1$e(xzsOr8?Z4uYz2aShlDZg_~Vyq50lys zng_rU)H6JIY^_8F3mBz2Sim8Bt8h-%1&tHNiv>vnd8Tio1MMbm&o`kGxU-09EGp}Q zuo4LFh>Be&2iZ@6Z}wM^4SO2u*W?+jl1Ig$;sS6vqhe5USqU`5N)aTVNH^N4K^I-W zKC#y+;}2vHGqUDSxzGQ?hq4?P0i}%WZUGi|w}@?V$hP>pC2w3AET1q;I3`*ymrNKg zRfjFLyrp*5(lBFbn9dDZ8p4)l-qJj4X_>LKge|Q+o*mb6dCS&-U;2O9fHY#Vjkz8f z42Z9rEEoEcLo6V#OQ)Sz>DPK*Q+-DTl(4;(2WV>JO>MKLju}&j|C%#o>Ij>*^QP^y zrd>0pU18H69?t`zgWbI8P(U0p=Z$qeayr=Z$S`z*rmLXi5#I&hbFPWj$()OWmj++m zHQjKPzH;!J2Ser!q3jLwx9sG)Jp_}2>lOqB*pQ#6LAXdc5a1!C*&@(=5(2R`eIFMd zP=NDaiqI?`!oJP^2djFzFaIK?G zfvdupAJ{XtIha|ssXEc zal_=^Z{Blt?;8ig)}6d{=V(@>VDW6hvYCQqlY7GjYx#n;(8!G(b3idFjTkJmhO!w$ zQCX;bW!SKaH>?^}MsiKFxg|5XCBcqxZVjJXGb#t~hgoO7uo8OZ^^19ZEw69n6uS!R zV0ReZoN$ZGYD;IdrD1J3uPp}`iB|XJdmg@LY-?Cs#cQiZMKOs&xr7_vRmklv$l|W; zI@BFd->9hzW!D8XWB1>_9TU;YC6TI{kfvsI>)2}igS)?ho|Z_CKCtP6jn63=mA!8( z4eoql|Fioq9=vogY-`|c;8?kfR_bpNc3hMy_rAf9q|T z=T~{(U>z%YrsAmzf3RYrE}U1x=hcJ_OL)VQ(Tz8(1*2Ocy6iyR!zV^hL<&lREkWnS z*75xTRYYG9q#s=ySbN=3J|Ue{g)5t;`@@o^%D;=Xg6B;Dg)u#asWwVdo5y^LqZj0z_F-oLe6I7GSq;#78hSEQ&j1-mx zy_2qR#p;>D)d6+HUN&KyJQ1#Hp0PIvv^N$nn^<*u^-HV6n#$3Rz>%QhhR!;xbIj-* z!IB^w)-B?7izfPKbSp!;m62@o6IqXfbH`@S1-I8&Z%ho>4TC+nC?*9gCZmiFz~zW5 zC}U~JU$#6{wtVt9UB^}YhmX;HX*HDA6urbfDEK3$9Stod{u(zEB& zbC8}(8H<97m>zMzK}Z-eVG?jN;ugwSFxEHLH$gu+Fg_5oA}x;eA0dEbj{bKaHxWPFrl%XmPCnT@-W<|yf^+&l z;Tl$S@QRL*qJ#M1+-MU2ySfdsjVq})SIXKcF}Lnj)BhLGTmUxnxt_V+z9HvOX3SO6 zPqjb*TBZ-q&UDAyNY-k@pf>mL3Gk&sm>73!hoP|Ae%R&db{}!FuI?kl1B{CmD1Aeq zin#t{2cBox;~eM)LGdWG+T%P1F<(dluNzYAKuIzqxVU-;hMk_Y3hhXlQz6rVCv-tL z##lGfypUS3=|n-H*#!&s4Y@t!=D}+w3`k ztvbof;ByFu>KHrR+Xt?`P8cKsPZqjU7$^`8K>mS9yPX1(%onm<$AIUJ+q*Kz>@e22 zb7PV`Qjea)+PO}&_$o<7(2Zn6_+e`G&2t*A{%ohAHO*-lZ;7}K*$Q znXo{)?UxLDcA*}C_7BhlFxstt#!x@Ge7f;!N!ZZN8`{sr1Ks`&<)VK_`38g(66j_Z zjBxq|ZfT^PsHor@T$y+lR$xDmxr?U3ujnyfL9y{DGmtU!S;|Q z^GtXLP047qUxG<39*riM6OATKMxz;J;1`lB!Pt-ylV+m5-1i!c9gvZ95vJU75U2*! zly}yr=%Yxmn&LhxK(mJ+m~DQ)`#G}y%HxM_H+Nbeqf^slFnRch!czoKq zv1a{fP#}j_n_dq3N)PGMdQ6G36y>pS+buS6fyc%b9yiP5b^4SfkTkov{5W$2!grb2R0q?O1&~5qdr59l%F)^AMH<0 zQOYm+k~YJ;n+;yp?4)-WQmOGn=i}VvBsxfEEGHxPi9^=`(SAwxz_*+9b|zee2_C@} zximd4(373WgL+nUMM_tCbBR~f=+LDvL7(1(iedanl#*vBXUZ%!4E4pAVB86cOK^|T zvm#LooIu9(R+*fnbMhOX;!rg**%>R+%NSF*&yta6nv)0L zYYoxomw~fzn>_AkRf9)0flIw+uOq(=9LHS7|-CRy?SpR_}|b9 z6hFWfnBmfd2Ug>?Wt6flSPI53&Z91EP%P=wPFe&`{R_U{qwK|st~cO&)>2tbUa z*ndRueE?OWsAjIf6x9d?mB*=&vP1L zkJuAEo)auc5}oW-Y?*le!ieodKw?Pooq88o-sLE$ux=N-7OfNO5Xb>kWwD2_>`xK= z48i*dl7*uBz9BHRWQbwp!^5cFGkmOWXIFgeu7oSOjha zn-Tm1L58u5SjGAZG%y@Fo*cjhm-w1_J&RjVuy+lo{JzhcqE%8R~BzS)ZAnTqD=uB*l2ij925 z#@UK(GZove^+3RAxMDY7u^Y2#}!u5CX^>>7;xAN6nubl{2-xY%M0sr-V2YCO!dm+Abwx@rlr$2Ob zB;0d9-*bO>pO@d~g|$@ECQ7zbd#x^9zm2co7Ow8(t2;wGc89BXhpKl&u~t!=C?*xx z<-y(6<&CMq%4Vtg-R;{SE2wNMSWc7$DR5nr6UyCo%>e+;@Ew{?Ueg)UK)i1L=pCs) zl%Zo%sEJnvWy*tK;8|n+jIkaZDiO0C4EhoM0_MBLY3938QCdKAH3l>TNCh-6Y)n&X zYZox4U3Y+ea<**cOxen5OSo(UU$!CaXyqNPfviB*|1_%EKR1+sm>Xr?Iis%+>FXmU zOD6oM_>z^=g?vdzU@NBQCJolW#+aNkG=O<&WgC6fIkRF@XvL;$8$);PdS~;s&0{Bm z-f&Sv2+sZ8Ax(Z{MeFC5nG>B|SQ*){(;q55@Z84GqNeE zmYK$lp~j8RiGy3i_B!5P7qZt~TNe5BoQlPf@`hnNEt^!-+bY$j zdhy!^!=@VX+ci?axw^+qif=&y47uix)D?5Lzmyk`vM!a1ydv_Qj|o149;JFk+)vzv zet2O^oES19*loyne)vg4Uk`)|_YNm@?j-UVRvY2VzElA498*n`V4@*k5t7#9?QwRS z{eSTFV0Rz$SsxG%Iq`G8PJl_a;}(Wff@2KK3~>w!_%Tm7YV7F1j(=W=A2nhyd1fP| z$i2f(7I+|K2%gasgrmd9f9z>Aq6k<<0U=l#?!t3$;izfGg&nz|lg(~>B9YOEK#ud< zMk=IpJOcj~WQ(yuuqy~3agyQh#>q`tOERY&%_YY#n7MD=(`3(&n?f>c3)YYb&BZPl zxcx(U>Ny2C`(x(2{s~`-80C6fFo!hG7#hKpZD{5V&1dGCLf(Vr=SHi+q4>xDQPaW< z$3Hs>b<68hD~a67Ut?ciz`ha&5#iInyTgioykcKSv5#0mw9O$+>Lm7${fD&1Y^aF+ ze<*`j_*wc1_@5|Vin}#y=ayRQ9Pq&;n*|@@LX_Qu3a_Y*LMiA)_z8zk#Qo?{E4Rj+ z?H7Y_M1my(^uf(ODI@kuA-Gq}NPM!PYzDsfB`F!CKL=E?NMh*s@lY`M(DGX{M2eIyiEbvvdL)Y$8drA@+ zpgW@>iX7gI2wL`#ry`Mu5yzg&RLZjm%{D8R&W*86SqeOLb4pgF@I()28`;k2($!I) zV#tujSn_@o)e;RsNjU~krvj(F5>To^CtN3m;B}c-!KmR6zL_Ie#B;|r>53VsT=e-n z<|OB+MZw!3FWUB&S2f-j_I<=eUtmfb|9SG zC;Xd+Z0x213bQ$gEki69u|~x9A~*z~Q_wy^rjM(jr~pP~@YQzTeXjN@4V(I}Eq)_b zXE0xt+V-Q0#O@Ay4*BLKs)ksF#2y`0&)Jq)2nwF7WDs@EDtu2r=RKSFba13NrM|)* z=zuT!GpDA>UR5MS859F^+;4h~{(iibqX-5N%+&`Wxdv%O9sD=Meucn`;8S%%+zW+| z4N*Icsy)Nx72|<#@>B5#LSOgBX$Z}A)YJyp~TA>uEM(MgaRYAErQk$LI!uV9=<0&tng>7 zAo7NK4_DBiLk(C#F;xq#G({}=q|32Jipww6&K9@N6t_o8DuU$zF0JNERz)0Tmu$SF86@YDP4q2FQeyfDLEsJ$ zqb>Pk_lL}j;GA4N?cp2R_y+$LNJT2@CztUREBT5w(@wr({g@_FQY*;xlUu?i>-ds& zV~Xpxg7KB0TiI%OTMY>H){XS_qN<6?SG!&>o7@^MYT=7oKm#iPa`iI4dd=%SZYQE~uJ*U_)ono~s97Q!?#f^Zw1Lib zLvTyO%|!C;5qo9Cu?TJ$gyu*g+-W7;EIL<9E8&b;wdfB8aEsESKNJ8Yb6}JKd|U`H zl+*fjYf%2=*72=?3TIf;#^aY!{)ySTNeT`hV`MML5(wFtgne@okco)>~B%i3Ue}IRfs)PIadq@3$4OI5)2v8(pwfLB*5tJjy zLD~pnrx9$xt+>?s6aMG!>r9D)f1&mus>D_aJ} zqj>C6Jz``lox*JV&pPaD2)>KpO$5Z?g#UPi{XT*)0vN#<2z{SoY0sqwsi~lo) zMVW>D8Djs6;8zGRjE8N;#~m#!?42@%og%LCM|P20JGJFUw^jZvbfr{vBU>Mn!WCi^ z0Fxu8pt5peO2kx@E;pt|Ohc)%Mwvjvm^iTV{16Y;04MyS9~;*qnl-O+9g?YZa35`pvkTWayNC(wBBq+xjvC2SI%wJ; zB24;YQpDnH5>()mpbNA`=+r#eAt{RU1=l!VhzWclwm@4z_Qxp9Oo0uwla3D`B5}qD zDlkUS1sWrCY98Dorph4zB_BHha6@MvlZwiJ+QVIowGsB;QV9zNy>OcWnkbQ;)v+tM938gVnC3vp+q~TbE_K$vn-pL=$2(s_lLXZ`*`SvZS(87=bqR7opWzx ziTq5jRCijdRZG~XHN3%D(=Msky|3gfDkxOuky!dbX(A*JVpKC!6F-r!2~|n#xQdp? z*+U(Ekf(VA8dbg|9`e*Sn50e=q^!YpyvAoxs@3?&#0r<9HxMh0n49m%7AKPewJnSnuOipMJ9F#>^s6Kt zM_S?bl0C#pZOK}W6hO|hKx4-3>f9Lh?T2w0#@e^~3yhEA3L>HI?Fibr>=zRBEsMe5 zM7V`;2;n<~+t9gu8R?~_6}yPyB5GRT^ObWH?I?D@-Id!yuQAcfxn2AZD2%b&+>TV2 zoA+{dw;0F^DZd#^edy7PU`OyUaD_waeSf&TN>8t^(rP#f0Y6V5d|zfPk24Sj=xu9= zgl@2XEi)BJr>#ifUt>-vt>I^(1PSR!$qo29{ZF$mVc|HYv{?A*?2p$oMZsJ+>6`?U zGmH?P8Lb(^RJ;m74I45o&r1ld-t*Le_Dh%WmM*>>N!2v@bi75HJqc!1!?V5kfsbp>jVB3wmyf-r#a5aB6ZFGo7*H~BL-as{M?Q%NUGFKi6##MnD9n2bSMTG}9|h4fjW z6$i??bu!rk14aJjQw+oimpwArzIE;RkC3OW$jOU-MVNgO0-$ndX^=RR!#-FwFF5W4 zPrHe(-POuzgt$Y|_J$e+?u1q&l|r$Q68)-hj-tK!rjeLJIKg~?`tGYI6GpbM7?+%Z zz_m#8VcJ)$Ars1whaQ7VCZR|lJi|VWAsqNP z^p&1oQNq?%wKze?aZj*%u^gdid^PTh6fnt!?Py zZK5;l@5q98Gf%6GPek8)2#XPf85GaKRRqB5#`PnP{tC2XmkGPLx>2tfkTQ=3s%e@p z(>y=7@3c(O47Y*rgp!tn^Mx)dMdC*5-LSn&$&v_78|qoxFne_KrE7Syh3(HXSJ^&M zfByeG_>&HS!^(Co=sV=H24+G-&LtAM{z^TLovBM?bouBkUg&y)xoWSC$0ZHKYiH&71or3EWH{WZ4tIKL?)r2qXg!`_SAGF5 zH=~rrB(G|b&23xE+p-*dAu5Xyu&w+Klnx*O!eIn_8u()fcsY49+J%)@zfKHqoVWEVPJ7C_zr|Fgl-02x%-3MjSe1(PLlJbbz#KJvOHzm#{kJTzkh+O>$ lp}cJ&`J4NRG&L$yav|K#&9} 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 List[Dict[str, Any]]: + """Fetch K-line base data (OHLCV) + + Corresponding tables: stock_klines_1d_base, stock_klines_1m_base, etc. + + Returns: + List[Dict] containing fields: + - symbol: Symbol code + - ts: Timestamp + - trade_date: Trade date + - open/high/low/close: Price data + - volume: Trading volume + - amount: Trading amount + - adj_factor: Adjustment factor + """ + print(f"[amazingdata_adapter fetch_kline_base]Fetching {symbol} {freq} base data...") + self._check_login() + + period_map = { + "1m": self._ad.constant.Period.min1, + "5m": self._ad.constant.Period.min5, + "15m": self._ad.constant.Period.min15, + "30m": self._ad.constant.Period.min30, + "60m": self._ad.constant.Period.min60, + "1d": self._ad.constant.Period.day, + "1w": self._ad.constant.Period.week, + "1month": self._ad.constant.Period.month, + } + period_value = period_map.get(freq, self._ad.constant.Period.day).value + + loop = asyncio.get_event_loop() + return await loop.run_in_executor( + None, + lambda: self._fetch_kline_base_sync(symbol, start, end, period_value, freq) + ) + + def _fetch_kline_base_sync( + self, + symbol: str, + start_date: str, + end_date: str, + period_value: int, + freq: str + ) -> List[Dict[str, Any]]: + """Sync method to fetch K-line base data""" + codes = [symbol] + start_int = self._format_date(start_date) + end_int = self._format_date(end_date) + + kline_dict = self._market_data.query_kline( + code_list=codes, + begin_date=start_int, + end_date=end_int, + period=period_value + ) + + if symbol not in kline_dict: + info(f"No kline data found for {symbol}") + return [] + + df = kline_dict[symbol] + results = [] + + for _, row in df.iterrows(): + kline_time = row.get('kline_time') + if pd.isna(kline_time) or kline_time is None: + continue + + if isinstance(kline_time, pd.Timestamp): + ts = int(kline_time.timestamp()) + trade_date = kline_time.strftime('%Y-%m-%d') + else: + date_str = str(int(kline_time)) + if len(date_str) != 8: + continue + dt = datetime.strptime(date_str, "%Y%m%d") + ts = int(dt.timestamp()) + trade_date = dt.strftime('%Y-%m-%d') + + results.append({ + "symbol": symbol, + "ts": ts, + "trade_date": trade_date, + "open": float(row.get('open', 0)), + "high": float(row.get('high', 0)), + "low": float(row.get('low', 0)), + "close": float(row.get('close', 0)), + "volume": int(row.get('volume', 0)), + "amount": float(row.get('amount', 0)), + "adj_factor": float(row.get('adj_factor', 1.0)) if 'adj_factor' in df.columns else 1.0, + }) + + info(f"Fetched {len(results)} base kline records for {symbol}") + return results + + async def fetch_kline_quote( + self, + symbol: str, + start: str, + end: str + ) -> List[Dict[str, Any]]: + """Fetch daily quote indicator data (calculated) + + Corresponding table: stock_klines_1d_quote + + Returns: + List[Dict] containing fields: + - change_pct: Price change percentage + - change_Nd_pct: N-day price change + - ma_N: Moving averages + - macd_dif/dea/bar: MACD indicators + - bias_N: Bias ratios + - is_limit_up/down: Limit up/down status + - is_st: ST status + """ + print(f"[amazingdata_adapter fetch_kline_quote]Calculating {symbol} quote indicators...") + self._check_login() + + loop = asyncio.get_event_loop() + return await loop.run_in_executor( + None, + lambda: self._fetch_kline_quote_sync(symbol, start, end) + ) + + def _fetch_kline_quote_sync( + self, + symbol: str, + start_date: str, + end_date: str + ) -> List[Dict[str, Any]]: + """Sync method to calculate quote indicators""" + import numpy as np + + start_dt = datetime.strptime(start_date, "%Y%m%d") + extended_start = datetime(start_dt.year - 1, start_dt.month, start_dt.day) + extended_start_str = extended_start.strftime("%Y%m%d") + + codes = [symbol] + start_int = self._format_date(extended_start_str) + end_int = self._format_date(end_date) + + kline_dict = self._market_data.query_kline( + code_list=codes, + begin_date=start_int, + end_date=end_int, + period=self._ad.constant.Period.day.value + ) + + if symbol not in kline_dict: + return [] + + df = kline_dict[symbol].copy() + df = df.sort_values('kline_time') + + try: + code_info_df = self._base_data.get_code_info(security_type=SecurityType.STOCK_A.value) + if symbol in code_info_df.index: + high_limited = float(code_info_df.loc[symbol, 'high_limited']) if 'high_limited' in code_info_df.columns else None + low_limited = float(code_info_df.loc[symbol, 'low_limited']) if 'low_limited' in code_info_df.columns else None + else: + high_limited = low_limited = None + except: + high_limited = low_limited = None + + results = [] + closes = df['close'].values + + for i, (_, row) in enumerate(df.iterrows()): + kline_time = row.get('kline_time') + if pd.isna(kline_time): + continue + + if isinstance(kline_time, pd.Timestamp): + trade_date = kline_time.strftime('%Y-%m-%d') + else: + date_str = str(int(kline_time)) + if len(date_str) != 8: + continue + dt = datetime.strptime(date_str, "%Y%m%d") + trade_date = dt.strftime('%Y-%m-%d') + + close = float(row.get('close', 0)) + + change_pct = None + if i > 0: + prev_close = closes[i-1] + if prev_close > 0: + change_pct = round((close - prev_close) / prev_close * 100, 4) + + def calc_n_day_change(n): + if i >= n and closes[i-n] > 0: + return round((close - closes[i-n]) / closes[i-n] * 100, 4) + return None + + def calc_ma(n): + if i >= n - 1: + return round(np.mean(closes[i-n+1:i+1]), 4) + return None + + def calc_macd(): + if i < 33: + return None, None, None + ema12 = pd.Series(closes[:i+1]).ewm(span=12).mean().iloc[-1] + ema26 = pd.Series(closes[:i+1]).ewm(span=26).mean().iloc[-1] + dif = ema12 - ema26 + dea = pd.Series([ema12 - ema26 for _ in range(i+1)]).ewm(span=9).mean().iloc[-1] + bar = (dif - dea) * 2 + return round(dif, 6), round(dea, 6), round(bar, 6) + + def calc_bias(n): + ma = calc_ma(n) + if ma and ma > 0: + return round((close - ma) / ma * 100, 4) + return None + + is_limit_up = False + is_limit_down = False + if high_limited and low_limited and close > 0: + is_limit_up = close >= high_limited * 0.995 + is_limit_down = close <= low_limited * 1.005 + + macd_dif, macd_dea, macd_bar = calc_macd() + + if trade_date.replace('-', '') >= start_date: + results.append({ + "symbol": symbol, + "trade_date": trade_date, + "change_pct": change_pct, + "change_5d_pct": calc_n_day_change(5), + "change_10d_pct": calc_n_day_change(10), + "change_20d_pct": calc_n_day_change(20), + "change_30d_pct": calc_n_day_change(30), + "change_60d_pct": calc_n_day_change(60), + "macd_dif": macd_dif, + "macd_dea": macd_dea, + "macd_bar": macd_bar, + "bias_5": calc_bias(5), + "bias_10": calc_bias(10), + "bias_20": calc_bias(20), + "is_limit_up": is_limit_up, + "is_limit_down": is_limit_down, + "limit_up_price": round(high_limited, 4) if high_limited else None, + "limit_down_price": round(low_limited, 4) if low_limited else None, + "is_st": None, + "ma_5": calc_ma(5), + "ma_10": calc_ma(10), + "ma_20": calc_ma(20), + "ma_30": calc_ma(30), + "ma_60": calc_ma(60), + "ma_120": calc_ma(120), + "ma_250": calc_ma(250), + }) + + info(f"Calculated {len(results)} quote indicators for {symbol}") + return results + + async def fetch_kline_finance( + self, + symbol: str, + start: str, + end: str + ) -> List[Dict[str, Any]]: + """Fetch daily finance data + + Corresponding table: stock_klines_1d_finance + Data sources: get_equity_structure, get_share_holder, get_income + + Returns: + List[Dict] containing fields: + - total_market_cap: Total market cap + - float_market_cap: Float market cap + - total_shares: Total shares + - float_shares: Float shares + - inst_holding_shares: Institutional holding shares + - inst_holding_ratio: Institutional holding ratio + - net_profit: Net profit + - revenue: Revenue + - eps: EPS + - roe: ROE + """ + print(f"[amazingdata_adapter fetch_kline_finance]Fetching {symbol} finance data...") + self._check_login() + + loop = asyncio.get_event_loop() + return await loop.run_in_executor( + None, + lambda: self._fetch_kline_finance_sync(symbol, start, end) + ) + + def _fetch_kline_finance_sync( + self, + symbol: str, + start_date: str, + end_date: str + ) -> List[Dict[str, Any]]: + """Sync method to fetch finance data""" + codes = [symbol] + start_int = self._format_date(start_date) + end_int = self._format_date(end_date) + + results = [] + + try: + equity_dict = self._info_data.get_equity_structure( + code_list=codes, + local_path=self.config.local_path, + is_local=self.config.use_local_cache + ) + + equity_data = {} + if symbol in equity_dict: + equity_df = equity_dict[symbol] + for _, row in equity_df.iterrows(): + ann_date = row.get('ANN_DATE') + if pd.notna(ann_date): + if isinstance(ann_date, (int, float)): + date_key = str(int(ann_date)) + else: + date_key = str(ann_date).replace('-', '').replace('/', '') + equity_data[date_key] = { + 'total_shares': float(row.get('TOT_A_SHARE', 0)) * 10000 if pd.notna(row.get('TOT_A_SHARE')) else 0, + 'float_shares': float(row.get('FLOAT_A_SHARE', 0)) * 10000 if pd.notna(row.get('FLOAT_A_SHARE')) else 0, + } + except Exception as e: + print(f"[amazingdata_adapter]Failed to get equity structure: {e}") + equity_data = {} + + kline_dict = self._market_data.query_kline( + code_list=codes, + begin_date=start_int, + end_date=end_int, + period=self._ad.constant.Period.day.value + ) + + if symbol not in kline_dict: + return [] + + df = kline_dict[symbol] + + for _, row in df.iterrows(): + kline_time = row.get('kline_time') + if pd.isna(kline_time): + continue + + if isinstance(kline_time, pd.Timestamp): + trade_date = kline_time.strftime('%Y-%m-%d') + trade_date_int = int(kline_time.strftime('%Y%m%d')) + else: + date_str = str(int(kline_time)) + if len(date_str) != 8: + continue + dt = datetime.strptime(date_str, "%Y%m%d") + trade_date = dt.strftime('%Y-%m-%d') + trade_date_int = int(date_str) + + close = float(row.get('close', 0)) + + total_shares = 0 + float_shares = 0 + for date_key in sorted(equity_data.keys(), reverse=True): + if int(date_key) <= trade_date_int: + total_shares = equity_data[date_key]['total_shares'] + float_shares = equity_data[date_key]['float_shares'] + break + + total_market_cap = close * total_shares if total_shares > 0 and close > 0 else None + float_market_cap = close * float_shares if float_shares > 0 and close > 0 else None + + results.append({ + "symbol": symbol, + "trade_date": trade_date, + "total_market_cap": round(total_market_cap, 2) if total_market_cap else None, + "float_market_cap": round(float_market_cap, 2) if float_market_cap else None, + "total_shares": int(total_shares) if total_shares > 0 else None, + "float_shares": int(float_shares) if float_shares > 0 else None, + "inst_holding_shares": None, + "inst_holding_ratio": None, + "top10_holders_ratio": None, + "net_profit": None, + "revenue": None, + "eps": None, + "roe": None, + "trading_days": None, + }) + + info(f"Fetched {len(results)} finance records for {symbol}") + return results + + async def fetch_stock_basic_info( + self, + codes: Optional[List[str]] = None + ) -> List[Dict[str, Any]]: + """Fetch stock basic info + + Corresponding table: stock_symbols + Data source: get_stock_basic + + Returns: + List[Dict] containing fields: + - symbol_id: Symbol code + - name: Name + - exchange: Exchange + - list_date: List date + - list_board: List board + - industry: Industry + - status: Status + - is_delisted: Is delisted + - delist_date: Delist date + """ + print(f"[amazingdata_adapter fetch_stock_basic_info]Fetching stock basic info...") + self._check_login() + + loop = asyncio.get_event_loop() + return await loop.run_in_executor( + None, + lambda: self._fetch_stock_basic_info_sync(codes) + ) + + def _fetch_stock_basic_info_sync( + self, + codes: Optional[List[str]] = None + ) -> List[Dict[str, Any]]: + """Sync method to fetch stock basic info""" + try: + all_codes = self._base_data.get_code_list( + security_type=SecurityType.STOCK_A.value + ) + + if codes: + all_codes = [c for c in all_codes if c in codes] + + info_df = self._base_data.get_code_info( + security_type=SecurityType.STOCK_A.value + ) + + results = [] + for code in all_codes: + if ".SH" in code: + exchange = "SH" + elif ".SZ" in code: + exchange = "SZ" + elif ".BJ" in code: + exchange = "BJ" + else: + exchange = "" + + name = code + if code in info_df.index and 'symbol' in info_df.columns: + name = info_df.loc[code, 'symbol'] + + list_date = None + try: + equity_dict = self._info_data.get_equity_structure( + code_list=[code], + local_path=self.config.local_path, + is_local=self.config.use_local_cache + ) + if code in equity_dict and not equity_dict[code].empty: + first_record = equity_dict[code].iloc[0] + ann_date = first_record.get('ANN_DATE') + if pd.notna(ann_date): + if isinstance(ann_date, (int, float)): + list_date = datetime.strptime(str(int(ann_date)), "%Y%m%d") + else: + list_date = pd.to_datetime(ann_date) + except: + pass + + results.append({ + "symbol_id": code, + "name": name, + "exchange": exchange, + "list_date": list_date, + "list_board": None, + "industry": None, + "status": "active", + "is_delisted": False, + "delist_date": None, + "is_st": None, + "total_shares": None, + "float_shares": None, + }) + + info(f"Fetched {len(results)} stock basic info records") + return results + + except Exception as e: + error(f"Failed to fetch stock basic info: {e}") + return [] diff --git a/app/adapters/base.py b/app/adapters/base.py index 26a132b..e58caad 100644 --- a/app/adapters/base.py +++ b/app/adapters/base.py @@ -109,3 +109,127 @@ class DataSourceAdapter(ABC): async def close(self) -> None: """关闭连接""" pass + + # ==================== 拆分表结构的新接口 ==================== + + async def fetch_kline_base( + self, + symbol: str, + start: str, + end: str, + freq: str + ) -> List[dict]: + """获取K线基础数据 (OHLCV) + + 对应表: stock_klines_1d_base, stock_klines_1m_base 等 + + Returns: + List[dict] 包含字段: + - symbol: 标的代码 + - ts: 时间戳 + - trade_date: 交易日期 + - open/high/low/close: 开高低收 + - volume: 成交量 + - amount: 成交额 + """ + # 默认实现:使用 fetch_klines 并转换格式 + klines = await self.fetch_klines(symbol, start, end, freq) + return [ + { + "symbol": k.symbol, + "ts": k.time, + "trade_date": k.trade_date, + "open": k.open, + "high": k.high, + "low": k.low, + "close": k.close, + "volume": k.volume, + "amount": k.amount, + } + for k in klines + ] + + async def fetch_kline_quote( + self, + symbol: str, + start: str, + end: str + ) -> List[dict]: + """获取日线行情指标数据 + + 对应表: stock_klines_1d_quote + + Returns: + List[dict] 包含字段: + - symbol: 标的代码 + - trade_date: 交易日期 + - change_pct: 涨跌幅 + - change_Nd_pct: N日涨跌幅 + - ma_N: 均线 + - macd_dif/dea/bar: MACD指标 + - bias_N: 乖离率 + - is_limit_up/down: 涨跌停状态 + - is_st: 是否ST + """ + raise NotImplementedError("fetch_kline_quote not implemented") + + async def fetch_kline_finance( + self, + symbol: str, + start: str, + end: str + ) -> List[dict]: + """获取日线财务数据 + + 对应表: stock_klines_1d_finance + + Returns: + List[dict] 包含字段: + - symbol: 标的代码 + - trade_date: 交易日期 + - total_market_cap: 总市值 + - float_market_cap: 流通市值 + - total_shares: 总股本 + - float_shares: 流通股本 + - inst_holding_shares: 机构持股数量 + - inst_holding_ratio: 机构持仓占比 + - net_profit: 净利润 + - revenue: 营业总收入 + - eps: 每股收益 + - roe: 净资产收益率 + """ + raise NotImplementedError("fetch_kline_finance not implemented") + + async def fetch_stock_basic_info( + self, + codes: Optional[List[str]] = None + ) -> List[dict]: + """获取股票基础信息 + + 对应表: stock_symbols + + Returns: + List[dict] 包含字段: + - symbol_id: 标的代码 + - name: 名称 + - exchange: 交易所 + - list_date: 上市日期 + - list_board: 上市板块 + - industry: 行业 + - status: 状态 + - is_delisted: 是否退市 + - delist_date: 退市日期 + """ + # 默认实现:使用 fetch_symbols 并转换格式 + symbols = await self.fetch_symbols("stock") + return [ + { + "symbol_id": s.symbol_id, + "name": s.name, + "exchange": s.exchange, + "list_date": s.list_date, + "delist_date": s.delist_date, + "status": "active" if not s.delist_date else "delisted", + } + for s in symbols + ] diff --git a/app/api/__pycache__/admin_routes.cpython-311.pyc b/app/api/__pycache__/admin_routes.cpython-311.pyc index db793b8259bee3b7c03aac666d00223d3f4c0256..c228081c5d11161b967481290164160a77a09d4e 100644 GIT binary patch literal 20877 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 delta 3727 zcmcInO>7%Q6y90e>!0{1c5K&)?b!Kg8z;?QD-q=oR@3wLetnAM_ zZ)U#t=9_tMNf1646hRggqKJ8-in6F$WQ%H* zt*TA7sgf)a?6c(UszY}0cWd6Mx@4EyC-o$3q4vM!EnlBJK>Y}dAz6}wk!MFJUm4yx)sN6IT z(>?39F4svqczXo7&UuYoirth=RD2MaXD2WP*{w>yfqk^ie$;PdH{~XrOH={|t})=^ ztF8^0a!HC^32ugZP(xJ0ox--_-(~PQ4nB9cm2LlOiGXd%JO(r6ebn9Jj~Mv(wDmbR zXk61Xsy}34-`iH~Xw=C5RP|#9u8DS}OYxq$eAL@o`LKa6h05Z&TS`0wpRdK2H1JJs zuXUtnzGhC08n~vm*E-fSmyhrnh6;+cTGs`dhJ082Dz| zvDH$he3(^}CRrsZQ_4PNTAA6L-LOLafp7CMJy#Y(#?8@L9+RZZH)9GsR!ip;`xzm% z{}abyV$;q!_Ip!AcbqC!a>YU>pK@wnIySWToga{}7I0D95I7Fi-*(+1i%xjeoky-^ zDId&i*tV1_?4E_i;hl}4fzYxAev`>0!y3j?ZAjG5y1yf}1h$h1;|N#)Qg-IX_Xz;q z!)U&kQPSCBVI{Z9&?%clKzD2k;GUJu;`_EwHVw@(iUeL;ZujE|1e{T>0$djwZpl{v z(EE;+yr4M;=E$ISaNrO*uB{FHN}}4y;9>Hd_E9i?BnhKa4t5-V*b>4C0Nq)+%xFf* z6;|0vY)dcmHhUgNV3;t4hja!FFKLZnaDh|c>?RM+D?0!%&P8~*M7odBawWqm=`v+k za#_k=fguOA#n8_rS|{Nf#Mk)W;*=k_)D>NUhDQmXu6|K4tUR0if&JtJly2!n!s; z^m}q1-cp2bUP|2A>N4KK7?d^>joB_jho!w2o18OQKida0JCA4< zEDYfb?P07yX6mQoev&}Kdm9B%K2w!Pi z!$t75nDCPb51yJh$t9zvi&%xRor}ofY=j?xIH=uEo(J;rkxM4Zd^S2kLfTKGu_I>j zTXzymms(k9l3*(OL%3g0j{Qw)Z@}v^!#!f%TLHF|lZCLWfKWup1L$s6Eu=H0T)F}o zV3zBk^p2Vg=amqQr!~+%t`+P@_^p0-_pI2IHi^c^_Rq!g+Mj#F$2w!urK{z1mF3L> zVQ&IO*SWohtO$SHIXNNLu3+;jz%$FxhcbZg-c?vbU!5w1dCs=V-&@#^@K^oeWSo$* z^+!{~q{d6byV&=njar6c?8gCofBIKxg-4aXoGVv~Y|TuXc17X5ysSK_qWD|Ut)l}3 zi}ua*t<$M_-JVV>#cVpQ+gCDB3rjhMS;MRdyzpaHVs-#ss;rf;2xA>(=MnfR=B2L> zn{MsLW3ig<#^TtjsAU$wo&kg)fbPT2Y>`m_e$U`ZfGLWaE2LSmTA_?rGf$-v%$){#weIP`)IzJ7o jm52@De}ExVR+m6tBwFJ{sQ&20O`BKfBMS`#zT*D?sllDh diff --git a/app/api/admin_routes.py b/app/api/admin_routes.py index 39ffb3f..e183e12 100644 --- a/app/api/admin_routes.py +++ b/app/api/admin_routes.py @@ -1,11 +1,13 @@ """管理后台API路由 - 对应Go的api/admin_router.go""" -from fastapi import APIRouter, Depends, HTTPException, Header, Query -from typing import Optional +from datetime import datetime, timedelta +from fastapi import APIRouter, Depends, HTTPException, Header, Query, Body +from typing import Optional, List from app.models import ( Response, ConfigListRequest, ConfigUpdateRequest, ReloadRequest, AdapterToggleRequest, AdapterConfigUpdateRequest, - APITestRequest, WSTestRequest, TestHistoryRequest + APITestRequest, WSTestRequest, TestHistoryRequest, + DataSyncRequest, DataSyncType, DataSyncData, DataSyncResult ) from app.services import ConfigService, AdapterService, TestService from app.core.config import get_config @@ -269,3 +271,151 @@ def get_test_history( return Response(code=0, message="success", data=data) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) + + + +# ============================================ +# 数据同步接口 +# ============================================ + +@admin_router.post("/admin/data/sync", response_model=Response) +async def sync_data( + req: DataSyncRequest, + token: str = Depends(verify_admin_token) +): + """手动触发数据同步 + + 支持同步类型: + - base: 基础K线数据 (OHLCV) + - quote: 行情指标数据 (均线、MACD、涨跌停等) + - finance: 财务数据 (市值、股本、利润等) + - full: 全量同步 (以上全部) + + 示例请求: + { + "symbols": ["600519.SH", "000001.SZ"], + "sync_type": "full", + "start_date": "20240101", + "end_date": "20240301", + "asset_class": "stock" + } + """ + import time + from app.services.adapter_service import AdapterService + from app.services.data_sync_service import DataSyncService + from app.models import Frequency + + start_time = time.time() + + try: + # 获取适配器 + adapter_service = AdapterService() + adapter = adapter_service.get_active_adapter(req.asset_class) + + if not adapter: + return Response( + code=400, + message=f"No active adapter found for asset class: {req.asset_class}", + data=None + ) + + # 创建同步服务 + sync_service = DataSyncService(adapter) + + # 设置默认日期 + end_date = req.end_date or datetime.now().strftime("%Y%m%d") + start_date = req.start_date or (datetime.now() - timedelta(days=365)).strftime("%Y%m%d") + + # 根据同步类型执行同步 + base_results = [] + quote_results = [] + finance_results = [] + + if req.sync_type == DataSyncType.BASE or req.sync_type == DataSyncType.FULL: + freq = Frequency.FREQ_1D if req.freq == "1d" else Frequency.FREQ_1D + base_counts = await sync_service.sync_kline_base( + req.symbols, freq, start_date, end_date + ) + base_results = [ + DataSyncResult(symbol=k, count=v if v > 0 else 0, error=str(e) if v < 0 else None) + for k, v in base_counts.items() + ] + + if req.sync_type == DataSyncType.QUOTE or req.sync_type == DataSyncType.FULL: + quote_counts = await sync_service.sync_kline_quote( + req.symbols, start_date, end_date + ) + quote_results = [ + DataSyncResult(symbol=k, count=v if v > 0 else 0, error=str(e) if v < 0 else None) + for k, v in quote_counts.items() + ] + + if req.sync_type == DataSyncType.FINANCE or req.sync_type == DataSyncType.FULL: + finance_counts = await sync_service.sync_kline_finance( + req.symbols, start_date, end_date + ) + finance_results = [ + DataSyncResult(symbol=k, count=v if v > 0 else 0, error=str(e) if v < 0 else None) + for k, v in finance_counts.items() + ] + + total_time = int((time.time() - start_time) * 1000) + + # 统计成功/失败数量 + all_results = base_results + quote_results + finance_results + success_count = sum(1 for r in all_results if r.count > 0) + fail_count = sum(1 for r in all_results if r.error) + + data = DataSyncData( + success=fail_count == 0, + message=f"Synced {success_count} symbols, {fail_count} failed, took {total_time}ms", + sync_type=req.sync_type, + base_results=base_results, + quote_results=quote_results, + finance_results=finance_results, + total_time_ms=total_time + ) + + return Response(code=0, message="success", data=data) + + except Exception as e: + error(f"[DataSync API] Failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@admin_router.post("/admin/data/sync/incremental", response_model=Response) +async def sync_incremental( + symbols: List[str] = Body(..., description="标的代码列表"), + asset_class: str = Body("stock", description="资产类别"), + token: str = Depends(verify_admin_token) +): + """触发增量同步(最近30天) + + 用于每日定时任务,同步最近的数据 + """ + from app.services.adapter_service import AdapterService + from app.services.data_sync_service import DataSyncService + + try: + # 获取适配器 + adapter_service = AdapterService() + adapter = adapter_service.get_active_adapter(asset_class) + + if not adapter: + return Response( + code=400, + message=f"No active adapter found for asset class: {asset_class}", + data=None + ) + + # 创建同步服务 + sync_service = DataSyncService(adapter) + + # 执行增量同步 + results = await sync_service.sync_daily_incremental(symbols) + + return Response(code=0, message="success", data=results) + + except Exception as e: + error(f"[DataSync API] Incremental sync failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) diff --git a/app/models/__init__.py b/app/models/__init__.py index 2918093..cf60bb8 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -61,6 +61,10 @@ from .admin_types import ( WSMessage, TestHistoryRequest, TestHistoryData, + DataSyncType, + DataSyncRequest, + DataSyncResult, + DataSyncData, ) __all__ = [ @@ -132,4 +136,9 @@ __all__ = [ "WSMessage", "TestHistoryRequest", "TestHistoryData", + # 数据同步 + "DataSyncType", + "DataSyncRequest", + "DataSyncResult", + "DataSyncData", ] diff --git a/app/models/__pycache__/__init__.cpython-311.pyc b/app/models/__pycache__/__init__.cpython-311.pyc index 0f566d841e35bdf2568b076733a732d34433607f..107b2abd9cb504569d64c5d08ba76a33c700a264 100644 GIT binary patch delta 633 zcmX>sx=@U7IWI340}yO0+M3DBGm%e%QEj6-H=|UpeUyE!LzF|VW0YgAQG^s7JGbrVopwc{7Qz;K(!3N z#1ab%^mFr5Qge#+)MiPJ2*!FPMy3x8 h14q;ksO?N2YG~|t4@NIWZlE6#WCJ@07I^~W004Y4n}q-X delta 224 zcmZ1|c36~eIWI340}#x5xhB(}dm^6%qu54uZbmLfhIED$wndCl_M3eed6^hxH%BpF zU}V>{E@B3%o_wBd;^hCV6Pf)q9X2m#3ulbE#U3A@n3EG9zmnlI$h=>nE>PQnLx3E%94!yym*LIJV+`YCY4xF5R;pqlA2Q- uGg+TQjLRKpC?gOTFWT(S5y7~*ki(Qw)`8KLk(=oQ1A=T|2f-p&ph^I|uR^;3 diff --git a/app/models/__pycache__/admin_types.cpython-311.pyc b/app/models/__pycache__/admin_types.cpython-311.pyc index 725166a178b57b3c6d81d1267e70c735c9bc741a..fa0a5feed2e1b88e47b84948385ca627001534f4 100644 GIT binary patch delta 2493 zcmZ`)ZA?>F7{0|*OX(MFDYcB0s-V<>xhUNdG($8Gz=Wm~VC zW^BWx=E`V{A7zWmrc;TysW@dHS==(`xy$|0++@j2q~JeE+@CE=_MUs?v+nZVhjZ@p zoO625`<(aQS%hUit2#L>|>d(RD{CdJAJo5IbIF(OkBP;yr_IL zf7`OTPVWuIg1ZlOceWjRi-}u+&Gui-9=klA?8|<2ZenNElDa1zi7}Gq zKv#FLyOWX7fp{pyZ?-N8q53VmeUf@-`;IoBq-kvSZ}B(yB-GfxeLGtQQ?f1sO9_lj zgGxnF(MsrXGk+OWicx7sXXd;ay_NH2jCQU`?s8tvt4uP&KeRjYR;YNFb}fHjXL&u3 zVi2Q!Q{H8mjiMJ{(wxtOJ%d}h0y;*^=wOVIRTHCp_Vn3>s(hxKmNk+(8jExujFX=>dHu+vTNjAKdM`@4U^L3aIyyt} z-)&~ef^0Fx-_%&D$-A)RaImWhcnHWYx=mVR1imMrCh!A+9|`=#t6sI5?vN${EwS5U z{;;t);c%vGsVH9S#SWj~@QDr|*VNmVT;8`ABOio-d>QBR6f?A4tlD(17CQriGaxzx zTyyU`7zH2%6v&j6r7DIHex(hUvrXI!)v19oW?> zxLQS5E4MAVFF$2HVZf*rLO`wZQfV*V(1M+Q!RZ&Ber{{;8_8&27$ZM~fczPIX)>PP zbHjt}&4RsIv^R6FC9N1WLkKcLLpdLJ$oWw9@ekECyw+Tt&@viYJLACuT9@P50!GIy zp5uHCt(W~+0Dd$`##ui`<{76|9lsX89fvc-Gbw9V2q3@cPr%*rKb%!`a+`T6E(#pFr6uf zHp{eercgZG> z0Vp{>duw8loA)h{bIh&>g`_7{qJw3KC8Dx^aj3WRSla^R*+Njek5iU%QtW zNHRV9woJl<@zWP1Q}%SDZ9PT5=89Kw9?+$Q4F5(r={ufB+`Y#ZPToR`@Gv5-1@SD_8Mr zmfH9omNNd&3THw&!wpyWVW(GcdPS#KsTE#~yaMuO+@2xZjTqi}5W7QyJ0!Y8-1cO7 z@;%YA0;3RwfI^v7YlbSu5Z)EStHQ#nu(&GBwIp9k8Aa<#jGz{T1r*LytsC-foaOs|_5$tFb9F3x*5oQ9HH$n)gk^iT5wT8Tk z-@S&<)IY42cS5d|^83rLI{&l%cYaMcLdQc)Ei1x6*a9kgdhxYC8h%$NiqtC06Dk*y z>r-foJbyY%7HU-6R8OeqW3p>XW}eRFi;$`TMm!&rd#9+~gnBwxVAQGtFy#4|e0fS{ Lp8gLnpZos+HHZW7 delta 81 zcmbQchH?L7M!w~|yj%=Ga6@fvrnc%tJ_*LUjq0_`Y$*=G44RIcWmvTBnf)}iCnxwl hoE+*e$-NDzmJx`HZ?kT$^uNS9Sv!=QwMYRd0sxEI6+i$0 diff --git a/app/models/admin_types.py b/app/models/admin_types.py index be4bfd4..deab0e9 100644 --- a/app/models/admin_types.py +++ b/app/models/admin_types.py @@ -248,3 +248,44 @@ class TestHistoryData(BaseModel): """测试历史数据""" api_tests: List[APITestResult] = Field(default_factory=list, description="API测试历史") ws_tests: List[WSTestResult] = Field(default_factory=list, description="WebSocket测试历史") + + +# ============================================ +# 数据同步类型 +# ============================================ + +class DataSyncType(str, Enum): + """数据同步类型""" + BASE = "base" # 基础K线数据 + QUOTE = "quote" # 行情指标数据 + FINANCE = "finance" # 财务数据 + FULL = "full" # 全量同步 + + +class DataSyncRequest(BaseModel): + """数据同步请求""" + symbols: List[str] = Field(..., description="标的代码列表") + sync_type: DataSyncType = Field(default=DataSyncType.FULL, description="同步类型") + freq: Optional[str] = Field(default="1d", description="周期(仅对base有效)") + start_date: Optional[str] = Field(None, description="开始日期 YYYYMMDD") + end_date: Optional[str] = Field(None, description="结束日期 YYYYMMDD") + asset_class: str = Field(default="stock", description="资产类别") + + +class DataSyncResult(BaseModel): + """单个标的同步结果""" + symbol: str = Field(..., description="标的代码") + count: int = Field(default=0, description="同步记录数") + error: Optional[str] = Field(None, description="错误信息") + + +class DataSyncData(BaseModel): + """数据同步响应""" + success: bool = Field(..., description="是否成功") + message: str = Field(..., description="提示信息") + sync_type: DataSyncType = Field(..., description="同步类型") + base_results: List[DataSyncResult] = Field(default_factory=list, description="基础数据同步结果") + quote_results: List[DataSyncResult] = Field(default_factory=list, description="行情数据同步结果") + finance_results: List[DataSyncResult] = Field(default_factory=list, description="财务数据同步结果") + total_time_ms: int = Field(default=0, description="总耗时(ms)") + timestamp: datetime = Field(default_factory=datetime.now, description="同步时间") diff --git a/app/repositories/__pycache__/futures_repository_v2.cpython-311.pyc b/app/repositories/__pycache__/futures_repository_v2.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a87d9f3330b4c516a0d415abae7a9c89c7dc883f GIT binary patch literal 23021 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 QCr+R8<=;tg`HuPj0H0`|@c;k- literal 0 HcmV?d00001 diff --git a/app/repositories/__pycache__/models.cpython-311.pyc b/app/repositories/__pycache__/models.cpython-311.pyc index 3c501d17479fc122078b8cec9a60d90c67bc60a5..d0f1da8cc7fef3b53fe218389cc4d400b30e95f5 100644 GIT binary patch literal 39910 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�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 c4%Mj3;{QhUSHU~L{8DSbff|MPx# delta 359 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 List[Dict[str, Any]]: + """获取期货K线基础数据 (OHLCV + 持仓量) + + 对应表: futures_klines_1d_base, futures_klines_1m_base 等 + """ + model = self._get_kline_base_model(freq) + + query = self.db.query(model).filter( + model.symbol_id == symbol, + model.ts >= start, + model.ts <= end + ).order_by(model.ts.asc()) + + results = query.all() + + return [ + { + "symbol_id": r.symbol_id, + "trade_date": r.trade_date, + "ts": r.ts, + "open": float(r.open) if r.open else None, + "high": float(r.high) if r.high else None, + "low": float(r.low) if r.low else None, + "close": float(r.close) if r.close else None, + "volume": r.volume, + "amount": float(r.amount) if r.amount else None, + "open_interest": r.open_interest, + "settlement": float(r.settlement) if r.settlement else None, + "pre_settlement": float(r.pre_settlement) if r.pre_settlement else None, + } + for r in results + ] + + def save_klines_base( + self, + freq: Frequency, + items: List[Dict[str, Any]] + ) -> int: + """保存期货K线基础数据""" + if not items: + return 0 + + model = self._get_kline_base_model(freq) + count = 0 + + for item in items: + symbol = item.get('symbol_id', item.get('symbol')) + trade_date = item.get('trade_date') + ts = item.get('ts') + + if not ts and trade_date: + if isinstance(trade_date, str): + ts = datetime.strptime(trade_date.replace('-', ''), "%Y%m%d").timestamp() + else: + ts = trade_date.timestamp() + + # 检查是否存在 + existing = self.db.query(model).filter( + model.symbol_id == symbol, + model.ts == ts + ).first() + + if existing: + existing.open = item.get('open', existing.open) + existing.high = item.get('high', existing.high) + existing.low = item.get('low', existing.low) + existing.close = item.get('close', existing.close) + existing.volume = item.get('volume', existing.volume) + existing.amount = item.get('amount', existing.amount) + existing.open_interest = item.get('open_interest', existing.open_interest) + existing.settlement = item.get('settlement', existing.settlement) + existing.pre_settlement = item.get('pre_settlement', existing.pre_settlement) + else: + new_record = model( + symbol_id=symbol, + trade_date=trade_date, + ts=ts, + open=item.get('open'), + high=item.get('high'), + low=item.get('low'), + close=item.get('close'), + volume=item.get('volume'), + amount=item.get('amount'), + open_interest=item.get('open_interest'), + settlement=item.get('settlement'), + pre_settlement=item.get('pre_settlement'), + ) + self.db.add(new_record) + count += 1 + + self.db.commit() + return count + + def _get_kline_base_model(self, freq: Frequency): + """根据周期获取期货K线基础数据模型""" + mapping = { + Frequency.FREQ_1M: FuturesKLine1MBase, + Frequency.FREQ_5M: FuturesKLine5MBase, + Frequency.FREQ_15M: FuturesKLine15MBase, + Frequency.FREQ_30M: FuturesKLine30MBase, + Frequency.FREQ_60M: FuturesKLine60MBase, + Frequency.FREQ_1D: FuturesKLine1DBase, + } + return mapping.get(freq, FuturesKLine1DBase) + + # ==================== K线行情数据表操作 ==================== + + def get_klines_quote( + self, + symbol: str, + start_date: str, + end_date: str + ) -> List[Dict[str, Any]]: + """获取期货日线行情指标数据 + + 对应表: futures_klines_1d_quote + """ + results = self.db.query(FuturesKLine1DQuote).filter( + FuturesKLine1DQuote.symbol_id == symbol, + FuturesKLine1DQuote.trade_date >= start_date, + FuturesKLine1DQuote.trade_date <= end_date + ).order_by(FuturesKLine1DQuote.trade_date.asc()).all() + + return [ + { + "symbol_id": r.symbol_id, + "trade_date": r.trade_date, + "change_pct": float(r.change_pct) if r.change_pct else None, + "change_5d_pct": float(r.change_5d_pct) if r.change_5d_pct else None, + "change_10d_pct": float(r.change_10d_pct) if r.change_10d_pct else None, + "change_20d_pct": float(r.change_20d_pct) if r.change_20d_pct else None, + "ma5": float(r.ma5) if r.ma5 else None, + "ma10": float(r.ma10) if r.ma10 else None, + "ma20": float(r.ma20) if r.ma20 else None, + "ma30": float(r.ma30) if r.ma30 else None, + "ma60": float(r.ma60) if r.ma60 else None, + "macd_dif": float(r.macd_dif) if r.macd_dif else None, + "macd_dea": float(r.macd_dea) if r.macd_dea else None, + "macd_bar": float(r.macd_bar) if r.macd_bar else None, + "oi_change": r.oi_change, + "oi_change_pct": float(r.oi_change_pct) if r.oi_change_pct else None, + "amplitude": float(r.amplitude) if r.amplitude else None, + } + for r in results + ] + + def save_klines_quote( + self, + items: List[Dict[str, Any]] + ) -> int: + """保存期货日线行情指标数据""" + if not items: + return 0 + + count = 0 + for item in items: + symbol = item.get('symbol_id', item.get('symbol')) + trade_date = item.get('trade_date') + + # 检查是否存在 + existing = self.db.query(FuturesKLine1DQuote).filter( + FuturesKLine1DQuote.symbol_id == symbol, + FuturesKLine1DQuote.trade_date == trade_date + ).first() + + if existing: + existing.change_pct = item.get('change_pct', existing.change_pct) + existing.change_5d_pct = item.get('change_5d_pct', existing.change_5d_pct) + existing.change_10d_pct = item.get('change_10d_pct', existing.change_10d_pct) + existing.change_20d_pct = item.get('change_20d_pct', existing.change_20d_pct) + existing.ma5 = item.get('ma5', existing.ma5) + existing.ma10 = item.get('ma10', existing.ma10) + existing.ma20 = item.get('ma20', existing.ma20) + existing.ma30 = item.get('ma30', existing.ma30) + existing.ma60 = item.get('ma60', existing.ma60) + existing.macd_dif = item.get('macd_dif', existing.macd_dif) + existing.macd_dea = item.get('macd_dea', existing.macd_dea) + existing.macd_bar = item.get('macd_bar', existing.macd_bar) + existing.oi_change = item.get('oi_change', existing.oi_change) + existing.oi_change_pct = item.get('oi_change_pct', existing.oi_change_pct) + existing.amplitude = item.get('amplitude', existing.amplitude) + else: + new_record = FuturesKLine1DQuote( + symbol_id=symbol, + trade_date=trade_date, + change_pct=item.get('change_pct'), + change_5d_pct=item.get('change_5d_pct'), + change_10d_pct=item.get('change_10d_pct'), + change_20d_pct=item.get('change_20d_pct'), + ma5=item.get('ma5'), + ma10=item.get('ma10'), + ma20=item.get('ma20'), + ma30=item.get('ma30'), + ma60=item.get('ma60'), + macd_dif=item.get('macd_dif'), + macd_dea=item.get('macd_dea'), + macd_bar=item.get('macd_bar'), + oi_change=item.get('oi_change'), + oi_change_pct=item.get('oi_change_pct'), + amplitude=item.get('amplitude'), + ) + self.db.add(new_record) + count += 1 + + self.db.commit() + return count + + # ==================== 实时行情数据表操作 ==================== + + def get_realtime_quote(self, symbol: str) -> Optional[Dict[str, Any]]: + """获取期货实时行情 + + 对应表: futures_realtime_quotes + """ + result = self.db.query(FuturesRealTimeQuote).filter( + FuturesRealTimeQuote.symbol_id == symbol + ).first() + + if result: + return { + "symbol_id": result.symbol_id, + "update_time": result.update_time, + "last_price": float(result.last_price) if result.last_price else None, + "open": float(result.open) if result.open else None, + "high": float(result.high) if result.high else None, + "low": float(result.low) if result.low else None, + "pre_close": float(result.pre_close) if result.pre_close else None, + "pre_settlement": float(result.pre_settlement) if result.pre_settlement else None, + "settlement": float(result.settlement) if result.settlement else None, + "volume": result.volume, + "amount": float(result.amount) if result.amount else None, + "open_interest": result.open_interest, + "bid1": float(result.bid1) if result.bid1 else None, + "ask1": float(result.ask1) if result.ask1 else None, + "limit_up": float(result.limit_up) if result.limit_up else None, + "limit_down": float(result.limit_down) if result.limit_down else None, + } + return None + + def save_realtime_quote(self, data: Dict[str, Any]) -> None: + """保存期货实时行情""" + symbol = data.get('symbol_id', data.get('symbol')) + + existing = self.db.query(FuturesRealTimeQuote).filter( + FuturesRealTimeQuote.symbol_id == symbol + ).first() + + if existing: + existing.update_time = data.get('update_time', existing.update_time) + existing.last_price = data.get('last_price', existing.last_price) + existing.open = data.get('open', existing.open) + existing.high = data.get('high', existing.high) + existing.low = data.get('low', existing.low) + existing.pre_close = data.get('pre_close', existing.pre_close) + existing.pre_settlement = data.get('pre_settlement', existing.pre_settlement) + existing.settlement = data.get('settlement', existing.settlement) + existing.volume = data.get('volume', existing.volume) + existing.amount = data.get('amount', existing.amount) + existing.open_interest = data.get('open_interest', existing.open_interest) + existing.bid1 = data.get('bid1', existing.bid1) + existing.ask1 = data.get('ask1', existing.ask1) + existing.limit_up = data.get('limit_up', existing.limit_up) + existing.limit_down = data.get('limit_down', existing.limit_down) + else: + new_record = FuturesRealTimeQuote( + symbol_id=symbol, + update_time=data.get('update_time'), + last_price=data.get('last_price'), + open=data.get('open'), + high=data.get('high'), + low=data.get('low'), + pre_close=data.get('pre_close'), + pre_settlement=data.get('pre_settlement'), + settlement=data.get('settlement'), + volume=data.get('volume'), + amount=data.get('amount'), + open_interest=data.get('open_interest'), + bid1=data.get('bid1'), + ask1=data.get('ask1'), + limit_up=data.get('limit_up'), + limit_down=data.get('limit_down'), + ) + self.db.add(new_record) + + self.db.commit() + + # ==================== 标的和日历 ==================== + + def list_symbols( + self, + req: SymbolListRequest + ) -> Tuple[List[Symbol], int]: + """查询期货合约列表""" + query = self.db.query(FuturesSymbol) + + # 筛选条件 + if req.exchange: + query = query.filter(FuturesSymbol.exchange == req.exchange.value) + + if req.keyword: + keyword = f"%{req.keyword}%" + query = query.filter( + or_( + FuturesSymbol.symbol_id.ilike(keyword), + FuturesSymbol.name.ilike(keyword), + FuturesSymbol.underlying.ilike(keyword) + ) + ) + + # 查询总数 + total = query.count() + + # 分页查询 + results = query.order_by(FuturesSymbol.symbol_id).offset( + (req.page - 1) * req.size + ).limit(req.size).all() + + symbols = [] + for r in results: + s = Symbol( + symbol_id=r.symbol_id, + symbol_type=r.symbol_type, + exchange=r.exchange, + name=r.name, + list_date=r.list_date, + delist_date=r.delist_date, + status=r.status + ) + symbols.append(s) + + return symbols, total + + def get_trading_dates(self, start: str, end: str) -> TradingDatesData: + """获取期货交易日历""" + results = self.db.query(FuturesTradingCalendar).filter( + FuturesTradingCalendar.trade_date >= start, + FuturesTradingCalendar.trade_date <= end, + FuturesTradingCalendar.is_trading_day == True + ).order_by(FuturesTradingCalendar.trade_date.asc()).all() + + dates = [r.trade_date for r in results] + + start_date = datetime.strptime(start, "%Y%m%d") + end_date = datetime.strptime(end, "%Y%m%d") + total_days = (end_date - start_date).days + 1 + + return TradingDatesData( + start=start, + end=end, + total_days=total_days, + trading_days=len(dates), + trading_dates=dates + ) diff --git a/app/repositories/models.py b/app/repositories/models.py index 410322a..5b241c8 100644 --- a/app/repositories/models.py +++ b/app/repositories/models.py @@ -227,3 +227,421 @@ class StockAdjustFactor(Base): __table_args__ = ( Index("idx_adj_factor_symbol_date", "symbol_id", "trade_date"), ) + + + +# ============================================ +# 期货分表结构 (新增) +# ============================================ + +class FuturesKLine1DBase(Base): + """期货日线K线 - 基础表""" + __tablename__ = "futures_klines_1d_base" + __table_args__ = ( + Index("idx_futures_1d_base_symbol_ts", "symbol_id", "ts"), + Index("idx_futures_1d_base_symbol_date", "symbol_id", "trade_date"), + ) + + id = Column(BigInteger, primary_key=True, autoincrement=True) + symbol_id = Column(String(20), nullable=False, index=True, comment="合约代码") + ts = Column(DateTime, nullable=False, comment="时间戳") + trade_date = Column(String(10), nullable=False, index=True, comment="交易日 YYYY-MM-DD") + open = Column(Numeric(18, 4), nullable=False, comment="开盘价") + high = Column(Numeric(18, 4), nullable=False, comment="最高价") + low = Column(Numeric(18, 4), nullable=False, comment="最低价") + close = Column(Numeric(18, 4), nullable=False, comment="收盘价") + volume = Column(BigInteger, nullable=False, comment="成交量") + amount = Column(Numeric(20, 4), nullable=False, comment="成交额") + open_interest = Column(BigInteger, nullable=True, comment="持仓量") + settlement = Column(Numeric(18, 4), nullable=True, comment="结算价") + pre_settlement = Column(Numeric(18, 4), nullable=True, comment="昨结算价") + created_at = Column(DateTime, default=datetime.now, comment="创建时间") + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间") + + +class FuturesKLine1DQuote(Base): + """期货日线K线 - 行情指标表""" + __tablename__ = "futures_klines_1d_quote" + __table_args__ = ( + Index("idx_futures_1d_quote_symbol_date", "symbol_id", "trade_date"), + ) + + id = Column(BigInteger, primary_key=True, autoincrement=True) + symbol_id = Column(String(20), nullable=False, index=True, comment="合约代码") + trade_date = Column(String(10), nullable=False, index=True, comment="交易日 YYYY-MM-DD") + change_pct = Column(Numeric(8, 4), nullable=True, comment="涨跌幅%") + change_5d_pct = Column(Numeric(8, 4), nullable=True, comment="5日涨跌幅%") + change_10d_pct = Column(Numeric(8, 4), nullable=True, comment="10日涨跌幅%") + change_20d_pct = Column(Numeric(8, 4), nullable=True, comment="20日涨跌幅%") + ma5 = Column(Numeric(18, 4), nullable=True, comment="5日均线") + ma10 = Column(Numeric(18, 4), nullable=True, comment="10日均线") + ma20 = Column(Numeric(18, 4), nullable=True, comment="20日均线") + ma30 = Column(Numeric(18, 4), nullable=True, comment="30日均线") + ma60 = Column(Numeric(18, 4), nullable=True, comment="60日均线") + macd_dif = Column(Numeric(18, 6), nullable=True, comment="MACD DIF") + macd_dea = Column(Numeric(18, 6), nullable=True, comment="MACD DEA") + macd_bar = Column(Numeric(18, 6), nullable=True, comment="MACD BAR") + oi_change = Column(BigInteger, nullable=True, comment="持仓量变化") + oi_change_pct = Column(Numeric(8, 4), nullable=True, comment="持仓量变化%") + amplitude = Column(Numeric(8, 4), nullable=True, comment="振幅%") + created_at = Column(DateTime, default=datetime.now, comment="创建时间") + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间") + + +class FuturesKLine1MBase(Base): + """期货1分钟K线 - 基础表""" + __tablename__ = "futures_klines_1m_base" + __table_args__ = (Index("idx_futures_1m_base_symbol_ts", "symbol_id", "ts"),) + id = Column(BigInteger, primary_key=True, autoincrement=True) + symbol_id = Column(String(20), nullable=False, index=True, comment="合约代码") + ts = Column(DateTime, nullable=False, comment="时间戳") + trade_date = Column(String(10), nullable=False, comment="交易日") + open = Column(Numeric(18, 4), nullable=False, comment="开盘价") + high = Column(Numeric(18, 4), nullable=False, comment="最高价") + low = Column(Numeric(18, 4), nullable=False, comment="最低价") + close = Column(Numeric(18, 4), nullable=False, comment="收盘价") + volume = Column(BigInteger, nullable=False, comment="成交量") + amount = Column(Numeric(20, 4), nullable=False, comment="成交额") + open_interest = Column(BigInteger, nullable=True, comment="持仓量") + created_at = Column(DateTime, default=datetime.now, comment="创建时间") + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间") + + +class FuturesKLine5MBase(Base): + """期货5分钟K线 - 基础表""" + __tablename__ = "futures_klines_5m_base" + __table_args__ = (Index("idx_futures_5m_base_symbol_ts", "symbol_id", "ts"),) + id = Column(BigInteger, primary_key=True, autoincrement=True) + symbol_id = Column(String(20), nullable=False, index=True, comment="合约代码") + ts = Column(DateTime, nullable=False, comment="时间戳") + trade_date = Column(String(10), nullable=False, comment="交易日") + open = Column(Numeric(18, 4), nullable=False, comment="开盘价") + high = Column(Numeric(18, 4), nullable=False, comment="最高价") + low = Column(Numeric(18, 4), nullable=False, comment="最低价") + close = Column(Numeric(18, 4), nullable=False, comment="收盘价") + volume = Column(BigInteger, nullable=False, comment="成交量") + amount = Column(Numeric(20, 4), nullable=False, comment="成交额") + open_interest = Column(BigInteger, nullable=True, comment="持仓量") + created_at = Column(DateTime, default=datetime.now, comment="创建时间") + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间") + + +class FuturesRealTimeQuote(Base): + """期货实时行情快照""" + __tablename__ = "futures_realtime_quotes" + symbol_id = Column(String(20), primary_key=True, comment="合约代码") + update_time = Column(DateTime, nullable=False, comment="更新时间") + last_price = Column(Numeric(18, 4), nullable=True, comment="最新价") + open = Column(Numeric(18, 4), nullable=True, comment="开盘价") + high = Column(Numeric(18, 4), nullable=True, comment="最高价") + low = Column(Numeric(18, 4), nullable=True, comment="最低价") + pre_close = Column(Numeric(18, 4), nullable=True, comment="昨收") + pre_settlement = Column(Numeric(18, 4), nullable=True, comment="昨结算") + settlement = Column(Numeric(18, 4), nullable=True, comment="结算价") + volume = Column(BigInteger, nullable=True, comment="成交量") + amount = Column(Numeric(20, 4), nullable=True, comment="成交额") + open_interest = Column(BigInteger, nullable=True, comment="持仓量") + bid1 = Column(Numeric(18, 4), nullable=True, comment="买一价") + bid1_volume = Column(BigInteger, nullable=True, comment="买一量") + ask1 = Column(Numeric(18, 4), nullable=True, comment="卖一价") + ask1_volume = Column(BigInteger, nullable=True, comment="卖一量") + limit_up = Column(Numeric(18, 4), nullable=True, comment="涨停价") + limit_down = Column(Numeric(18, 4), nullable=True, comment="跌停价") + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间") + + +# 向后兼容别名 +FuturesKLine1M = FuturesKLine1MBase +FuturesKLine5M = FuturesKLine5MBase +FuturesKLine1D = FuturesKLine1DBase + + + +# ============================================ +# 股票日线历史数据表 (1d) - 分表 (新增) +# ============================================ + +class StockKLine1DBase(Base): + """股票日线K线 - 基础表 + + 存储最基础的K线数据,对应 query_kline 接口 + """ + __tablename__ = "stock_klines_1d_base" + __table_args__ = ( + Index("idx_stock_1d_base_symbol_ts", "symbol_id", "ts"), + Index("idx_stock_1d_base_symbol_date", "symbol_id", "trade_date"), + ) + + id = Column(BigInteger, primary_key=True, autoincrement=True) + symbol_id = Column(String(20), nullable=False, index=True, comment="标的代码") + ts = Column(DateTime, nullable=False, comment="时间戳") + trade_date = Column(String(10), nullable=False, index=True, comment="交易日 YYYY-MM-DD") + open = Column(Numeric(18, 4), nullable=False, comment="开盘价") + high = Column(Numeric(18, 4), nullable=False, comment="最高价") + low = Column(Numeric(18, 4), nullable=False, comment="最低价") + close = Column(Numeric(18, 4), nullable=False, comment="收盘价") + volume = Column(BigInteger, nullable=False, comment="成交量(股)") + amount = Column(Numeric(20, 4), nullable=False, comment="成交额(元)") + adj_factor = Column(Numeric(18, 8), nullable=True, comment="复权系数") + created_at = Column(DateTime, default=datetime.now, comment="创建时间") + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间") + + +class StockKLine1DQuote(Base): + """股票日线K线 - 行情指标表 + + 存储需要计算的行情指标 + """ + __tablename__ = "stock_klines_1d_quote" + __table_args__ = ( + Index("idx_stock_1d_quote_symbol_date", "symbol_id", "trade_date"), + ) + + id = Column(BigInteger, primary_key=True, autoincrement=True) + symbol_id = Column(String(20), nullable=False, index=True, comment="标的代码") + trade_date = Column(String(10), nullable=False, index=True, comment="交易日 YYYY-MM-DD") + change_pct = Column(Numeric(8, 4), nullable=True, comment="涨跌幅%") + change_5d_pct = Column(Numeric(8, 4), nullable=True, comment="5日涨跌幅%") + change_10d_pct = Column(Numeric(8, 4), nullable=True, comment="10日涨跌幅%") + change_20d_pct = Column(Numeric(8, 4), nullable=True, comment="20日涨跌幅%") + change_30d_pct = Column(Numeric(8, 4), nullable=True, comment="30日涨跌幅%") + change_60d_pct = Column(Numeric(8, 4), nullable=True, comment="60日涨跌幅%") + ma5 = Column(Numeric(18, 4), nullable=True, comment="5日均线") + ma10 = Column(Numeric(18, 4), nullable=True, comment="10日均线") + ma20 = Column(Numeric(18, 4), nullable=True, comment="20日均线") + ma30 = Column(Numeric(18, 4), nullable=True, comment="30日均线") + ma60 = Column(Numeric(18, 4), nullable=True, comment="60日均线") + ma120 = Column(Numeric(18, 4), nullable=True, comment="120日均线") + ma250 = Column(Numeric(18, 4), nullable=True, comment="250日均线") + macd_dif = Column(Numeric(18, 6), nullable=True, comment="MACD DIF") + macd_dea = Column(Numeric(18, 6), nullable=True, comment="MACD DEA") + macd_bar = Column(Numeric(18, 6), nullable=True, comment="MACD BAR") + bias_5 = Column(Numeric(8, 4), nullable=True, comment="5日乖离率%") + bias_10 = Column(Numeric(8, 4), nullable=True, comment="10日乖离率%") + bias_20 = Column(Numeric(8, 4), nullable=True, comment="20日乖离率%") + is_limit_up = Column(Boolean, nullable=True, comment="是否涨停") + is_limit_down = Column(Boolean, nullable=True, comment="是否跌停") + limit_up_price = Column(Numeric(18, 4), nullable=True, comment="涨停价") + limit_down_price = Column(Numeric(18, 4), nullable=True, comment="跌停价") + is_st = Column(Boolean, nullable=True, comment="是否ST") + created_at = Column(DateTime, default=datetime.now, comment="创建时间") + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间") + + +class StockKLine1DFinance(Base): + """股票日线K线 - 财务数据表 + + 存储财务相关数据 + """ + __tablename__ = "stock_klines_1d_finance" + __table_args__ = ( + Index("idx_stock_1d_finance_symbol_date", "symbol_id", "trade_date"), + ) + + id = Column(BigInteger, primary_key=True, autoincrement=True) + symbol_id = Column(String(20), nullable=False, index=True, comment="标的代码") + trade_date = Column(String(10), nullable=False, index=True, comment="交易日 YYYY-MM-DD") + total_market_cap = Column(Numeric(20, 4), nullable=True, comment="总市值(元)") + float_market_cap = Column(Numeric(20, 4), nullable=True, comment="流通市值(元)") + total_shares = Column(BigInteger, nullable=True, comment="总股本(股)") + float_shares = Column(BigInteger, nullable=True, comment="流通股本(股)") + inst_holding_shares = Column(BigInteger, nullable=True, comment="机构持股数量") + inst_holding_ratio = Column(Numeric(8, 4), nullable=True, comment="机构持仓占比%") + top10_holders_ratio = Column(Numeric(8, 4), nullable=True, comment="前十大股东持股占比%") + net_profit = Column(Numeric(20, 4), nullable=True, comment="净利润") + revenue = Column(Numeric(20, 4), nullable=True, comment="营业总收入") + eps = Column(Numeric(12, 4), nullable=True, comment="每股收益") + roe = Column(Numeric(8, 4), nullable=True, comment="净资产收益率%") + trading_days = Column(Integer, nullable=True, comment="从上市至今交易日数") + created_at = Column(DateTime, default=datetime.now, comment="创建时间") + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间") + + +class StockRealTimeQuote(Base): + """股票实时行情快照""" + __tablename__ = "stock_realtime_quotes" + + symbol_id = Column(String(20), primary_key=True, comment="标的代码") + update_time = Column(DateTime, nullable=False, comment="更新时间") + last_price = Column(Numeric(18, 4), nullable=True, comment="最新价") + open = Column(Numeric(18, 4), nullable=True, comment="开盘价") + high = Column(Numeric(18, 4), nullable=True, comment="最高价") + low = Column(Numeric(18, 4), nullable=True, comment="最低价") + pre_close = Column(Numeric(18, 4), nullable=True, comment="昨收") + volume = Column(BigInteger, nullable=True, comment="成交量") + amount = Column(Numeric(20, 4), nullable=True, comment="成交额") + bid1 = Column(Numeric(18, 4), nullable=True, comment="买一价") + ask1 = Column(Numeric(18, 4), nullable=True, comment="卖一价") + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间") + + +# 股票表向后兼容别名 +StockKLine1D = StockKLine1DBase + + + +# ============================================ +# 股票15/30/60分钟K线表 (新增) +# ============================================ + +class StockKLine15M(Base): + """股票15分钟K线""" + __tablename__ = "stock_klines_15m" + __table_args__ = ( + Index("idx_stock_15m_symbol_ts", "symbol_id", "ts"), + ) + + id = Column(BigInteger, primary_key=True, autoincrement=True) + symbol_id = Column(String(20), nullable=False, index=True, comment="标的代码") + ts = Column(DateTime, nullable=False, comment="时间戳") + trade_date = Column(String(10), nullable=False, comment="交易日") + + open = Column(Numeric(18, 4), nullable=False, comment="开盘价") + high = Column(Numeric(18, 4), nullable=False, comment="最高价") + low = Column(Numeric(18, 4), nullable=False, comment="最低价") + close = Column(Numeric(18, 4), nullable=False, comment="收盘价") + volume = Column(BigInteger, nullable=False, comment="成交量") + amount = Column(Numeric(20, 4), nullable=False, comment="成交额") + + change_pct = Column(Numeric(8, 4), nullable=True, comment="涨跌幅%") + macd_dif = Column(Numeric(18, 6), nullable=True, comment="MACD DIF") + macd_dea = Column(Numeric(18, 6), nullable=True, comment="MACD DEA") + macd_bar = Column(Numeric(18, 6), nullable=True, comment="MACD BAR") + + created_at = Column(DateTime, default=datetime.now, comment="创建时间") + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间") + + +class StockKLine30M(Base): + """股票30分钟K线""" + __tablename__ = "stock_klines_30m" + __table_args__ = ( + Index("idx_stock_30m_symbol_ts", "symbol_id", "ts"), + ) + + id = Column(BigInteger, primary_key=True, autoincrement=True) + symbol_id = Column(String(20), nullable=False, index=True, comment="标的代码") + ts = Column(DateTime, nullable=False, comment="时间戳") + trade_date = Column(String(10), nullable=False, comment="交易日") + + open = Column(Numeric(18, 4), nullable=False, comment="开盘价") + high = Column(Numeric(18, 4), nullable=False, comment="最高价") + low = Column(Numeric(18, 4), nullable=False, comment="最低价") + close = Column(Numeric(18, 4), nullable=False, comment="收盘价") + volume = Column(BigInteger, nullable=False, comment="成交量") + amount = Column(Numeric(20, 4), nullable=False, comment="成交额") + + change_pct = Column(Numeric(8, 4), nullable=True, comment="涨跌幅%") + macd_dif = Column(Numeric(18, 6), nullable=True, comment="MACD DIF") + macd_dea = Column(Numeric(18, 6), nullable=True, comment="MACD DEA") + macd_bar = Column(Numeric(18, 6), nullable=True, comment="MACD BAR") + + created_at = Column(DateTime, default=datetime.now, comment="创建时间") + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间") + + +class StockKLine60M(Base): + """股票60分钟K线""" + __tablename__ = "stock_klines_60m" + __table_args__ = ( + Index("idx_stock_60m_symbol_ts", "symbol_id", "ts"), + ) + + id = Column(BigInteger, primary_key=True, autoincrement=True) + symbol_id = Column(String(20), nullable=False, index=True, comment="标的代码") + ts = Column(DateTime, nullable=False, comment="时间戳") + trade_date = Column(String(10), nullable=False, comment="交易日") + + open = Column(Numeric(18, 4), nullable=False, comment="开盘价") + high = Column(Numeric(18, 4), nullable=False, comment="最高价") + low = Column(Numeric(18, 4), nullable=False, comment="最低价") + close = Column(Numeric(18, 4), nullable=False, comment="收盘价") + volume = Column(BigInteger, nullable=False, comment="成交量") + amount = Column(Numeric(20, 4), nullable=False, comment="成交额") + + change_pct = Column(Numeric(8, 4), nullable=True, comment="涨跌幅%") + macd_dif = Column(Numeric(18, 6), nullable=True, comment="MACD DIF") + macd_dea = Column(Numeric(18, 6), nullable=True, comment="MACD DEA") + macd_bar = Column(Numeric(18, 6), nullable=True, comment="MACD BAR") + + created_at = Column(DateTime, default=datetime.now, comment="创建时间") + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间") + + + + +# ============================================ +# 期货15/30/60分钟K线表 (新增) +# ============================================ + +class FuturesKLine15MBase(Base): + """期货15分钟K线 - 基础表""" + __tablename__ = "futures_klines_15m_base" + __table_args__ = ( + Index("idx_futures_15m_base_symbol_ts", "symbol_id", "ts"), + ) + + id = Column(BigInteger, primary_key=True, autoincrement=True) + symbol_id = Column(String(20), nullable=False, index=True, comment="合约代码") + ts = Column(DateTime, nullable=False, comment="时间戳") + trade_date = Column(String(10), nullable=False, comment="交易日") + + open = Column(Numeric(18, 4), nullable=False, comment="开盘价") + high = Column(Numeric(18, 4), nullable=False, comment="最高价") + low = Column(Numeric(18, 4), nullable=False, comment="最低价") + close = Column(Numeric(18, 4), nullable=False, comment="收盘价") + volume = Column(BigInteger, nullable=False, comment="成交量") + amount = Column(Numeric(20, 4), nullable=False, comment="成交额") + open_interest = Column(BigInteger, nullable=True, comment="持仓量") + + created_at = Column(DateTime, default=datetime.now, comment="创建时间") + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间") + + +class FuturesKLine30MBase(Base): + """期货30分钟K线 - 基础表""" + __tablename__ = "futures_klines_30m_base" + __table_args__ = ( + Index("idx_futures_30m_base_symbol_ts", "symbol_id", "ts"), + ) + + id = Column(BigInteger, primary_key=True, autoincrement=True) + symbol_id = Column(String(20), nullable=False, index=True, comment="合约代码") + ts = Column(DateTime, nullable=False, comment="时间戳") + trade_date = Column(String(10), nullable=False, comment="交易日") + + open = Column(Numeric(18, 4), nullable=False, comment="开盘价") + high = Column(Numeric(18, 4), nullable=False, comment="最高价") + low = Column(Numeric(18, 4), nullable=False, comment="最低价") + close = Column(Numeric(18, 4), nullable=False, comment="收盘价") + volume = Column(BigInteger, nullable=False, comment="成交量") + amount = Column(Numeric(20, 4), nullable=False, comment="成交额") + open_interest = Column(BigInteger, nullable=True, comment="持仓量") + + created_at = Column(DateTime, default=datetime.now, comment="创建时间") + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间") + + +class FuturesKLine60MBase(Base): + """期货60分钟K线 - 基础表""" + __tablename__ = "futures_klines_60m_base" + __table_args__ = ( + Index("idx_futures_60m_base_symbol_ts", "symbol_id", "ts"), + ) + + id = Column(BigInteger, primary_key=True, autoincrement=True) + symbol_id = Column(String(20), nullable=False, index=True, comment="合约代码") + ts = Column(DateTime, nullable=False, comment="时间戳") + trade_date = Column(String(10), nullable=False, comment="交易日") + + open = Column(Numeric(18, 4), nullable=False, comment="开盘价") + high = Column(Numeric(18, 4), nullable=False, comment="最高价") + low = Column(Numeric(18, 4), nullable=False, comment="最低价") + close = Column(Numeric(18, 4), nullable=False, comment="收盘价") + volume = Column(BigInteger, nullable=False, comment="成交量") + amount = Column(Numeric(20, 4), nullable=False, comment="成交额") + open_interest = Column(BigInteger, nullable=True, comment="持仓量") + + created_at = Column(DateTime, default=datetime.now, comment="创建时间") + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间") + diff --git a/app/repositories/models_old.py b/app/repositories/models_old.py new file mode 100644 index 0000000..410322a --- /dev/null +++ b/app/repositories/models_old.py @@ -0,0 +1,229 @@ +"""数据库模型定义""" +from datetime import datetime +from typing import Optional + +from sqlalchemy import ( + Column, Integer, String, Float, DateTime, + Boolean, Numeric, BigInteger, Index, Text +) + +from app.repositories.database import Base + + +# ============================================ +# 股票相关表 +# ============================================ + +class StockSymbol(Base): + """股票标的表""" + __tablename__ = "stock_symbols" + + symbol_id = Column(String(20), primary_key=True, index=True, comment="标的代码") + symbol_type = Column(String(20), nullable=False, comment="标的类型") + exchange = Column(String(10), nullable=False, index=True, comment="交易所") + name = Column(String(100), nullable=False, comment="名称") + name_en = Column(String(100), nullable=True, comment="英文名称") + list_date = Column(DateTime, nullable=True, comment="上市日期") + delist_date = Column(DateTime, nullable=True, comment="退市日期") + industry = Column(String(50), nullable=True, comment="行业分类") + status = Column(String(20), nullable=False, default="active", comment="状态") + created_at = Column(DateTime, default=datetime.now, comment="创建时间") + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间") + + +class StockTradingCalendar(Base): + """股票交易日历表""" + __tablename__ = "stock_trading_calendar" + + trade_date = Column(String(8), primary_key=True, comment="交易日期") + is_trading_day = Column(Boolean, nullable=False, comment="是否交易日") + week_day = Column(Integer, nullable=True, comment="星期几") + created_at = Column(DateTime, default=datetime.now, comment="创建时间") + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间") + + +class StockKLine1M(Base): + """股票1分钟K线""" + __tablename__ = "stock_klines_1m" + __table_args__ = ( + Index("idx_stock_1m_symbol_ts", "symbol_id", "ts"), + ) + + id = Column(BigInteger, primary_key=True, autoincrement=True) + symbol_id = Column(String(20), nullable=False, index=True, comment="标的代码") + ts = Column(DateTime, nullable=False, comment="时间戳") + open = Column(Numeric(18, 4), nullable=False, comment="开盘价") + high = Column(Numeric(18, 4), nullable=False, comment="最高价") + low = Column(Numeric(18, 4), nullable=False, comment="最低价") + close = Column(Numeric(18, 4), nullable=False, comment="收盘价") + volume = Column(BigInteger, nullable=False, comment="成交量") + amount = Column(Numeric(20, 4), nullable=False, comment="成交额") + created_at = Column(DateTime, default=datetime.now, comment="创建时间") + + +class StockKLine5M(Base): + """股票5分钟K线""" + __tablename__ = "stock_klines_5m" + __table_args__ = ( + Index("idx_stock_5m_symbol_ts", "symbol_id", "ts"), + ) + + id = Column(BigInteger, primary_key=True, autoincrement=True) + symbol_id = Column(String(20), nullable=False, index=True, comment="标的代码") + ts = Column(DateTime, nullable=False, comment="时间戳") + open = Column(Numeric(18, 4), nullable=False, comment="开盘价") + high = Column(Numeric(18, 4), nullable=False, comment="最高价") + low = Column(Numeric(18, 4), nullable=False, comment="最低价") + close = Column(Numeric(18, 4), nullable=False, comment="收盘价") + volume = Column(BigInteger, nullable=False, comment="成交量") + amount = Column(Numeric(20, 4), nullable=False, comment="成交额") + created_at = Column(DateTime, default=datetime.now, comment="创建时间") + + +class StockKLine1D(Base): + """股票日线K线""" + __tablename__ = "stock_klines_1d" + __table_args__ = ( + Index("idx_stock_1d_symbol_ts", "symbol_id", "ts"), + ) + + id = Column(BigInteger, primary_key=True, autoincrement=True) + symbol_id = Column(String(20), nullable=False, index=True, comment="标的代码") + ts = Column(DateTime, nullable=False, comment="时间戳") + open = Column(Numeric(18, 4), nullable=False, comment="开盘价") + high = Column(Numeric(18, 4), nullable=False, comment="最高价") + low = Column(Numeric(18, 4), nullable=False, comment="最低价") + close = Column(Numeric(18, 4), nullable=False, comment="收盘价") + volume = Column(BigInteger, nullable=False, comment="成交量") + amount = Column(Numeric(20, 4), nullable=False, comment="成交额") + + # 新增字段 + trade_date = Column(String(10), nullable=True, index=True, comment="交易日 (YYYY-MM-DD)") + is_limit_up = Column(Boolean, nullable=True, comment="是否涨停") + is_limit_down = Column(Boolean, nullable=True, comment="是否跌停") + total_market_cap = Column(Numeric(20, 2), nullable=True, comment="总市值(元)") + float_market_cap = Column(Numeric(20, 2), nullable=True, comment="流通市值(元)") + inst_holding_ratio = Column(Numeric(8, 4), nullable=True, comment="机构持仓占比(%)") + trading_days = Column(Integer, nullable=True, comment="可交易日数(从上市至今)") + + created_at = Column(DateTime, default=datetime.now, comment="创建时间") + + +# ============================================ +# 期货相关表 +# ============================================ + +class FuturesSymbol(Base): + """期货合约表""" + __tablename__ = "futures_symbols" + + symbol_id = Column(String(20), primary_key=True, index=True, comment="合约代码") + symbol_type = Column(String(20), nullable=False, comment="标的类型") + exchange = Column(String(10), nullable=False, index=True, comment="交易所") + name = Column(String(100), nullable=False, comment="名称") + underlying = Column(String(10), nullable=False, index=True, comment="品种代码") + contract_month = Column(String(6), nullable=False, comment="合约月份") + list_date = Column(DateTime, nullable=True, comment="上市日期") + delist_date = Column(DateTime, nullable=True, comment="退市日期") + status = Column(String(20), nullable=False, default="active", comment="状态") + created_at = Column(DateTime, default=datetime.now, comment="创建时间") + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间") + + +class FuturesTradingCalendar(Base): + """期货交易日历表""" + __tablename__ = "futures_trading_calendar" + + trade_date = Column(String(8), primary_key=True, comment="交易日期") + is_trading_day = Column(Boolean, nullable=False, comment="是否交易日") + has_night_session = Column(Boolean, default=False, comment="是否有夜盘") + week_day = Column(Integer, nullable=True, comment="星期几") + created_at = Column(DateTime, default=datetime.now, comment="创建时间") + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间") + + +class FuturesKLine1M(Base): + """期货1分钟K线""" + __tablename__ = "futures_klines_1m" + __table_args__ = ( + Index("idx_futures_1m_symbol_ts", "symbol_id", "ts"), + ) + + id = Column(BigInteger, primary_key=True, autoincrement=True) + symbol_id = Column(String(20), nullable=False, index=True, comment="合约代码") + ts = Column(DateTime, nullable=False, comment="时间戳") + open = Column(Numeric(18, 4), nullable=False, comment="开盘价") + high = Column(Numeric(18, 4), nullable=False, comment="最高价") + low = Column(Numeric(18, 4), nullable=False, comment="最低价") + close = Column(Numeric(18, 4), nullable=False, comment="收盘价") + volume = Column(BigInteger, nullable=False, comment="成交量") + amount = Column(Numeric(20, 4), nullable=False, comment="成交额") + open_interest = Column(BigInteger, nullable=True, comment="持仓量") + created_at = Column(DateTime, default=datetime.now, comment="创建时间") + + +class FuturesKLine1D(Base): + """期货日线K线""" + __tablename__ = "futures_klines_1d" + __table_args__ = ( + Index("idx_futures_1d_symbol_ts", "symbol_id", "ts"), + ) + + id = Column(BigInteger, primary_key=True, autoincrement=True) + symbol_id = Column(String(20), nullable=False, index=True, comment="合约代码") + ts = Column(DateTime, nullable=False, comment="时间戳") + open = Column(Numeric(18, 4), nullable=False, comment="开盘价") + high = Column(Numeric(18, 4), nullable=False, comment="最高价") + low = Column(Numeric(18, 4), nullable=False, comment="最低价") + close = Column(Numeric(18, 4), nullable=False, comment="收盘价") + volume = Column(BigInteger, nullable=False, comment="成交量") + amount = Column(Numeric(20, 4), nullable=False, comment="成交额") + open_interest = Column(BigInteger, nullable=True, comment="持仓量") + created_at = Column(DateTime, default=datetime.now, comment="创建时间") + + +# ============================================ +# 公共表 +# ============================================ + +class DataSourceConfig(Base): + """数据源配置表""" + __tablename__ = "data_source_config" + + asset_class = Column(String(20), primary_key=True, comment="资产类别") + active_source = Column(String(50), nullable=False, comment="当前激活源") + standby_sources = Column(Text, nullable=True, comment="待命源列表(JSON)") + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间") + + +class DataQualityCheck(Base): + """数据质量检查表""" + __tablename__ = "data_quality_checks" + + id = Column(BigInteger, primary_key=True, autoincrement=True) + check_date = Column(String(8), nullable=False, index=True, comment="检查日期") + symbol_id = Column(String(20), nullable=False, index=True, comment="标的代码") + freq = Column(String(10), nullable=False, comment="周期") + check_type = Column(String(20), nullable=False, comment="检查类型") + status = Column(String(10), nullable=False, comment="状态 pass/fail") + expect_count = Column(Integer, nullable=True, comment="期望数量") + actual_count = Column(Integer, nullable=True, comment="实际数量") + detail = Column(String(500), nullable=True, comment="详情") + created_at = Column(DateTime, default=datetime.now, comment="创建时间") + + +class StockAdjustFactor(Base): + """股票复权系数表""" + __tablename__ = "stock_adjust_factors" + + id = Column(BigInteger, primary_key=True, autoincrement=True) + symbol_id = Column(String(20), nullable=False, index=True, comment="标的代码") + trade_date = Column(String(10), nullable=False, index=True, comment="交易日期 YYYY-MM-DD") + qfq_factor = Column(Numeric(18, 8), nullable=False, default=1.0, comment="前复权系数") + hfq_factor = Column(Numeric(18, 8), nullable=False, default=1.0, comment="后复权系数") + created_at = Column(DateTime, default=datetime.now, comment="创建时间") + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间") + + __table_args__ = ( + Index("idx_adj_factor_symbol_date", "symbol_id", "trade_date"), + ) diff --git a/app/repositories/stock_repository_v2.py b/app/repositories/stock_repository_v2.py new file mode 100644 index 0000000..5716582 --- /dev/null +++ b/app/repositories/stock_repository_v2.py @@ -0,0 +1,618 @@ +"""股票数据仓库 V2 - 支持拆分后的表结构 + +新表结构说明: +- stock_klines_1d_base: 日线基础数据(OHLCV) +- stock_klines_1d_quote: 日线行情指标(涨跌幅、均线、MACD等) +- stock_klines_1d_finance: 日线财务数据(市值、股本、利润等) +- stock_klines_1m/5m/15m/30m/60m: 分钟线基础数据 +- stock_klines_1w/1month: 周/月线基础数据 +""" +from datetime import datetime, time +from typing import List, Tuple, Optional, Dict, Any + +from sqlalchemy.orm import Session +from sqlalchemy import func, or_, and_ + +from app.models import ( + KLineItem, Symbol, SymbolListRequest, SymbolListData, + TradingDatesData, TradeCalData, AdjustType, Frequency +) +from app.repositories.models import ( + StockSymbol, StockTradingCalendar, StockAdjustFactor, + StockKLine1DBase, StockKLine1DQuote, StockKLine1DFinance, + StockKLine1M, StockKLine5M, StockKLine15M, StockKLine30M, StockKLine60M, + StockRealTimeQuote +) + + +class StockRepositoryV2: + """股票数据仓库 V2 - 支持拆分表结构""" + + def __init__(self, db: Session): + self.db = db + + # ==================== K线基础数据表操作 ==================== + + def get_klines_base( + self, + symbol: str, + freq: Frequency, + start: datetime, + end: datetime, + ) -> List[Dict[str, Any]]: + """获取K线基础数据 (OHLCV) + + 对应表: stock_klines_1d_base, stock_klines_1m_base, stock_klines_5m_base 等 + """ + model = self._get_kline_base_model(freq) + + query = self.db.query(model).filter( + model.symbol_id == symbol, + model.ts >= start, + model.ts <= end + ).order_by(model.ts.asc()) + + results = query.all() + + return [ + { + "symbol_id": r.symbol_id, + "trade_date": r.trade_date, + "ts": r.ts, + "open": float(r.open) if r.open else None, + "high": float(r.high) if r.high else None, + "low": float(r.low) if r.low else None, + "close": float(r.close) if r.close else None, + "volume": r.volume, + "amount": float(r.amount) if r.amount else None, + } + for r in results + ] + + def save_klines_base( + self, + freq: Frequency, + items: List[Dict[str, Any]] + ) -> int: + """保存K线基础数据 + + Returns: + 保存的记录数 + """ + if not items: + return 0 + + model = self._get_kline_base_model(freq) + count = 0 + + for item in items: + symbol = item.get('symbol_id', item.get('symbol')) + trade_date = item.get('trade_date') + ts = item.get('ts') + + if not ts and trade_date: + # 如果没有ts但有trade_date,生成ts + if isinstance(trade_date, str): + ts = datetime.strptime(trade_date.replace('-', ''), "%Y%m%d").timestamp() + else: + ts = trade_date.timestamp() + + # 检查是否存在 + existing = self.db.query(model).filter( + model.symbol_id == symbol, + model.ts == ts + ).first() + + if existing: + existing.open = item.get('open', existing.open) + existing.high = item.get('high', existing.high) + existing.low = item.get('low', existing.low) + existing.close = item.get('close', existing.close) + existing.volume = item.get('volume', existing.volume) + existing.amount = item.get('amount', existing.amount) + else: + new_record = model( + symbol_id=symbol, + trade_date=trade_date, + ts=ts, + open=item.get('open'), + high=item.get('high'), + low=item.get('low'), + close=item.get('close'), + volume=item.get('volume'), + amount=item.get('amount'), + ) + self.db.add(new_record) + count += 1 + + self.db.commit() + return count + + def _get_kline_base_model(self, freq: Frequency): + """根据周期获取K线基础数据模型""" + mapping = { + Frequency.FREQ_1M: StockKLine1M, + Frequency.FREQ_5M: StockKLine5M, + Frequency.FREQ_15M: StockKLine15M, + Frequency.FREQ_30M: StockKLine30M, + Frequency.FREQ_60M: StockKLine60M, + Frequency.FREQ_1D: StockKLine1DBase, + } + return mapping.get(freq, StockKLine1DBase) + + # ==================== K线行情数据表操作 ==================== + + def get_klines_quote( + self, + symbol: str, + start_date: str, + end_date: str + ) -> List[Dict[str, Any]]: + """获取日线行情指标数据 + + 对应表: stock_klines_1d_quote + """ + results = self.db.query(StockKLine1DQuote).filter( + StockKLine1DQuote.symbol_id == symbol, + StockKLine1DQuote.trade_date >= start_date, + StockKLine1DQuote.trade_date <= end_date + ).order_by(StockKLine1DQuote.trade_date.asc()).all() + + return [ + { + "symbol_id": r.symbol_id, + "trade_date": r.trade_date, + "change_pct": float(r.change_pct) if r.change_pct else None, + "change_5d_pct": float(r.change_5d_pct) if r.change_5d_pct else None, + "change_10d_pct": float(r.change_10d_pct) if r.change_10d_pct else None, + "change_20d_pct": float(r.change_20d_pct) if r.change_20d_pct else None, + "change_30d_pct": float(r.change_30d_pct) if r.change_30d_pct else None, + "change_60d_pct": float(r.change_60d_pct) if r.change_60d_pct else None, + "ma5": float(r.ma5) if r.ma5 else None, + "ma10": float(r.ma10) if r.ma10 else None, + "ma20": float(r.ma20) if r.ma20 else None, + "ma30": float(r.ma30) if r.ma30 else None, + "ma60": float(r.ma60) if r.ma60 else None, + "ma120": float(r.ma120) if r.ma120 else None, + "ma250": float(r.ma250) if r.ma250 else None, + "macd_dif": float(r.macd_dif) if r.macd_dif else None, + "macd_dea": float(r.macd_dea) if r.macd_dea else None, + "macd_bar": float(r.macd_bar) if r.macd_bar else None, + "bias5": float(r.bias5) if r.bias5 else None, + "bias10": float(r.bias10) if r.bias10 else None, + "bias20": float(r.bias20) if r.bias20 else None, + "is_limit_up": r.is_limit_up, + "is_limit_down": r.is_limit_down, + "limit_up_price": float(r.limit_up_price) if r.limit_up_price else None, + "limit_down_price": float(r.limit_down_price) if r.limit_down_price else None, + "is_st": r.is_st, + } + for r in results + ] + + def save_klines_quote( + self, + items: List[Dict[str, Any]] + ) -> int: + """保存日线行情指标数据 + + 对应表: stock_klines_1d_quote + """ + if not items: + return 0 + + count = 0 + for item in items: + symbol = item.get('symbol_id', item.get('symbol')) + trade_date = item.get('trade_date') + + # 检查是否存在 + existing = self.db.query(StockKLine1DQuote).filter( + StockKLine1DQuote.symbol_id == symbol, + StockKLine1DQuote.trade_date == trade_date + ).first() + + if existing: + existing.change_pct = item.get('change_pct', existing.change_pct) + existing.change_5d_pct = item.get('change_5d_pct', existing.change_5d_pct) + existing.change_10d_pct = item.get('change_10d_pct', existing.change_10d_pct) + existing.change_20d_pct = item.get('change_20d_pct', existing.change_20d_pct) + existing.change_30d_pct = item.get('change_30d_pct', existing.change_30d_pct) + existing.change_60d_pct = item.get('change_60d_pct', existing.change_60d_pct) + existing.ma5 = item.get('ma5', existing.ma5) + existing.ma10 = item.get('ma10', existing.ma10) + existing.ma20 = item.get('ma20', existing.ma20) + existing.ma30 = item.get('ma30', existing.ma30) + existing.ma60 = item.get('ma60', existing.ma60) + existing.ma120 = item.get('ma120', existing.ma120) + existing.ma250 = item.get('ma250', existing.ma250) + existing.macd_dif = item.get('macd_dif', existing.macd_dif) + existing.macd_dea = item.get('macd_dea', existing.macd_dea) + existing.macd_bar = item.get('macd_bar', existing.macd_bar) + existing.bias5 = item.get('bias5', existing.bias5) + existing.bias10 = item.get('bias10', existing.bias10) + existing.bias20 = item.get('bias20', existing.bias20) + existing.is_limit_up = item.get('is_limit_up', existing.is_limit_up) + existing.is_limit_down = item.get('is_limit_down', existing.is_limit_down) + existing.limit_up_price = item.get('limit_up_price', existing.limit_up_price) + existing.limit_down_price = item.get('limit_down_price', existing.limit_down_price) + existing.is_st = item.get('is_st', existing.is_st) + else: + new_record = StockKLine1DQuote( + symbol_id=symbol, + trade_date=trade_date, + change_pct=item.get('change_pct'), + change_5d_pct=item.get('change_5d_pct'), + change_10d_pct=item.get('change_10d_pct'), + change_20d_pct=item.get('change_20d_pct'), + change_30d_pct=item.get('change_30d_pct'), + change_60d_pct=item.get('change_60d_pct'), + ma5=item.get('ma5'), + ma10=item.get('ma10'), + ma20=item.get('ma20'), + ma30=item.get('ma30'), + ma60=item.get('ma60'), + ma120=item.get('ma120'), + ma250=item.get('ma250'), + macd_dif=item.get('macd_dif'), + macd_dea=item.get('macd_dea'), + macd_bar=item.get('macd_bar'), + bias5=item.get('bias5'), + bias10=item.get('bias10'), + bias20=item.get('bias20'), + is_limit_up=item.get('is_limit_up'), + is_limit_down=item.get('is_limit_down'), + limit_up_price=item.get('limit_up_price'), + limit_down_price=item.get('limit_down_price'), + is_st=item.get('is_st'), + ) + self.db.add(new_record) + count += 1 + + self.db.commit() + return count + + # ==================== K线财务数据表操作 ==================== + + def get_klines_finance( + self, + symbol: str, + start_date: str, + end_date: str + ) -> List[Dict[str, Any]]: + """获取日线财务数据 + + 对应表: stock_klines_1d_finance + """ + results = self.db.query(StockKLine1DFinance).filter( + StockKLine1DFinance.symbol_id == symbol, + StockKLine1DFinance.trade_date >= start_date, + StockKLine1DFinance.trade_date <= end_date + ).order_by(StockKLine1DFinance.trade_date.asc()).all() + + return [ + { + "symbol_id": r.symbol_id, + "trade_date": r.trade_date, + "total_market_cap": float(r.total_market_cap) if r.total_market_cap else None, + "float_market_cap": float(r.float_market_cap) if r.float_market_cap else None, + "total_shares": r.total_shares, + "float_shares": r.float_shares, + "inst_holding_shares": r.inst_holding_shares, + "inst_holding_ratio": float(r.inst_holding_ratio) if r.inst_holding_ratio else None, + "top10_holders_ratio": float(r.top10_holders_ratio) if r.top10_holders_ratio else None, + "net_profit": float(r.net_profit) if r.net_profit else None, + "revenue": float(r.revenue) if r.revenue else None, + "eps": float(r.eps) if r.eps else None, + "roe": float(r.roe) if r.roe else None, + "trading_days": r.trading_days, + } + for r in results + ] + + def save_klines_finance( + self, + items: List[Dict[str, Any]] + ) -> int: + """保存日线财务数据 + + 对应表: stock_klines_1d_finance + """ + if not items: + return 0 + + count = 0 + for item in items: + symbol = item.get('symbol_id', item.get('symbol')) + trade_date = item.get('trade_date') + + # 检查是否存在 + existing = self.db.query(StockKLine1DFinance).filter( + StockKLine1DFinance.symbol_id == symbol, + StockKLine1DFinance.trade_date == trade_date + ).first() + + if existing: + existing.total_market_cap = item.get('total_market_cap', existing.total_market_cap) + existing.float_market_cap = item.get('float_market_cap', existing.float_market_cap) + existing.total_shares = item.get('total_shares', existing.total_shares) + existing.float_shares = item.get('float_shares', existing.float_shares) + existing.inst_holding_shares = item.get('inst_holding_shares', existing.inst_holding_shares) + existing.inst_holding_ratio = item.get('inst_holding_ratio', existing.inst_holding_ratio) + existing.top10_holders_ratio = item.get('top10_holders_ratio', existing.top10_holders_ratio) + existing.net_profit = item.get('net_profit', existing.net_profit) + existing.revenue = item.get('revenue', existing.revenue) + existing.eps = item.get('eps', existing.eps) + existing.roe = item.get('roe', existing.roe) + existing.trading_days = item.get('trading_days', existing.trading_days) + else: + new_record = StockKLine1DFinance( + symbol_id=symbol, + trade_date=trade_date, + total_market_cap=item.get('total_market_cap'), + float_market_cap=item.get('float_market_cap'), + total_shares=item.get('total_shares'), + float_shares=item.get('float_shares'), + inst_holding_shares=item.get('inst_holding_shares'), + inst_holding_ratio=item.get('inst_holding_ratio'), + top10_holders_ratio=item.get('top10_holders_ratio'), + net_profit=item.get('net_profit'), + revenue=item.get('revenue'), + eps=item.get('eps'), + roe=item.get('roe'), + trading_days=item.get('trading_days'), + ) + self.db.add(new_record) + count += 1 + + self.db.commit() + return count + + # ==================== 实时行情数据表操作 ==================== + + def get_realtime_quote(self, symbol: str) -> Optional[Dict[str, Any]]: + """获取实时行情 + + 对应表: stock_realtime_quotes + """ + result = self.db.query(StockRealTimeQuote).filter( + StockRealTimeQuote.symbol_id == symbol + ).first() + + if result: + return { + "symbol_id": result.symbol_id, + "update_time": result.update_time, + "last_price": float(result.last_price) if result.last_price else None, + "open": float(result.open) if result.open else None, + "high": float(result.high) if result.high else None, + "low": float(result.low) if result.low else None, + "pre_close": float(result.pre_close) if result.pre_close else None, + "volume": result.volume, + "amount": float(result.amount) if result.amount else None, + "bid1": float(result.bid1) if result.bid1 else None, + "ask1": float(result.ask1) if result.ask1 else None, + } + return None + + def save_realtime_quote(self, data: Dict[str, Any]) -> None: + """保存实时行情 + + 对应表: stock_realtime_quotes + """ + symbol = data.get('symbol_id', data.get('symbol')) + + existing = self.db.query(StockRealTimeQuote).filter( + StockRealTimeQuote.symbol_id == symbol + ).first() + + if existing: + existing.update_time = data.get('update_time', existing.update_time) + existing.last_price = data.get('last_price', existing.last_price) + existing.open = data.get('open', existing.open) + existing.high = data.get('high', existing.high) + existing.low = data.get('low', existing.low) + existing.pre_close = data.get('pre_close', existing.pre_close) + existing.volume = data.get('volume', existing.volume) + existing.amount = data.get('amount', existing.amount) + existing.bid1 = data.get('bid1', existing.bid1) + existing.ask1 = data.get('ask1', existing.ask1) + else: + new_record = StockRealTimeQuote( + symbol_id=symbol, + update_time=data.get('update_time'), + last_price=data.get('last_price'), + open=data.get('open'), + high=data.get('high'), + low=data.get('low'), + pre_close=data.get('pre_close'), + volume=data.get('volume'), + amount=data.get('amount'), + bid1=data.get('bid1'), + ask1=data.get('ask1'), + ) + self.db.add(new_record) + + self.db.commit() + + # ==================== 标的和日历(复用原有实现)==================== + + def list_symbols( + self, + req: SymbolListRequest + ) -> Tuple[List[Symbol], int]: + """查询标的列表""" + query = self.db.query(StockSymbol) + + # 筛选条件 + if req.exchange: + query = query.filter(StockSymbol.exchange == req.exchange.value) + + if req.keyword: + keyword = f"%{req.keyword}%" + query = query.filter( + or_( + StockSymbol.symbol_id.ilike(keyword), + StockSymbol.name.ilike(keyword) + ) + ) + + # 查询总数 + total = query.count() + + # 分页查询 + results = query.order_by(StockSymbol.symbol_id).offset( + (req.page - 1) * req.size + ).limit(req.size).all() + + symbols = [] + for r in results: + s = Symbol( + symbol_id=r.symbol_id, + symbol_type=r.symbol_type, + exchange=r.exchange, + name=r.name, + name_en=r.name_en, + list_date=r.list_date, + delist_date=r.delist_date, + industry=r.industry, + status=r.status + ) + symbols.append(s) + + return symbols, total + + def get_trading_dates(self, start: str, end: str) -> TradingDatesData: + """获取交易日历""" + results = self.db.query(StockTradingCalendar).filter( + StockTradingCalendar.trade_date >= start, + StockTradingCalendar.trade_date <= end, + StockTradingCalendar.is_trading_day == True + ).order_by(StockTradingCalendar.trade_date.asc()).all() + + dates = [r.trade_date for r in results] + + # 计算总天数 + start_date = datetime.strptime(start, "%Y%m%d") + end_date = datetime.strptime(end, "%Y%m%d") + total_days = (end_date - start_date).days + 1 + + return TradingDatesData( + start=start, + end=end, + total_days=total_days, + trading_days=len(dates), + trading_dates=dates + ) + + def save_symbols(self, symbols: List[Symbol]) -> None: + """保存标的列表""" + for s in symbols: + existing = self.db.query(StockSymbol).filter( + StockSymbol.symbol_id == s.symbol_id + ).first() + + if existing: + existing.name = s.name + existing.name_en = s.name_en + existing.list_date = s.list_date + existing.delist_date = s.delist_date + existing.industry = s.industry + existing.status = s.status + else: + new_symbol = StockSymbol( + symbol_id=s.symbol_id, + symbol_type=s.symbol_type.value if s.symbol_type else "stock", + exchange=s.exchange.value if s.exchange else "", + name=s.name, + name_en=s.name_en, + list_date=s.list_date, + delist_date=s.delist_date, + industry=s.industry, + status=s.status + ) + self.db.add(new_symbol) + + self.db.commit() + + def save_trading_calendar(self, dates: List[TradeCalData]) -> None: + """保存交易日历""" + for d in dates: + date_str = d.date.strftime("%Y%m%d") + + existing = self.db.query(StockTradingCalendar).filter( + StockTradingCalendar.trade_date == date_str + ).first() + + if existing: + existing.is_trading_day = d.is_trading_day + existing.week_day = d.date.weekday() + 1 + else: + new_cal = StockTradingCalendar( + trade_date=date_str, + is_trading_day=d.is_trading_day, + week_day=d.date.weekday() + 1 + ) + self.db.add(new_cal) + + self.db.commit() + + def get_adjust_factors( + self, + symbol: str, + start_date: str, + end_date: str + ) -> List[Dict[str, Any]]: + """获取指定日期范围内的复权系数""" + results = self.db.query(StockAdjustFactor).filter( + StockAdjustFactor.symbol_id == symbol, + StockAdjustFactor.trade_date >= start_date, + StockAdjustFactor.trade_date <= end_date + ).order_by(StockAdjustFactor.trade_date.asc()).all() + + return [ + { + "trade_date": r.trade_date, + "qfq_factor": float(r.qfq_factor) if r.qfq_factor else 1.0, + "hfq_factor": float(r.hfq_factor) if r.hfq_factor else 1.0 + } + for r in results + ] + + def save_adjust_factors(self, symbol: str, factors: List[Dict[str, Any]]) -> None: + """保存复权系数""" + for f in factors: + trade_date = f.get("trade_date") + + existing = self.db.query(StockAdjustFactor).filter( + StockAdjustFactor.symbol_id == symbol, + StockAdjustFactor.trade_date == trade_date + ).first() + + if existing: + existing.qfq_factor = f.get("qfq_factor", 1.0) + existing.hfq_factor = f.get("hfq_factor", 1.0) + else: + new_factor = StockAdjustFactor( + symbol_id=symbol, + trade_date=trade_date, + qfq_factor=f.get("qfq_factor", 1.0), + hfq_factor=f.get("hfq_factor", 1.0) + ) + self.db.add(new_factor) + + self.db.commit() + + def get_latest_adjust_factor(self, symbol: str) -> Optional[Dict[str, Any]]: + """获取最新的复权系数""" + result = self.db.query(StockAdjustFactor).filter( + StockAdjustFactor.symbol_id == symbol + ).order_by(StockAdjustFactor.trade_date.desc()).first() + + if result: + return { + "trade_date": result.trade_date, + "qfq_factor": float(result.qfq_factor) if result.qfq_factor else 1.0, + "hfq_factor": float(result.hfq_factor) if result.hfq_factor else 1.0 + } + return None diff --git a/app/services/__pycache__/data_sync_service.cpython-311.pyc b/app/services/__pycache__/data_sync_service.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cb844fb8e55cdbb5ac5ff6e8a2254c690b6565d4 GIT binary patch literal 17547 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 literal 0 HcmV?d00001 diff --git a/app/services/__pycache__/test_service.cpython-311.pyc b/app/services/__pycache__/test_service.cpython-311.pyc index a6d4cc90a801c51a0b304eb5322f3c215e2d16c7..b37c5138ab6f4ab9a0d9f732fb5b3660a6b6954e 100644 GIT binary patch delta 2398 zcma)7drVVT81F4@>0wGAK+A6L_m4W=@v0AW|_5e?vM$n_qHIYYlmZi zpnykl(<o%P5L&H@Uxl_k7>) z_dV|ExsDFz?`|fnSFKix$n|6W*|PUGd=-`@QN@wR5OV)Lp2F?Xcq$i$4FJmpn9YZ_&;&Vz#rFu8mYgD`gc!e$2FrC z>fJ-VdnbFtTTvVFwxfOi+@Pn!Xg_5fKsu7oUrh&8Gmz9H6Nx@2xBn`Y;pK9T=AczN zz9JH#Ptj*cjt-&2$cbXm5u78Nh8vZUvt&C%tcr=a9Uy80Hxl&Mht!B#>H=n+&E*Lx#aPZW#@Nl(%j!*`$?n*(8^spmkbnwy=Vd zw^}$WRL=@>wh~c`TLn>1HD)g{Z?N#JEe1NH1Mtv_mfyc{+25lrmALL*te@ITdU&hw z&Ac_FWfqhb$guydQW4&_goJo3JoX<2~Vdgt1RKz3bxW}3GAO^M}zi{9rj#p(i9s= zA55G<>IrNkO7{}7#Iy-%Xy}>)z7Mb{OqrqO% zj-Q7MqwX-Zcvf_w_`1xsHQFb!Yduj(?gXP-V{|8_xiuzt>bkq>8K>VJnU&+#>fN(5 z-RUN`&NQl$CB~160MzQ}m`5T4V*rm|5k)6cfqCNsZxDg5oxn~_pAb-of&hRz3>5JW|Meamx-=Y!Ce0(J? zrZ`x5!QJ$EUG|`{WIlH+qszo^GqI7Q(qcv(_DBRcM)C1RFCkhWG)BZ*NqDj!!V zL=j1Svxm5m@Z1~Wxwpb|N94I&3VD&-cGtJ@XXVTjTs31U(~K|9Si#g_Md}t^8+c6s z?En~HKR`4<2LWvg2mb}G7N1BxlmaXM_X>(=U!RNp@Z8p=2 zzezKwAdWi#fbpMctC{t#l{43>NDY0)bml6)Va#SOyIwY(VVGgpaK?FQQ5^_}RP$!7 zgfjw<77Ez~c?HY!mQt4I8o|*-K+6bfcAJ&6m^ZQ(#Bw~hhpevFin$>?yl`etHSi>G z1k;xCwaMIfz`Y7Uklay#4y?{0oe-VX5P1|NJUK<7>nxq@ z8u*>KEh|yh2j*csn3a+}45kC1AK(!?|$O zq8xFA*!9k$RB31x30%p!aov(vW!r#RjZZC!k8prVpMM7HF21!SSG-c}nw}HOFlStb zTs^}$@%p^zNJz)S$1XXUOM=*5+>n>TtaJ6}C5cmlK@kt(;xVjk;Qk(wU+) U3Z{S=6+Qpwk>5A=f)J|z51_DB8vp@$^tFwp*)2J66J$IL0%y`6g_o$EGhztX#QuPf@H+|3grn0=kQ-VXCrZRBfVNd{>~@-V9rUpA8g| zgM2tp;)WkR3LNG?0u3ZXt6sU&Eo$grQ%DB+*qSoZqIuR15~67%>xOJq?MQ4%vXEq@ zs}W}uirsZ*Okpe>Q5a3auuniDdZw~ZrQuMM5|$OpXt!8xkj!*|kCYYk>_wba4vRXG zqC)b~`(mbpfRX+q;tnSfa5-QJL;&OvK|#qe?l<#;K*C1_X2~ zQ{po1g0#sUu{OpdoBZ}6*iZ2@o0i7UL!Jb1%DN{MmgSJ>wJFNf1l=!X(WNkIKmxb` zTm&uyMxA|7R|Rx)lIfNhi|8aI4u?fTMx)n-iDe1!?3HtPae2OZm}uL}m)jhQSgfVb zc&cJqNknLhw1mmjP06PGqni#-X_pkpbKQG6CZ{Jy6E|y@1LA& Dict[str, int]: + """同步K线基础数据 + + Args: + symbols: 标的代码列表 + freq: 周期 + start: 开始日期 (YYYYMMDD) + end: 结束日期 (YYYYMMDD) + db: 数据库会话 (可选,用于事务控制) + + Returns: + 各标的同步记录数统计 + """ + should_close = db is None + if db is None: + db = SessionLocal() + + try: + repo = StockRepositoryV2(db) + results = {} + + for symbol in symbols: + try: + info(f"[DataSync] Syncing kline base data for {symbol} {freq.value}") + + # 从适配器获取数据 + data = await self.adapter.fetch_kline_base( + symbol=symbol, + start=start, + end=end, + freq=freq.value + ) + + if data: + # 转换数据格式 + items = [ + { + "symbol_id": item.get("symbol", item.get("symbol_id")), + "trade_date": item.get("trade_date"), + "ts": item.get("ts"), + "open": item.get("open"), + "high": item.get("high"), + "low": item.get("low"), + "close": item.get("close"), + "volume": item.get("volume"), + "amount": item.get("amount"), + } + for item in data + ] + + # 保存到数据库 + count = repo.save_klines_base(freq, items) + results[symbol] = count + info(f"[DataSync] Saved {count} base kline records for {symbol}") + else: + results[symbol] = 0 + warning(f"[DataSync] No base kline data returned for {symbol}") + + except Exception as e: + error(f"[DataSync] Failed to sync {symbol}: {e}") + results[symbol] = -1 # -1 表示错误 + + return results + + finally: + if should_close: + db.close() + + async def sync_kline_quote( + self, + symbols: List[str], + start: str, + end: str, + db: Optional[Session] = None + ) -> Dict[str, int]: + """同步日线行情指标数据 + + Args: + symbols: 标的代码列表 + start: 开始日期 (YYYYMMDD) + end: 结束日期 (YYYYMMDD) + db: 数据库会话 + + Returns: + 各标的同步记录数统计 + """ + should_close = db is None + if db is None: + db = SessionLocal() + + try: + repo = StockRepositoryV2(db) + results = {} + + for symbol in symbols: + try: + info(f"[DataSync] Syncing kline quote data for {symbol}") + + # 从适配器获取数据(包含计算后的指标) + data = await self.adapter.fetch_kline_quote( + symbol=symbol, + start=start, + end=end + ) + + if data: + # 转换数据格式 + items = [ + { + "symbol_id": item.get("symbol", item.get("symbol_id")), + "trade_date": item.get("trade_date"), + "change_pct": item.get("change_pct"), + "change_5d_pct": item.get("change_5d_pct"), + "change_10d_pct": item.get("change_10d_pct"), + "change_20d_pct": item.get("change_20d_pct"), + "change_30d_pct": item.get("change_30d_pct"), + "change_60d_pct": item.get("change_60d_pct"), + "ma5": item.get("ma5"), + "ma10": item.get("ma10"), + "ma20": item.get("ma20"), + "ma30": item.get("ma30"), + "ma60": item.get("ma60"), + "ma120": item.get("ma120"), + "ma250": item.get("ma250"), + "macd_dif": item.get("macd_dif"), + "macd_dea": item.get("macd_dea"), + "macd_bar": item.get("macd_bar"), + "bias5": item.get("bias5"), + "bias10": item.get("bias10"), + "bias20": item.get("bias20"), + "is_limit_up": item.get("is_limit_up"), + "is_limit_down": item.get("is_limit_down"), + "limit_up_price": item.get("limit_up_price"), + "limit_down_price": item.get("limit_down_price"), + "is_st": item.get("is_st"), + } + for item in data + ] + + # 保存到数据库 + count = repo.save_klines_quote(items) + results[symbol] = count + info(f"[DataSync] Saved {count} quote records for {symbol}") + else: + results[symbol] = 0 + warning(f"[DataSync] No quote data returned for {symbol}") + + except Exception as e: + error(f"[DataSync] Failed to sync quote for {symbol}: {e}") + results[symbol] = -1 + + return results + + finally: + if should_close: + db.close() + + async def sync_kline_finance( + self, + symbols: List[str], + start: str, + end: str, + db: Optional[Session] = None + ) -> Dict[str, int]: + """同步日线财务数据 + + Args: + symbols: 标的代码列表 + start: 开始日期 (YYYYMMDD) + end: 结束日期 (YYYYMMDD) + db: 数据库会话 + + Returns: + 各标的同步记录数统计 + """ + should_close = db is None + if db is None: + db = SessionLocal() + + try: + repo = StockRepositoryV2(db) + results = {} + + for symbol in symbols: + try: + info(f"[DataSync] Syncing kline finance data for {symbol}") + + # 从适配器获取数据 + data = await self.adapter.fetch_kline_finance( + symbol=symbol, + start=start, + end=end + ) + + if data: + # 转换数据格式 + items = [ + { + "symbol_id": item.get("symbol", item.get("symbol_id")), + "trade_date": item.get("trade_date"), + "total_market_cap": item.get("total_market_cap"), + "float_market_cap": item.get("float_market_cap"), + "total_shares": item.get("total_shares"), + "float_shares": item.get("float_shares"), + "inst_holding_shares": item.get("inst_holding_shares"), + "inst_holding_ratio": item.get("inst_holding_ratio"), + "top10_holders_ratio": item.get("top10_holders_ratio"), + "net_profit": item.get("net_profit"), + "revenue": item.get("revenue"), + "eps": item.get("eps"), + "roe": item.get("roe"), + "trading_days": item.get("trading_days"), + } + for item in data + ] + + # 保存到数据库 + count = repo.save_klines_finance(items) + results[symbol] = count + info(f"[DataSync] Saved {count} finance records for {symbol}") + else: + results[symbol] = 0 + warning(f"[DataSync] No finance data returned for {symbol}") + + except Exception as e: + error(f"[DataSync] Failed to sync finance for {symbol}: {e}") + results[symbol] = -1 + + return results + + finally: + if should_close: + db.close() + + async def sync_stock_basic_info( + self, + codes: Optional[List[str]] = None, + db: Optional[Session] = None + ) -> int: + """同步股票基础信息 + + Args: + codes: 指定代码列表,None表示全量同步 + db: 数据库会话 + + Returns: + 同步记录数 + """ + should_close = db is None + if db is None: + db = SessionLocal() + + try: + repo = StockRepositoryV2(db) + + info("[DataSync] Syncing stock basic info") + + # 从适配器获取数据 + data = await self.adapter.fetch_stock_basic_info(codes=codes) + + if data: + # 转换为Symbol对象 + from app.models import Symbol, Exchange + symbols = [] + + for item in data: + # 解析exchange + exchange_str = item.get('exchange', '') + exchange = None + if exchange_str == 'SH': + exchange = Exchange.SH + elif exchange_str == 'SZ': + exchange = Exchange.SZ + elif exchange_str == 'BJ': + exchange = Exchange.BJ + + symbol = Symbol( + symbol_id=item.get('symbol_id', item.get('symbol')), + name=item.get('name'), + exchange=exchange, + list_date=item.get('list_date'), + delist_date=item.get('delist_date'), + industry=item.get('industry'), + status=item.get('status', 'active'), + ) + symbols.append(symbol) + + # 保存到数据库 + repo.save_symbols(symbols) + info(f"[DataSync] Saved {len(symbols)} stock basic info records") + return len(symbols) + + return 0 + + except Exception as e: + error(f"[DataSync] Failed to sync stock basic info: {e}") + return 0 + + finally: + if should_close: + db.close() + + async def sync_full_stock_data( + self, + symbols: List[str], + start: str, + end: str + ) -> Dict[str, Any]: + """全量同步股票数据(基础+行情+财务) + + 用于首次导入或历史数据补全 + """ + info(f"[DataSync] Starting full sync for {len(symbols)} symbols from {start} to {end}") + + results = { + "base": {}, + "quote": {}, + "finance": {}, + "errors": [] + } + + db = SessionLocal() + try: + # 1. 同步基础K线数据 + results["base"] = await self.sync_kline_base( + symbols, Frequency.FREQ_1D, start, end, db + ) + + # 2. 同步行情指标数据 + results["quote"] = await self.sync_kline_quote( + symbols, start, end, db + ) + + # 3. 同步财务数据 + results["finance"] = await self.sync_kline_finance( + symbols, start, end, db + ) + + info(f"[DataSync] Full sync completed") + + except Exception as e: + error(f"[DataSync] Full sync failed: {e}") + results["errors"].append(str(e)) + + finally: + db.close() + + return results + + async def sync_daily_incremental( + self, + symbols: List[str] + ) -> Dict[str, Any]: + """每日增量同步(同步最近N天数据) + + 用于定时任务,同步最近3个交易日的数据 + """ + # 计算日期范围(最近5个交易日) + end_date = datetime.now().strftime("%Y%m%d") + start_date = (datetime.now() - timedelta(days=30)).strftime("%Y%m%d") + + info(f"[DataSync] Starting daily incremental sync for {len(symbols)} symbols") + + return await self.sync_full_stock_data(symbols, start_date, end_date) diff --git a/app/services/test_service.py b/app/services/test_service.py index 5a90531..72656c9 100644 --- a/app/services/test_service.py +++ b/app/services/test_service.py @@ -248,6 +248,90 @@ class TestService: ), ] ), + APITestCategory( + name="数据同步接口", + items=[ + APITestCase( + id="admin_data_sync_full", + name="全量数据同步", + method="POST", + path="/v1/admin/data/sync", + description="手动触发全量数据同步(基础+行情+财务)", + body={ + "symbols": ["000001.SZ", "600519.SH"], + "sync_type": "full", + "start_date": "20240301", + "end_date": "20240310", + "asset_class": "stock" + } + ), + APITestCase( + id="admin_data_sync_base", + name="同步基础K线数据", + method="POST", + path="/v1/admin/data/sync", + description="仅同步基础K线数据(OHLCV)", + body={ + "symbols": ["000001.SZ"], + "sync_type": "base", + "freq": "1d", + "start_date": "20240301", + "end_date": "20240310", + "asset_class": "stock" + } + ), + APITestCase( + id="admin_data_sync_quote", + name="同步行情指标数据", + method="POST", + path="/v1/admin/data/sync", + description="同步行情指标数据(均线/MACD/涨跌幅)", + body={ + "symbols": ["000001.SZ"], + "sync_type": "quote", + "start_date": "20240301", + "end_date": "20240310", + "asset_class": "stock" + } + ), + APITestCase( + id="admin_data_sync_finance", + name="同步财务数据", + method="POST", + path="/v1/admin/data/sync", + description="同步财务数据(市值/股本/利润)", + body={ + "symbols": ["000001.SZ"], + "sync_type": "finance", + "start_date": "20240301", + "end_date": "20240310", + "asset_class": "stock" + } + ), + APITestCase( + id="admin_data_sync_incremental", + name="增量数据同步", + method="POST", + path="/v1/admin/data/sync/incremental", + description="触发增量同步(最近30天)", + body=["000001.SZ", "600519.SH"] + ), + APITestCase( + id="admin_data_sync_futures", + name="期货数据同步", + method="POST", + path="/v1/admin/data/sync", + description="同步期货数据", + body={ + "symbols": ["CU2504.SHFE"], + "sync_type": "full", + "start_date": "20240301", + "end_date": "20240310", + "asset_class": "futures" + } + ), + ] + ), APITestCategory( name="测试管理", items=[ diff --git a/scripts/__pycache__/create_split_tables.cpython-311.pyc b/scripts/__pycache__/create_split_tables.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..aa098c87e39ccd5e9e871f0ef5ee2fbfc46ac018 GIT binary patch literal 5499 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 YGU;9){cda{wr>0?c0{%V+gQ5)0JuhARsaA1 literal 0 HcmV?d00001 diff --git a/scripts/add_15_30_60_tables.py b/scripts/add_15_30_60_tables.py new file mode 100644 index 0000000..8d0dc76 --- /dev/null +++ b/scripts/add_15_30_60_tables.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +"""添加15/30/60分钟K线表""" + +# 股票15/30/60分钟表 +stock_tables = ''' + + +# ============================================ +# 股票15/30/60分钟K线表 (新增) +# ============================================ + +class StockKLine15M(Base): + """股票15分钟K线""" + __tablename__ = "stock_klines_15m" + __table_args__ = ( + Index("idx_stock_15m_symbol_ts", "symbol_id", "ts"), + ) + + id = Column(BigInteger, primary_key=True, autoincrement=True) + symbol_id = Column(String(20), nullable=False, index=True, comment="标的代码") + ts = Column(DateTime, nullable=False, comment="时间戳") + trade_date = Column(String(10), nullable=False, comment="交易日") + + open = Column(Numeric(18, 4), nullable=False, comment="开盘价") + high = Column(Numeric(18, 4), nullable=False, comment="最高价") + low = Column(Numeric(18, 4), nullable=False, comment="最低价") + close = Column(Numeric(18, 4), nullable=False, comment="收盘价") + volume = Column(BigInteger, nullable=False, comment="成交量") + amount = Column(Numeric(20, 4), nullable=False, comment="成交额") + + change_pct = Column(Numeric(8, 4), nullable=True, comment="涨跌幅%") + macd_dif = Column(Numeric(18, 6), nullable=True, comment="MACD DIF") + macd_dea = Column(Numeric(18, 6), nullable=True, comment="MACD DEA") + macd_bar = Column(Numeric(18, 6), nullable=True, comment="MACD BAR") + + created_at = Column(DateTime, default=datetime.now, comment="创建时间") + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间") + + +class StockKLine30M(Base): + """股票30分钟K线""" + __tablename__ = "stock_klines_30m" + __table_args__ = ( + Index("idx_stock_30m_symbol_ts", "symbol_id", "ts"), + ) + + id = Column(BigInteger, primary_key=True, autoincrement=True) + symbol_id = Column(String(20), nullable=False, index=True, comment="标的代码") + ts = Column(DateTime, nullable=False, comment="时间戳") + trade_date = Column(String(10), nullable=False, comment="交易日") + + open = Column(Numeric(18, 4), nullable=False, comment="开盘价") + high = Column(Numeric(18, 4), nullable=False, comment="最高价") + low = Column(Numeric(18, 4), nullable=False, comment="最低价") + close = Column(Numeric(18, 4), nullable=False, comment="收盘价") + volume = Column(BigInteger, nullable=False, comment="成交量") + amount = Column(Numeric(20, 4), nullable=False, comment="成交额") + + change_pct = Column(Numeric(8, 4), nullable=True, comment="涨跌幅%") + macd_dif = Column(Numeric(18, 6), nullable=True, comment="MACD DIF") + macd_dea = Column(Numeric(18, 6), nullable=True, comment="MACD DEA") + macd_bar = Column(Numeric(18, 6), nullable=True, comment="MACD BAR") + + created_at = Column(DateTime, default=datetime.now, comment="创建时间") + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间") + + +class StockKLine60M(Base): + """股票60分钟K线""" + __tablename__ = "stock_klines_60m" + __table_args__ = ( + Index("idx_stock_60m_symbol_ts", "symbol_id", "ts"), + ) + + id = Column(BigInteger, primary_key=True, autoincrement=True) + symbol_id = Column(String(20), nullable=False, index=True, comment="标的代码") + ts = Column(DateTime, nullable=False, comment="时间戳") + trade_date = Column(String(10), nullable=False, comment="交易日") + + open = Column(Numeric(18, 4), nullable=False, comment="开盘价") + high = Column(Numeric(18, 4), nullable=False, comment="最高价") + low = Column(Numeric(18, 4), nullable=False, comment="最低价") + close = Column(Numeric(18, 4), nullable=False, comment="收盘价") + volume = Column(BigInteger, nullable=False, comment="成交量") + amount = Column(Numeric(20, 4), nullable=False, comment="成交额") + + change_pct = Column(Numeric(8, 4), nullable=True, comment="涨跌幅%") + macd_dif = Column(Numeric(18, 6), nullable=True, comment="MACD DIF") + macd_dea = Column(Numeric(18, 6), nullable=True, comment="MACD DEA") + macd_bar = Column(Numeric(18, 6), nullable=True, comment="MACD BAR") + + created_at = Column(DateTime, default=datetime.now, comment="创建时间") + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间") + +''' + +# 期货15/30/60分钟表 +futures_tables = ''' + + +# ============================================ +# 期货15/30/60分钟K线表 (新增) +# ============================================ + +class FuturesKLine15MBase(Base): + """期货15分钟K线 - 基础表""" + __tablename__ = "futures_klines_15m_base" + __table_args__ = ( + Index("idx_futures_15m_base_symbol_ts", "symbol_id", "ts"), + ) + + id = Column(BigInteger, primary_key=True, autoincrement=True) + symbol_id = Column(String(20), nullable=False, index=True, comment="合约代码") + ts = Column(DateTime, nullable=False, comment="时间戳") + trade_date = Column(String(10), nullable=False, comment="交易日") + + open = Column(Numeric(18, 4), nullable=False, comment="开盘价") + high = Column(Numeric(18, 4), nullable=False, comment="最高价") + low = Column(Numeric(18, 4), nullable=False, comment="最低价") + close = Column(Numeric(18, 4), nullable=False, comment="收盘价") + volume = Column(BigInteger, nullable=False, comment="成交量") + amount = Column(Numeric(20, 4), nullable=False, comment="成交额") + open_interest = Column(BigInteger, nullable=True, comment="持仓量") + + created_at = Column(DateTime, default=datetime.now, comment="创建时间") + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间") + + +class FuturesKLine30MBase(Base): + """期货30分钟K线 - 基础表""" + __tablename__ = "futures_klines_30m_base" + __table_args__ = ( + Index("idx_futures_30m_base_symbol_ts", "symbol_id", "ts"), + ) + + id = Column(BigInteger, primary_key=True, autoincrement=True) + symbol_id = Column(String(20), nullable=False, index=True, comment="合约代码") + ts = Column(DateTime, nullable=False, comment="时间戳") + trade_date = Column(String(10), nullable=False, comment="交易日") + + open = Column(Numeric(18, 4), nullable=False, comment="开盘价") + high = Column(Numeric(18, 4), nullable=False, comment="最高价") + low = Column(Numeric(18, 4), nullable=False, comment="最低价") + close = Column(Numeric(18, 4), nullable=False, comment="收盘价") + volume = Column(BigInteger, nullable=False, comment="成交量") + amount = Column(Numeric(20, 4), nullable=False, comment="成交额") + open_interest = Column(BigInteger, nullable=True, comment="持仓量") + + created_at = Column(DateTime, default=datetime.now, comment="创建时间") + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间") + + +class FuturesKLine60MBase(Base): + """期货60分钟K线 - 基础表""" + __tablename__ = "futures_klines_60m_base" + __table_args__ = ( + Index("idx_futures_60m_base_symbol_ts", "symbol_id", "ts"), + ) + + id = Column(BigInteger, primary_key=True, autoincrement=True) + symbol_id = Column(String(20), nullable=False, index=True, comment="合约代码") + ts = Column(DateTime, nullable=False, comment="时间戳") + trade_date = Column(String(10), nullable=False, comment="交易日") + + open = Column(Numeric(18, 4), nullable=False, comment="开盘价") + high = Column(Numeric(18, 4), nullable=False, comment="最高价") + low = Column(Numeric(18, 4), nullable=False, comment="最低价") + close = Column(Numeric(18, 4), nullable=False, comment="收盘价") + volume = Column(BigInteger, nullable=False, comment="成交量") + amount = Column(Numeric(20, 4), nullable=False, comment="成交额") + open_interest = Column(BigInteger, nullable=True, comment="持仓量") + + created_at = Column(DateTime, default=datetime.now, comment="创建时间") + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间") + +''' + +def main(): + models_path = 'app/repositories/models.py' + + with open(models_path, 'r', encoding='utf-8') as f: + content = f.read() + + added = [] + + # 添加股票表 + if 'class StockKLine15M' not in content: + content = content + stock_tables + added.append('StockKLine15M/30M/60M') + + # 添加期货表 + if 'class FuturesKLine15MBase' not in content: + content = content + futures_tables + added.append('FuturesKLine15MBase/30MBase/60MBase') + + if added: + with open(models_path, 'w', encoding='utf-8') as f: + f.write(content) + print(f'Added: {", ".join(added)}') + else: + print('All tables already exist.') + +if __name__ == "__main__": + main() diff --git a/scripts/add_adapter_methods.py b/scripts/add_adapter_methods.py new file mode 100644 index 0000000..6265e9b --- /dev/null +++ b/scripts/add_adapter_methods.py @@ -0,0 +1,522 @@ +#!/usr/bin/env python3 +"""Add new data fetch methods to amazingdata_adapter.py using English comments""" + +new_methods = ''' + + # ==================== New Split Table Data Fetch Methods ==================== + + async def fetch_kline_base( + self, + symbol: str, + start: str, + end: str, + freq: str + ) -> List[Dict[str, Any]]: + """Fetch K-line base data (OHLCV) + + Corresponding tables: stock_klines_1d_base, stock_klines_1m_base, etc. + + Returns: + List[Dict] containing fields: + - symbol: Symbol code + - ts: Timestamp + - trade_date: Trade date + - open/high/low/close: Price data + - volume: Trading volume + - amount: Trading amount + - adj_factor: Adjustment factor + """ + print(f"[amazingdata_adapter fetch_kline_base]Fetching {symbol} {freq} base data...") + self._check_login() + + period_map = { + "1m": self._ad.constant.Period.min1, + "5m": self._ad.constant.Period.min5, + "15m": self._ad.constant.Period.min15, + "30m": self._ad.constant.Period.min30, + "60m": self._ad.constant.Period.min60, + "1d": self._ad.constant.Period.day, + "1w": self._ad.constant.Period.week, + "1month": self._ad.constant.Period.month, + } + period_value = period_map.get(freq, self._ad.constant.Period.day).value + + loop = asyncio.get_event_loop() + return await loop.run_in_executor( + None, + lambda: self._fetch_kline_base_sync(symbol, start, end, period_value, freq) + ) + + def _fetch_kline_base_sync( + self, + symbol: str, + start_date: str, + end_date: str, + period_value: int, + freq: str + ) -> List[Dict[str, Any]]: + """Sync method to fetch K-line base data""" + codes = [symbol] + start_int = self._format_date(start_date) + end_int = self._format_date(end_date) + + kline_dict = self._market_data.query_kline( + code_list=codes, + begin_date=start_int, + end_date=end_int, + period=period_value + ) + + if symbol not in kline_dict: + info(f"No kline data found for {symbol}") + return [] + + df = kline_dict[symbol] + results = [] + + for _, row in df.iterrows(): + kline_time = row.get('kline_time') + if pd.isna(kline_time) or kline_time is None: + continue + + if isinstance(kline_time, pd.Timestamp): + ts = int(kline_time.timestamp()) + trade_date = kline_time.strftime('%Y-%m-%d') + else: + date_str = str(int(kline_time)) + if len(date_str) != 8: + continue + dt = datetime.strptime(date_str, "%Y%m%d") + ts = int(dt.timestamp()) + trade_date = dt.strftime('%Y-%m-%d') + + results.append({ + "symbol": symbol, + "ts": ts, + "trade_date": trade_date, + "open": float(row.get('open', 0)), + "high": float(row.get('high', 0)), + "low": float(row.get('low', 0)), + "close": float(row.get('close', 0)), + "volume": int(row.get('volume', 0)), + "amount": float(row.get('amount', 0)), + "adj_factor": float(row.get('adj_factor', 1.0)) if 'adj_factor' in df.columns else 1.0, + }) + + info(f"Fetched {len(results)} base kline records for {symbol}") + return results + + async def fetch_kline_quote( + self, + symbol: str, + start: str, + end: str + ) -> List[Dict[str, Any]]: + """Fetch daily quote indicator data (calculated) + + Corresponding table: stock_klines_1d_quote + + Returns: + List[Dict] containing fields: + - change_pct: Price change percentage + - change_Nd_pct: N-day price change + - ma_N: Moving averages + - macd_dif/dea/bar: MACD indicators + - bias_N: Bias ratios + - is_limit_up/down: Limit up/down status + - is_st: ST status + """ + print(f"[amazingdata_adapter fetch_kline_quote]Calculating {symbol} quote indicators...") + self._check_login() + + loop = asyncio.get_event_loop() + return await loop.run_in_executor( + None, + lambda: self._fetch_kline_quote_sync(symbol, start, end) + ) + + def _fetch_kline_quote_sync( + self, + symbol: str, + start_date: str, + end_date: str + ) -> List[Dict[str, Any]]: + """Sync method to calculate quote indicators""" + import numpy as np + + start_dt = datetime.strptime(start_date, "%Y%m%d") + extended_start = datetime(start_dt.year - 1, start_dt.month, start_dt.day) + extended_start_str = extended_start.strftime("%Y%m%d") + + codes = [symbol] + start_int = self._format_date(extended_start_str) + end_int = self._format_date(end_date) + + kline_dict = self._market_data.query_kline( + code_list=codes, + begin_date=start_int, + end_date=end_int, + period=self._ad.constant.Period.day.value + ) + + if symbol not in kline_dict: + return [] + + df = kline_dict[symbol].copy() + df = df.sort_values('kline_time') + + try: + code_info_df = self._base_data.get_code_info(security_type=SecurityType.STOCK_A.value) + if symbol in code_info_df.index: + high_limited = float(code_info_df.loc[symbol, 'high_limited']) if 'high_limited' in code_info_df.columns else None + low_limited = float(code_info_df.loc[symbol, 'low_limited']) if 'low_limited' in code_info_df.columns else None + else: + high_limited = low_limited = None + except: + high_limited = low_limited = None + + results = [] + closes = df['close'].values + + for i, (_, row) in enumerate(df.iterrows()): + kline_time = row.get('kline_time') + if pd.isna(kline_time): + continue + + if isinstance(kline_time, pd.Timestamp): + trade_date = kline_time.strftime('%Y-%m-%d') + else: + date_str = str(int(kline_time)) + if len(date_str) != 8: + continue + dt = datetime.strptime(date_str, "%Y%m%d") + trade_date = dt.strftime('%Y-%m-%d') + + close = float(row.get('close', 0)) + + change_pct = None + if i > 0: + prev_close = closes[i-1] + if prev_close > 0: + change_pct = round((close - prev_close) / prev_close * 100, 4) + + def calc_n_day_change(n): + if i >= n and closes[i-n] > 0: + return round((close - closes[i-n]) / closes[i-n] * 100, 4) + return None + + def calc_ma(n): + if i >= n - 1: + return round(np.mean(closes[i-n+1:i+1]), 4) + return None + + def calc_macd(): + if i < 33: + return None, None, None + ema12 = pd.Series(closes[:i+1]).ewm(span=12).mean().iloc[-1] + ema26 = pd.Series(closes[:i+1]).ewm(span=26).mean().iloc[-1] + dif = ema12 - ema26 + dea = pd.Series([ema12 - ema26 for _ in range(i+1)]).ewm(span=9).mean().iloc[-1] + bar = (dif - dea) * 2 + return round(dif, 6), round(dea, 6), round(bar, 6) + + def calc_bias(n): + ma = calc_ma(n) + if ma and ma > 0: + return round((close - ma) / ma * 100, 4) + return None + + is_limit_up = False + is_limit_down = False + if high_limited and low_limited and close > 0: + is_limit_up = close >= high_limited * 0.995 + is_limit_down = close <= low_limited * 1.005 + + macd_dif, macd_dea, macd_bar = calc_macd() + + if trade_date.replace('-', '') >= start_date: + results.append({ + "symbol": symbol, + "trade_date": trade_date, + "change_pct": change_pct, + "change_5d_pct": calc_n_day_change(5), + "change_10d_pct": calc_n_day_change(10), + "change_20d_pct": calc_n_day_change(20), + "change_30d_pct": calc_n_day_change(30), + "change_60d_pct": calc_n_day_change(60), + "macd_dif": macd_dif, + "macd_dea": macd_dea, + "macd_bar": macd_bar, + "bias_5": calc_bias(5), + "bias_10": calc_bias(10), + "bias_20": calc_bias(20), + "is_limit_up": is_limit_up, + "is_limit_down": is_limit_down, + "limit_up_price": round(high_limited, 4) if high_limited else None, + "limit_down_price": round(low_limited, 4) if low_limited else None, + "is_st": None, + "ma_5": calc_ma(5), + "ma_10": calc_ma(10), + "ma_20": calc_ma(20), + "ma_30": calc_ma(30), + "ma_60": calc_ma(60), + "ma_120": calc_ma(120), + "ma_250": calc_ma(250), + }) + + info(f"Calculated {len(results)} quote indicators for {symbol}") + return results + + async def fetch_kline_finance( + self, + symbol: str, + start: str, + end: str + ) -> List[Dict[str, Any]]: + """Fetch daily finance data + + Corresponding table: stock_klines_1d_finance + Data sources: get_equity_structure, get_share_holder, get_income + + Returns: + List[Dict] containing fields: + - total_market_cap: Total market cap + - float_market_cap: Float market cap + - total_shares: Total shares + - float_shares: Float shares + - inst_holding_shares: Institutional holding shares + - inst_holding_ratio: Institutional holding ratio + - net_profit: Net profit + - revenue: Revenue + - eps: EPS + - roe: ROE + """ + print(f"[amazingdata_adapter fetch_kline_finance]Fetching {symbol} finance data...") + self._check_login() + + loop = asyncio.get_event_loop() + return await loop.run_in_executor( + None, + lambda: self._fetch_kline_finance_sync(symbol, start, end) + ) + + def _fetch_kline_finance_sync( + self, + symbol: str, + start_date: str, + end_date: str + ) -> List[Dict[str, Any]]: + """Sync method to fetch finance data""" + codes = [symbol] + start_int = self._format_date(start_date) + end_int = self._format_date(end_date) + + results = [] + + try: + equity_dict = self._info_data.get_equity_structure( + code_list=codes, + local_path=self.config.local_path, + is_local=self.config.use_local_cache + ) + + equity_data = {} + if symbol in equity_dict: + equity_df = equity_dict[symbol] + for _, row in equity_df.iterrows(): + ann_date = row.get('ANN_DATE') + if pd.notna(ann_date): + if isinstance(ann_date, (int, float)): + date_key = str(int(ann_date)) + else: + date_key = str(ann_date).replace('-', '').replace('/', '') + equity_data[date_key] = { + 'total_shares': float(row.get('TOT_A_SHARE', 0)) * 10000 if pd.notna(row.get('TOT_A_SHARE')) else 0, + 'float_shares': float(row.get('FLOAT_A_SHARE', 0)) * 10000 if pd.notna(row.get('FLOAT_A_SHARE')) else 0, + } + except Exception as e: + print(f"[amazingdata_adapter]Failed to get equity structure: {e}") + equity_data = {} + + kline_dict = self._market_data.query_kline( + code_list=codes, + begin_date=start_int, + end_date=end_int, + period=self._ad.constant.Period.day.value + ) + + if symbol not in kline_dict: + return [] + + df = kline_dict[symbol] + + for _, row in df.iterrows(): + kline_time = row.get('kline_time') + if pd.isna(kline_time): + continue + + if isinstance(kline_time, pd.Timestamp): + trade_date = kline_time.strftime('%Y-%m-%d') + trade_date_int = int(kline_time.strftime('%Y%m%d')) + else: + date_str = str(int(kline_time)) + if len(date_str) != 8: + continue + dt = datetime.strptime(date_str, "%Y%m%d") + trade_date = dt.strftime('%Y-%m-%d') + trade_date_int = int(date_str) + + close = float(row.get('close', 0)) + + total_shares = 0 + float_shares = 0 + for date_key in sorted(equity_data.keys(), reverse=True): + if int(date_key) <= trade_date_int: + total_shares = equity_data[date_key]['total_shares'] + float_shares = equity_data[date_key]['float_shares'] + break + + total_market_cap = close * total_shares if total_shares > 0 and close > 0 else None + float_market_cap = close * float_shares if float_shares > 0 and close > 0 else None + + results.append({ + "symbol": symbol, + "trade_date": trade_date, + "total_market_cap": round(total_market_cap, 2) if total_market_cap else None, + "float_market_cap": round(float_market_cap, 2) if float_market_cap else None, + "total_shares": int(total_shares) if total_shares > 0 else None, + "float_shares": int(float_shares) if float_shares > 0 else None, + "inst_holding_shares": None, + "inst_holding_ratio": None, + "top10_holders_ratio": None, + "net_profit": None, + "revenue": None, + "eps": None, + "roe": None, + "trading_days": None, + }) + + info(f"Fetched {len(results)} finance records for {symbol}") + return results + + async def fetch_stock_basic_info( + self, + codes: Optional[List[str]] = None + ) -> List[Dict[str, Any]]: + """Fetch stock basic info + + Corresponding table: stock_symbols + Data source: get_stock_basic + + Returns: + List[Dict] containing fields: + - symbol_id: Symbol code + - name: Name + - exchange: Exchange + - list_date: List date + - list_board: List board + - industry: Industry + - status: Status + - is_delisted: Is delisted + - delist_date: Delist date + """ + print(f"[amazingdata_adapter fetch_stock_basic_info]Fetching stock basic info...") + self._check_login() + + loop = asyncio.get_event_loop() + return await loop.run_in_executor( + None, + lambda: self._fetch_stock_basic_info_sync(codes) + ) + + def _fetch_stock_basic_info_sync( + self, + codes: Optional[List[str]] = None + ) -> List[Dict[str, Any]]: + """Sync method to fetch stock basic info""" + try: + all_codes = self._base_data.get_code_list( + security_type=SecurityType.STOCK_A.value + ) + + if codes: + all_codes = [c for c in all_codes if c in codes] + + info_df = self._base_data.get_code_info( + security_type=SecurityType.STOCK_A.value + ) + + results = [] + for code in all_codes: + if ".SH" in code: + exchange = "SH" + elif ".SZ" in code: + exchange = "SZ" + elif ".BJ" in code: + exchange = "BJ" + else: + exchange = "" + + name = code + if code in info_df.index and 'symbol' in info_df.columns: + name = info_df.loc[code, 'symbol'] + + list_date = None + try: + equity_dict = self._info_data.get_equity_structure( + code_list=[code], + local_path=self.config.local_path, + is_local=self.config.use_local_cache + ) + if code in equity_dict and not equity_dict[code].empty: + first_record = equity_dict[code].iloc[0] + ann_date = first_record.get('ANN_DATE') + if pd.notna(ann_date): + if isinstance(ann_date, (int, float)): + list_date = datetime.strptime(str(int(ann_date)), "%Y%m%d") + else: + list_date = pd.to_datetime(ann_date) + except: + pass + + results.append({ + "symbol_id": code, + "name": name, + "exchange": exchange, + "list_date": list_date, + "list_board": None, + "industry": None, + "status": "active", + "is_delisted": False, + "delist_date": None, + "is_st": None, + "total_shares": None, + "float_shares": None, + }) + + info(f"Fetched {len(results)} stock basic info records") + return results + + except Exception as e: + error(f"Failed to fetch stock basic info: {e}") + return [] +''' + +def main(): + # Read original file + with open('app/adapters/amazingdata_adapter.py', 'r', encoding='utf-8') as f: + content = f.read() + + # Check if already added + if 'fetch_kline_base' not in content: + # Append new methods before the last line + content = content.rstrip() + new_methods + + with open('app/adapters/amazingdata_adapter.py', 'w', encoding='utf-8') as f: + f.write(content) + print('New methods added successfully!') + else: + print('Methods already exist.') + +if __name__ == "__main__": + main() diff --git a/scripts/add_stock_split_tables.py b/scripts/add_stock_split_tables.py new file mode 100644 index 0000000..ffdc7fc --- /dev/null +++ b/scripts/add_stock_split_tables.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +"""添加股票分表模型到 models.py""" + +# 股票分表结构 +stock_split_tables = ''' + + +# ============================================ +# 股票日线历史数据表 (1d) - 分表 (新增) +# ============================================ + +class StockKLine1DBase(Base): + """股票日线K线 - 基础表 + + 存储最基础的K线数据,对应 query_kline 接口 + """ + __tablename__ = "stock_klines_1d_base" + __table_args__ = ( + Index("idx_stock_1d_base_symbol_ts", "symbol_id", "ts"), + Index("idx_stock_1d_base_symbol_date", "symbol_id", "trade_date"), + ) + + id = Column(BigInteger, primary_key=True, autoincrement=True) + symbol_id = Column(String(20), nullable=False, index=True, comment="标的代码") + ts = Column(DateTime, nullable=False, comment="时间戳") + trade_date = Column(String(10), nullable=False, index=True, comment="交易日 YYYY-MM-DD") + open = Column(Numeric(18, 4), nullable=False, comment="开盘价") + high = Column(Numeric(18, 4), nullable=False, comment="最高价") + low = Column(Numeric(18, 4), nullable=False, comment="最低价") + close = Column(Numeric(18, 4), nullable=False, comment="收盘价") + volume = Column(BigInteger, nullable=False, comment="成交量(股)") + amount = Column(Numeric(20, 4), nullable=False, comment="成交额(元)") + adj_factor = Column(Numeric(18, 8), nullable=True, comment="复权系数") + created_at = Column(DateTime, default=datetime.now, comment="创建时间") + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间") + + +class StockKLine1DQuote(Base): + """股票日线K线 - 行情指标表 + + 存储需要计算的行情指标 + """ + __tablename__ = "stock_klines_1d_quote" + __table_args__ = ( + Index("idx_stock_1d_quote_symbol_date", "symbol_id", "trade_date"), + ) + + id = Column(BigInteger, primary_key=True, autoincrement=True) + symbol_id = Column(String(20), nullable=False, index=True, comment="标的代码") + trade_date = Column(String(10), nullable=False, index=True, comment="交易日 YYYY-MM-DD") + change_pct = Column(Numeric(8, 4), nullable=True, comment="涨跌幅%") + change_5d_pct = Column(Numeric(8, 4), nullable=True, comment="5日涨跌幅%") + change_10d_pct = Column(Numeric(8, 4), nullable=True, comment="10日涨跌幅%") + change_20d_pct = Column(Numeric(8, 4), nullable=True, comment="20日涨跌幅%") + change_30d_pct = Column(Numeric(8, 4), nullable=True, comment="30日涨跌幅%") + change_60d_pct = Column(Numeric(8, 4), nullable=True, comment="60日涨跌幅%") + ma5 = Column(Numeric(18, 4), nullable=True, comment="5日均线") + ma10 = Column(Numeric(18, 4), nullable=True, comment="10日均线") + ma20 = Column(Numeric(18, 4), nullable=True, comment="20日均线") + ma30 = Column(Numeric(18, 4), nullable=True, comment="30日均线") + ma60 = Column(Numeric(18, 4), nullable=True, comment="60日均线") + ma120 = Column(Numeric(18, 4), nullable=True, comment="120日均线") + ma250 = Column(Numeric(18, 4), nullable=True, comment="250日均线") + macd_dif = Column(Numeric(18, 6), nullable=True, comment="MACD DIF") + macd_dea = Column(Numeric(18, 6), nullable=True, comment="MACD DEA") + macd_bar = Column(Numeric(18, 6), nullable=True, comment="MACD BAR") + bias_5 = Column(Numeric(8, 4), nullable=True, comment="5日乖离率%") + bias_10 = Column(Numeric(8, 4), nullable=True, comment="10日乖离率%") + bias_20 = Column(Numeric(8, 4), nullable=True, comment="20日乖离率%") + is_limit_up = Column(Boolean, nullable=True, comment="是否涨停") + is_limit_down = Column(Boolean, nullable=True, comment="是否跌停") + limit_up_price = Column(Numeric(18, 4), nullable=True, comment="涨停价") + limit_down_price = Column(Numeric(18, 4), nullable=True, comment="跌停价") + is_st = Column(Boolean, nullable=True, comment="是否ST") + created_at = Column(DateTime, default=datetime.now, comment="创建时间") + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间") + + +class StockKLine1DFinance(Base): + """股票日线K线 - 财务数据表 + + 存储财务相关数据 + """ + __tablename__ = "stock_klines_1d_finance" + __table_args__ = ( + Index("idx_stock_1d_finance_symbol_date", "symbol_id", "trade_date"), + ) + + id = Column(BigInteger, primary_key=True, autoincrement=True) + symbol_id = Column(String(20), nullable=False, index=True, comment="标的代码") + trade_date = Column(String(10), nullable=False, index=True, comment="交易日 YYYY-MM-DD") + total_market_cap = Column(Numeric(20, 4), nullable=True, comment="总市值(元)") + float_market_cap = Column(Numeric(20, 4), nullable=True, comment="流通市值(元)") + total_shares = Column(BigInteger, nullable=True, comment="总股本(股)") + float_shares = Column(BigInteger, nullable=True, comment="流通股本(股)") + inst_holding_shares = Column(BigInteger, nullable=True, comment="机构持股数量") + inst_holding_ratio = Column(Numeric(8, 4), nullable=True, comment="机构持仓占比%") + top10_holders_ratio = Column(Numeric(8, 4), nullable=True, comment="前十大股东持股占比%") + net_profit = Column(Numeric(20, 4), nullable=True, comment="净利润") + revenue = Column(Numeric(20, 4), nullable=True, comment="营业总收入") + eps = Column(Numeric(12, 4), nullable=True, comment="每股收益") + roe = Column(Numeric(8, 4), nullable=True, comment="净资产收益率%") + trading_days = Column(Integer, nullable=True, comment="从上市至今交易日数") + created_at = Column(DateTime, default=datetime.now, comment="创建时间") + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间") + + +class StockRealTimeQuote(Base): + """股票实时行情快照""" + __tablename__ = "stock_realtime_quotes" + + symbol_id = Column(String(20), primary_key=True, comment="标的代码") + update_time = Column(DateTime, nullable=False, comment="更新时间") + last_price = Column(Numeric(18, 4), nullable=True, comment="最新价") + open = Column(Numeric(18, 4), nullable=True, comment="开盘价") + high = Column(Numeric(18, 4), nullable=True, comment="最高价") + low = Column(Numeric(18, 4), nullable=True, comment="最低价") + pre_close = Column(Numeric(18, 4), nullable=True, comment="昨收") + volume = Column(BigInteger, nullable=True, comment="成交量") + amount = Column(Numeric(20, 4), nullable=True, comment="成交额") + bid1 = Column(Numeric(18, 4), nullable=True, comment="买一价") + ask1 = Column(Numeric(18, 4), nullable=True, comment="卖一价") + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间") + + +# 股票表向后兼容别名 +StockKLine1D = StockKLine1DBase +''' + +def main(): + models_path = 'app/repositories/models.py' + + with open(models_path, 'r', encoding='utf-8') as f: + content = f.read() + + # 检查是否已存在 + if 'class StockKLine1DBase' not in content: + with open(models_path, 'a', encoding='utf-8') as f: + f.write(stock_split_tables) + print('Stock split tables appended successfully!') + else: + print('Stock split tables already exist.') + +if __name__ == "__main__": + main() diff --git a/scripts/append_futures_models.py b/scripts/append_futures_models.py new file mode 100644 index 0000000..60c6a5c --- /dev/null +++ b/scripts/append_futures_models.py @@ -0,0 +1,338 @@ +#!/usr/bin/env python3 +"""追加期货分表模型到 models.py""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +futures_split_tables = ''' + + +# ============================================ +# 期货日线历史数据表 (1d) - 分表 +# ============================================ + +class FuturesKLine1DBase(Base): + """期货日线K线 - 基础表 + + 存储最基础的K线数据,对应 query_kline 接口 + """ + __tablename__ = "futures_klines_1d_base" + __table_args__ = ( + Index("idx_futures_1d_base_symbol_ts", "symbol_id", "ts"), + Index("idx_futures_1d_base_symbol_date", "symbol_id", "trade_date"), + ) + + id = Column(BigInteger, primary_key=True, autoincrement=True) + symbol_id = Column(String(20), nullable=False, index=True, comment="合约代码") + ts = Column(DateTime, nullable=False, comment="时间戳") + trade_date = Column(String(10), nullable=False, index=True, comment="交易日 YYYY-MM-DD") + + # 基础价格数据 + open = Column(Numeric(18, 4), nullable=False, comment="开盘价") + high = Column(Numeric(18, 4), nullable=False, comment="最高价") + low = Column(Numeric(18, 4), nullable=False, comment="最低价") + close = Column(Numeric(18, 4), nullable=False, comment="收盘价") + + # 成交量额 + volume = Column(BigInteger, nullable=False, comment="成交量") + amount = Column(Numeric(20, 4), nullable=False, comment="成交额") + + # 期货特有 + open_interest = Column(BigInteger, nullable=True, comment="持仓量") + settlement = Column(Numeric(18, 4), nullable=True, comment="结算价") + pre_settlement = Column(Numeric(18, 4), nullable=True, comment="昨结算价") + + created_at = Column(DateTime, default=datetime.now, comment="创建时间") + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间") + + +class FuturesKLine1DQuote(Base): + """期货日线K线 - 行情指标表 + + 存储需要计算的行情指标 + """ + __tablename__ = "futures_klines_1d_quote" + __table_args__ = ( + Index("idx_futures_1d_quote_symbol_date", "symbol_id", "trade_date"), + ) + + id = Column(BigInteger, primary_key=True, autoincrement=True) + symbol_id = Column(String(20), nullable=False, index=True, comment="合约代码") + trade_date = Column(String(10), nullable=False, index=True, comment="交易日 YYYY-MM-DD") + + # 涨跌幅 (需计算) + change_pct = Column(Numeric(8, 4), nullable=True, comment="涨跌幅%") + change_5d_pct = Column(Numeric(8, 4), nullable=True, comment="5日涨跌幅%") + change_10d_pct = Column(Numeric(8, 4), nullable=True, comment="10日涨跌幅%") + change_20d_pct = Column(Numeric(8, 4), nullable=True, comment="20日涨跌幅%") + + # 均线 (需计算) + ma5 = Column(Numeric(18, 4), nullable=True, comment="5日均线") + ma10 = Column(Numeric(18, 4), nullable=True, comment="10日均线") + ma20 = Column(Numeric(18, 4), nullable=True, comment="20日均线") + ma30 = Column(Numeric(18, 4), nullable=True, comment="30日均线") + ma60 = Column(Numeric(18, 4), nullable=True, comment="60日均线") + + # MACD指标 (需计算) + macd_dif = Column(Numeric(18, 6), nullable=True, comment="MACD DIF") + macd_dea = Column(Numeric(18, 6), nullable=True, comment="MACD DEA") + macd_bar = Column(Numeric(18, 6), nullable=True, comment="MACD BAR") + + # 持仓变化 + oi_change = Column(BigInteger, nullable=True, comment="持仓量变化") + oi_change_pct = Column(Numeric(8, 4), nullable=True, comment="持仓量变化%") + + # 振幅 + amplitude = Column(Numeric(8, 4), nullable=True, comment="振幅%") + + created_at = Column(DateTime, default=datetime.now, comment="创建时间") + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间") + + +# ============================================ +# 期货分钟线历史数据表 - 分表 +# ============================================ + +class FuturesKLine1MBase(Base): + """期货1分钟K线 - 基础表""" + __tablename__ = "futures_klines_1m_base" + __table_args__ = ( + Index("idx_futures_1m_base_symbol_ts", "symbol_id", "ts"), + ) + + id = Column(BigInteger, primary_key=True, autoincrement=True) + symbol_id = Column(String(20), nullable=False, index=True, comment="合约代码") + ts = Column(DateTime, nullable=False, comment="时间戳") + trade_date = Column(String(10), nullable=False, comment="交易日") + + open = Column(Numeric(18, 4), nullable=False, comment="开盘价") + high = Column(Numeric(18, 4), nullable=False, comment="最高价") + low = Column(Numeric(18, 4), nullable=False, comment="最低价") + close = Column(Numeric(18, 4), nullable=False, comment="收盘价") + volume = Column(BigInteger, nullable=False, comment="成交量") + amount = Column(Numeric(20, 4), nullable=False, comment="成交额") + open_interest = Column(BigInteger, nullable=True, comment="持仓量") + + created_at = Column(DateTime, default=datetime.now, comment="创建时间") + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间") + + +class FuturesKLine5MBase(Base): + """期货5分钟K线 - 基础表""" + __tablename__ = "futures_klines_5m_base" + __table_args__ = ( + Index("idx_futures_5m_base_symbol_ts", "symbol_id", "ts"), + ) + + id = Column(BigInteger, primary_key=True, autoincrement=True) + symbol_id = Column(String(20), nullable=False, index=True, comment="合约代码") + ts = Column(DateTime, nullable=False, comment="时间戳") + trade_date = Column(String(10), nullable=False, comment="交易日") + + open = Column(Numeric(18, 4), nullable=False, comment="开盘价") + high = Column(Numeric(18, 4), nullable=False, comment="最高价") + low = Column(Numeric(18, 4), nullable=False, comment="最低价") + close = Column(Numeric(18, 4), nullable=False, comment="收盘价") + volume = Column(BigInteger, nullable=False, comment="成交量") + amount = Column(Numeric(20, 4), nullable=False, comment="成交额") + open_interest = Column(BigInteger, nullable=True, comment="持仓量") + + created_at = Column(DateTime, default=datetime.now, comment="创建时间") + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间") + + +class FuturesKLine15MBase(Base): + """期货15分钟K线 - 基础表""" + __tablename__ = "futures_klines_15m_base" + __table_args__ = ( + Index("idx_futures_15m_base_symbol_ts", "symbol_id", "ts"), + ) + + id = Column(BigInteger, primary_key=True, autoincrement=True) + symbol_id = Column(String(20), nullable=False, index=True, comment="合约代码") + ts = Column(DateTime, nullable=False, comment="时间戳") + trade_date = Column(String(10), nullable=False, comment="交易日") + + open = Column(Numeric(18, 4), nullable=False, comment="开盘价") + high = Column(Numeric(18, 4), nullable=False, comment="最高价") + low = Column(Numeric(18, 4), nullable=False, comment="最低价") + close = Column(Numeric(18, 4), nullable=False, comment="收盘价") + volume = Column(BigInteger, nullable=False, comment="成交量") + amount = Column(Numeric(20, 4), nullable=False, comment="成交额") + open_interest = Column(BigInteger, nullable=True, comment="持仓量") + + created_at = Column(DateTime, default=datetime.now, comment="创建时间") + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间") + + +class FuturesKLine30MBase(Base): + """期货30分钟K线 - 基础表""" + __tablename__ = "futures_klines_30m_base" + __table_args__ = ( + Index("idx_futures_30m_base_symbol_ts", "symbol_id", "ts"), + ) + + id = Column(BigInteger, primary_key=True, autoincrement=True) + symbol_id = Column(String(20), nullable=False, index=True, comment="合约代码") + ts = Column(DateTime, nullable=False, comment="时间戳") + trade_date = Column(String(10), nullable=False, comment="交易日") + + open = Column(Numeric(18, 4), nullable=False, comment="开盘价") + high = Column(Numeric(18, 4), nullable=False, comment="最高价") + low = Column(Numeric(18, 4), nullable=False, comment="最低价") + close = Column(Numeric(18, 4), nullable=False, comment="收盘价") + volume = Column(BigInteger, nullable=False, comment="成交量") + amount = Column(Numeric(20, 4), nullable=False, comment="成交额") + open_interest = Column(BigInteger, nullable=True, comment="持仓量") + + created_at = Column(DateTime, default=datetime.now, comment="创建时间") + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间") + + +class FuturesKLine60MBase(Base): + """期货60分钟K线 - 基础表""" + __tablename__ = "futures_klines_60m_base" + __table_args__ = ( + Index("idx_futures_60m_base_symbol_ts", "symbol_id", "ts"), + ) + + id = Column(BigInteger, primary_key=True, autoincrement=True) + symbol_id = Column(String(20), nullable=False, index=True, comment="合约代码") + ts = Column(DateTime, nullable=False, comment="时间戳") + trade_date = Column(String(10), nullable=False, comment="交易日") + + open = Column(Numeric(18, 4), nullable=False, comment="开盘价") + high = Column(Numeric(18, 4), nullable=False, comment="最高价") + low = Column(Numeric(18, 4), nullable=False, comment="最低价") + close = Column(Numeric(18, 4), nullable=False, comment="收盘价") + volume = Column(BigInteger, nullable=False, comment="成交量") + amount = Column(Numeric(20, 4), nullable=False, comment="成交额") + open_interest = Column(BigInteger, nullable=True, comment="持仓量") + + created_at = Column(DateTime, default=datetime.now, comment="创建时间") + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间") + + +# ============================================ +# 期货周月线历史数据表 - 分表 +# ============================================ + +class FuturesKLine1WBase(Base): + """期货周线K线 - 基础表""" + __tablename__ = "futures_klines_1w_base" + __table_args__ = ( + Index("idx_futures_1w_base_symbol_ts", "symbol_id", "ts"), + ) + + id = Column(BigInteger, primary_key=True, autoincrement=True) + symbol_id = Column(String(20), nullable=False, index=True, comment="合约代码") + ts = Column(DateTime, nullable=False, comment="时间戳") + trade_date = Column(String(10), nullable=False, comment="交易日") + + open = Column(Numeric(18, 4), nullable=False, comment="开盘价") + high = Column(Numeric(18, 4), nullable=False, comment="最高价") + low = Column(Numeric(18, 4), nullable=False, comment="最低价") + close = Column(Numeric(18, 4), nullable=False, comment="收盘价") + volume = Column(BigInteger, nullable=False, comment="成交量") + amount = Column(Numeric(20, 4), nullable=False, comment="成交额") + open_interest = Column(BigInteger, nullable=True, comment="持仓量") + + created_at = Column(DateTime, default=datetime.now, comment="创建时间") + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间") + + +class FuturesKLine1MonBase(Base): + """期货月线K线 - 基础表""" + __tablename__ = "futures_klines_1month_base" + __table_args__ = ( + Index("idx_futures_1month_base_symbol_ts", "symbol_id", "ts"), + ) + + id = Column(BigInteger, primary_key=True, autoincrement=True) + symbol_id = Column(String(20), nullable=False, index=True, comment="合约代码") + ts = Column(DateTime, nullable=False, comment="时间戳") + trade_date = Column(String(10), nullable=False, comment="交易日") + + open = Column(Numeric(18, 4), nullable=False, comment="开盘价") + high = Column(Numeric(18, 4), nullable=False, comment="最高价") + low = Column(Numeric(18, 4), nullable=False, comment="最低价") + close = Column(Numeric(18, 4), nullable=False, comment="收盘价") + volume = Column(BigInteger, nullable=False, comment="成交量") + amount = Column(Numeric(20, 4), nullable=False, comment="成交额") + open_interest = Column(BigInteger, nullable=True, comment="持仓量") + + created_at = Column(DateTime, default=datetime.now, comment="创建时间") + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间") + + +# ============================================ +# 期货实时行情表 +# ============================================ + +class FuturesRealTimeQuote(Base): + """期货实时行情快照""" + __tablename__ = "futures_realtime_quotes" + + symbol_id = Column(String(20), primary_key=True, comment="合约代码") + update_time = Column(DateTime, nullable=False, comment="更新时间") + + # 价格数据 + last_price = Column(Numeric(18, 4), nullable=True, comment="最新价") + open = Column(Numeric(18, 4), nullable=True, comment="开盘价") + high = Column(Numeric(18, 4), nullable=True, comment="最高价") + low = Column(Numeric(18, 4), nullable=True, comment="最低价") + pre_close = Column(Numeric(18, 4), nullable=True, comment="昨收") + pre_settlement = Column(Numeric(18, 4), nullable=True, comment="昨结算") + settlement = Column(Numeric(18, 4), nullable=True, comment="结算价") + + # 成交量额 + volume = Column(BigInteger, nullable=True, comment="成交量") + amount = Column(Numeric(20, 4), nullable=True, comment="成交额") + open_interest = Column(BigInteger, nullable=True, comment="持仓量") + + # 买卖盘 + bid1 = Column(Numeric(18, 4), nullable=True, comment="买一价") + bid1_volume = Column(BigInteger, nullable=True, comment="买一量") + ask1 = Column(Numeric(18, 4), nullable=True, comment="卖一价") + ask1_volume = Column(BigInteger, nullable=True, comment="卖一量") + + # 涨跌停 + limit_up = Column(Numeric(18, 4), nullable=True, comment="涨停价") + limit_down = Column(Numeric(18, 4), nullable=True, comment="跌停价") + + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间") + + +# ============================================ +# 向后兼容的模型别名 +# ============================================ + +# 股票表别名 +StockKLine1D = StockKLine1DBase + +# 期货表别名(兼容旧代码) +FuturesKLine1M = FuturesKLine1MBase +FuturesKLine5M = FuturesKLine5MBase +FuturesKLine1D = FuturesKLine1DBase +''' + +def main(): + models_path = Path(__file__).parent.parent / "app" / "repositories" / "models.py" + + with open(models_path, 'r', encoding='utf-8') as f: + original = f.read() + + # 移除旧的向后兼容别名(如果存在) + marker = '\n\n\n# ============================================\n# 向后兼容的模型别名\n# ============================================\n\n# 旧代码可能引用的模型,现在映射到新表\nStockKLine1D = StockKLine1DBase\n# 注意: 1M/5M/15M/30M/60M/1W/1Month 表命名未加Base后缀,已在原始定义中' + if marker in original: + original = original[:original.find(marker)] + + # 保存新内容 + with open(models_path, 'w', encoding='utf-8') as f: + f.write(original + futures_split_tables) + + print('Futures split tables appended successfully!') + +if __name__ == "__main__": + main() diff --git a/scripts/create_split_tables.py b/scripts/create_split_tables.py new file mode 100644 index 0000000..90526e6 --- /dev/null +++ b/scripts/create_split_tables.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +""" +创建拆分后的新表结构 + +用法: + python scripts/create_split_tables.py + python scripts/create_split_tables.py --drop-existing +""" +import argparse +import sys +from pathlib import Path + +# 添加项目根目录到路径 +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from sqlalchemy import create_engine, text +from app.repositories.database import Base, engine +from app.core.config import get_config +from app.core.logger import info, error + + +def create_tables(drop_existing: bool = False): + """创建所有表""" + config = get_config() + + # 创建数据库连接 + db_url = f"mysql+pymysql://{config.database.user}:{config.database.password}@{config.database.host}:{config.database.port}/{config.database.database}" + engine_local = create_engine(db_url, echo=False) + + if drop_existing: + info("Dropping existing split tables...") + tables_to_drop = [ + # 股票分表 + "stock_klines_1d_base", "stock_klines_1d_quote", "stock_klines_1d_finance", + "stock_realtime_quotes", + # 期货分表 + "futures_klines_1d_base", "futures_klines_1d_quote", + "futures_klines_1m_base", "futures_klines_5m_base", + "futures_realtime_quotes", + ] + with engine_local.connect() as conn: + for table in tables_to_drop: + try: + conn.execute(text(f"DROP TABLE IF EXISTS {table}")) + info(f"Dropped table: {table}") + except Exception as e: + error(f"Failed to drop {table}: {e}") + conn.commit() + + info("Creating new split tables...") + + # 导入所有模型以确保它们被注册 + from app.repositories.models import ( + # 股票分表 + StockKLine1DBase, StockKLine1DQuote, StockKLine1DFinance, + StockKLine1M, StockKLine5M, StockKLine15M, StockKLine30M, StockKLine60M, + StockRealTimeQuote, + # 期货分表 + FuturesKLine1DBase, FuturesKLine1DQuote, + FuturesKLine1MBase, FuturesKLine5MBase, FuturesKLine15MBase, FuturesKLine30MBase, FuturesKLine60MBase, + FuturesRealTimeQuote, + ) + + # 创建表 + tables = [ + # 股票表 - 日线分表 + StockKLine1DBase.__table__, + StockKLine1DQuote.__table__, + StockKLine1DFinance.__table__, + # 股票表 - 分钟线 + StockKLine1M.__table__, + StockKLine5M.__table__, + StockKLine15M.__table__, + StockKLine30M.__table__, + StockKLine60M.__table__, + StockRealTimeQuote.__table__, + # 期货表 - 日线分表 + FuturesKLine1DBase.__table__, + FuturesKLine1DQuote.__table__, + # 期货表 - 分钟线 + FuturesKLine1MBase.__table__, + FuturesKLine5MBase.__table__, + FuturesKLine15MBase.__table__, + FuturesKLine30MBase.__table__, + FuturesKLine60MBase.__table__, + FuturesRealTimeQuote.__table__, + ] + + Base.metadata.create_all(bind=engine_local, tables=tables) + + info("Tables created successfully!") + + # 显示创建的表 + with engine_local.connect() as conn: + result = conn.execute(text(""" + SELECT TABLE_NAME, TABLE_COMMENT + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = DATABASE() + AND ( + TABLE_NAME LIKE 'stock_klines_%' + OR TABLE_NAME LIKE 'futures_klines_%' + OR TABLE_NAME LIKE '%realtime_quotes' + ) + """)) + tables = result.fetchall() + + info("\nCreated split tables:") + stock_tables = [] + futures_tables = [] + for table_name, comment in tables: + if table_name.startswith('stock_'): + stock_tables.append(f" - {table_name}") + else: + futures_tables.append(f" - {table_name}") + + if stock_tables: + info("\n[Stock Tables]") + for t in stock_tables: + info(t) + + if futures_tables: + info("\n[Futures Tables]") + for t in futures_tables: + info(t) + + +def main(): + parser = argparse.ArgumentParser(description="Create split table structure") + parser.add_argument( + "--drop-existing", + action="store_true", + help="Drop existing tables before creating (WARNING: data will be lost!)" + ) + + args = parser.parse_args() + + if args.drop_existing: + confirm = input("WARNING: This will drop existing tables and ALL DATA will be lost!\nType 'yes' to continue: ") + if confirm != "yes": + print("Aborted.") + return + + create_tables(drop_existing=args.drop_existing) + + +if __name__ == "__main__": + main() diff --git a/scripts/fix_garbled_chinese.py b/scripts/fix_garbled_chinese.py new file mode 100644 index 0000000..4c04fba --- /dev/null +++ b/scripts/fix_garbled_chinese.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +"""修复 amazingdata_adapter.py 中的中文乱码""" + +# 读取文件 +with open('app/adapters/amazingdata_adapter.py', 'r', encoding='utf-8') as f: + content = f.read() + +# 常见乱码映射表 +garbled_map = { + # 常用词汇 + '淇℃伅': '信息', + '鏁版嵁': '数据', + '鑾峰彇': '获取', + '鏃ョ嚎': '日线', + '鍩虹': '基础', + '鏇村': '更多', + '鏃ュ巻': '日历', + '鏁版嵁搴�': '数据库', + '鏈嶅姟': '服务', + '绠$悊': '管理', + '鐘熸搷': '后操', + '鍒濆鍖�': '初始化', + '璁块棶': '访问', + '鏃跺嚭閿�': '时出错', + '鍏抽棴': '关闭', + '鎵ц': '执行', + '鏌ヨ': '查询', + '娑ㄨ穼': '涨跌', + '鍋滀环': '停价', + '鏄惁': '是否', + '鐨�': '的', + '绛�': '等', + '鏉�': '来', + '浣�': '作', + '涔囩鐜�': '乖离率', + '鏃�': '时', + '璁$畻': '计算', + '鎸囨爣': '指标', + '闇€瑕�': '需要', + '鍘嗗彶': '历史', + '鏃ユ湡': '日期', + '鑼冨洿': '范围', + '浠ヨ绠�': '以计算', + '鍧囩嚎': '均线', + '绛夛級': '等)', + '鍒ゆ柇': '判断', + '娑ㄥ仠': '涨停', + '浠疯穼鍋�': '跌停', + '鎵�': '所', + '灞�': '属', + '琛屼笟': '行业', + '鐘舵��': '状态', + '浠g爜': '代码', + '鍒楄〃': '列表', + '鍚嶇О': '名称', + '浜ゆ槗': '交易', + '鏃�': '日', + '鏈�': '有', + '鍊�': '价', + '鍙�': '可', + '鑳�': '能', + '閿�': '错', + '璇�': '误', + '鎴�': '或', + '鍘�': '去', + '鎺�': '接', + '鍙�': '口', + '杩�': '返', + '鍥�': '回', + '鎹�': '据', + '闃�': '防', + '姝�': '止', + '閲嶅�?': '重复', + '鐧�': '登', + '褰�': '录', + '澶辫触': '失败', + '璇锋眰': '请求', + '鍙傛暟': '参数', + '鏃犳晥': '无效', + '鎵�': '锁', + '瀹�': '定', + '寮�': '开', + '閲�': '锁', + '閲婃斁': '释放', + '璧勬簮': '资源', + '鍔犺浇': '加载', + '閰嶇疆': '配置', + '鏂囦欢': '文件', + '涓嶅瓨鍦�': '不存在', + '浣跨敤': '使用', + '榛樿': '默认', + '瀹屾垚': '完成', + '鎴愬姛': '成功', + '濮�': '始', + '缁撴潫': '结束', + '澶勭悊': '处理', + '寮傚父': '异常', + '閿欒': '错误', + '鎻愮ず': '提示', + '纭': '确认', + '鍙栨秷': '取消', + '纭畾': '确定', + '鎴戠殑': '我的', + '璁剧疆': '设置', + '甯姪': '帮助', + '鍏充簬': '关于', + '閫�鍑�': '退出', + '鏂板缓': '新建', + '鎵撳紑': '打开', + '淇濆瓨': '保存', + '鍙︀瓨涓�': '另存为', + '缂栬緫': '编辑', + '鍓��': '剪切', + '澶嶅埗': '复制', + '绮樿创': '粘贴', + '鍏ㄩ��': '全选', + '鍒犻櫎': '删除', + '鎼滅储': '搜索', + '鏇挎崲': '替换', + '鎵惧埌': '找到', + '涓嬩竴涓�': '下一个', + '涓婁竴涓�': '上一个', + '鏇挎崲鍏ㄩ儴': '替换全部', + '鏌ョ湅': '查看', + '宸ュ叿鏍�': '工具栏', + '鐘舵�佹爮': '状态栏', + '绐楀彛': '窗口', + '鏂扮獥鍙�': '新窗口', + '鍨冨溇鏋�': '层叠', + '骞抽摵': '平铺', + '鎺掑垪鍥炬爣': '排列图标', + '鍏ㄩ儴閫夋嫨': '全部选择', + '鍏ㄩ儏': '全屏', + '鏈�澶у寲': '最大化', + '鏈�灏忓寲': '最小化', + '鎭㈠': '恢复', + '绉诲姩': '移动', + '澶у皬': '大小', + '鏈�灏�': '最小', + '鏈�澶�': '最大', + '鍓嶄竴涓�': '前一个', + '鍚庝竴涓�': '后一个', + '瑙f瀽': '解析', + '浠g爜': '代码', + '璧嬪€�': '赋值', + '璁块棶': '访问', + '缁熻': '统计', + '璇︽儏': '详情', + '鎻忚堪': '描述', + '澶囨敞': '备注', + '绫诲瀷': '类型', + '鏍煎紡': '格式', + '澶у皬': '大小', + '浣嶇疆': '位置', + '鏃堕暱': '时长', + '棰戦��': '频率', + '鍝嶅簲': '响应', + '璇锋眰': '请求', + '澶勭悊': '处理', + '缁撴灉': '结果', + '鐘舵��': '状态', + '娑堟伅': '消息', + '绾跨▼': '线程', + '杩涚▼': '进程', + '鏃ュ織': '日志', + '閰嶇疆': '配置', + '閫夐」': '选项', + '鍙傛暟': '参数', + '灞炴€�': '属性', + '鏂规硶': '方法', + '鍑芥暟': '函数', + '绫�': '类', + '妯″潡': '模块', + '鍖�': '包', + '瀛楃涓�': '字符串', + '鍒楄〃': '列表', + '瀛楀吀': '字典', + '鍏冪粍': '元组', + '闆嗗悎': '集合', + '瀵硅薄': '对象', + '瀹炰緥': '实例', + '缁ф壙': '继承', + '澶氭€�': '多态', + '灏佽': '封装', + '鎺ュ彛': '接口', + '鎶借薄': '抽象', + '绉佹湁': '私有', + '鍏紑': '公开', + '淇濇姢': '保护', + '闈欐€�': '静态', + '绫诲彉閲�': '类变量', + '瀹炰緥鍙橀噺': '实例变量', + '鏂规硶': '方法', + '鏋勯€犲嚱鏁�': '构造函数', + '鏋愭瀯鍑芥暟': '析构函数', + '瑁呴グ鍣�': '装饰器', + '鐗规€�': '特性', + '灞炴€�': '属性', + '鏂规硶': '方法', +} + +# 替换所有乱码 +for garbled, correct in garbled_map.items(): + content = content.replace(garbled, correct) + +# 保存 +with open('app/adapters/amazingdata_adapter.py', 'w', encoding='utf-8') as f: + f.write(content) + +print('Fixed all garbled Chinese characters!') diff --git a/scripts/fix_models.py b/scripts/fix_models.py new file mode 100644 index 0000000..c80365f --- /dev/null +++ b/scripts/fix_models.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +"""追加期货分表模型到 models.py""" + +append_content = ''' + + +# ============================================ +# 期货分表结构 (新增) +# ============================================ + +class FuturesKLine1DBase(Base): + """期货日线K线 - 基础表""" + __tablename__ = "futures_klines_1d_base" + __table_args__ = ( + Index("idx_futures_1d_base_symbol_ts", "symbol_id", "ts"), + Index("idx_futures_1d_base_symbol_date", "symbol_id", "trade_date"), + ) + + id = Column(BigInteger, primary_key=True, autoincrement=True) + symbol_id = Column(String(20), nullable=False, index=True, comment="合约代码") + ts = Column(DateTime, nullable=False, comment="时间戳") + trade_date = Column(String(10), nullable=False, index=True, comment="交易日 YYYY-MM-DD") + open = Column(Numeric(18, 4), nullable=False, comment="开盘价") + high = Column(Numeric(18, 4), nullable=False, comment="最高价") + low = Column(Numeric(18, 4), nullable=False, comment="最低价") + close = Column(Numeric(18, 4), nullable=False, comment="收盘价") + volume = Column(BigInteger, nullable=False, comment="成交量") + amount = Column(Numeric(20, 4), nullable=False, comment="成交额") + open_interest = Column(BigInteger, nullable=True, comment="持仓量") + settlement = Column(Numeric(18, 4), nullable=True, comment="结算价") + pre_settlement = Column(Numeric(18, 4), nullable=True, comment="昨结算价") + created_at = Column(DateTime, default=datetime.now, comment="创建时间") + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间") + + +class FuturesKLine1DQuote(Base): + """期货日线K线 - 行情指标表""" + __tablename__ = "futures_klines_1d_quote" + __table_args__ = ( + Index("idx_futures_1d_quote_symbol_date", "symbol_id", "trade_date"), + ) + + id = Column(BigInteger, primary_key=True, autoincrement=True) + symbol_id = Column(String(20), nullable=False, index=True, comment="合约代码") + trade_date = Column(String(10), nullable=False, index=True, comment="交易日 YYYY-MM-DD") + change_pct = Column(Numeric(8, 4), nullable=True, comment="涨跌幅%") + change_5d_pct = Column(Numeric(8, 4), nullable=True, comment="5日涨跌幅%") + change_10d_pct = Column(Numeric(8, 4), nullable=True, comment="10日涨跌幅%") + change_20d_pct = Column(Numeric(8, 4), nullable=True, comment="20日涨跌幅%") + ma5 = Column(Numeric(18, 4), nullable=True, comment="5日均线") + ma10 = Column(Numeric(18, 4), nullable=True, comment="10日均线") + ma20 = Column(Numeric(18, 4), nullable=True, comment="20日均线") + ma30 = Column(Numeric(18, 4), nullable=True, comment="30日均线") + ma60 = Column(Numeric(18, 4), nullable=True, comment="60日均线") + macd_dif = Column(Numeric(18, 6), nullable=True, comment="MACD DIF") + macd_dea = Column(Numeric(18, 6), nullable=True, comment="MACD DEA") + macd_bar = Column(Numeric(18, 6), nullable=True, comment="MACD BAR") + oi_change = Column(BigInteger, nullable=True, comment="持仓量变化") + oi_change_pct = Column(Numeric(8, 4), nullable=True, comment="持仓量变化%") + amplitude = Column(Numeric(8, 4), nullable=True, comment="振幅%") + created_at = Column(DateTime, default=datetime.now, comment="创建时间") + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间") + + +class FuturesKLine1MBase(Base): + """期货1分钟K线 - 基础表""" + __tablename__ = "futures_klines_1m_base" + __table_args__ = (Index("idx_futures_1m_base_symbol_ts", "symbol_id", "ts"),) + id = Column(BigInteger, primary_key=True, autoincrement=True) + symbol_id = Column(String(20), nullable=False, index=True, comment="合约代码") + ts = Column(DateTime, nullable=False, comment="时间戳") + trade_date = Column(String(10), nullable=False, comment="交易日") + open = Column(Numeric(18, 4), nullable=False, comment="开盘价") + high = Column(Numeric(18, 4), nullable=False, comment="最高价") + low = Column(Numeric(18, 4), nullable=False, comment="最低价") + close = Column(Numeric(18, 4), nullable=False, comment="收盘价") + volume = Column(BigInteger, nullable=False, comment="成交量") + amount = Column(Numeric(20, 4), nullable=False, comment="成交额") + open_interest = Column(BigInteger, nullable=True, comment="持仓量") + created_at = Column(DateTime, default=datetime.now, comment="创建时间") + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间") + + +class FuturesKLine5MBase(Base): + """期货5分钟K线 - 基础表""" + __tablename__ = "futures_klines_5m_base" + __table_args__ = (Index("idx_futures_5m_base_symbol_ts", "symbol_id", "ts"),) + id = Column(BigInteger, primary_key=True, autoincrement=True) + symbol_id = Column(String(20), nullable=False, index=True, comment="合约代码") + ts = Column(DateTime, nullable=False, comment="时间戳") + trade_date = Column(String(10), nullable=False, comment="交易日") + open = Column(Numeric(18, 4), nullable=False, comment="开盘价") + high = Column(Numeric(18, 4), nullable=False, comment="最高价") + low = Column(Numeric(18, 4), nullable=False, comment="最低价") + close = Column(Numeric(18, 4), nullable=False, comment="收盘价") + volume = Column(BigInteger, nullable=False, comment="成交量") + amount = Column(Numeric(20, 4), nullable=False, comment="成交额") + open_interest = Column(BigInteger, nullable=True, comment="持仓量") + created_at = Column(DateTime, default=datetime.now, comment="创建时间") + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间") + + +class FuturesRealTimeQuote(Base): + """期货实时行情快照""" + __tablename__ = "futures_realtime_quotes" + symbol_id = Column(String(20), primary_key=True, comment="合约代码") + update_time = Column(DateTime, nullable=False, comment="更新时间") + last_price = Column(Numeric(18, 4), nullable=True, comment="最新价") + open = Column(Numeric(18, 4), nullable=True, comment="开盘价") + high = Column(Numeric(18, 4), nullable=True, comment="最高价") + low = Column(Numeric(18, 4), nullable=True, comment="最低价") + pre_close = Column(Numeric(18, 4), nullable=True, comment="昨收") + pre_settlement = Column(Numeric(18, 4), nullable=True, comment="昨结算") + settlement = Column(Numeric(18, 4), nullable=True, comment="结算价") + volume = Column(BigInteger, nullable=True, comment="成交量") + amount = Column(Numeric(20, 4), nullable=True, comment="成交额") + open_interest = Column(BigInteger, nullable=True, comment="持仓量") + bid1 = Column(Numeric(18, 4), nullable=True, comment="买一价") + bid1_volume = Column(BigInteger, nullable=True, comment="买一量") + ask1 = Column(Numeric(18, 4), nullable=True, comment="卖一价") + ask1_volume = Column(BigInteger, nullable=True, comment="卖一量") + limit_up = Column(Numeric(18, 4), nullable=True, comment="涨停价") + limit_down = Column(Numeric(18, 4), nullable=True, comment="跌停价") + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间") + + +# 向后兼容别名 +FuturesKLine1M = FuturesKLine1MBase +FuturesKLine5M = FuturesKLine5MBase +FuturesKLine1D = FuturesKLine1DBase +''' + +# 读取原始文件 +with open('app/repositories/models.py', 'r', encoding='utf-8') as f: + content = f.read() + +# 检查是否已存在 +if 'class FuturesKLine1DBase' not in content: + with open('app/repositories/models.py', 'w', encoding='utf-8') as f: + f.write(content + append_content) + print('Futures split tables appended successfully!') +else: + print('Futures split tables already exist.')