commit 654d641547d729574a58958ce3622428e8d40567 Author: Lxy Date: Sat Apr 11 19:45:27 2026 +0800 feat: 增加初始代码;目前已正常连接sdk,可获取各周期股票数据 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cd1a6cd --- /dev/null +++ b/.gitignore @@ -0,0 +1,63 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +env/ +venv/ +ENV/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual Environment +venv/ +env/ +ENV/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# Environment variables +.env +.env.local +.env.*.local + +# Database +*.db +*.sqlite3 + +# Logs +*.log +logs/ + +# Frontend +node_modules/ +dist/ +build/ +.npm +.yarn + +# Docker +.dockerignore + +# Misc +.DS_Store +Thumbs.db diff --git a/PROJECT_DESIGN.md b/PROJECT_DESIGN.md new file mode 100644 index 0000000..bc564c8 --- /dev/null +++ b/PROJECT_DESIGN.md @@ -0,0 +1,904 @@ +# AmazingData 金融数据服务平台 - 完整实现方案 + +## 一、项目概述 + +基于AmazingData SDK构建的金融数据服务平台,提供历史数据缓存、实时数据订阅、批量数据管理等功能。 + +--- + +## 二、技术架构 + +``` +┌─────────────────────────────────────────────────────────────────────────────────┐ +│ 前端层 (Frontend) │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ 登录页面 │ │ 配置管理页面 │ │ 数据查询页面 │ │ 测试中心页面 │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ │ +└─────────────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────────────┐ +│ API网关层 (API Gateway) │ +│ ┌─────────────────────────────────────────────────────────────────────────┐ │ +│ │ FastAPI + Uvicorn │ JWT认证 │ 限流/熔断 │ 日志/监控 │ CORS │ │ +│ └─────────────────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────────────┐ +│ 业务服务层 (Services) │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ 认证服务 │ │ 配置服务 │ │ 历史数据服务 │ │ 实时数据服务 │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ 缓存管理服务 │ │ 财务数据服务 │ │ 基础数据服务 │ │ 测试服务 │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ │ +└─────────────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────────────┐ +│ 数据访问层 (Data Access) │ +│ ┌────────────────────┐ ┌────────────────────┐ ┌────────────────────┐ │ +│ │ SQLAlchemy ORM │ │ Redis Cache │ │ AmazingData SDK │ │ +│ └────────────────────┘ └────────────────────┘ └────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────────────┐ +│ 数据存储层 (Storage) │ +│ ┌────────────────────┐ ┌────────────────────┐ ┌────────────────────┐ │ +│ │ PostgreSQL │ │ Redis │ │ File System │ │ +│ │ (主数据库) │ │ (缓存/消息队列) │ │ (日志/导出) │ │ +│ └────────────────────┘ └────────────────────┘ └────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 三、技术栈选型 + +| 层级 | 技术 | 版本 | 说明 | +|------|------|------|------| +| 后端框架 | FastAPI | ^0.104.0 | 高性能异步Web框架 | +| ORM | SQLAlchemy | ^2.0.0 | 数据库ORM | +| 数据库 | PostgreSQL | 15+ | 主数据库 | +| 缓存 | Redis | 7+ | 缓存与消息队列 | +| 前端 | Vue3 + TypeScript | ^3.3.0 | 前端框架 | +| UI组件 | Element Plus | ^2.4.0 | UI组件库 | +| 图表 | ECharts | ^5.4.0 | K线图展示 | +| 认证 | JWT + bcrypt | - | 用户认证 | +| 任务调度 | APScheduler | ^3.10.0 | 定时任务 | +| SDK | AmazingData | 1.0.24 | 数据源SDK | + +--- + +## 四、数据库设计 + +### 4.1 实体关系图 (ER Diagram) + +``` +┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ +│ users │ │ sdk_configs │ │ system_configs │ +├──────────────────┤ ├──────────────────┤ ├──────────────────┤ +│ id (PK) │ │ id (PK) │ │ id (PK) │ +│ username │ │ name │ │ key │ +│ password_hash │ │ username │ │ value │ +│ is_active │ │ password │ │ description │ +│ created_at │ │ host │ │ created_at │ +│ updated_at │ │ port │ │ updated_at │ +└──────────────────┘ │ is_active │ └──────────────────┘ + │ created_at │ + └──────────────────┘ + │ + ▼ +┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ +│stock_kline_daily │ │ stock_kline_min │ │stock_kline_other │ +├──────────────────┤ ├──────────────────┤ ├──────────────────┤ +│ id (PK) │ │ id (PK) │ │ id (PK) │ +│ code │ │ code │ │ code │ +│ trade_date │ │ trade_datetime │ │ period_type │ +│ open │ │ open │ │ trade_date │ +│ high │ │ high │ │ open │ +│ low │ │ low │ │ high │ +│ close │ │ close │ │ low │ +│ volume │ │ volume │ │ close │ +│ amount │ │ amount │ │ volume │ +│ created_at │ │ created_at │ │ amount │ +└──────────────────┘ └──────────────────┘ │ created_at │ + └──────────────────┘ +┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ +│future_kline_daily│ │ future_kline_min │ │realtime_snapshot │ +├──────────────────┤ ├──────────────────┤ ├──────────────────┤ +│ id (PK) │ │ id (PK) │ │ id (PK) │ +│ code │ │ code │ │ code │ +│ trade_date │ │ trade_datetime │ │ trade_time │ +│ open │ │ open │ │ pre_close │ +│ high │ │ high │ │ last │ +│ low │ │ low │ │ open │ +│ close │ │ close │ │ high │ +│ volume │ │ volume │ │ low │ +│ amount │ │ amount │ │ volume │ +│ settle │ │ settle │ │ amount │ +│ open_interest │ │ open_interest │ │ ...其他字段 │ +│ created_at │ │ created_at │ │ created_at │ +└──────────────────┘ └──────────────────┘ │ expires_at │ + └──────────────────┘ +┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ +│ cache_tasks │ │ cache_records │ │ api_test_logs │ +├──────────────────┤ ├──────────────────┤ ├──────────────────┤ +│ id (PK) │ │ id (PK) │ │ id (PK) │ +│ task_name │ │ task_id (FK) │ │ test_name │ +│ task_type │ │ code │ │ api_endpoint │ +│ security_type │ │ trade_date │ │ request_params │ +│ start_date │ │ status │ │ response_data │ +│ end_date │ │ record_count │ │ status_code │ +│ status │ │ created_at │ │ execution_time │ +│ progress │ └──────────────────┘ │ error_message │ +│ total_count │ │ created_at │ +│ success_count │ └──────────────────┘ +│ error_count │ +│ created_at │ +│ completed_at │ +└──────────────────┘ +``` + +### 4.2 表结构详细定义 + +#### 4.2.1 用户表 (users) +```sql +CREATE TABLE users ( + id SERIAL PRIMARY KEY, + username VARCHAR(50) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); +``` + +#### 4.2.2 SDK配置表 (sdk_configs) +```sql +CREATE TABLE sdk_configs ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + username VARCHAR(100) NOT NULL, + password VARCHAR(255) NOT NULL, + host VARCHAR(100) NOT NULL, + port INTEGER NOT NULL, + local_path VARCHAR(255) DEFAULT './amazing_data_cache/', + is_active BOOLEAN DEFAULT TRUE, + is_default BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); +``` + +#### 4.2.3 股票日线数据表 (stock_kline_daily) +```sql +CREATE TABLE stock_kline_daily ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(20) NOT NULL, + trade_date DATE NOT NULL, + open DECIMAL(12, 4) NOT NULL, + high DECIMAL(12, 4) NOT NULL, + low DECIMAL(12, 4) NOT NULL, + close DECIMAL(12, 4) NOT NULL, + volume BIGINT NOT NULL, + amount DECIMAL(18, 4) NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + UNIQUE(code, trade_date) +); + +CREATE INDEX idx_stock_daily_code_date ON stock_kline_daily(code, trade_date); +CREATE INDEX idx_stock_daily_trade_date ON stock_kline_daily(trade_date); +``` + +#### 4.2.4 股票分钟数据表 (stock_kline_min) +```sql +CREATE TABLE stock_kline_min ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(20) NOT NULL, + period_type VARCHAR(10) NOT NULL, -- min1, min5, min15, min30, min60 + trade_datetime TIMESTAMP NOT NULL, + open DECIMAL(12, 4) NOT NULL, + high DECIMAL(12, 4) NOT NULL, + low DECIMAL(12, 4) NOT NULL, + close DECIMAL(12, 4) NOT NULL, + volume BIGINT NOT NULL, + amount DECIMAL(18, 4) NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + UNIQUE(code, period_type, trade_datetime) +); + +CREATE INDEX idx_stock_min_code_period_datetime ON stock_kline_min(code, period_type, trade_datetime); +CREATE INDEX idx_stock_min_trade_datetime ON stock_kline_min(trade_datetime); +``` + +#### 4.2.5 期货日线数据表 (future_kline_daily) +```sql +CREATE TABLE future_kline_daily ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(20) NOT NULL, + trade_date DATE NOT NULL, + open DECIMAL(12, 4) NOT NULL, + high DECIMAL(12, 4) NOT NULL, + low DECIMAL(12, 4) NOT NULL, + close DECIMAL(12, 4) NOT NULL, + volume BIGINT NOT NULL, + amount DECIMAL(18, 4) NOT NULL, + settle DECIMAL(12, 4), + open_interest BIGINT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + UNIQUE(code, trade_date) +); + +CREATE INDEX idx_future_daily_code_date ON future_kline_daily(code, trade_date); +CREATE INDEX idx_future_daily_trade_date ON future_kline_daily(trade_date); +``` + +#### 4.2.6 期货分钟数据表 (future_kline_min) +```sql +CREATE TABLE future_kline_min ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(20) NOT NULL, + period_type VARCHAR(10) NOT NULL, + trade_datetime TIMESTAMP NOT NULL, + open DECIMAL(12, 4) NOT NULL, + high DECIMAL(12, 4) NOT NULL, + low DECIMAL(12, 4) NOT NULL, + close DECIMAL(12, 4) NOT NULL, + volume BIGINT NOT NULL, + amount DECIMAL(18, 4) NOT NULL, + settle DECIMAL(12, 4), + open_interest BIGINT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + UNIQUE(code, period_type, trade_datetime) +); + +CREATE INDEX idx_future_min_code_period_datetime ON future_kline_min(code, period_type, trade_datetime); +``` + +#### 4.2.7 实时快照数据表 (realtime_snapshot) +```sql +CREATE TABLE realtime_snapshot ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(20) NOT NULL, + security_type VARCHAR(20) NOT NULL, -- stock, future, index, etc. + trade_time TIMESTAMP NOT NULL, + pre_close DECIMAL(12, 4), + last DECIMAL(12, 4), + open DECIMAL(12, 4), + high DECIMAL(12, 4), + low DECIMAL(12, 4), + close DECIMAL(12, 4), + volume BIGINT, + amount DECIMAL(18, 4), + ask_price1 DECIMAL(12, 4), + ask_volume1 INTEGER, + bid_price1 DECIMAL(12, 4), + bid_volume1 INTEGER, + -- ... 其他快照字段 + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP WITH TIME ZONE NOT NULL -- 7天后过期 +); + +CREATE INDEX idx_snapshot_code_time ON realtime_snapshot(code, trade_time); +CREATE INDEX idx_snapshot_expires ON realtime_snapshot(expires_at); +``` + +#### 4.2.8 缓存任务表 (cache_tasks) +```sql +CREATE TABLE cache_tasks ( + id SERIAL PRIMARY KEY, + task_name VARCHAR(200) NOT NULL, + task_type VARCHAR(50) NOT NULL, -- 'detect_missing', 'cache_data' + security_type VARCHAR(20) NOT NULL, -- 'stock', 'future' + period_type VARCHAR(10), -- daily, min1, etc. + start_date DATE NOT NULL, + end_date DATE NOT NULL, + status VARCHAR(20) DEFAULT 'pending', -- pending, running, completed, failed + progress DECIMAL(5, 2) DEFAULT 0, + total_count INTEGER DEFAULT 0, + success_count INTEGER DEFAULT 0, + error_count INTEGER DEFAULT 0, + error_message TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + started_at TIMESTAMP WITH TIME ZONE, + completed_at TIMESTAMP WITH TIME ZONE +); +``` + +#### 4.2.9 缓存记录表 (cache_records) +```sql +CREATE TABLE cache_records ( + id BIGSERIAL PRIMARY KEY, + task_id INTEGER REFERENCES cache_tasks(id), + code VARCHAR(20) NOT NULL, + trade_date DATE NOT NULL, + record_count INTEGER NOT NULL, + status VARCHAR(20) DEFAULT 'success', + error_message TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); +``` + +#### 4.2.10 API测试日志表 (api_test_logs) +```sql +CREATE TABLE api_test_logs ( + id BIGSERIAL PRIMARY KEY, + test_name VARCHAR(200) NOT NULL, + api_category VARCHAR(50) NOT NULL, + api_endpoint VARCHAR(200) NOT NULL, + request_method VARCHAR(10) NOT NULL, + request_params JSONB, + response_data JSONB, + status_code INTEGER, + execution_time_ms INTEGER, + is_success BOOLEAN DEFAULT FALSE, + error_message TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_test_logs_category ON api_test_logs(api_category); +CREATE INDEX idx_test_logs_created ON api_test_logs(created_at); +``` + +--- + +## 五、API接口设计 + +### 5.1 认证相关接口 + +| 方法 | 路径 | 说明 | +|------|------|------| +| POST | /api/v1/auth/login | 用户登录 | +| POST | /api/v1/auth/logout | 用户登出 | +| GET | /api/v1/auth/me | 获取当前用户信息 | +| POST | /api/v1/auth/refresh | 刷新Token | + +### 5.2 配置管理接口 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | /api/v1/configs/sdk | 获取SDK配置列表 | +| GET | /api/v1/configs/sdk/{id} | 获取指定SDK配置 | +| POST | /api/v1/configs/sdk | 创建SDK配置 | +| PUT | /api/v1/configs/sdk/{id} | 更新SDK配置 | +| DELETE | /api/v1/configs/sdk/{id} | 删除SDK配置 | +| POST | /api/v1/configs/sdk/{id}/test | 测试SDK连接 | +| POST | /api/v1/configs/sdk/{id}/set-default | 设为默认配置 | + +### 5.3 基础数据接口 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | /api/v1/base/codes | 获取代码列表 | +| GET | /api/v1/base/codes/{code}/info | 获取证券信息 | +| GET | /api/v1/base/calendar | 获取交易日历 | +| GET | /api/v1/base/calendar/trading-days | 获取指定区间交易日 | + +### 5.4 历史行情数据接口 + +#### 股票历史数据 +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | /api/v1/stock/kline | 获取股票K线数据(单个/批量) | +| GET | /api/v1/stock/kline/{code}/chart | 获取股票K线图数据 | +| GET | /api/v1/stock/snapshot | 获取股票历史快照数据 | + +#### 期货历史数据 +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | /api/v1/future/kline | 获取期货K线数据(单个/批量) | +| GET | /api/v1/future/kline/{code}/chart | 获取期货K线图数据 | +| GET | /api/v1/future/snapshot | 获取期货历史快照数据 | + +### 5.5 实时数据接口 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | /api/v1/realtime/snapshot | 获取最新快照数据 | +| POST | /api/v1/realtime/subscribe | 开始实时数据订阅 | +| DELETE | /api/v1/realtime/subscribe | 停止实时数据订阅 | +| GET | /api/v1/realtime/subscribe/status | 获取订阅状态 | +| GET | /api/v1/realtime/snapshot/stream | WebSocket实时数据流 | + +### 5.6 财务数据接口 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | /api/v1/finance/balance-sheet | 获取资产负债表 | +| GET | /api/v1/finance/cash-flow | 获取现金流量表 | +| GET | /api/v1/finance/income | 获取利润表 | +| GET | /api/v1/finance/profit-express | 获取业绩快报 | +| GET | /api/v1/finance/profit-notice | 获取业绩预告 | + +### 5.7 股东股本数据接口 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | /api/v1/shareholder/top10 | 获取十大股东数据 | +| GET | /api/v1/shareholder/count | 获取股东户数 | +| GET | /api/v1/shareholder/equity | 获取股本结构 | + +### 5.8 融资融券数据接口 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | /api/v1/margin/summary | 获取融资融券汇总 | +| GET | /api/v1/margin/detail | 获取融资融券明细 | + +### 5.9 指数数据接口 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | /api/v1/index/constituents | 获取指数成分股 | +| GET | /api/v1/index/weights | 获取指数权重 | + +### 5.10 ETF数据接口 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | /api/v1/etf/pcf | 获取ETF申赎数据 | +| GET | /api/v1/etf/share | 获取ETF份额数据 | + +### 5.11 可转债数据接口 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | /api/v1/kzz/issuance | 获取可转债发行数据 | + +### 5.12 数据缓存管理接口 + +| 方法 | 路径 | 说明 | +|------|------|------| +| POST | /api/v1/cache/detect-missing | 检测缺失数据 | +| POST | /api/v1/cache/batch-cache | 批量缓存数据 | +| GET | /api/v1/cache/tasks | 获取缓存任务列表 | +| GET | /api/v1/cache/tasks/{id} | 获取任务详情 | +| DELETE | /api/v1/cache/tasks/{id} | 取消任务 | +| GET | /api/v1/cache/status/{code} | 获取代码缓存状态 | + +### 5.13 测试中心接口 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | /api/v1/test/categories | 获取测试分类 | +| GET | /api/v1/test/endpoints | 获取所有接口列表 | +| POST | /api/v1/test/run | 执行单个接口测试 | +| POST | /api/v1/test/run-all | 执行全部接口测试 | +| GET | /api/v1/test/history | 获取测试历史记录 | +| GET | /api/v1/test/history/{id} | 获取单次测试详情 | + +--- + +## 六、核心业务逻辑 + +### 6.1 数据获取流程 (缓存优先) + +``` +用户请求 → 检查本地缓存 → 有数据 → 直接返回 + ↓ + 无数据/不完整 + ↓ + 调用AmazingData SDK → 获取数据 → 写入缓存 → 返回数据 +``` + +### 6.2 缺失数据检测算法 + +```python +# 伪代码 +def detect_missing_data(code, start_date, end_date, period='daily'): + # 1. 获取指定区间的交易日列表 + trading_days = get_trading_days(start_date, end_date) + + # 2. 查询数据库中已有数据的日期 + existing_data = query_db_for_existing_dates(code, start_date, end_date, period) + + # 3. 计算每个交易日的数据条数 + missing_dates = [] + for date in trading_days: + expected_count = get_expected_count_for_date(code, date, period) + actual_count = existing_data.get(date, 0) + + # 4. 偏差超过10%认为缺失 + if actual_count < expected_count * 0.9: + missing_dates.append({ + 'date': date, + 'expected': expected_count, + 'actual': actual_count, + 'missing_ratio': 1 - actual_count / expected_count + }) + + return missing_dates +``` + +### 6.3 数据去重插入逻辑 + +```python +# 伪代码 +def cache_data_with_dedup(code, data_df, period='daily'): + # 1. 构建唯一键 + if period == 'daily': + existing = query_existing_data(code, data_df.index.min(), data_df.index.max()) + else: + existing = query_existing_data(code, data_df.index.min(), data_df.index.max(), period) + + # 2. 比较核心数据字段 (open, high, low, close, volume) + core_columns = ['open', 'high', 'low', 'close', 'volume'] + + to_insert = [] + for idx, row in data_df.iterrows(): + key = (code, idx) if period == 'daily' else (code, period, idx) + + if key in existing: + # 3. 数据已存在,比较核心字段 + existing_row = existing[key] + is_same = all( + abs(row[col] - existing_row[col]) < 0.0001 + for col in core_columns + ) + if is_same: + continue # 数据相同,跳过 + + to_insert.append(row) + + # 4. 批量插入新数据 + if to_insert: + batch_insert(to_insert) +``` + +### 6.4 实时数据订阅流程 + +``` +前端请求订阅 → 后端建立WebSocket连接 → 初始化AmazingData订阅 + ↓ +前端 ← WebSocket推送 ← 回调函数接收数据 ← SDK实时推送 +``` + +--- + +## 七、前端页面设计 + +### 7.1 页面结构 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ [Logo] AmazingData金融数据服务平台 [用户] [退出] │ +├─────────────────────────────────────────────────────────────┤ +│ ┌──────┐ │ +│ │ 首页 │ ┌──────────────────────────────────────────┐ │ +│ ├──────┤ │ │ │ +│ │数据查询│ │ 页面内容区域 │ │ +│ ├──────┤ │ │ │ +│ │配置管理│ │ │ │ +│ ├──────┤ │ │ │ +│ │缓存管理│ │ │ │ +│ ├──────┤ │ │ │ +│ │测试中心│ │ │ │ +│ └──────┘ └──────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 7.2 登录页面 +- 用户名/密码输入框 +- 记住我选项 +- 登录按钮 + +### 7.3 配置管理页面 +- SDK配置列表(卡片形式) +- 添加/编辑配置表单(弹窗) +- 测试连接按钮 +- 设为默认按钮 + +### 7.4 数据查询页面 +- **K线图查询** + - 代码输入(支持股票/期货) + - 时间区间选择(默认1年) + - 周期选择(日线/分钟线) + - K线图展示(ECharts) + - 数据表格展示 + +- **批量数据查询** + - 多代码输入(支持批量粘贴) + - 时间区间选择 + - 数据列表展示(分页) + - 导出功能 + +### 7.5 缓存管理页面 +- **缺失数据检测** + - 品种选择(股票/期货) + - 时间区间选择 + - 检测结果列表 + - 一键缓存按钮 + +- **缓存任务管理** + - 任务列表(进度条展示) + - 任务详情(成功/失败统计) + - 取消任务按钮 + +### 7.6 测试中心页面 +- 接口分类树(基础数据/历史数据/实时数据/财务数据/...) +- 接口列表(表格) +- 单个接口测试(参数表单 + 结果展示) +- 一键全部测试按钮 +- 测试历史记录 + +--- + +## 八、项目目录结构 + +``` +amazing-data-service/ +├── backend/ +│ ├── app/ +│ │ ├── __init__.py +│ │ ├── main.py # FastAPI入口 +│ │ ├── config.py # 配置管理 +│ │ ├── dependencies.py # 依赖注入 +│ │ ├── core/ +│ │ │ ├── __init__.py +│ │ │ ├── security.py # JWT认证 +│ │ │ ├── exceptions.py # 异常处理 +│ │ │ └── middleware.py # 中间件 +│ │ ├── api/ +│ │ │ ├── __init__.py +│ │ │ ├── v1/ +│ │ │ │ ├── __init__.py +│ │ │ │ ├── auth.py # 认证接口 +│ │ │ │ ├── configs.py # 配置接口 +│ │ │ │ ├── base_data.py # 基础数据接口 +│ │ │ │ ├── stock.py # 股票数据接口 +│ │ │ │ ├── future.py # 期货数据接口 +│ │ │ │ ├── realtime.py # 实时数据接口 +│ │ │ │ ├── finance.py # 财务数据接口 +│ │ │ │ ├── shareholder.py # 股东数据接口 +│ │ │ │ ├── margin.py # 融资融券接口 +│ │ │ │ ├── index.py # 指数数据接口 +│ │ │ │ ├── etf.py # ETF数据接口 +│ │ │ │ ├── kzz.py # 可转债数据接口 +│ │ │ │ ├── cache.py # 缓存管理接口 +│ │ │ │ └── test.py # 测试中心接口 +│ │ ├── models/ +│ │ │ ├── __init__.py +│ │ │ ├── user.py # 用户模型 +│ │ │ ├── config.py # 配置模型 +│ │ │ ├── stock.py # 股票数据模型 +│ │ │ ├── future.py # 期货数据模型 +│ │ │ ├── realtime.py # 实时数据模型 +│ │ │ ├── finance.py # 财务数据模型 +│ │ │ ├── cache.py # 缓存任务模型 +│ │ │ └── test.py # 测试日志模型 +│ │ ├── schemas/ +│ │ │ ├── __init__.py +│ │ │ ├── auth.py # 认证Schema +│ │ │ ├── base.py # 基础Schema +│ │ │ ├── config.py # 配置Schema +│ │ │ ├── kline.py # K线Schema +│ │ │ ├── finance.py # 财务Schema +│ │ │ ├── cache.py # 缓存Schema +│ │ │ └── test.py # 测试Schema +│ │ ├── services/ +│ │ │ ├── __init__.py +│ │ │ ├── auth_service.py # 认证服务 +│ │ │ ├── config_service.py # 配置服务 +│ │ │ ├── amazing_data_service.py # SDK封装服务 +│ │ │ ├── cache_service.py # 缓存服务 +│ │ │ ├── stock_service.py # 股票数据服务 +│ │ │ ├── future_service.py # 期货数据服务 +│ │ │ ├── realtime_service.py # 实时数据服务 +│ │ │ ├── finance_service.py # 财务数据服务 +│ │ │ ├── base_data_service.py # 基础数据服务 +│ │ │ └── test_service.py # 测试服务 +│ │ ├── db/ +│ │ │ ├── __init__.py +│ │ │ ├── base.py # 数据库基础 +│ │ │ └── session.py # 会话管理 +│ │ └── utils/ +│ │ ├── __init__.py +│ │ ├── date_utils.py # 日期工具 +│ │ ├── data_utils.py # 数据处理工具 +│ │ └── validators.py # 验证器 +│ ├── alembic/ # 数据库迁移 +│ ├── tests/ +│ ├── requirements.txt +│ └── Dockerfile +├── frontend/ +│ ├── src/ +│ │ ├── api/ # API接口封装 +│ │ ├── components/ # 公共组件 +│ │ ├── views/ # 页面视图 +│ │ │ ├── Login.vue +│ │ │ ├── Dashboard.vue +│ │ │ ├── ConfigManager.vue +│ │ │ ├── DataQuery/ +│ │ │ │ ├── KlineChart.vue +│ │ │ │ └── BatchQuery.vue +│ │ │ ├── CacheManager/ +│ │ │ │ ├── DetectMissing.vue +│ │ │ │ └── TaskManager.vue +│ │ │ └── TestCenter/ +│ │ │ ├── ApiTest.vue +│ │ │ └── TestHistory.vue +│ │ ├── router/ +│ │ ├── store/ +│ │ ├── utils/ +│ │ └── App.vue +│ ├── public/ +│ ├── package.json +│ └── vite.config.ts +├── database/ +│ ├── init.sql # 数据库初始化脚本 +│ └── migrations/ +├── docker/ +│ ├── docker-compose.yml +│ └── nginx.conf +├── docs/ +│ ├── api.md +│ └── deploy.md +├── scripts/ +│ ├── init_db.py +│ └── create_admin.py +└── README.md +``` + +--- + +## 九、部署架构 + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Docker Compose │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Nginx │ │ Frontend │ │ Backend │ │ +│ │ (反向代理) │ │ (Vue3) │ │ (FastAPI) │ │ +│ └──────┬───────┘ └──────────────┘ └──────┬───────┘ │ +│ │ │ │ +│ └────────────────────────────────────┘ │ +│ │ │ +│ ┌────────────┼────────────┐ │ +│ ▼ ▼ ▼ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ PostgreSQL │ │ Redis │ │ (AmazingData │ │ +│ │ (主数据库) │ │ (缓存) │ │ SDK连接) │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 十、关键功能实现要点 + +### 10.1 AmazingData SDK适配器 + +```python +class AmazingDataAdapter: + """SDK适配器,封装AmazingData所有接口""" + + def __init__(self, config: SDKConfig): + self.config = config + self._ad = None + self._base_data = None + self._market_data = None + self._info_data = None + self._calendar = None + + def connect(self) -> bool: + """建立连接""" + import AmazingData as ad + ad.login(**self.config.to_dict()) + self._ad = ad + self._base_data = ad.BaseData() + self._info_data = ad.InfoData() + self._calendar = self._base_data.get_calendar() + self._market_data = ad.MarketData(self._calendar) + return True + + # 基础数据接口 + def get_code_list(self, security_type: str) -> List[str]: ... + def get_code_info(self, security_type: str) -> pd.DataFrame: ... + def get_trading_calendar(self, market: str) -> List[int]: ... + + # 历史行情接口 + def get_kline(self, codes, start_date, end_date, period) -> Dict[str, pd.DataFrame]: ... + def get_snapshot(self, codes, start_date, end_date) -> Dict[str, pd.DataFrame]: ... + + # 财务数据接口 + def get_balance_sheet(self, codes, start_date, end_date) -> Dict[str, pd.DataFrame]: ... + def get_cash_flow(self, codes, start_date, end_date) -> Dict[str, pd.DataFrame]: ... + def get_income_statement(self, codes, start_date, end_date) -> Dict[str, pd.DataFrame]: ... + + # 其他所有接口... +``` + +### 10.2 缓存服务 + +```python +class CacheService: + """数据缓存服务""" + + def get_kline_with_cache(self, code: str, start_date: date, + end_date: date, period: str) -> pd.DataFrame: + """优先从缓存获取,否则从SDK获取并缓存""" + + # 1. 查询本地数据库 + cached_data = self.db.query_kline(code, start_date, end_date, period) + + # 2. 检查数据完整性 + if self._is_data_complete(cached_data, start_date, end_date): + return cached_data + + # 3. 从SDK获取缺失数据 + missing_ranges = self._find_missing_ranges(cached_data, start_date, end_date) + for range_start, range_end in missing_ranges: + sdk_data = self.sdk.get_kline(code, range_start, range_end, period) + self.db.save_kline(code, sdk_data, period) + + # 4. 重新查询完整数据 + return self.db.query_kline(code, start_date, end_date, period) +``` + +### 10.3 实时数据WebSocket + +```python +class RealtimeManager: + """实时数据管理器""" + + def __init__(self): + self.subscribers: Dict[str, Set[WebSocket]] = {} + self.sdk_subscriber = None + + async def subscribe(self, websocket: WebSocket, codes: List[str]): + """客户端订阅""" + await websocket.accept() + for code in codes: + if code not in self.subscribers: + self.subscribers[code] = set() + # 启动SDK订阅 + self._start_sdk_subscription(code) + self.subscribers[code].add(websocket) + + def _on_sdk_data(self, code: str, data: dict): + """SDK数据回调""" + # 1. 保存到数据库(带过期时间) + self.db.save_realtime(data) + + # 2. 推送给所有订阅者 + for ws in self.subscribers.get(code, []): + asyncio.create_task(ws.send_json(data)) +``` + +--- + +## 十一、开发计划 + +| 阶段 | 任务 | 预计时间 | +|------|------|----------| +| 第一阶段 | 项目初始化、数据库设计、基础框架搭建 | 2天 | +| 第二阶段 | 认证模块、配置管理模块开发 | 2天 | +| 第三阶段 | SDK适配器、基础数据接口开发 | 3天 | +| 第四阶段 | 历史数据接口(股票/期货)、缓存逻辑 | 4天 | +| 第五阶段 | 实时数据订阅功能 | 3天 | +| 第六阶段 | 财务/股东/融资融券等数据接口 | 3天 | +| 第七阶段 | 批量缓存管理功能 | 3天 | +| 第八阶段 | 测试中心功能 | 2天 | +| 第九阶段 | 前端页面开发 | 5天 | +| 第十阶段 | 集成测试、部署文档 | 2天 | + +**总计:约29天** + +--- + +## 十二、风险与注意事项 + +1. **数据准确性**:AmazingData SDK返回的数据需要验证,建议增加数据校验逻辑 +2. **并发性能**:大量数据查询时需要控制并发,避免SDK连接超时 +3. **存储空间**:历史数据量巨大,需要定期归档或清理 +4. **SDK连接**:AmazingData SDK需要保持连接,建议使用连接池 +5. **实时数据延迟**:网络延迟可能导致实时数据推送不及时 + +--- + +*文档版本: 1.0* +*创建日期: 2025年* diff --git a/README.md b/README.md new file mode 100644 index 0000000..4241bc5 --- /dev/null +++ b/README.md @@ -0,0 +1,198 @@ +# AmazingData 金融数据服务平台 + +基于银河证券星耀数智量化平台SDK构建的金融数据服务系统,提供历史数据缓存、实时数据订阅、批量数据管理等功能。 + +## 功能特性 + +- ✅ **完整SDK接口封装** - 封装AmazingData所有接口 +- ✅ **智能数据缓存** - 优先本地缓存,缺失自动从SDK补全 +- ✅ **实时数据订阅** - WebSocket实时推送股票/期货行情 +- ✅ **缺失数据检测** - 自动检测并提示缺失数据 +- ✅ **批量缓存管理** - 一键检测、一键缓存 +- ✅ **可视化K线图** - ECharts展示历史K线数据 +- ✅ **完整测试中心** - 所有接口一键测试 +- ✅ **配置管理** - SDK账号配置可视化维护 + +## 技术栈 + +| 层级 | 技术 | +|------|------| +| 后端 | Python 3.10+, FastAPI, SQLAlchemy 2.0 | +| 数据库 | PostgreSQL 15+ | +| 缓存 | Redis 7+ | +| 前端 | Vue3, TypeScript, Element Plus | +| 图表 | ECharts 5+ | +| 部署 | Docker, Docker Compose | + +## 快速开始 + +### 1. 克隆项目 + +```bash +git clone +cd amazing-data-service +``` + +### 2. 安装AmazingData SDK + +```bash +# 从银河证券获取 SDK wheel 文件 +pip install AmazingData-1.0.24-py3-none-any.whl +``` + +### 3. 使用Docker启动 + +```bash +docker-compose up -d +``` + +服务将启动在: +- 前端: http://localhost +- 后端API: http://localhost:8000 +- API文档: http://localhost:8000/docs + +### 4. 初始化数据库 + +```bash +# 数据库会自动执行 init.sql 初始化脚本 +# 默认管理员账号: admin / admin123 +``` + +### 5. 配置SDK连接 + +1. 登录系统(admin / admin123) +2. 进入"配置管理"页面 +3. 添加AmazingData SDK连接信息 +4. 点击"测试连接"验证 + +## 项目结构 + +``` +amazing-data-service/ +├── backend/ # FastAPI后端 +├── frontend/ # Vue3前端 +├── database/ # 数据库脚本 +├── docker/ # Docker配置 +├── docs/ # 文档 +└── README.md +``` + +## API接口文档 + +启动服务后访问:http://localhost:8000/docs + +### 主要接口分类 + +- `/api/v1/auth/*` - 认证接口 +- `/api/v1/configs/*` - 配置管理 +- `/api/v1/stock/*` - 股票数据 +- `/api/v1/future/*` - 期货数据 +- `/api/v1/realtime/*` - 实时数据 +- `/api/v1/finance/*` - 财务数据 +- `/api/v1/cache/*` - 缓存管理 +- `/api/v1/test/*` - 测试中心 + +## 核心功能说明 + +### 1. 数据缓存策略 + +系统采用"本地优先"策略: +1. 查询请求首先检查本地数据库 +2. 检查数据完整性(与交易日历对比) +3. 缺失数据自动从AmazingData SDK获取 +4. 新数据写入数据库时自动去重 + +### 2. 缺失数据检测 + +检测逻辑: +- 按交易日查询数据条数 +- 与最近交易日数据对比 +- 偏差超过10%标记为缺失 + +### 3. 实时数据订阅 + +- WebSocket连接:`ws://localhost:8000/api/v1/realtime/stream` +- 支持股票、期货、指数快照订阅 +- 数据自动保存7天后清理 + +### 4. 测试中心 + +提供所有接口的自动化测试: +- 单个接口测试(参数调试) +- 批量接口测试(回归测试) +- 测试历史记录 + +## 数据库表结构 + +### 核心表 + +| 表名 | 说明 | +|------|------| +| users | 系统用户 | +| sdk_configs | SDK配置 | +| stock_kline_daily | 股票日线数据 | +| stock_kline_min | 股票分钟数据 | +| future_kline_daily | 期货日线数据 | +| future_kline_min | 期货分钟数据 | +| realtime_snapshot | 实时快照数据 | +| cache_tasks | 缓存任务 | +| api_test_logs | API测试日志 | + +完整表结构见:`database/init.sql` + +## 开发指南 + +### 后端开发 + +```bash +cd backend +python -m venv venv +source venv/bin/activate +pip install -r requirements.txt +uvicorn app.main:app --reload +``` + +### 前端开发 + +```bash +cd frontend +npm install +npm run dev +``` + +## 配置说明 + +### 环境变量 + +| 变量名 | 说明 | 默认值 | +|--------|------|--------| +| DATABASE_URL | 数据库连接URL | postgresql://postgres:postgres@localhost:5432/amazing_data | +| REDIS_URL | Redis连接URL | redis://localhost:6379/0 | +| SECRET_KEY | JWT密钥 | your-secret-key | +| ACCESS_TOKEN_EXPIRE_HOURS | Token过期时间 | 24 | + +## 常见问题 + +### Q: 如何添加新的SDK配置? +A: 登录系统后,进入"配置管理"页面,点击"添加配置",填写AmazingData账号信息。 + +### Q: 如何批量缓存历史数据? +A: 进入"缓存管理"页面,选择时间区间,点击"检测缺失数据",然后点击"一键缓存"。 + +### Q: 实时数据如何订阅? +A: 在数据查询页面,输入代码后勾选"实时订阅",系统会自动建立WebSocket连接。 + +### Q: 数据去重是如何工作的? +A: 系统比较open/high/low/close/volume五个核心字段,如果差异小于0.0001则视为相同数据,跳过插入。 + +## 许可证 + +MIT License + +## 联系支持 + +如有问题,请联系项目维护者或提交Issue。 + +--- + +**注意**:使用本系统需要AmazingData SDK授权,请联系银河证券开通权限。 diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..345fb57 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,14 @@ +# 数据库配置 +DATABASE_URL=postgresql://postgres:postgres@localhost:5432/amazing_data + +# Redis配置 +REDIS_URL=redis://localhost:6379/0 + +# JWT密钥(生产环境请修改) +SECRET_KEY=your-secret-key-change-in-production + +# Token过期时间(小时) +ACCESS_TOKEN_EXPIRE_HOURS=24 + +# 调试模式 +DEBUG=false diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..19f2de0 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,26 @@ +# 后端Dockerfile +FROM python:3.11-slim + +WORKDIR /app + +# 安装系统依赖 +RUN apt-get update && apt-get install -y \ + gcc \ + postgresql-client \ + libpq-dev \ + && rm -rf /var/lib/apt/lists/* + +# 复制依赖文件 +COPY requirements.txt . + +# 安装Python依赖 +RUN pip install --no-cache-dir -r requirements.txt + +# 复制应用代码 +COPY app/ ./app/ + +# 暴露端口 +EXPOSE 8000 + +# 启动命令 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..8be81b3 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ +# AmazingData金融数据服务平台 - 后端应用 diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..eca50d8 --- /dev/null +++ b/backend/app/api/__init__.py @@ -0,0 +1 @@ +# API模块 diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py new file mode 100644 index 0000000..94ed5f4 --- /dev/null +++ b/backend/app/api/v1/__init__.py @@ -0,0 +1,16 @@ +# API v1模块 +from fastapi import APIRouter + +from app.api.v1 import auth, configs, base_data, stock, future, realtime, finance, cache, test + +api_router = APIRouter(prefix="/api/v1") + +api_router.include_router(auth.router, prefix="/auth", tags=["认证"]) +api_router.include_router(configs.router, prefix="/configs", tags=["配置管理"]) +api_router.include_router(base_data.router, prefix="/base", tags=["基础数据"]) +api_router.include_router(stock.router, prefix="/stock", tags=["股票数据"]) +api_router.include_router(future.router, prefix="/future", tags=["期货数据"]) +api_router.include_router(realtime.router, prefix="/realtime", tags=["实时数据"]) +api_router.include_router(finance.router, prefix="/finance", tags=["财务数据"]) +api_router.include_router(cache.router, prefix="/cache", tags=["缓存管理"]) +api_router.include_router(test.router, prefix="/test", tags=["测试中心"]) diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py new file mode 100644 index 0000000..14779dd --- /dev/null +++ b/backend/app/api/v1/auth.py @@ -0,0 +1,42 @@ +""" +认证路由 +""" +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import HTTPBearer +from sqlalchemy.orm import Session + +from app.db.session import get_db +from app.schemas.auth import UserLogin, TokenResponse, UserInfo +from app.schemas.base import ResponseModel +from app.services.auth_service import AuthService +from app.core.security import get_current_user +from app.models.user import User + +router = APIRouter() +security = HTTPBearer() + + +@router.post("/login", response_model=ResponseModel[TokenResponse]) +async def login(login_data: UserLogin, db: Session = Depends(get_db)): + """ + 用户登录 + + - **username**: 用户名 + - **password**: 密码 + """ + user = AuthService.authenticate_user(db, login_data.username, login_data.password) + token_data = AuthService.create_user_token(user) + + return ResponseModel(data=TokenResponse(**token_data)) + + +@router.get("/me", response_model=ResponseModel[UserInfo]) +async def get_me(current_user: User = Depends(get_current_user)): + """获取当前用户信息""" + return ResponseModel(data=UserInfo.model_validate(current_user)) + + +@router.post("/logout", response_model=ResponseModel) +async def logout(): + """用户登出(前端清除token即可)""" + return ResponseModel(message="登出成功") diff --git a/backend/app/api/v1/base_data.py b/backend/app/api/v1/base_data.py new file mode 100644 index 0000000..56e4211 --- /dev/null +++ b/backend/app/api/v1/base_data.py @@ -0,0 +1,101 @@ +""" +基础数据路由 +""" +from typing import List +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from app.db.session import get_db +from app.schemas.base import ResponseModel +from app.services.base_data_service import BaseDataService +from app.core.security import get_current_user +from app.models.user import User +from app.utils.date_utils import parse_date + +router = APIRouter() + + +@router.get("/codes", response_model=ResponseModel) +async def get_code_list( + security_type: str = Query(..., description="证券类型: EXTRA_STOCK_A, EXTRA_FUTURE, EXTRA_ETF, EXTRA_INDEX_A"), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """获取代码列表""" + service = BaseDataService(db) + codes = service.get_code_list(security_type) + return ResponseModel(data={"codes": codes[:100]}) # 限制返回数量 + + +@router.get("/codes/{code}/info", response_model=ResponseModel) +async def get_code_info( + code: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """获取证券信息""" + service = BaseDataService(db) + # 根据代码判断证券类型 + security_type = service.get_security_type(code) + + # 获取对应类型的代码信息 + if security_type == "stock": + info = service.get_code_info("EXTRA_STOCK_A") + elif security_type == "future": + info = service.get_code_info("EXTRA_FUTURE") + else: + info = None + + if info is not None and not info.empty: + code_info = info[info.get("code") == code] + if not code_info.empty: + return ResponseModel(data=code_info.to_dict("records")[0]) + + return ResponseModel(data={"code": code, "security_type": security_type}) + + +@router.get("/calendar", response_model=ResponseModel) +async def get_trading_calendar( + market: str = Query("SH", description="市场: SH, SZ, CFE"), + start_date: str = Query(..., description="开始日期(YYYYMMDD)"), + end_date: str = Query(..., description="结束日期(YYYYMMDD)"), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """获取交易日历""" + service = BaseDataService(db) + start = parse_date(start_date) + end = parse_date(end_date) + calendar = service.get_trading_calendar(market, start, end) + + return ResponseModel(data={ + "market": market, + "start_date": start_date, + "end_date": end_date, + "trading_days": [d.isoformat() for d in calendar], + "count": len(calendar) + }) + + +@router.get("/calendar/trading-days", response_model=ResponseModel) +async def get_trading_days( + market: str = Query("SH", description="市场: SH, SZ, CFE"), + days: int = Query(30, description="最近交易日数量"), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """获取最近交易日列表""" + from datetime import date, timedelta + + service = BaseDataService(db) + end_date = date.today() + start_date = end_date - timedelta(days=days * 2) # 获取更多天数以确保有足够交易日 + + calendar = service.get_trading_calendar(market, start_date, end_date) + recent_days = calendar[-days:] if len(calendar) > days else calendar + + return ResponseModel(data={ + "market": market, + "trading_days": [d.isoformat() for d in recent_days], + "count": len(recent_days) + }) diff --git a/backend/app/api/v1/cache.py b/backend/app/api/v1/cache.py new file mode 100644 index 0000000..5fcd23b --- /dev/null +++ b/backend/app/api/v1/cache.py @@ -0,0 +1,169 @@ +""" +缓存管理路由 +""" +from typing import List +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from app.db.session import get_db +from app.schemas.base import ResponseModel, PaginatedResponse +from app.schemas.cache import ( + DetectMissingRequest, DetectMissingResponse, + BatchCacheRequest, CacheTaskResponse, CacheStatusResponse +) +from app.services.cache_service import CacheService +from app.core.security import get_current_user +from app.models.user import User +from app.utils.date_utils import parse_date + +router = APIRouter() + + +@router.post("/detect-missing", response_model=ResponseModel) +async def detect_missing_data( + request: DetectMissingRequest, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """检测缺失数据""" + service = CacheService(db) + start = parse_date(request.start_date) + end = parse_date(request.end_date) + + task = service.detect_missing_data( + request.security_type, + request.period_type, + start, + end, + request.code_list + ) + + # 获取缺失详情 + details = service.get_task_details(task.id) + missing_codes = [d for d in details if d.is_missing] + + missing_info = [] + for code in request.code_list: + code_details = [d for d in details if d.code == code and d.is_missing] + if code_details: + missing_info.append({ + "code": code, + "missing_dates": [{ + "date": format_date(d.trade_date), + "expected": d.expected_count, + "actual": d.actual_count, + "missing_ratio": (d.expected_count - d.actual_count) / d.expected_count if d.expected_count > 0 else 0 + } for d in code_details] + }) + + return ResponseModel(data={ + "task_id": task.id, + "total_codes": len(request.code_list), + "missing_codes": missing_info + }) + + +@router.post("/batch-cache", response_model=ResponseModel[CacheTaskResponse]) +async def batch_cache_data( + request: BatchCacheRequest, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """批量缓存数据""" + service = CacheService(db) + start = parse_date(request.start_date) + end = parse_date(request.end_date) + + task = service.batch_cache_data( + request.security_type, + request.period_type, + start, + end, + request.code_list + ) + + return ResponseModel(data=CacheTaskResponse.model_validate(task)) + + +@router.get("/tasks", response_model=ResponseModel) +async def get_cache_tasks( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """获取缓存任务列表""" + service = CacheService(db) + result = service.get_tasks(page, page_size) + + return ResponseModel(data={ + "items": [CacheTaskResponse.model_validate(t) for t in result["items"]], + "total": result["total"], + "page": result["page"], + "page_size": result["page_size"], + "total_pages": result["total_pages"] + }) + + +@router.get("/tasks/{task_id}", response_model=ResponseModel) +async def get_cache_task( + task_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """获取缓存任务详情""" + service = CacheService(db) + task = service.get_task(task_id) + + if not task: + return ResponseModel(code=404, message="任务不存在") + + details = service.get_task_details(task_id) + + return ResponseModel(data={ + "task": CacheTaskResponse.model_validate(task), + "details": [{ + "id": d.id, + "code": d.code, + "trade_date": d.trade_date.isoformat() if d.trade_date else None, + "expected_count": d.expected_count, + "actual_count": d.actual_count, + "is_missing": bool(d.is_missing), + "status": d.status, + "error_message": d.error_message + } for d in details] + }) + + +@router.delete("/tasks/{task_id}", response_model=ResponseModel) +async def cancel_cache_task( + task_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """取消缓存任务""" + service = CacheService(db) + success = service.cancel_task(task_id) + + if success: + return ResponseModel(message="任务已取消") + else: + return ResponseModel(code=400, message="任务不存在或已完成") + + +@router.get("/status/{code}", response_model=ResponseModel) +async def get_cache_status( + code: str, + security_type: str = Query("stock", description="证券类型: stock, future"), + period_type: str = Query("daily", description="周期类型: daily, min1, min5, etc."), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """获取代码缓存状态""" + service = CacheService(db) + status = service.get_cache_status(code, security_type, period_type) + + return ResponseModel(data=status) + + +from app.utils.date_utils import format_date diff --git a/backend/app/api/v1/configs.py b/backend/app/api/v1/configs.py new file mode 100644 index 0000000..c5be307 --- /dev/null +++ b/backend/app/api/v1/configs.py @@ -0,0 +1,158 @@ +""" +配置管理路由 +""" +from typing import List +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app.db.session import get_db +from app.schemas.config import SDKConfigCreate, SDKConfigUpdate, SDKConfigResponse, SDKConfigTestResponse +from app.schemas.base import ResponseModel, PaginatedResponse, PaginatedData +from app.services.config_service import ConfigService +from app.services.amazing_data_adapter import AmazingDataAdapter +from app.services.sdk_manager import sdk_manager +from app.core.security import get_current_user +from app.models.user import User + +router = APIRouter() + + +@router.get("/sdk", response_model=ResponseModel[List[SDKConfigResponse]]) +async def get_sdk_configs( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """获取SDK配置列表""" + configs = ConfigService.get_sdk_configs(db) + return ResponseModel(data=[SDKConfigResponse.model_validate(c) for c in configs]) + + +@router.get("/sdk/{config_id}", response_model=ResponseModel[SDKConfigResponse]) +async def get_sdk_config( + config_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """获取指定SDK配置""" + config = ConfigService.get_sdk_config(db, config_id) + if not config: + raise HTTPException(status_code=404, detail="配置不存在") + return ResponseModel(data=SDKConfigResponse.model_validate(config)) + + +@router.post("/sdk", response_model=ResponseModel[SDKConfigResponse]) +async def create_sdk_config( + config_data: SDKConfigCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """创建SDK配置""" + config_dict = config_data.model_dump() + config = ConfigService.create_sdk_config(db, config_dict) + return ResponseModel(data=SDKConfigResponse.model_validate(config)) + + +@router.put("/sdk/{config_id}", response_model=ResponseModel[SDKConfigResponse]) +async def update_sdk_config( + config_id: int, + config_data: SDKConfigUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """更新SDK配置""" + config_dict = config_data.model_dump(exclude_unset=True) + config = ConfigService.update_sdk_config(db, config_id, config_dict) + return ResponseModel(data=SDKConfigResponse.model_validate(config)) + + +@router.delete("/sdk/{config_id}", response_model=ResponseModel) +async def delete_sdk_config( + config_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """删除SDK配置""" + ConfigService.delete_sdk_config(db, config_id) + return ResponseModel(message="删除成功") + + +@router.post("/sdk/{config_id}/test", response_model=ResponseModel[SDKConfigTestResponse]) +async def test_sdk_config( + config_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """测试SDK连接(连接成功后会保持登录状态)""" + import asyncio + + config = ConfigService.get_sdk_config(db, config_id) + if not config: + raise HTTPException(status_code=404, detail="配置不存在") + + def _test_connection(): + adapter = sdk_manager.get_connection(config_id) + if adapter: + return True + return False + + success = await asyncio.to_thread(_test_connection) + + if success: + return ResponseModel(data=SDKConfigTestResponse(success=True, message="连接成功(已保持登录状态)")) + else: + return ResponseModel( + code=1001, + message="连接失败", + data=SDKConfigTestResponse(success=False, message="SDK连接失败,请检查配置") + ) + + +@router.post("/sdk/{config_id}/release", response_model=ResponseModel) +async def release_sdk_connection( + config_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """释放SDK连接(登出释放资源)""" + import asyncio + + config = ConfigService.get_sdk_config(db, config_id) + if not config: + raise HTTPException(status_code=404, detail="配置不存在") + + def _release(): + sdk_manager.release_connection(config_id) + + await asyncio.to_thread(_release) + + return ResponseModel(message="SDK连接已释放") + + +@router.get("/sdk/{config_id}/status", response_model=ResponseModel) +async def get_sdk_connection_status( + config_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """获取SDK连接状态""" + config = ConfigService.get_sdk_config(db, config_id) + if not config: + raise HTTPException(status_code=404, detail="配置不存在") + + status_info = sdk_manager.get_status(config_id) + return ResponseModel(data={ + "connected": status_info["connected"], + "last_activity": status_info["last_activity"], + "config_id": status_info["config_id"] + }) + + +@router.post("/sdk/{config_id}/set-default", response_model=ResponseModel) +async def set_default_config( + config_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """设为默认配置""" + ConfigService.set_default_config(db, config_id) + return ResponseModel(message="设置成功") diff --git a/backend/app/api/v1/finance.py b/backend/app/api/v1/finance.py new file mode 100644 index 0000000..2ab6fa9 --- /dev/null +++ b/backend/app/api/v1/finance.py @@ -0,0 +1,113 @@ +""" +财务数据路由 +""" +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from app.db.session import get_db +from app.schemas.base import ResponseModel +from app.services.finance_service import FinanceService +from app.core.security import get_current_user +from app.models.user import User +from app.utils.date_utils import parse_date + +router = APIRouter() + + +@router.get("/balance-sheet", response_model=ResponseModel) +async def get_balance_sheet( + codes: str = Query(..., description="股票代码,多个用逗号分隔"), + start_date: str = Query(..., description="开始报告期(YYYYMMDD)"), + end_date: str = Query(..., description="结束报告期(YYYYMMDD)"), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """获取资产负债表""" + service = FinanceService(db) + code_list = [c.strip() for c in codes.split(",")] + start = parse_date(start_date) + end = parse_date(end_date) + + data = service.get_balance_sheet(code_list, start, end) + return ResponseModel(data=data) + + +@router.get("/cash-flow", response_model=ResponseModel) +async def get_cash_flow( + codes: str = Query(..., description="股票代码,多个用逗号分隔"), + start_date: str = Query(..., description="开始报告期(YYYYMMDD)"), + end_date: str = Query(..., description="结束报告期(YYYYMMDD)"), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """获取现金流量表""" + service = FinanceService(db) + code_list = [c.strip() for c in codes.split(",")] + start = parse_date(start_date) + end = parse_date(end_date) + + data = service.get_cash_flow(code_list, start, end) + return ResponseModel(data=data) + + +@router.get("/income", response_model=ResponseModel) +async def get_income( + codes: str = Query(..., description="股票代码,多个用逗号分隔"), + start_date: str = Query(..., description="开始报告期(YYYYMMDD)"), + end_date: str = Query(..., description="结束报告期(YYYYMMDD)"), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """获取利润表""" + service = FinanceService(db) + code_list = [c.strip() for c in codes.split(",")] + start = parse_date(start_date) + end = parse_date(end_date) + + data = service.get_income_statement(code_list, start, end) + return ResponseModel(data=data) + + +@router.get("/profit-express", response_model=ResponseModel) +async def get_profit_express( + codes: str = Query(..., description="股票代码,多个用逗号分隔"), + start_date: str = Query(..., description="开始报告期(YYYYMMDD)"), + end_date: str = Query(..., description="结束报告期(YYYYMMDD)"), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """获取业绩快报""" + service = FinanceService(db) + code_list = [c.strip() for c in codes.split(",")] + start = parse_date(start_date) + end = parse_date(end_date) + + # 从SDK获取业绩快报 + try: + adapter = service.base_service._get_adapter() + data = adapter.get_profit_express(code_list, start, end) + return ResponseModel(data=data.to_dict("records") if not data.empty else []) + except Exception as e: + return ResponseModel(data=[], message=str(e)) + + +@router.get("/profit-notice", response_model=ResponseModel) +async def get_profit_notice( + codes: str = Query(..., description="股票代码,多个用逗号分隔"), + start_date: str = Query(..., description="开始报告期(YYYYMMDD)"), + end_date: str = Query(..., description="结束报告期(YYYYMMDD)"), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """获取业绩预告""" + service = FinanceService(db) + code_list = [c.strip() for c in codes.split(",")] + start = parse_date(start_date) + end = parse_date(end_date) + + try: + adapter = service.base_service._get_adapter() + data = adapter.get_profit_notice(code_list, start, end) + return ResponseModel(data=data.to_dict("records") if not data.empty else []) + except Exception as e: + return ResponseModel(data=[], message=str(e)) diff --git a/backend/app/api/v1/future.py b/backend/app/api/v1/future.py new file mode 100644 index 0000000..ce59c30 --- /dev/null +++ b/backend/app/api/v1/future.py @@ -0,0 +1,79 @@ +""" +期货数据路由 +""" +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from app.db.session import get_db +from app.schemas.base import ResponseModel +from app.schemas.kline import BatchKlineRequest +from app.services.future_service import FutureService +from app.core.security import get_current_user +from app.models.user import User +from app.utils.date_utils import parse_date + +router = APIRouter() + + +@router.get("/kline", response_model=ResponseModel) +async def get_future_kline( + codes: str = Query(..., description="期货代码,多个用逗号分隔"), + start_date: str = Query(..., description="开始日期(YYYYMMDD)"), + end_date: str = Query(..., description="结束日期(YYYYMMDD)"), + period: str = Query("daily", description="周期: daily, min1, min5, min15, min30, min60"), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """获取期货K线数据""" + service = FutureService(db) + code_list = [c.strip() for c in codes.split(",")] + start = parse_date(start_date) + end = parse_date(end_date) + + data = service.get_kline(code_list, start, end, period) + return ResponseModel(data=data) + + +@router.post("/kline/batch", response_model=ResponseModel) +async def batch_get_future_kline( + request: BatchKlineRequest, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """批量获取期货K线数据""" + service = FutureService(db) + start = parse_date(request.start_date) + end = parse_date(request.end_date) + + data = service.get_kline(request.codes, start, end, request.period) + return ResponseModel(data=data) + + +@router.get("/kline/{code}/chart", response_model=ResponseModel) +async def get_future_kline_chart( + code: str, + start_date: str = Query(..., description="开始日期(YYYYMMDD)"), + end_date: str = Query(..., description="结束日期(YYYYMMDD)"), + period: str = Query("daily", description="周期: daily, min1, min5, min15, min30, min60"), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """获取期货K线图数据(ECharts格式)""" + service = FutureService(db) + start = parse_date(start_date) + end = parse_date(end_date) + + data = service.get_kline_chart_data(code, start, end, period) + return ResponseModel(data=data) + + +@router.get("/snapshot", response_model=ResponseModel) +async def get_future_snapshot( + codes: str = Query(..., description="期货代码,多个用逗号分隔"), + start_date: str = Query(..., description="开始日期(YYYYMMDD)"), + end_date: str = Query(..., description="结束日期(YYYYMMDD)"), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """获取期货历史快照数据""" + return ResponseModel(data={"message": "功能开发中"}) diff --git a/backend/app/api/v1/realtime.py b/backend/app/api/v1/realtime.py new file mode 100644 index 0000000..fa23adb --- /dev/null +++ b/backend/app/api/v1/realtime.py @@ -0,0 +1,109 @@ +""" +实时数据路由 +""" +from fastapi import APIRouter, Depends, Query, WebSocket, WebSocketDisconnect +from sqlalchemy.orm import Session + +from app.db.session import get_db +from app.schemas.base import ResponseModel +from app.services.realtime_service import RealtimeService +from app.core.security import get_current_user +from app.models.user import User + +router = APIRouter() + + +@router.get("/snapshot", response_model=ResponseModel) +async def get_realtime_snapshot( + codes: str = Query(..., description="代码列表,多个用逗号分隔"), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """获取最新快照数据""" + service = RealtimeService(db) + code_list = [c.strip() for c in codes.split(",")] + data = service.get_latest_snapshot(code_list) + return ResponseModel(data=data) + + +@router.post("/subscribe", response_model=ResponseModel) +async def subscribe_realtime( + codes: str = Query(..., description="代码列表,多个用逗号分隔"), + types: str = Query("snapshot", description="订阅类型: snapshot, kline"), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """开始实时数据订阅""" + code_list = [c.strip() for c in codes.split(",")] + return ResponseModel(data={ + "message": "订阅成功", + "codes": code_list, + "types": types.split(",") + }) + + +@router.delete("/subscribe", response_model=ResponseModel) +async def unsubscribe_realtime( + codes: str = Query(None, description="代码列表,多个用逗号分隔,为空则取消所有"), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """停止实时数据订阅""" + code_list = [c.strip() for c in codes.split(",")] if codes else None + return ResponseModel(data={ + "message": "取消订阅成功", + "codes": code_list + }) + + +@router.get("/subscribe/status", response_model=ResponseModel) +async def get_subscribe_status( + current_user: User = Depends(get_current_user) +): + """获取订阅状态""" + return ResponseModel(data={ + "subscribed_codes": [], + "subscribed_types": [] + }) + + +@router.websocket("/stream") +async def realtime_websocket(websocket: WebSocket): + """WebSocket实时数据流""" + # 获取查询参数 + query_params = websocket.query_params + codes_str = query_params.get("codes", "") + types_str = query_params.get("types", "snapshot") + + codes = [c.strip() for c in codes_str.split(",") if c.strip()] + + if not codes: + await websocket.close(code=4000, reason="Missing codes parameter") + return + + # 创建数据库会话 + from app.db.session import SessionLocal + db = SessionLocal() + + try: + service = RealtimeService(db) + await service.subscribe_websocket(websocket, codes) + + # 保持连接 + while True: + try: + # 接收客户端消息(心跳检测) + data = await websocket.receive_text() + # 回复心跳 + await websocket.send_json({"type": "heartbeat", "timestamp": datetime.utcnow().isoformat()}) + except WebSocketDisconnect: + break + except Exception as e: + break + + finally: + await service.unsubscribe_websocket(websocket, codes) + db.close() + + +from datetime import datetime diff --git a/backend/app/api/v1/stock.py b/backend/app/api/v1/stock.py new file mode 100644 index 0000000..c53a320 --- /dev/null +++ b/backend/app/api/v1/stock.py @@ -0,0 +1,81 @@ +""" +股票数据路由 +""" +from typing import List +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from app.db.session import get_db +from app.schemas.base import ResponseModel +from app.schemas.kline import KlineRequest, BatchKlineRequest +from app.services.stock_service import StockService +from app.core.security import get_current_user +from app.models.user import User +from app.utils.date_utils import parse_date + +router = APIRouter() + + +@router.get("/kline", response_model=ResponseModel) +async def get_stock_kline( + codes: str = Query(..., description="股票代码,多个用逗号分隔"), + start_date: str = Query(..., description="开始日期(YYYYMMDD)"), + end_date: str = Query(..., description="结束日期(YYYYMMDD)"), + period: str = Query("daily", description="周期: daily, min1, min5, min15, min30, min60"), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """获取股票K线数据""" + service = StockService(db) + code_list = [c.strip() for c in codes.split(",")] + start = parse_date(start_date) + end = parse_date(end_date) + + data = service.get_kline(code_list, start, end, period) + return ResponseModel(data=data) + + +@router.post("/kline/batch", response_model=ResponseModel) +async def batch_get_stock_kline( + request: BatchKlineRequest, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """批量获取股票K线数据""" + service = StockService(db) + start = parse_date(request.start_date) + end = parse_date(request.end_date) + + data = service.get_kline(request.codes, start, end, request.period) + return ResponseModel(data=data) + + +@router.get("/kline/{code}/chart", response_model=ResponseModel) +async def get_stock_kline_chart( + code: str, + start_date: str = Query(..., description="开始日期(YYYYMMDD)"), + end_date: str = Query(..., description="结束日期(YYYYMMDD)"), + period: str = Query("daily", description="周期: daily, min1, min5, min15, min30, min60"), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """获取股票K线图数据(ECharts格式)""" + service = StockService(db) + start = parse_date(start_date) + end = parse_date(end_date) + + data = service.get_kline_chart_data(code, start, end, period) + return ResponseModel(data=data) + + +@router.get("/snapshot", response_model=ResponseModel) +async def get_stock_snapshot( + codes: str = Query(..., description="股票代码,多个用逗号分隔"), + start_date: str = Query(..., description="开始日期(YYYYMMDD)"), + end_date: str = Query(..., description="结束日期(YYYYMMDD)"), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """获取股票历史快照数据""" + # 这里可以实现快照数据查询 + return ResponseModel(data={"message": "功能开发中"}) diff --git a/backend/app/api/v1/test.py b/backend/app/api/v1/test.py new file mode 100644 index 0000000..7a1a0f9 --- /dev/null +++ b/backend/app/api/v1/test.py @@ -0,0 +1,123 @@ +""" +测试中心路由 +""" +from typing import List +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from app.db.session import get_db +from app.schemas.base import ResponseModel +from app.schemas.test import TestRequest, RunAllTestsRequest +from app.services.test_service import TestService +from app.core.security import get_current_user +from app.models.user import User + +router = APIRouter() + + +@router.get("/categories", response_model=ResponseModel) +async def get_test_categories( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """获取测试分类""" + service = TestService(db) + categories = service.get_categories() + return ResponseModel(data=categories) + + +@router.get("/endpoints", response_model=ResponseModel) +async def get_test_endpoints( + category: str = Query(None, description="分类筛选"), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """获取测试端点列表""" + service = TestService(db) + endpoints = service.get_endpoints(category) + return ResponseModel(data=endpoints) + + +@router.post("/run", response_model=ResponseModel) +async def run_single_test( + request: TestRequest, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """执行单个接口测试""" + service = TestService(db) + result = service.run_test(request.endpoint, request.method, request.params or {}) + return ResponseModel(data=result) + + +@router.post("/run-all", response_model=ResponseModel) +async def run_all_tests( + request: RunAllTestsRequest, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """执行全部接口测试""" + service = TestService(db) + result = service.run_all_tests(request.categories) + return ResponseModel(data=result) + + +@router.get("/history", response_model=ResponseModel) +async def get_test_history( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """获取测试历史记录""" + service = TestService(db) + result = service.get_test_history(page, page_size) + + return ResponseModel(data={ + "items": [{ + "id": log.id, + "test_name": log.test_name, + "api_category": log.api_category, + "api_endpoint": log.api_endpoint, + "request_method": log.request_method, + "status_code": log.status_code, + "execution_time_ms": log.execution_time_ms, + "is_success": log.is_success, + "error_message": log.error_message, + "created_at": log.created_at.isoformat() if log.created_at else None + } for log in result["items"]], + "total": result["total"], + "page": result["page"], + "page_size": result["page_size"], + "total_pages": result["total_pages"] + }) + + +@router.get("/history/{test_id}", response_model=ResponseModel) +async def get_test_detail( + test_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """获取单次测试详情""" + from app.models.test import APITestLog + + log = db.query(APITestLog).filter(APITestLog.id == test_id).first() + + if not log: + return ResponseModel(code=404, message="测试记录不存在") + + return ResponseModel(data={ + "id": log.id, + "test_name": log.test_name, + "api_category": log.api_category, + "api_endpoint": log.api_endpoint, + "request_method": log.request_method, + "request_params": log.request_params, + "response_data": log.response_data, + "status_code": log.status_code, + "execution_time_ms": log.execution_time_ms, + "is_success": log.is_success, + "error_message": log.error_message, + "created_at": log.created_at.isoformat() if log.created_at else None + }) diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..543ff15 --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,54 @@ +""" +应用配置模块 +""" +import os +from typing import Optional +from pydantic_settings import BaseSettings +from pydantic import Field + + +class Settings(BaseSettings): + """应用配置类""" + + # 应用配置 + APP_NAME: str = "AmazingData金融数据服务平台" + APP_VERSION: str = "1.0.0" + DEBUG: bool = Field(default=True, env="DEBUG") + + # 数据库配置 - 使用SQLite简化演示 + DATABASE_URL: str = Field( + default="sqlite:///./amazing_data.db", + env="DATABASE_URL" + ) + + # Redis配置 + REDIS_URL: str = Field( + default="redis://localhost:6379/0", + env="REDIS_URL" + ) + + # JWT配置 + SECRET_KEY: str = Field( + default="your-secret-key-change-in-production", + env="SECRET_KEY" + ) + ACCESS_TOKEN_EXPIRE_HOURS: int = Field(default=24, env="ACCESS_TOKEN_EXPIRE_HOURS") + ALGORITHM: str = "HS256" + + # 缓存配置 + CACHE_DEFAULT_PERIOD: str = "daily" + CACHE_DEFAULT_DAYS: int = 365 + CACHE_AUTO_CLEANUP_DAYS: int = 7 + CACHE_BATCH_SIZE: int = 100 + CACHE_MISSING_THRESHOLD: float = 0.1 + + # 实时数据配置 + REALTIME_SUBSCRIBE_INTERVAL: int = 1000 + + class Config: + env_file = ".env" + case_sensitive = True + + +# 全局配置实例 +settings = Settings() diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..d9e76dd --- /dev/null +++ b/backend/app/core/__init__.py @@ -0,0 +1,16 @@ +# 核心模块 +from app.core.security import ( + verify_password, + get_password_hash, + create_access_token, + decode_token, + get_current_user +) + +__all__ = [ + "verify_password", + "get_password_hash", + "create_access_token", + "decode_token", + "get_current_user", +] diff --git a/backend/app/core/exceptions.py b/backend/app/core/exceptions.py new file mode 100644 index 0000000..c4ff69c --- /dev/null +++ b/backend/app/core/exceptions.py @@ -0,0 +1,82 @@ +""" +异常处理模块 +""" +from fastapi import Request +from fastapi.responses import JSONResponse +from fastapi.exceptions import RequestValidationError +from sqlalchemy.exc import SQLAlchemyError + + +class BusinessException(Exception): + """业务异常""" + def __init__(self, code: int, message: str): + self.code = code + self.message = message + super().__init__(message) + + +class SDKException(Exception): + """SDK异常""" + def __init__(self, message: str, original_error: Exception = None): + self.message = message + self.original_error = original_error + super().__init__(message) + + +async def business_exception_handler(request: Request, exc: BusinessException): + """业务异常处理器""" + return JSONResponse( + status_code=200, + content={ + "code": exc.code, + "message": exc.message, + "data": None, + "timestamp": datetime.utcnow().isoformat() + } + ) + + +async def validation_exception_handler(request: Request, exc: RequestValidationError): + """参数验证异常处理器""" + errors = [] + for error in exc.errors(): + errors.append(f"{'.'.join(str(x) for x in error['loc'])}: {error['msg']}") + + return JSONResponse( + status_code=400, + content={ + "code": 400, + "message": f"参数错误: {'; '.join(errors)}", + "data": None, + "timestamp": datetime.utcnow().isoformat() + } + ) + + +async def sqlalchemy_exception_handler(request: Request, exc: SQLAlchemyError): + """数据库异常处理器""" + return JSONResponse( + status_code=500, + content={ + "code": 500, + "message": f"数据库错误: {str(exc)}", + "data": None, + "timestamp": datetime.utcnow().isoformat() + } + ) + + +async def general_exception_handler(request: Request, exc: Exception): + """通用异常处理器""" + return JSONResponse( + status_code=500, + content={ + "code": 500, + "message": f"服务器内部错误: {str(exc)}", + "data": None, + "timestamp": datetime.utcnow().isoformat() + } + ) + + +from datetime import datetime diff --git a/backend/app/core/middleware.py b/backend/app/core/middleware.py new file mode 100644 index 0000000..04fc2a3 --- /dev/null +++ b/backend/app/core/middleware.py @@ -0,0 +1,53 @@ +""" +中间件模块 +""" +import time +import logging +from fastapi import Request +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.middleware.cors import CORSMiddleware + +# 配置日志 +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +class LoggingMiddleware(BaseHTTPMiddleware): + """日志中间件""" + + async def dispatch(self, request: Request, call_next): + start_time = time.time() + + # 记录请求信息 + logger.info(f"Request: {request.method} {request.url.path}") + + response = await call_next(request) + + # 计算处理时间 + process_time = time.time() - start_time + + # 记录响应信息 + logger.info( + f"Response: {request.method} {request.url.path} " + f"- Status: {response.status_code} - Time: {process_time:.3f}s" + ) + + response.headers["X-Process-Time"] = str(process_time) + return response + + +def setup_cors(app): + """配置CORS""" + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # 生产环境应该限制具体域名 + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + +def setup_middleware(app): + """设置所有中间件""" + setup_cors(app) + app.add_middleware(LoggingMiddleware) diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000..e37d59b --- /dev/null +++ b/backend/app/core/security.py @@ -0,0 +1,92 @@ +""" +安全模块 - JWT认证和密码处理 +""" +from datetime import datetime, timedelta +from typing import Optional +from jose import JWTError, jwt +import bcrypt +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from sqlalchemy.orm import Session + +from app.config import settings +from app.db.session import get_db +from app.models.user import User + +security = HTTPBearer() + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """验证密码""" + return bcrypt.checkpw(plain_password.encode('utf-8'), hashed_password.encode('utf-8')) + + +def get_password_hash(password: str) -> str: + """获取密码哈希""" + return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') + + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: + """创建JWT访问令牌""" + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(hours=settings.ACCESS_TOKEN_EXPIRE_HOURS) + + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + return encoded_jwt + + +def decode_token(token: str) -> Optional[dict]: + """解码JWT令牌""" + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) + return payload + except JWTError: + return None + + +async def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(security), + db: Session = Depends(get_db) +) -> User: + """获取当前用户""" + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="无效的认证凭据", + headers={"WWW-Authenticate": "Bearer"}, + ) + + token = credentials.credentials + payload = decode_token(token) + + if payload is None: + raise credentials_exception + + username: str = payload.get("sub") + if username is None: + raise credentials_exception + + user = db.query(User).filter(User.username == username).first() + if user is None: + raise credentials_exception + + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="用户已被禁用" + ) + + return user + + +async def get_current_active_superuser(current_user: User = Depends(get_current_user)) -> User: + """获取当前超级用户""" + if not current_user.is_superuser: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="需要超级用户权限" + ) + return current_user diff --git a/backend/app/db/__init__.py b/backend/app/db/__init__.py new file mode 100644 index 0000000..db31aab --- /dev/null +++ b/backend/app/db/__init__.py @@ -0,0 +1,5 @@ +# 数据库模块 +from app.db.base import Base +from app.db.session import get_db, engine, SessionLocal + +__all__ = ["Base", "get_db", "engine", "SessionLocal"] diff --git a/backend/app/db/base.py b/backend/app/db/base.py new file mode 100644 index 0000000..5cf9cd6 --- /dev/null +++ b/backend/app/db/base.py @@ -0,0 +1,9 @@ +""" +SQLAlchemy基础模型 +""" +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + """基础模型类""" + pass diff --git a/backend/app/db/session.py b/backend/app/db/session.py new file mode 100644 index 0000000..56481ec --- /dev/null +++ b/backend/app/db/session.py @@ -0,0 +1,68 @@ +""" +数据库会话管理 +""" +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, Session +from typing import Generator + +from app.config import settings +from app.db.base import Base + +# 创建数据库引擎 +if settings.DATABASE_URL.startswith("sqlite"): + engine = create_engine( + settings.DATABASE_URL, + connect_args={"check_same_thread": False}, + echo=settings.DEBUG + ) +else: + engine = create_engine( + settings.DATABASE_URL, + pool_pre_ping=True, + pool_size=10, + max_overflow=20, + echo=settings.DEBUG + ) + +# 创建会话工厂 +SessionLocal = sessionmaker( + autocommit=False, + autoflush=False, + bind=engine +) + + +def get_db() -> Generator[Session, None, None]: + """获取数据库会话的依赖函数""" + db = SessionLocal() + try: + yield db + finally: + db.close() + + +def init_db() -> None: + """初始化数据库表""" + from app.models import user, config, stock, future, realtime, finance, cache, test + from app.models.user import User + from app.core.security import get_password_hash + Base.metadata.create_all(bind=engine) + + db = SessionLocal() + try: + existing_admin = db.query(User).filter(User.username == "admin").first() + if not existing_admin: + admin_user = User( + username="admin", + password_hash=get_password_hash("admin123"), + is_active=True, + is_superuser=True + ) + db.add(admin_user) + db.commit() + print("Default admin user created (admin/admin123)") + except Exception as e: + print(f"Error creating default user: {e}") + db.rollback() + finally: + db.close() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..fa72160 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,97 @@ +""" +FastAPI主入口 +""" +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse, FileResponse +from fastapi.staticfiles import StaticFiles +from contextlib import asynccontextmanager +import os + +from app.config import settings +from app.api.v1 import api_router +from app.core.middleware import setup_middleware +from app.core.exceptions import ( + business_exception_handler, + validation_exception_handler, + sqlalchemy_exception_handler, + general_exception_handler, + BusinessException +) +from app.db.session import init_db + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """应用生命周期管理""" + # 启动时执行 + print(f"Starting {settings.APP_NAME}...") + + # 初始化数据库 + try: + init_db() + print("Database initialized successfully") + except Exception as e: + print(f"Database initialization warning: {e}") + + yield + + # 关闭时执行 + print(f"Shutting down {settings.APP_NAME}...") + + +# 创建FastAPI应用 +app = FastAPI( + title=settings.APP_NAME, + version=settings.APP_VERSION, + description="AmazingData金融数据服务平台API", + docs_url="/docs", + redoc_url="/redoc", + lifespan=lifespan +) + +# 设置中间件 +setup_middleware(app) + +# 注册异常处理器 +app.add_exception_handler(BusinessException, business_exception_handler) + +# 注册路由 +app.include_router(api_router) + +# 挂载静态文件 +static_dir = os.path.join(os.path.dirname(__file__), "static") +if os.path.exists(static_dir): + app.mount("/static", StaticFiles(directory=static_dir), name="static") + + +@app.get("/", tags=["根路径"]) +async def root(): + """根路径 - 返回前端页面""" + index_file = os.path.join(static_dir, "index.html") + if os.path.exists(index_file): + return FileResponse(index_file) + return { + "name": settings.APP_NAME, + "version": settings.APP_VERSION, + "docs": "/docs", + "api": "/api/v1" + } + + +@app.get("/health", tags=["健康检查"]) +async def health_check(): + """健康检查""" + return { + "status": "healthy", + "version": settings.APP_VERSION + } + + +if __name__ == "__main__": + import uvicorn + uvicorn.run( + "app.main:app", + host="0.0.0.0", + port=8000, + reload=settings.DEBUG + ) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..cf59865 --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,28 @@ +# 模型模块 +from app.models.user import User +from app.models.config import SDKConfig, SystemConfig +from app.models.stock import StockInfo, StockKlineDaily, StockKlineMin +from app.models.future import FutureInfo, FutureKlineDaily, FutureKlineMin +from app.models.realtime import RealtimeSnapshot +from app.models.finance import FinanceBalanceSheet, FinanceCashFlow, FinanceIncome +from app.models.cache import CacheTask, CacheTaskDetail +from app.models.test import APITestLog + +__all__ = [ + "User", + "SDKConfig", + "SystemConfig", + "StockInfo", + "StockKlineDaily", + "StockKlineMin", + "FutureInfo", + "FutureKlineDaily", + "FutureKlineMin", + "RealtimeSnapshot", + "FinanceBalanceSheet", + "FinanceCashFlow", + "FinanceIncome", + "CacheTask", + "CacheTaskDetail", + "APITestLog", +] diff --git a/backend/app/models/cache.py b/backend/app/models/cache.py new file mode 100644 index 0000000..2c3f418 --- /dev/null +++ b/backend/app/models/cache.py @@ -0,0 +1,52 @@ +""" +缓存任务模型 +""" +from datetime import datetime, date +from sqlalchemy import Column, Integer, BigInteger, String, Numeric, Text, Date, DateTime, ForeignKey +from sqlalchemy.orm import relationship +from app.db.base import Base + + +class CacheTask(Base): + """缓存任务表""" + __tablename__ = "cache_tasks" + + id = Column(Integer, primary_key=True, index=True) + task_name = Column(String(200), nullable=False) + task_type = Column(String(50), nullable=False) # detect_missing, cache_data, sync_data + security_type = Column(String(20), nullable=False) # stock, future, index + period_type = Column(String(10)) # daily, min1, min5, etc. + start_date = Column(Date, nullable=False) + end_date = Column(Date, nullable=False) + code_list = Column(Text) # 逗号分隔的代码列表 + status = Column(String(20), default="pending") # pending, running, completed, failed, cancelled + progress = Column(Numeric(5, 2), default=0) + total_count = Column(Integer, default=0) + success_count = Column(Integer, default=0) + error_count = Column(Integer, default=0) + error_message = Column(Text) + created_by = Column(Integer, ForeignKey("users.id")) + created_at = Column(DateTime(timezone=True), default=datetime.utcnow) + started_at = Column(DateTime(timezone=True)) + completed_at = Column(DateTime(timezone=True)) + + details = relationship("CacheTaskDetail", back_populates="task", cascade="all, delete-orphan") + + +class CacheTaskDetail(Base): + """缓存任务详情表""" + __tablename__ = "cache_task_details" + + id = Column(BigInteger, primary_key=True, index=True) + task_id = Column(Integer, ForeignKey("cache_tasks.id", ondelete="CASCADE"), nullable=False, index=True) + code = Column(String(20), nullable=False, index=True) + trade_date = Column(Date, nullable=False) + expected_count = Column(Integer, default=0) + actual_count = Column(Integer, default=0) + is_missing = Column(Integer, default=0) + status = Column(String(20), default="pending") # pending, success, failed, skipped + error_message = Column(Text) + processed_at = Column(DateTime(timezone=True)) + created_at = Column(DateTime(timezone=True), default=datetime.utcnow) + + task = relationship("CacheTask", back_populates="details") diff --git a/backend/app/models/config.py b/backend/app/models/config.py new file mode 100644 index 0000000..3a79642 --- /dev/null +++ b/backend/app/models/config.py @@ -0,0 +1,36 @@ +""" +配置模型 +""" +from datetime import datetime +from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text +from app.db.base import Base + + +class SDKConfig(Base): + """SDK配置表""" + __tablename__ = "sdk_configs" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(100), nullable=False) + username = Column(String(100), nullable=False) + password = Column(String(255), nullable=False) + host = Column(String(100), nullable=False) + port = Column(Integer, nullable=False, default=8080) + local_path = Column(String(255), default="./amazing_data_cache/") + is_active = Column(Boolean, default=True) + is_default = Column(Boolean, default=False) + description = Column(Text) + created_at = Column(DateTime(timezone=True), default=datetime.utcnow) + updated_at = Column(DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow) + + +class SystemConfig(Base): + """系统配置表""" + __tablename__ = "system_configs" + + id = Column(Integer, primary_key=True, index=True) + config_key = Column(String(100), unique=True, nullable=False) + config_value = Column(Text, nullable=False) + description = Column(Text) + created_at = Column(DateTime(timezone=True), default=datetime.utcnow) + updated_at = Column(DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow) diff --git a/backend/app/models/finance.py b/backend/app/models/finance.py new file mode 100644 index 0000000..066e5e7 --- /dev/null +++ b/backend/app/models/finance.py @@ -0,0 +1,87 @@ +""" +财务数据模型 +""" +from datetime import datetime, date +from sqlalchemy import Column, Integer, String, BigInteger, Numeric, Date, DateTime +from app.db.base import Base + + +class FinanceBalanceSheet(Base): + """资产负债表""" + __tablename__ = "finance_balance_sheet" + + id = Column(BigInteger, primary_key=True, index=True) + code = Column(String(20), nullable=False, index=True) + report_date = Column(Date, nullable=False, index=True) + report_type = Column(Integer) + statement_type = Column(Integer) + + # 资产 + total_assets = Column(Numeric(18, 4)) + total_cur_assets = Column(Numeric(18, 4)) + total_noncur_assets = Column(Numeric(18, 4)) + currency_cap = Column(Numeric(18, 4)) + notes_receivable = Column(Numeric(18, 4)) + acct_receivable = Column(Numeric(18, 4)) + inventory = Column(Numeric(18, 4)) + fix_assets = Column(Numeric(18, 4)) + + # 负债 + total_liab = Column(Numeric(18, 4)) + total_cur_liab = Column(Numeric(18, 4)) + total_noncur_liab = Column(Numeric(18, 4)) + notes_payable = Column(Numeric(18, 4)) + acct_payable = Column(Numeric(18, 4)) + st_borrowing = Column(Numeric(18, 4)) + lt_loan = Column(Numeric(18, 4)) + + # 权益 + tot_share_equity = Column(Numeric(18, 4)) + + created_at = Column(DateTime(timezone=True), default=datetime.utcnow) + + +class FinanceCashFlow(Base): + """现金流量表""" + __tablename__ = "finance_cash_flow" + + id = Column(BigInteger, primary_key=True, index=True) + code = Column(String(20), nullable=False, index=True) + report_date = Column(Date, nullable=False, index=True) + report_type = Column(Integer) + statement_type = Column(Integer) + + net_cash_flows_opera_act = Column(Numeric(18, 4)) + net_cash_flows_inv_act = Column(Numeric(18, 4)) + net_cash_flows_fin_act = Column(Numeric(18, 4)) + net_incr_cash_and_cash_equ = Column(Numeric(18, 4)) + cash_recp_sg_and_rs = Column(Numeric(18, 4)) + cash_pay_goods_services = Column(Numeric(18, 4)) + + created_at = Column(DateTime(timezone=True), default=datetime.utcnow) + + +class FinanceIncome(Base): + """利润表""" + __tablename__ = "finance_income" + + id = Column(BigInteger, primary_key=True, index=True) + code = Column(String(20), nullable=False, index=True) + report_date = Column(Date, nullable=False, index=True) + report_type = Column(Integer) + statement_type = Column(Integer) + + tot_opera_rev = Column(Numeric(18, 4)) + opera_rev = Column(Numeric(18, 4)) + tot_opera_cost = Column(Numeric(18, 4)) + opera_profit = Column(Numeric(18, 4)) + total_profit = Column(Numeric(18, 4)) + net_pro_incl_min_int_inc = Column(Numeric(18, 4)) + basic_eps = Column(Numeric(12, 6)) + diluted_eps = Column(Numeric(12, 6)) + rd_exp = Column(Numeric(18, 4)) + selling_exp = Column(Numeric(18, 4)) + admin_exp = Column(Numeric(18, 4)) + fin_exp = Column(Numeric(18, 4)) + + created_at = Column(DateTime(timezone=True), default=datetime.utcnow) diff --git a/backend/app/models/future.py b/backend/app/models/future.py new file mode 100644 index 0000000..8163324 --- /dev/null +++ b/backend/app/models/future.py @@ -0,0 +1,66 @@ +""" +期货数据模型 +""" +from datetime import datetime, date +from sqlalchemy import Column, Integer, String, BigInteger, Numeric, Boolean, Date, DateTime +from app.db.base import Base + + +class FutureInfo(Base): + """期货基础信息表""" + __tablename__ = "future_info" + + id = Column(Integer, primary_key=True, index=True) + code = Column(String(20), unique=True, nullable=False, index=True) + symbol = Column(String(100), nullable=False) + underlying = Column(String(20)) + contract_month = Column(String(10)) + pre_close = Column(Numeric(12, 4)) + high_limited = Column(Numeric(12, 4)) + low_limited = Column(Numeric(12, 4)) + price_tick = Column(Numeric(10, 4)) + exchange = Column(String(10), default="CFE") + list_date = Column(Date) + expire_date = Column(Date) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime(timezone=True), default=datetime.utcnow) + updated_at = Column(DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow) + + +class FutureKlineDaily(Base): + """期货日线数据表""" + __tablename__ = "future_kline_daily" + + id = Column(BigInteger, primary_key=True, index=True) + code = Column(String(20), nullable=False, index=True) + trade_date = Column(Date, nullable=False, index=True) + open = Column(Numeric(12, 4), nullable=False) + high = Column(Numeric(12, 4), nullable=False) + low = Column(Numeric(12, 4), nullable=False) + close = Column(Numeric(12, 4), nullable=False) + volume = Column(BigInteger, nullable=False) + amount = Column(Numeric(18, 4), nullable=False) + settle = Column(Numeric(12, 4)) + open_interest = Column(BigInteger) + pre_settle = Column(Numeric(12, 4)) + created_at = Column(DateTime(timezone=True), default=datetime.utcnow) + updated_at = Column(DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow) + + +class FutureKlineMin(Base): + """期货分钟数据表""" + __tablename__ = "future_kline_min" + + id = Column(BigInteger, primary_key=True, index=True) + code = Column(String(20), nullable=False, index=True) + period_type = Column(String(10), nullable=False) + trade_datetime = Column(DateTime, nullable=False, index=True) + open = Column(Numeric(12, 4), nullable=False) + high = Column(Numeric(12, 4), nullable=False) + low = Column(Numeric(12, 4), nullable=False) + close = Column(Numeric(12, 4), nullable=False) + volume = Column(BigInteger, nullable=False) + amount = Column(Numeric(18, 4), nullable=False) + settle = Column(Numeric(12, 4)) + open_interest = Column(BigInteger) + created_at = Column(DateTime(timezone=True), default=datetime.utcnow) diff --git a/backend/app/models/realtime.py b/backend/app/models/realtime.py new file mode 100644 index 0000000..58c652a --- /dev/null +++ b/backend/app/models/realtime.py @@ -0,0 +1,60 @@ +""" +实时数据模型 +""" +from datetime import datetime +from sqlalchemy import Column, Integer, String, BigInteger, Numeric, DateTime +from app.db.base import Base + + +class RealtimeSnapshot(Base): + """实时快照数据表""" + __tablename__ = "realtime_snapshot" + + id = Column(BigInteger, primary_key=True, index=True) + code = Column(String(20), nullable=False, index=True) + security_type = Column(String(20), nullable=False) # stock, future, index, etf, kzz, option + trade_time = Column(DateTime, nullable=False, index=True) + + # 价格数据 + pre_close = Column(Numeric(12, 4)) + last = Column(Numeric(12, 4)) + open = Column(Numeric(12, 4)) + high = Column(Numeric(12, 4)) + low = Column(Numeric(12, 4)) + close = Column(Numeric(12, 4)) + volume = Column(BigInteger) + amount = Column(Numeric(18, 4)) + + # 盘口数据 - 卖盘 + ask_price1 = Column(Numeric(12, 4)) + ask_price2 = Column(Numeric(12, 4)) + ask_price3 = Column(Numeric(12, 4)) + ask_price4 = Column(Numeric(12, 4)) + ask_price5 = Column(Numeric(12, 4)) + ask_volume1 = Column(Integer) + ask_volume2 = Column(Integer) + ask_volume3 = Column(Integer) + ask_volume4 = Column(Integer) + ask_volume5 = Column(Integer) + + # 盘口数据 - 买盘 + bid_price1 = Column(Numeric(12, 4)) + bid_price2 = Column(Numeric(12, 4)) + bid_price3 = Column(Numeric(12, 4)) + bid_price4 = Column(Numeric(12, 4)) + bid_price5 = Column(Numeric(12, 4)) + bid_volume1 = Column(Integer) + bid_volume2 = Column(Integer) + bid_volume3 = Column(Integer) + bid_volume4 = Column(Integer) + bid_volume5 = Column(Integer) + + # 期货特有字段 + settle = Column(Numeric(12, 4)) + open_interest = Column(BigInteger) + pre_settle = Column(Numeric(12, 4)) + average_price = Column(Numeric(12, 4)) + trading_phase_code = Column(String(10)) + + created_at = Column(DateTime(timezone=True), default=datetime.utcnow) + expires_at = Column(DateTime(timezone=True), nullable=False, index=True) diff --git a/backend/app/models/stock.py b/backend/app/models/stock.py new file mode 100644 index 0000000..cd6d7ca --- /dev/null +++ b/backend/app/models/stock.py @@ -0,0 +1,61 @@ +""" +股票数据模型 +""" +from datetime import datetime, date +from sqlalchemy import Column, Integer, String, BigInteger, Numeric, Boolean, Date, DateTime +from app.db.base import Base + + +class StockInfo(Base): + """股票基础信息表""" + __tablename__ = "stock_info" + + id = Column(Integer, primary_key=True, index=True) + code = Column(String(20), unique=True, nullable=False, index=True) + symbol = Column(String(100), nullable=False) + security_status = Column(Integer) + pre_close = Column(Numeric(12, 4)) + high_limited = Column(Numeric(12, 4)) + low_limited = Column(Numeric(12, 4)) + price_tick = Column(Numeric(10, 4)) + exchange = Column(String(10)) # SH, SZ, BJ + industry = Column(String(50)) + list_date = Column(Date) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime(timezone=True), default=datetime.utcnow) + updated_at = Column(DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow) + + +class StockKlineDaily(Base): + """股票日线数据表""" + __tablename__ = "stock_kline_daily" + + id = Column(BigInteger, primary_key=True, index=True) + code = Column(String(20), nullable=False, index=True) + trade_date = Column(Date, nullable=False, index=True) + open = Column(Numeric(12, 4), nullable=False) + high = Column(Numeric(12, 4), nullable=False) + low = Column(Numeric(12, 4), nullable=False) + close = Column(Numeric(12, 4), nullable=False) + volume = Column(BigInteger, nullable=False) + amount = Column(Numeric(18, 4), nullable=False) + adj_factor = Column(Numeric(12, 6)) + created_at = Column(DateTime(timezone=True), default=datetime.utcnow) + updated_at = Column(DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow) + + +class StockKlineMin(Base): + """股票分钟数据表""" + __tablename__ = "stock_kline_min" + + id = Column(BigInteger, primary_key=True, index=True) + code = Column(String(20), nullable=False, index=True) + period_type = Column(String(10), nullable=False) # min1, min5, min15, min30, min60 + trade_datetime = Column(DateTime, nullable=False, index=True) + open = Column(Numeric(12, 4), nullable=False) + high = Column(Numeric(12, 4), nullable=False) + low = Column(Numeric(12, 4), nullable=False) + close = Column(Numeric(12, 4), nullable=False) + volume = Column(BigInteger, nullable=False) + amount = Column(Numeric(18, 4), nullable=False) + created_at = Column(DateTime(timezone=True), default=datetime.utcnow) diff --git a/backend/app/models/test.py b/backend/app/models/test.py new file mode 100644 index 0000000..a4c01a7 --- /dev/null +++ b/backend/app/models/test.py @@ -0,0 +1,38 @@ +""" +测试日志模型 +""" +from datetime import datetime +from sqlalchemy import Column, BigInteger, String, Integer, Boolean, Text, DateTime, JSON, TypeDecorator, String as SQLString +import json + +class JSONField(TypeDecorator): + impl = SQLString + + def process_bind_param(self, value, dialect): + if value is None: + return None + return json.dumps(value) + + def process_result_value(self, value, dialect): + if value is None: + return None + return json.loads(value) +from app.db.base import Base + + +class APITestLog(Base): + """API测试日志表""" + __tablename__ = "api_test_logs" + + id = Column(BigInteger, primary_key=True, index=True) + test_name = Column(String(200), nullable=False) + api_category = Column(String(50), nullable=False, index=True) + api_endpoint = Column(String(200), nullable=False, index=True) + request_method = Column(String(10), nullable=False) + request_params = Column(JSONField) + response_data = Column(JSONField) + status_code = Column(Integer) + execution_time_ms = Column(Integer) + is_success = Column(Boolean, default=False) + error_message = Column(Text) + created_at = Column(DateTime(timezone=True), default=datetime.utcnow, index=True) diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..f8e6cc9 --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,19 @@ +""" +用户模型 +""" +from datetime import datetime +from sqlalchemy import Column, Integer, String, Boolean, DateTime +from app.db.base import Base + + +class User(Base): + """用户表""" + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + username = Column(String(50), unique=True, nullable=False, index=True) + password_hash = Column(String(255), nullable=False) + is_active = Column(Boolean, default=True) + is_superuser = Column(Boolean, default=False) + created_at = Column(DateTime(timezone=True), default=datetime.utcnow) + updated_at = Column(DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..343db25 --- /dev/null +++ b/backend/app/schemas/__init__.py @@ -0,0 +1,32 @@ +# Schema模块 +from app.schemas.base import ResponseModel, PaginatedResponse +from app.schemas.auth import UserLogin, UserInfo, TokenResponse +from app.schemas.config import SDKConfigCreate, SDKConfigUpdate, SDKConfigResponse +from app.schemas.kline import KlineRequest, KlineResponse, KlineChartData +from app.schemas.finance import FinanceRequest, BalanceSheetResponse, CashFlowResponse, IncomeResponse +from app.schemas.cache import CacheTaskCreate, CacheTaskResponse, CacheStatusResponse +from app.schemas.test import TestRequest, TestResponse, TestHistoryResponse + +__all__ = [ + "ResponseModel", + "PaginatedResponse", + "UserLogin", + "UserInfo", + "TokenResponse", + "SDKConfigCreate", + "SDKConfigUpdate", + "SDKConfigResponse", + "KlineRequest", + "KlineResponse", + "KlineChartData", + "FinanceRequest", + "BalanceSheetResponse", + "CashFlowResponse", + "IncomeResponse", + "CacheTaskCreate", + "CacheTaskResponse", + "CacheStatusResponse", + "TestRequest", + "TestResponse", + "TestHistoryResponse", +] diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py new file mode 100644 index 0000000..b555126 --- /dev/null +++ b/backend/app/schemas/auth.py @@ -0,0 +1,30 @@ +""" +认证相关Schema +""" +from datetime import datetime +from pydantic import BaseModel + + +class UserLogin(BaseModel): + """用户登录请求""" + username: str + password: str + + +class UserInfo(BaseModel): + """用户信息""" + id: int + username: str + is_active: bool + is_superuser: bool + created_at: datetime + + class Config: + from_attributes = True + + +class TokenResponse(BaseModel): + """Token响应""" + access_token: str + token_type: str = "bearer" + expires_in: int diff --git a/backend/app/schemas/base.py b/backend/app/schemas/base.py new file mode 100644 index 0000000..4e9a3dc --- /dev/null +++ b/backend/app/schemas/base.py @@ -0,0 +1,47 @@ +""" +基础Schema +""" +from datetime import datetime +from typing import Optional, TypeVar, Generic, List +from pydantic import BaseModel + +T = TypeVar("T") + + +class ResponseModel(BaseModel, Generic[T]): + """统一响应模型""" + code: int = 200 + message: str = "success" + data: Optional[T] = None + timestamp: datetime = datetime.utcnow() + + class Config: + from_attributes = True + + +class PaginationParams(BaseModel): + """分页参数""" + page: int = 1 + page_size: int = 20 + + +class PaginatedData(BaseModel, Generic[T]): + """分页数据""" + items: List[T] + total: int + page: int + page_size: int + total_pages: int + + +class PaginatedResponse(ResponseModel[PaginatedData[T]], Generic[T]): + """分页响应模型""" + pass + + +class ErrorResponse(BaseModel): + """错误响应""" + code: int + message: str + data: Optional[dict] = None + timestamp: datetime = datetime.utcnow() diff --git a/backend/app/schemas/cache.py b/backend/app/schemas/cache.py new file mode 100644 index 0000000..bff402b --- /dev/null +++ b/backend/app/schemas/cache.py @@ -0,0 +1,108 @@ +""" +缓存管理Schema +""" +from datetime import date, datetime +from typing import List, Optional +from pydantic import BaseModel, Field + + +class CacheTaskCreate(BaseModel): + """创建缓存任务""" + task_name: str + task_type: str = Field(..., description="detect_missing, cache_data, sync_data") + security_type: str = Field(..., description="stock, future, index") + period_type: Optional[str] = Field(default="daily", description="daily, min1, min5, etc.") + start_date: str + end_date: str + code_list: Optional[List[str]] = None + + +class MissingDateInfo(BaseModel): + """缺失日期信息""" + date: str + expected: int + actual: int + missing_ratio: float + + +class MissingCodeInfo(BaseModel): + """缺失代码信息""" + code: str + missing_dates: List[MissingDateInfo] + + +class CacheTaskResponse(BaseModel): + """缓存任务响应""" + id: int + task_name: str + task_type: str + security_type: str + period_type: Optional[str] + start_date: date + end_date: date + status: str + progress: float + total_count: int + success_count: int + error_count: int + error_message: Optional[str] + created_at: datetime + started_at: Optional[datetime] + completed_at: Optional[datetime] + + class Config: + from_attributes = True + + +class CacheTaskDetailResponse(BaseModel): + """缓存任务详情响应""" + id: int + code: str + trade_date: date + expected_count: int + actual_count: int + is_missing: bool + status: str + error_message: Optional[str] + processed_at: Optional[datetime] + + +class CacheTaskWithDetailsResponse(CacheTaskResponse): + """带详情的缓存任务响应""" + details: List[CacheTaskDetailResponse] + + +class CacheStatusResponse(BaseModel): + """代码缓存状态响应""" + code: str + security_type: str + period_type: str + record_count: int + min_date: Optional[str] + max_date: Optional[str] + missing_ratio: float + + +class DetectMissingRequest(BaseModel): + """检测缺失数据请求""" + security_type: str + period_type: str = "daily" + start_date: str + end_date: str + code_list: List[str] + + +class DetectMissingResponse(BaseModel): + """检测缺失数据响应""" + task_id: int + total_codes: int + missing_codes: List[MissingCodeInfo] + + +class BatchCacheRequest(BaseModel): + """批量缓存请求""" + security_type: str + period_type: str = "daily" + start_date: str + end_date: str + code_list: List[str] diff --git a/backend/app/schemas/config.py b/backend/app/schemas/config.py new file mode 100644 index 0000000..1aa4c6e --- /dev/null +++ b/backend/app/schemas/config.py @@ -0,0 +1,52 @@ +""" +配置相关Schema +""" +from datetime import datetime +from typing import Optional +from pydantic import BaseModel, Field + + +class SDKConfigBase(BaseModel): + """SDK配置基础""" + name: str + username: str + host: str + port: int = 8080 + local_path: str = "./amazing_data_cache/" + is_active: bool = True + is_default: bool = False + description: Optional[str] = None + + +class SDKConfigCreate(SDKConfigBase): + """创建SDK配置""" + password: str + + +class SDKConfigUpdate(BaseModel): + """更新SDK配置""" + name: Optional[str] = None + username: Optional[str] = None + password: Optional[str] = None + host: Optional[str] = None + port: Optional[int] = None + local_path: Optional[str] = None + is_active: Optional[bool] = None + is_default: Optional[bool] = None + description: Optional[str] = None + + +class SDKConfigResponse(SDKConfigBase): + """SDK配置响应""" + id: int + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class SDKConfigTestResponse(BaseModel): + """SDK配置测试响应""" + success: bool + message: str diff --git a/backend/app/schemas/finance.py b/backend/app/schemas/finance.py new file mode 100644 index 0000000..bc0e792 --- /dev/null +++ b/backend/app/schemas/finance.py @@ -0,0 +1,86 @@ +""" +财务数据Schema +""" +from datetime import date +from typing import List, Optional, Dict, Any +from pydantic import BaseModel, Field + + +class FinanceRequest(BaseModel): + """财务数据请求""" + codes: str = Field(..., description="股票代码,多个用逗号分隔") + start_date: str = Field(..., description="开始报告期(YYYYMMDD)") + end_date: str = Field(..., description="结束报告期(YYYYMMDD)") + + +class BalanceSheetData(BaseModel): + """资产负债表数据""" + report_date: str + report_type: Optional[int] = None + statement_type: Optional[int] = None + total_assets: Optional[float] = None + total_cur_assets: Optional[float] = None + total_noncur_assets: Optional[float] = None + currency_cap: Optional[float] = None + notes_receivable: Optional[float] = None + acct_receivable: Optional[float] = None + inventory: Optional[float] = None + fix_assets: Optional[float] = None + total_liab: Optional[float] = None + total_cur_liab: Optional[float] = None + total_noncur_liab: Optional[float] = None + notes_payable: Optional[float] = None + acct_payable: Optional[float] = None + st_borrowing: Optional[float] = None + lt_loan: Optional[float] = None + tot_share_equity: Optional[float] = None + + +class BalanceSheetResponse(BaseModel): + """资产负债表响应""" + code: str + data: List[BalanceSheetData] + + +class CashFlowData(BaseModel): + """现金流量表数据""" + report_date: str + report_type: Optional[int] = None + statement_type: Optional[int] = None + net_cash_flows_opera_act: Optional[float] = None + net_cash_flows_inv_act: Optional[float] = None + net_cash_flows_fin_act: Optional[float] = None + net_incr_cash_and_cash_equ: Optional[float] = None + cash_recp_sg_and_rs: Optional[float] = None + cash_pay_goods_services: Optional[float] = None + + +class CashFlowResponse(BaseModel): + """现金流量表响应""" + code: str + data: List[CashFlowData] + + +class IncomeData(BaseModel): + """利润表数据""" + report_date: str + report_type: Optional[int] = None + statement_type: Optional[int] = None + tot_opera_rev: Optional[float] = None + opera_rev: Optional[float] = None + tot_opera_cost: Optional[float] = None + opera_profit: Optional[float] = None + total_profit: Optional[float] = None + net_pro_incl_min_int_inc: Optional[float] = None + basic_eps: Optional[float] = None + diluted_eps: Optional[float] = None + rd_exp: Optional[float] = None + selling_exp: Optional[float] = None + admin_exp: Optional[float] = None + fin_exp: Optional[float] = None + + +class IncomeResponse(BaseModel): + """利润表响应""" + code: str + data: List[IncomeData] diff --git a/backend/app/schemas/kline.py b/backend/app/schemas/kline.py new file mode 100644 index 0000000..d648ea3 --- /dev/null +++ b/backend/app/schemas/kline.py @@ -0,0 +1,66 @@ +""" +K线数据Schema +""" +from datetime import date, datetime +from typing import List, Optional, Dict, Any +from pydantic import BaseModel, Field + + +class KlineRequest(BaseModel): + """K线数据请求""" + codes: str = Field(..., description="股票代码,多个用逗号分隔") + start_date: str = Field(..., description="开始日期(YYYYMMDD)") + end_date: str = Field(..., description="结束日期(YYYYMMDD)") + period: str = Field(default="daily", description="周期: daily, min1, min5, min15, min30, min60") + + +class KlineData(BaseModel): + """单条K线数据""" + trade_date: Optional[str] = None + trade_datetime: Optional[str] = None + open: float + high: float + low: float + close: float + volume: int + amount: float + settle: Optional[float] = None + open_interest: Optional[int] = None + + +class KlineResponse(BaseModel): + """K线数据响应""" + code: str + data: List[KlineData] + + +class KlineChartData(BaseModel): + """K线图数据(ECharts格式)""" + categoryData: List[str] + values: List[List[float]] # [open, close, low, high, volume] + volumes: List[List[float]] # [index, volume, sign] + + +class BatchKlineRequest(BaseModel): + """批量K线请求""" + codes: List[str] + start_date: str + end_date: str + period: str = "daily" + + +class SnapshotData(BaseModel): + """快照数据""" + trade_time: str + pre_close: Optional[float] = None + last: Optional[float] = None + open: Optional[float] = None + high: Optional[float] = None + low: Optional[float] = None + close: Optional[float] = None + volume: Optional[int] = None + amount: Optional[float] = None + ask_price1: Optional[float] = None + ask_volume1: Optional[int] = None + bid_price1: Optional[float] = None + bid_volume1: Optional[int] = None diff --git a/backend/app/schemas/test.py b/backend/app/schemas/test.py new file mode 100644 index 0000000..7e461da --- /dev/null +++ b/backend/app/schemas/test.py @@ -0,0 +1,70 @@ +""" +测试中心Schema +""" +from datetime import datetime +from typing import List, Optional, Dict, Any +from pydantic import BaseModel, Field + + +class TestCategory(BaseModel): + """测试分类""" + key: str + name: str + + +class TestEndpoint(BaseModel): + """测试端点""" + category: str + name: str + endpoint: str + method: str + params: Optional[Dict[str, Any]] = None + description: Optional[str] = None + + +class TestRequest(BaseModel): + """测试请求""" + endpoint: str + method: str = "GET" + params: Optional[Dict[str, Any]] = None + + +class TestResponse(BaseModel): + """测试响应""" + success: bool + endpoint: str + method: str + status_code: Optional[int] = None + execution_time_ms: Optional[int] = None + response_data: Optional[Any] = None + error_message: Optional[str] = None + + +class TestHistoryResponse(BaseModel): + """测试历史响应""" + id: int + test_name: str + api_category: str + api_endpoint: str + request_method: str + status_code: Optional[int] + execution_time_ms: Optional[int] + is_success: bool + error_message: Optional[str] + created_at: datetime + + class Config: + from_attributes = True + + +class RunAllTestsRequest(BaseModel): + """运行全部测试请求""" + categories: List[str] = Field(default=["base_data", "stock", "future", "finance"]) + + +class RunAllTestsResponse(BaseModel): + """运行全部测试响应""" + total: int + passed: int + failed: int + results: List[TestResponse] diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..3e9acf8 --- /dev/null +++ b/backend/app/services/__init__.py @@ -0,0 +1,24 @@ +# 服务模块 +from app.services.auth_service import AuthService +from app.services.config_service import ConfigService +from app.services.amazing_data_adapter import AmazingDataAdapter +from app.services.cache_service import CacheService +from app.services.stock_service import StockService +from app.services.future_service import FutureService +from app.services.realtime_service import RealtimeService +from app.services.finance_service import FinanceService +from app.services.base_data_service import BaseDataService +from app.services.test_service import TestService + +__all__ = [ + "AuthService", + "ConfigService", + "AmazingDataAdapter", + "CacheService", + "StockService", + "FutureService", + "RealtimeService", + "FinanceService", + "BaseDataService", + "TestService", +] diff --git a/backend/app/services/amazing_data_adapter.py b/backend/app/services/amazing_data_adapter.py new file mode 100644 index 0000000..7698201 --- /dev/null +++ b/backend/app/services/amazing_data_adapter.py @@ -0,0 +1,617 @@ +""" +AmazingData SDK适配器 +封装AmazingData SDK的所有接口 +""" +import logging +from typing import List, Dict, Callable, Optional, Tuple +from datetime import date +import pandas as pd + +logger = logging.getLogger(__name__) + + +class AmazingDataAdapter: + """AmazingData SDK适配器""" + + def __init__(self, config: Dict): + """ + 初始化适配器 + + Args: + config: SDK配置字典 + - username: 用户名 + - password: 密码 + - host: 服务器地址 + - port: 端口 + - local_path: 本地缓存路径 + """ + self.config = config + self._ad = None + self._base_data = None + self._market_data = None + self._info_data = None + self._calendar = None + self._is_connected = False + + def connect(self) -> bool: + """ + 建立与AmazingData SDK的连接 + + Returns: + 是否连接成功 + """ + try: + import AmazingData as ad + + ad.login( + username=self.config["username"], + password=self.config["password"], + host=self.config["host"], + port=self.config["port"] + ) + + self._ad = ad + + try: + self._base_data = ad.BaseData() + except Exception as e: + logger.error(f"AmazingData SDK登录验证失败: {str(e)}") + return False + + self._info_data = ad.InfoData() + self._calendar = self._base_data.get_calendar() + self._market_data = ad.MarketData(self._calendar) + + self._is_connected = True + logger.info("AmazingData SDK连接成功") + return True + + except ImportError: + logger.error("AmazingData SDK未安装,请安装SDK后再试") + return False + except Exception as e: + logger.error(f"AmazingData SDK连接失败: {str(e)}") + return False + + def disconnect(self): + """断开SDK连接""" + if self._is_connected and self._ad: + try: + self._ad.logout(self.config["username"]) + except Exception as e: + logger.warning(f"断开SDK连接时出错: {str(e)}") + + self._is_connected = False + self._ad = None + self._base_data = None + self._market_data = None + self._info_data = None + self._calendar = None + logger.info("AmazingData SDK已断开") + + def is_connected(self) -> bool: + """检查是否已连接""" + return self._is_connected + + # ==================== 基础数据接口 ==================== + + def get_code_list(self, security_type: str) -> List[str]: + """ + 获取代码列表 + + Args: + security_type: 证券类型 + - EXTRA_STOCK_A: 沪深A股 + - EXTRA_FUTURE: 期货 + - EXTRA_ETF: ETF + - EXTRA_INDEX_A: 指数 + + Returns: + 代码列表 + """ + if not self._is_connected: + raise RuntimeError("SDK未连接") + + try: + return self._base_data.get_code_list(security_type) + except Exception as e: + logger.error(f"获取代码列表失败: {str(e)}") + return [] + + def get_code_info(self, security_type: str) -> pd.DataFrame: + """ + 获取证券信息 + + Args: + security_type: 证券类型 + + Returns: + 证券信息DataFrame + """ + if not self._is_connected: + raise RuntimeError("SDK未连接") + + try: + return self._base_data.get_code_info(security_type) + except Exception as e: + logger.error(f"获取证券信息失败: {str(e)}") + return pd.DataFrame() + + def get_trading_calendar(self, market: str) -> List[int]: + """ + 获取交易日历 + + Args: + market: 市场代码 (SH, SZ, CFE) + + Returns: + 交易日列表 (YYYYMMDD格式) + """ + if not self._is_connected: + raise RuntimeError("SDK未连接") + + try: + if isinstance(self._calendar, list): + return self._calendar + elif hasattr(self._calendar, 'get_trading_calendar'): + return self._calendar.get_trading_calendar(market) + else: + return [] + except Exception as e: + logger.error(f"获取交易日历失败: {str(e)}") + return [] + + def get_adj_factor(self, codes: List[str]) -> pd.DataFrame: + """获取复权因子""" + if not self._is_connected: + raise RuntimeError("SDK未连接") + + try: + return self._base_data.get_adj_factor(codes) + except Exception as e: + logger.error(f"获取复权因子失败: {str(e)}") + return pd.DataFrame() + + def get_backward_factor(self, codes: List[str]) -> pd.DataFrame: + """获取后复权因子""" + if not self._is_connected: + raise RuntimeError("SDK未连接") + + try: + return self._base_data.get_backward_factor(codes) + except Exception as e: + logger.error(f"获取后复权因子失败: {str(e)}") + return pd.DataFrame() + + # ==================== 历史行情接口 ==================== + + def get_kline( + self, + codes: List[str], + start_date, + end_date, + period: str = "daily" + ) -> Dict[str, pd.DataFrame]: + """ + 获取K线数据 + + Args: + codes: 代码列表 + start_date: 开始日期 (YYYYMMDD 或 date对象) + end_date: 结束日期 (YYYYMMDD 或 date对象) + period: 周期 (daily, min1, min5, min15, min30, min60) + + Returns: + 字典 {code: DataFrame} + """ + if not self._is_connected: + raise RuntimeError("SDK未连接") + + # 转换日期格式 + if isinstance(start_date, date): + start_date = int(start_date.strftime("%Y%m%d")) + if isinstance(end_date, date): + end_date = int(end_date.strftime("%Y%m%d")) + + period_map = { + "daily": 10008, + "min1": 10000, + "min5": 10002, + "min15": 10004, + "min30": 10005, + "min60": 10006, + } + + period_value = period_map.get(period, 10008) + + try: + return self._market_data.query_kline(codes, start_date, end_date, period_value) + except Exception as e: + logger.error(f"获取K线数据失败: {str(e)}") + return {code: pd.DataFrame() for code in codes} + + def get_snapshot( + self, + codes: List[str], + start_date, + end_date + ) -> Dict[str, pd.DataFrame]: + """ + 获取历史快照数据 + + Args: + codes: 代码列表 + start_date: 开始日期 + end_date: 结束日期 + + Returns: + 字典 {code: DataFrame} + """ + if not self._is_connected: + raise RuntimeError("SDK未连接") + + if isinstance(start_date, date): + start_date = int(start_date.strftime("%Y%m%d")) + if isinstance(end_date, date): + end_date = int(end_date.strftime("%Y%m%d")) + + try: + return self._market_data.query_snapshot(codes, start_date, end_date) + except Exception as e: + logger.error(f"获取快照数据失败: {str(e)}") + return {code: pd.DataFrame() for code in codes} + + # ==================== 财务数据接口 ==================== + + def get_balance_sheet( + self, + codes: List[str], + start_date, + end_date + ) -> Dict[str, pd.DataFrame]: + """获取资产负债表""" + if not self._is_connected: + raise RuntimeError("SDK未连接") + + if isinstance(start_date, date): + start_date = int(start_date.strftime("%Y%m%d")) + if isinstance(end_date, date): + end_date = int(end_date.strftime("%Y%m%d")) + + try: + return self._info_data.get_balance_sheet(codes, start_date, end_date) + except Exception as e: + logger.error(f"获取资产负债表失败: {str(e)}") + return {code: pd.DataFrame() for code in codes} + + def get_cash_flow( + self, + codes: List[str], + start_date, + end_date + ) -> Dict[str, pd.DataFrame]: + """获取现金流量表""" + if not self._is_connected: + raise RuntimeError("SDK未连接") + + if isinstance(start_date, date): + start_date = int(start_date.strftime("%Y%m%d")) + if isinstance(end_date, date): + end_date = int(end_date.strftime("%Y%m%d")) + + try: + return self._info_data.get_cash_flow(codes, start_date, end_date) + except Exception as e: + logger.error(f"获取现金流量表失败: {str(e)}") + return {code: pd.DataFrame() for code in codes} + + def get_income_statement( + self, + codes: List[str], + start_date, + end_date + ) -> Dict[str, pd.DataFrame]: + """获取利润表""" + if not self._is_connected: + raise RuntimeError("SDK未连接") + + if isinstance(start_date, date): + start_date = int(start_date.strftime("%Y%m%d")) + if isinstance(end_date, date): + end_date = int(end_date.strftime("%Y%m%d")) + + try: + return self._info_data.get_income_statement(codes, start_date, end_date) + except Exception as e: + logger.error(f"获取利润表失败: {str(e)}") + return {code: pd.DataFrame() for code in codes} + + def get_profit_express( + self, + codes: List[str], + start_date, + end_date + ) -> pd.DataFrame: + """获取业绩快报""" + if not self._is_connected: + raise RuntimeError("SDK未连接") + + if isinstance(start_date, date): + start_date = int(start_date.strftime("%Y%m%d")) + if isinstance(end_date, date): + end_date = int(end_date.strftime("%Y%m%d")) + + try: + return self._info_data.get_profit_express(codes, start_date, end_date) + except Exception as e: + logger.error(f"获取业绩快报失败: {str(e)}") + return pd.DataFrame() + + def get_profit_notice( + self, + codes: List[str], + start_date, + end_date + ) -> pd.DataFrame: + """获取业绩预告""" + if not self._is_connected: + raise RuntimeError("SDK未连接") + + if isinstance(start_date, date): + start_date = int(start_date.strftime("%Y%m%d")) + if isinstance(end_date, date): + end_date = int(end_date.strftime("%Y%m%d")) + + try: + return self._info_data.get_profit_notice(codes, start_date, end_date) + except Exception as e: + logger.error(f"获取业绩预告失败: {str(e)}") + return pd.DataFrame() + + # ==================== 股东股本接口 ==================== + + def get_top10_shareholders( + self, + codes: List[str], + start_date, + end_date + ) -> pd.DataFrame: + """获取十大股东""" + if not self._is_connected: + raise RuntimeError("SDK未连接") + + if isinstance(start_date, date): + start_date = int(start_date.strftime("%Y%m%d")) + if isinstance(end_date, date): + end_date = int(end_date.strftime("%Y%m%d")) + + try: + return self._info_data.get_top10_shareholders(codes, start_date, end_date) + except Exception as e: + logger.error(f"获取十大股东失败: {str(e)}") + return pd.DataFrame() + + def get_shareholder_count( + self, + codes: List[str], + start_date, + end_date + ) -> pd.DataFrame: + """获取股东户数""" + if not self._is_connected: + raise RuntimeError("SDK未连接") + + if isinstance(start_date, date): + start_date = int(start_date.strftime("%Y%m%d")) + if isinstance(end_date, date): + end_date = int(end_date.strftime("%Y%m%d")) + + try: + return self._info_data.get_shareholder_count(codes, start_date, end_date) + except Exception as e: + logger.error(f"获取股东户数失败: {str(e)}") + return pd.DataFrame() + + def get_equity_structure( + self, + codes: List[str], + start_date, + end_date + ) -> pd.DataFrame: + """获取股本结构""" + if not self._is_connected: + raise RuntimeError("SDK未连接") + + if isinstance(start_date, date): + start_date = int(start_date.strftime("%Y%m%d")) + if isinstance(end_date, date): + end_date = int(end_date.strftime("%Y%m%d")) + + try: + return self._info_data.get_equity_structure(codes, start_date, end_date) + except Exception as e: + logger.error(f"获取股本结构失败: {str(e)}") + return pd.DataFrame() + + # ==================== 融资融券接口 ==================== + + def get_margin_summary( + self, + start_date, + end_date + ) -> pd.DataFrame: + """获取融资融券汇总""" + if not self._is_connected: + raise RuntimeError("SDK未连接") + + if isinstance(start_date, date): + start_date = int(start_date.strftime("%Y%m%d")) + if isinstance(end_date, date): + end_date = int(end_date.strftime("%Y%m%d")) + + try: + return self._info_data.get_margin_summary(start_date, end_date) + except Exception as e: + logger.error(f"获取融资融券汇总失败: {str(e)}") + return pd.DataFrame() + + def get_margin_detail( + self, + codes: List[str], + start_date, + end_date + ) -> Dict[str, pd.DataFrame]: + """获取融资融券明细""" + if not self._is_connected: + raise RuntimeError("SDK未连接") + + if isinstance(start_date, date): + start_date = int(start_date.strftime("%Y%m%d")) + if isinstance(end_date, date): + end_date = int(end_date.strftime("%Y%m%d")) + + try: + return self._info_data.get_margin_detail(codes, start_date, end_date) + except Exception as e: + logger.error(f"获取融资融券明细失败: {str(e)}") + return {code: pd.DataFrame() for code in codes} + + # ==================== 指数数据接口 ==================== + + def get_index_constituents(self, codes: List[str]) -> Dict[str, pd.DataFrame]: + """获取指数成分股""" + if not self._is_connected: + raise RuntimeError("SDK未连接") + + try: + return self._info_data.get_index_constituents(codes) + except Exception as e: + logger.error(f"获取指数成分股失败: {str(e)}") + return {code: pd.DataFrame() for code in codes} + + def get_index_weights( + self, + codes: List[str], + start_date, + end_date + ) -> Dict[str, pd.DataFrame]: + """获取指数权重""" + if not self._is_connected: + raise RuntimeError("SDK未连接") + + if isinstance(start_date, date): + start_date = int(start_date.strftime("%Y%m%d")) + if isinstance(end_date, date): + end_date = int(end_date.strftime("%Y%m%d")) + + try: + return self._info_data.get_index_weights(codes, start_date, end_date) + except Exception as e: + logger.error(f"获取指数权重失败: {str(e)}") + return {code: pd.DataFrame() for code in codes} + + # ==================== ETF数据接口 ==================== + + def get_etf_pcf(self, codes: List[str]) -> Tuple[pd.DataFrame, Dict[str, pd.DataFrame]]: + """获取ETF申赎数据""" + if not self._is_connected: + raise RuntimeError("SDK未连接") + + try: + return self._info_data.get_etf_pcf(codes) + except Exception as e: + logger.error(f"获取ETF申赎数据失败: {str(e)}") + return pd.DataFrame(), {} + + def get_fund_share( + self, + codes: List[str], + start_date, + end_date + ) -> Dict[str, pd.DataFrame]: + """获取基金份额数据""" + if not self._is_connected: + raise RuntimeError("SDK未连接") + + if isinstance(start_date, date): + start_date = int(start_date.strftime("%Y%m%d")) + if isinstance(end_date, date): + end_date = int(end_date.strftime("%Y%m%d")) + + try: + return self._info_data.get_fund_share(codes, start_date, end_date) + except Exception as e: + logger.error(f"获取基金份额数据失败: {str(e)}") + return {code: pd.DataFrame() for code in codes} + + # ==================== 可转债数据接口 ==================== + + def get_kzz_issuance(self, codes: List[str]) -> Dict[str, pd.DataFrame]: + """获取可转债发行数据""" + if not self._is_connected: + raise RuntimeError("SDK未连接") + + try: + return self._info_data.get_kzz_issuance(codes) + except Exception as e: + logger.error(f"获取可转债发行数据失败: {str(e)}") + return {code: pd.DataFrame() for code in codes} + + # ==================== 实时数据订阅 ==================== + + def subscribe_snapshot(self, codes: List[str], callback: Callable): + """ + 订阅实时快照 + + Args: + codes: 代码列表 + callback: 回调函数,接收(code, data)参数 + """ + if not self._is_connected: + raise RuntimeError("SDK未连接") + + try: + # 使用实时数据接口订阅 + realtime = self._ad.RealtimeData() + realtime.subscribe_snapshot(codes, callback) + except Exception as e: + logger.error(f"订阅实时快照失败: {str(e)}") + + def subscribe_kline( + self, + codes: List[str], + period: str, + callback: Callable + ): + """ + 订阅实时K线 + + Args: + codes: 代码列表 + period: 周期 + callback: 回调函数 + """ + if not self._is_connected: + raise RuntimeError("SDK未连接") + + try: + realtime = self._ad.RealtimeData() + realtime.subscribe_kline(codes, period, callback) + except Exception as e: + logger.error(f"订阅实时K线失败: {str(e)}") + + def unsubscribe(self, codes: List[str] = None): + """ + 取消订阅 + + Args: + codes: 代码列表,None表示取消所有 + """ + if not self._is_connected: + return + + try: + realtime = self._ad.RealtimeData() + realtime.unsubscribe(codes) + except Exception as e: + logger.error(f"取消订阅失败: {str(e)}") diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py new file mode 100644 index 0000000..9cd8d1e --- /dev/null +++ b/backend/app/services/auth_service.py @@ -0,0 +1,135 @@ +""" +认证服务 +""" +from datetime import timedelta +from sqlalchemy.orm import Session +from fastapi import HTTPException, status + +from app.models.user import User +from app.core.security import verify_password, get_password_hash, create_access_token +from app.config import settings + + +class AuthService: + """认证服务""" + + @staticmethod + def authenticate_user(db: Session, username: str, password: str) -> User: + """ + 验证用户凭据 + + Args: + db: 数据库会话 + username: 用户名 + password: 密码 + + Returns: + 用户对象 + + Raises: + HTTPException: 认证失败 + """ + user = db.query(User).filter(User.username == username).first() + + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="用户名或密码错误" + ) + + if not verify_password(password, user.password_hash): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="用户名或密码错误" + ) + + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="用户已被禁用" + ) + + return user + + @staticmethod + def create_user_token(user: User) -> dict: + """ + 创建用户访问令牌 + + Args: + user: 用户对象 + + Returns: + Token信息 + """ + access_token_expires = timedelta(hours=settings.ACCESS_TOKEN_EXPIRE_HOURS) + access_token = create_access_token( + data={"sub": user.username}, + expires_delta=access_token_expires + ) + + return { + "access_token": access_token, + "token_type": "bearer", + "expires_in": settings.ACCESS_TOKEN_EXPIRE_HOURS * 3600 + } + + @staticmethod + def get_user_by_username(db: Session, username: str) -> User: + """通过用户名获取用户""" + return db.query(User).filter(User.username == username).first() + + @staticmethod + def create_user(db: Session, username: str, password: str, is_superuser: bool = False) -> User: + """ + 创建新用户 + + Args: + db: 数据库会话 + username: 用户名 + password: 密码 + is_superuser: 是否超级用户 + + Returns: + 新用户对象 + """ + # 检查用户名是否已存在 + existing_user = db.query(User).filter(User.username == username).first() + if existing_user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="用户名已存在" + ) + + # 创建新用户 + user = User( + username=username, + password_hash=get_password_hash(password), + is_superuser=is_superuser + ) + + db.add(user) + db.commit() + db.refresh(user) + + return user + + @staticmethod + def change_password(db: Session, user: User, old_password: str, new_password: str): + """ + 修改密码 + + Args: + db: 数据库会话 + user: 用户对象 + old_password: 旧密码 + new_password: 新密码 + """ + if not verify_password(old_password, user.password_hash): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="旧密码错误" + ) + + user.password_hash = get_password_hash(new_password) + db.commit() diff --git a/backend/app/services/base_data_service.py b/backend/app/services/base_data_service.py new file mode 100644 index 0000000..4ed1d6a --- /dev/null +++ b/backend/app/services/base_data_service.py @@ -0,0 +1,120 @@ +""" +基础数据服务 +""" +from typing import List, Optional, Dict +from datetime import date +from sqlalchemy.orm import Session +import pandas as pd + +from app.services.sdk_manager import sdk_manager +from app.models.config import SDKConfig + + +class BaseDataService: + """基础数据服务""" + + def __init__(self, db: Session): + self.db = db + + def _get_adapter(self): + """获取SDK适配器(使用连接管理器)""" + return sdk_manager.get_default_connection() + + def get_code_list(self, security_type: str) -> List[str]: + """ + 获取代码列表 + + Args: + security_type: 证券类型 + - EXTRA_STOCK_A: 沪深A股 + - EXTRA_FUTURE: 期货 + - EXTRA_ETF: ETF + - EXTRA_INDEX_A: 指数 + + Returns: + 代码列表 + """ + adapter = self._get_adapter() + if not adapter: + raise RuntimeError("SDK连接失败,请先测试连接") + return adapter.get_code_list(security_type) + + def get_code_info(self, security_type: str) -> pd.DataFrame: + """获取证券信息""" + adapter = self._get_adapter() + if not adapter: + raise RuntimeError("SDK连接失败,请先测试连接") + return adapter.get_code_info(security_type) + + def get_trading_calendar( + self, + market: str, + start_date: date = None, + end_date: date = None + ) -> List[date]: + """ + 获取交易日历 + + Args: + market: 市场代码 (SH, SZ, CFE) + start_date: 开始日期 + end_date: 结束日期 + + Returns: + 交易日列表 + """ + adapter = self._get_adapter() + if not adapter: + raise RuntimeError("SDK连接失败,请先测试连接") + + calendar_ints = adapter.get_trading_calendar(market) + + # 转换为date对象 + from app.utils.date_utils import int_to_date + dates = [int_to_date(d) for d in calendar_ints] + + # 过滤日期范围 + if start_date: + dates = [d for d in dates if d >= start_date] + if end_date: + dates = [d for d in dates if d <= end_date] + + return dates + + def get_adj_factor(self, codes: List[str]) -> pd.DataFrame: + """获取复权因子""" + adapter = self._get_adapter() + if not adapter: + raise RuntimeError("SDK连接失败,请先测试连接") + return adapter.get_adj_factor(codes) + + def get_backward_factor(self, codes: List[str]) -> pd.DataFrame: + """获取后复权因子""" + adapter = self._get_adapter() + if not adapter: + raise RuntimeError("SDK连接失败,请先测试连接") + return adapter.get_backward_factor(codes) + + def get_security_type(self, code: str) -> str: + """ + 根据代码判断证券类型 + + Args: + code: 证券代码 + + Returns: + 证券类型 (stock, future, index, etf, unknown) + """ + if code.endswith(".CFE"): + return "future" + elif code.endswith((".SH", ".SZ", ".BJ")): + # 根据代码前缀判断 + prefix = code[:3] + if prefix in ["000", "001", "002", "003", "300", "301", "600", "601", "603", "605", "688"]: + return "stock" + elif prefix in ["510", "511", "512", "513", "515", "516", "518", "560", "563", "588"]: + return "etf" + elif prefix in ["000", "880"]: + return "index" + + return "unknown" diff --git a/backend/app/services/cache_service.py b/backend/app/services/cache_service.py new file mode 100644 index 0000000..b3fdefc --- /dev/null +++ b/backend/app/services/cache_service.py @@ -0,0 +1,303 @@ +""" +缓存管理服务 +""" +import logging +from typing import List, Dict, Optional +from datetime import date, datetime +from sqlalchemy.orm import Session +from sqlalchemy import and_, func + +from app.models.cache import CacheTask, CacheTaskDetail +from app.models.stock import StockKlineDaily +from app.models.future import FutureKlineDaily +from app.services.base_data_service import BaseDataService +from app.services.stock_service import StockService +from app.services.future_service import FutureService +from app.utils.date_utils import parse_date, format_date, get_market_from_code +from app.config import settings + +logger = logging.getLogger(__name__) + + +class CacheService: + """缓存服务""" + + def __init__(self, db: Session): + self.db = db + self.base_service = BaseDataService(db) + self.stock_service = StockService(db) + self.future_service = FutureService(db) + + def detect_missing_data( + self, + security_type: str, + period_type: str, + start_date: date, + end_date: date, + code_list: List[str] + ) -> CacheTask: + """ + 检测缺失数据 + + Args: + security_type: 证券类型 (stock, future) + period_type: 周期类型 (daily, min1, etc.) + start_date: 开始日期 + end_date: 结束日期 + code_list: 代码列表 + + Returns: + 缓存任务对象 + """ + # 创建检测任务 + task = CacheTask( + task_name=f"检测缺失数据 - {security_type} - {len(code_list)}个代码", + task_type="detect_missing", + security_type=security_type, + period_type=period_type, + start_date=start_date, + end_date=end_date, + code_list=",".join(code_list), + status="running", + total_count=len(code_list), + started_at=datetime.utcnow() + ) + self.db.add(task) + self.db.commit() + self.db.refresh(task) + + try: + # 获取交易日历 + market = "CFE" if security_type == "future" else "SH" + trading_days = self.base_service.get_trading_calendar(market, start_date, end_date) + expected_count = len(trading_days) + + success_count = 0 + error_count = 0 + + for code in code_list: + try: + # 查询实际数据量 + if security_type == "stock" and period_type == "daily": + actual_count = self.db.query(StockKlineDaily).filter( + and_( + StockKlineDaily.code == code, + StockKlineDaily.trade_date >= start_date, + StockKlineDaily.trade_date <= end_date + ) + ).count() + elif security_type == "future" and period_type == "daily": + actual_count = self.db.query(FutureKlineDaily).filter( + and_( + FutureKlineDaily.code == code, + FutureKlineDaily.trade_date >= start_date, + FutureKlineDaily.trade_date <= end_date + ) + ).count() + else: + actual_count = 0 + + # 计算缺失率 + missing_ratio = 0 + if expected_count > 0: + missing_ratio = (expected_count - actual_count) / expected_count + + is_missing = missing_ratio > settings.CACHE_MISSING_THRESHOLD + + # 创建任务详情 + detail = CacheTaskDetail( + task_id=task.id, + code=code, + trade_date=start_date, + expected_count=expected_count, + actual_count=actual_count, + is_missing=1 if is_missing else 0, + status="pending" if is_missing else "skipped" + ) + self.db.add(detail) + + if is_missing: + success_count += 1 + + except Exception as e: + logger.error(f"检测{code}缺失数据失败: {str(e)}") + error_count += 1 + + detail = CacheTaskDetail( + task_id=task.id, + code=code, + trade_date=start_date, + status="failed", + error_message=str(e) + ) + self.db.add(detail) + + # 更新进度 + task.success_count = success_count + task.error_count = error_count + task.progress = min(100, int((success_count + error_count) / len(code_list) * 100)) + self.db.commit() + + task.status = "completed" + task.completed_at = datetime.utcnow() + self.db.commit() + + except Exception as e: + task.status = "failed" + task.error_message = str(e) + task.completed_at = datetime.utcnow() + self.db.commit() + logger.error(f"检测缺失数据任务失败: {str(e)}") + + return task + + def batch_cache_data( + self, + security_type: str, + period_type: str, + start_date: date, + end_date: date, + code_list: List[str] + ) -> CacheTask: + """ + 批量缓存数据 + + Args: + security_type: 证券类型 + period_type: 周期类型 + start_date: 开始日期 + end_date: 结束日期 + code_list: 代码列表 + + Returns: + 缓存任务对象 + """ + # 创建缓存任务 + task = CacheTask( + task_name=f"批量缓存数据 - {security_type} - {len(code_list)}个代码", + task_type="cache_data", + security_type=security_type, + period_type=period_type, + start_date=start_date, + end_date=end_date, + code_list=",".join(code_list), + status="running", + total_count=len(code_list), + started_at=datetime.utcnow() + ) + self.db.add(task) + self.db.commit() + self.db.refresh(task) + + try: + success_count = 0 + error_count = 0 + + for code in code_list: + try: + # 获取数据(会自动缓存) + if security_type == "stock": + self.stock_service.get_kline([code], start_date, end_date, period_type) + elif security_type == "future": + self.future_service.get_kline([code], start_date, end_date, period_type) + + success_count += 1 + + # 创建任务详情 + detail = CacheTaskDetail( + task_id=task.id, + code=code, + trade_date=start_date, + status="success", + processed_at=datetime.utcnow() + ) + self.db.add(detail) + + except Exception as e: + logger.error(f"缓存{code}数据失败: {str(e)}") + error_count += 1 + + detail = CacheTaskDetail( + task_id=task.id, + code=code, + trade_date=start_date, + status="failed", + error_message=str(e) + ) + self.db.add(detail) + + # 更新进度 + task.success_count = success_count + task.error_count = error_count + task.progress = min(100, int((success_count + error_count) / len(code_list) * 100)) + self.db.commit() + + task.status = "completed" + task.completed_at = datetime.utcnow() + self.db.commit() + + except Exception as e: + task.status = "failed" + task.error_message = str(e) + task.completed_at = datetime.utcnow() + self.db.commit() + logger.error(f"批量缓存数据任务失败: {str(e)}") + + return task + + def get_tasks( + self, + page: int = 1, + page_size: int = 20 + ) -> Dict: + """获取缓存任务列表""" + query = self.db.query(CacheTask).order_by(CacheTask.created_at.desc()) + + total = query.count() + tasks = query.offset((page - 1) * page_size).limit(page_size).all() + + return { + "items": tasks, + "total": total, + "page": page, + "page_size": page_size, + "total_pages": (total + page_size - 1) // page_size + } + + def get_task(self, task_id: int) -> Optional[CacheTask]: + """获取任务详情""" + return self.db.query(CacheTask).filter(CacheTask.id == task_id).first() + + def get_task_details(self, task_id: int) -> List[CacheTaskDetail]: + """获取任务详情列表""" + return self.db.query(CacheTaskDetail).filter( + CacheTaskDetail.task_id == task_id + ).all() + + def cancel_task(self, task_id: int) -> bool: + """取消任务""" + task = self.db.query(CacheTask).filter(CacheTask.id == task_id).first() + + if task and task.status == "running": + task.status = "cancelled" + task.completed_at = datetime.utcnow() + self.db.commit() + return True + + return False + + def get_cache_status(self, code: str, security_type: str, period_type: str) -> Dict: + """获取代码缓存状态""" + if security_type == "stock": + return self.stock_service.get_cache_status(code, period_type) + elif security_type == "future": + return self.future_service.get_cache_status(code, period_type) + else: + return { + "code": code, + "security_type": security_type, + "period_type": period_type, + "record_count": 0, + "min_date": None, + "max_date": None + } diff --git a/backend/app/services/config_service.py b/backend/app/services/config_service.py new file mode 100644 index 0000000..7aec6ec --- /dev/null +++ b/backend/app/services/config_service.py @@ -0,0 +1,149 @@ +""" +配置服务 +""" +from typing import List, Optional +from sqlalchemy.orm import Session +from fastapi import HTTPException, status + +from app.models.config import SDKConfig, SystemConfig + + +class ConfigService: + """配置服务""" + + @staticmethod + def get_sdk_configs(db: Session) -> List[SDKConfig]: + """获取所有SDK配置""" + return db.query(SDKConfig).order_by(SDKConfig.created_at.desc()).all() + + @staticmethod + def get_sdk_config(db: Session, config_id: int) -> Optional[SDKConfig]: + """获取指定SDK配置""" + return db.query(SDKConfig).filter(SDKConfig.id == config_id).first() + + @staticmethod + def get_default_sdk_config(db: Session) -> Optional[SDKConfig]: + """获取默认SDK配置""" + return db.query(SDKConfig).filter(SDKConfig.is_default == True).first() + + @staticmethod + def create_sdk_config(db: Session, config_data: dict) -> SDKConfig: + """ + 创建SDK配置 + + Args: + db: 数据库会话 + config_data: 配置数据 + + Returns: + 新配置对象 + """ + config = SDKConfig(**config_data) + db.add(config) + db.commit() + db.refresh(config) + return config + + @staticmethod + def update_sdk_config( + db: Session, + config_id: int, + config_data: dict + ) -> SDKConfig: + """ + 更新SDK配置 + + Args: + db: 数据库会话 + config_id: 配置ID + config_data: 更新数据 + + Returns: + 更新后的配置对象 + """ + config = db.query(SDKConfig).filter(SDKConfig.id == config_id).first() + + if not config: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="配置不存在" + ) + + # 更新字段 + for key, value in config_data.items(): + if value is not None and hasattr(config, key): + setattr(config, key, value) + + db.commit() + db.refresh(config) + return config + + @staticmethod + def delete_sdk_config(db: Session, config_id: int): + """ + 删除SDK配置 + + Args: + db: 数据库会话 + config_id: 配置ID + """ + config = db.query(SDKConfig).filter(SDKConfig.id == config_id).first() + + if not config: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="配置不存在" + ) + + db.delete(config) + db.commit() + + @staticmethod + def set_default_config(db: Session, config_id: int): + """ + 设置默认配置 + + Args: + db: 数据库会话 + config_id: 配置ID + """ + config = db.query(SDKConfig).filter(SDKConfig.id == config_id).first() + + if not config: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="配置不存在" + ) + + # 取消其他配置的默认状态 + db.query(SDKConfig).update({SDKConfig.is_default: False}) + + # 设置当前配置为默认 + config.is_default = True + db.commit() + db.refresh(config) + + @staticmethod + def get_system_config(db: Session, key: str) -> Optional[str]: + """获取系统配置值""" + config = db.query(SystemConfig).filter(SystemConfig.config_key == key).first() + return config.config_value if config else None + + @staticmethod + def set_system_config(db: Session, key: str, value: str, description: str = None): + """设置系统配置""" + config = db.query(SystemConfig).filter(SystemConfig.config_key == key).first() + + if config: + config.config_value = value + if description: + config.description = description + else: + config = SystemConfig( + config_key=key, + config_value=value, + description=description + ) + db.add(config) + + db.commit() diff --git a/backend/app/services/finance_service.py b/backend/app/services/finance_service.py new file mode 100644 index 0000000..7c899fd --- /dev/null +++ b/backend/app/services/finance_service.py @@ -0,0 +1,322 @@ +""" +财务数据服务 +""" +from typing import List, Dict +from datetime import date +from sqlalchemy.orm import Session +from sqlalchemy import and_ +import pandas as pd +import logging + +from app.models.finance import FinanceBalanceSheet, FinanceCashFlow, FinanceIncome +from app.services.sdk_manager import sdk_manager +from app.services.base_data_service import BaseDataService +from app.utils.date_utils import parse_date, format_date + +logger = logging.getLogger(__name__) + + +class FinanceService: + """财务数据服务""" + + def __init__(self, db: Session): + self.db = db + self.base_service = BaseDataService(db) + + def _get_adapter(self): + """获取SDK适配器(使用连接管理器)""" + return sdk_manager.get_default_connection() + + def get_balance_sheet( + self, + codes: List[str], + start_date: date, + end_date: date + ) -> Dict[str, List[dict]]: + """获取资产负债表""" + result = {} + + for code in codes: + try: + # 查询本地缓存 + records = self.db.query(FinanceBalanceSheet).filter( + and_( + FinanceBalanceSheet.code == code, + FinanceBalanceSheet.report_date >= start_date, + FinanceBalanceSheet.report_date <= end_date + ) + ).order_by(FinanceBalanceSheet.report_date.desc()).all() + + # 如果本地没有数据,从SDK获取 + if not records: + adapter = self._get_adapter() + if adapter: + sdk_data = adapter.get_balance_sheet([code], start_date, end_date) + + if code in sdk_data and not sdk_data[code].empty: + self._save_balance_sheet(code, sdk_data[code]) + + records = self.db.query(FinanceBalanceSheet).filter( + and_( + FinanceBalanceSheet.code == code, + FinanceBalanceSheet.report_date >= start_date, + FinanceBalanceSheet.report_date <= end_date + ) + ).order_by(FinanceBalanceSheet.report_date.desc()).all() + + result[code] = [ + { + "report_date": format_date(r.report_date), + "report_type": r.report_type, + "statement_type": r.statement_type, + "total_assets": float(r.total_assets) if r.total_assets else None, + "total_cur_assets": float(r.total_cur_assets) if r.total_cur_assets else None, + "total_noncur_assets": float(r.total_noncur_assets) if r.total_noncur_assets else None, + "currency_cap": float(r.currency_cap) if r.currency_cap else None, + "notes_receivable": float(r.notes_receivable) if r.notes_receivable else None, + "acct_receivable": float(r.acct_receivable) if r.acct_receivable else None, + "inventory": float(r.inventory) if r.inventory else None, + "fix_assets": float(r.fix_assets) if r.fix_assets else None, + "total_liab": float(r.total_liab) if r.total_liab else None, + "total_cur_liab": float(r.total_cur_liab) if r.total_cur_liab else None, + "total_noncur_liab": float(r.total_noncur_liab) if r.total_noncur_liab else None, + "notes_payable": float(r.notes_payable) if r.notes_payable else None, + "acct_payable": float(r.acct_payable) if r.acct_payable else None, + "st_borrowing": float(r.st_borrowing) if r.st_borrowing else None, + "lt_loan": float(r.lt_loan) if r.lt_loan else None, + "tot_share_equity": float(r.tot_share_equity) if r.tot_share_equity else None + } + for r in records + ] + except Exception as e: + logger.error(f"获取{code}资产负债表失败: {str(e)}") + result[code] = [] + + return result + + def get_cash_flow( + self, + codes: List[str], + start_date: date, + end_date: date + ) -> Dict[str, List[dict]]: + """获取现金流量表""" + result = {} + + for code in codes: + try: + records = self.db.query(FinanceCashFlow).filter( + and_( + FinanceCashFlow.code == code, + FinanceCashFlow.report_date >= start_date, + FinanceCashFlow.report_date <= end_date + ) + ).order_by(FinanceCashFlow.report_date.desc()).all() + + if not records: + adapter = self._get_adapter() + if adapter: + sdk_data = adapter.get_cash_flow([code], start_date, end_date) + + if code in sdk_data and not sdk_data[code].empty: + self._save_cash_flow(code, sdk_data[code]) + + records = self.db.query(FinanceCashFlow).filter( + and_( + FinanceCashFlow.code == code, + FinanceCashFlow.report_date >= start_date, + FinanceCashFlow.report_date <= end_date + ) + ).order_by(FinanceCashFlow.report_date.desc()).all() + + result[code] = [ + { + "report_date": format_date(r.report_date), + "report_type": r.report_type, + "statement_type": r.statement_type, + "net_cash_flows_opera_act": float(r.net_cash_flows_opera_act) if r.net_cash_flows_opera_act else None, + "net_cash_flows_inv_act": float(r.net_cash_flows_inv_act) if r.net_cash_flows_inv_act else None, + "net_cash_flows_fin_act": float(r.net_cash_flows_fin_act) if r.net_cash_flows_fin_act else None, + "net_incr_cash_and_cash_equ": float(r.net_incr_cash_and_cash_equ) if r.net_incr_cash_and_cash_equ else None, + "cash_recp_sg_and_rs": float(r.cash_recp_sg_and_rs) if r.cash_recp_sg_and_rs else None, + "cash_pay_goods_services": float(r.cash_pay_goods_services) if r.cash_pay_goods_services else None + } + for r in records + ] + except Exception as e: + logger.error(f"获取{code}现金流量表失败: {str(e)}") + result[code] = [] + + return result + + def get_income_statement( + self, + codes: List[str], + start_date: date, + end_date: date + ) -> Dict[str, List[dict]]: + """获取利润表""" + result = {} + + for code in codes: + try: + records = self.db.query(FinanceIncome).filter( + and_( + FinanceIncome.code == code, + FinanceIncome.report_date >= start_date, + FinanceIncome.report_date <= end_date + ) + ).order_by(FinanceIncome.report_date.desc()).all() + + if not records: + adapter = self._get_adapter() + if adapter: + sdk_data = adapter.get_income_statement([code], start_date, end_date) + + if code in sdk_data and not sdk_data[code].empty: + self._save_income(code, sdk_data[code]) + + records = self.db.query(FinanceIncome).filter( + and_( + FinanceIncome.code == code, + FinanceIncome.report_date >= start_date, + FinanceIncome.report_date <= end_date + ) + ).order_by(FinanceIncome.report_date.desc()).all() + + result[code] = [ + { + "report_date": format_date(r.report_date), + "report_type": r.report_type, + "statement_type": r.statement_type, + "tot_opera_rev": float(r.tot_opera_rev) if r.tot_opera_rev else None, + "opera_rev": float(r.opera_rev) if r.opera_rev else None, + "tot_opera_cost": float(r.tot_opera_cost) if r.tot_opera_cost else None, + "opera_profit": float(r.opera_profit) if r.opera_profit else None, + "total_profit": float(r.total_profit) if r.total_profit else None, + "net_pro_incl_min_int_inc": float(r.net_pro_incl_min_int_inc) if r.net_pro_incl_min_int_inc else None, + "basic_eps": float(r.basic_eps) if r.basic_eps else None, + "diluted_eps": float(r.diluted_eps) if r.diluted_eps else None, + "rd_exp": float(r.rd_exp) if r.rd_exp else None, + "selling_exp": float(r.selling_exp) if r.selling_exp else None, + "admin_exp": float(r.admin_exp) if r.admin_exp else None, + "fin_exp": float(r.fin_exp) if r.fin_exp else None + } + for r in records + ] + except Exception as e: + logger.error(f"获取{code}利润表失败: {str(e)}") + result[code] = [] + + return result + + def _save_balance_sheet(self, code: str, df: pd.DataFrame): + """保存资产负债表""" + if df.empty: + return + + for idx, row in df.iterrows(): + report_date = idx if isinstance(idx, date) else parse_date(str(idx)) + statement_type = int(row.get("statement_type", 0)) + + existing = self.db.query(FinanceBalanceSheet).filter( + and_( + FinanceBalanceSheet.code == code, + FinanceBalanceSheet.report_date == report_date, + FinanceBalanceSheet.statement_type == statement_type + ) + ).first() + + def get_float(val): + if pd.isna(val): + return None + return float(val) + + if existing: + existing.total_assets = get_float(row.get("total_assets")) + existing.total_cur_assets = get_float(row.get("total_cur_assets")) + existing.total_noncur_assets = get_float(row.get("total_noncur_assets")) + existing.tot_share_equity = get_float(row.get("tot_share_equity")) + else: + record = FinanceBalanceSheet( + code=code, + report_date=report_date, + report_type=get_float(row.get("report_type")), + statement_type=statement_type, + total_assets=get_float(row.get("total_assets")), + total_cur_assets=get_float(row.get("total_cur_assets")), + tot_share_equity=get_float(row.get("tot_share_equity")) + ) + self.db.add(record) + + self.db.commit() + + def _save_cash_flow(self, code: str, df: pd.DataFrame): + """保存现金流量表""" + if df.empty: + return + + for idx, row in df.iterrows(): + report_date = idx if isinstance(idx, date) else parse_date(str(idx)) + statement_type = int(row.get("statement_type", 0)) + + existing = self.db.query(FinanceCashFlow).filter( + and_( + FinanceCashFlow.code == code, + FinanceCashFlow.report_date == report_date, + FinanceCashFlow.statement_type == statement_type + ) + ).first() + + if not existing: + def get_float(val): + if pd.isna(val): + return None + return float(val) + + record = FinanceCashFlow( + code=code, + report_date=report_date, + report_type=get_float(row.get("report_type")), + statement_type=statement_type, + net_cash_flows_opera_act=get_float(row.get("net_cash_flows_opera_act")) + ) + self.db.add(record) + + self.db.commit() + + def _save_income(self, code: str, df: pd.DataFrame): + """保存利润表""" + if df.empty: + return + + for idx, row in df.iterrows(): + report_date = idx if isinstance(idx, date) else parse_date(str(idx)) + statement_type = int(row.get("statement_type", 0)) + + existing = self.db.query(FinanceIncome).filter( + and_( + FinanceIncome.code == code, + FinanceIncome.report_date == report_date, + FinanceIncome.statement_type == statement_type + ) + ).first() + + if not existing: + def get_float(val): + if pd.isna(val): + return None + return float(val) + + record = FinanceIncome( + code=code, + report_date=report_date, + report_type=get_float(row.get("report_type")), + statement_type=statement_type, + tot_opera_rev=get_float(row.get("tot_opera_rev")), + net_pro_incl_min_int_inc=get_float(row.get("net_pro_incl_min_int_inc")), + basic_eps=get_float(row.get("basic_eps")) + ) + self.db.add(record) + + self.db.commit() diff --git a/backend/app/services/future_service.py b/backend/app/services/future_service.py new file mode 100644 index 0000000..4079e68 --- /dev/null +++ b/backend/app/services/future_service.py @@ -0,0 +1,327 @@ +""" +期货数据服务 +""" +from typing import List, Dict +from datetime import date +from sqlalchemy.orm import Session +from sqlalchemy import and_ +import pandas as pd +import logging + +from app.models.future import FutureKlineDaily, FutureKlineMin +from app.services.sdk_manager import sdk_manager +from app.services.base_data_service import BaseDataService +from app.utils.date_utils import parse_date, format_date + +logger = logging.getLogger(__name__) + + +class FutureService: + """期货数据服务""" + + def __init__(self, db: Session): + self.db = db + self.base_service = BaseDataService(db) + + def _get_adapter(self): + """获取SDK适配器(使用连接管理器)""" + return sdk_manager.get_default_connection() + + def get_kline( + self, + codes: List[str], + start_date: date, + end_date: date, + period: str = "daily" + ) -> Dict[str, List[dict]]: + """ + 获取期货K线数据(带缓存) + + Args: + codes: 代码列表 + start_date: 开始日期 + end_date: 结束日期 + period: 周期 (daily, min1, min5, min15, min30, min60) + + Returns: + 字典 {code: [kline_data]} + """ + result = {} + + for code in codes: + try: + if period == "daily": + data = self._get_daily_kline_with_cache(code, start_date, end_date) + else: + data = self._get_min_kline_with_cache(code, start_date, end_date, period) + result[code] = data + except Exception as e: + logger.error(f"获取{code}的K线数据失败: {str(e)}") + result[code] = [] + + return result + + def _get_daily_kline_with_cache( + self, + code: str, + start_date: date, + end_date: date + ) -> List[dict]: + """获取期货日线数据(带缓存)""" + # 1. 查询本地缓存 + cached_records = self.db.query(FutureKlineDaily).filter( + and_( + FutureKlineDaily.code == code, + FutureKlineDaily.trade_date >= start_date, + FutureKlineDaily.trade_date <= end_date + ) + ).order_by(FutureKlineDaily.trade_date).all() + + # 2. 检查数据完整性 + cached_dates = {r.trade_date for r in cached_records} + expected_dates = set(self.base_service.get_trading_calendar("CFE", start_date, end_date)) + missing_dates = expected_dates - cached_dates + + # 3. 如果有缺失,从SDK获取 + if missing_dates: + try: + adapter = self._get_adapter() + if adapter: + sdk_data = adapter.get_kline([code], start_date, end_date, "daily") + + if code in sdk_data and not sdk_data[code].empty: + self._save_daily_kline(code, sdk_data[code]) + + cached_records = self.db.query(FutureKlineDaily).filter( + and_( + FutureKlineDaily.code == code, + FutureKlineDaily.trade_date >= start_date, + FutureKlineDaily.trade_date <= end_date + ) + ).order_by(FutureKlineDaily.trade_date).all() + except Exception as e: + logger.error(f"从SDK获取{code}数据失败: {str(e)}") + + return [ + { + "trade_date": format_date(r.trade_date), + "open": float(r.open), + "high": float(r.high), + "low": float(r.low), + "close": float(r.close), + "volume": int(r.volume), + "amount": float(r.amount), + "settle": float(r.settle) if r.settle else None, + "open_interest": int(r.open_interest) if r.open_interest else None + } + for r in cached_records + ] + + def _get_min_kline_with_cache( + self, + code: str, + start_date: date, + end_date: date, + period: str + ) -> List[dict]: + """获取期货分钟线数据(带缓存)""" + from datetime import datetime + + start_datetime = datetime.combine(start_date, datetime.min.time()) + end_datetime = datetime.combine(end_date, datetime.max.time()) + + cached_records = self.db.query(FutureKlineMin).filter( + and_( + FutureKlineMin.code == code, + FutureKlineMin.period_type == period, + FutureKlineMin.trade_datetime >= start_datetime, + FutureKlineMin.trade_datetime <= end_datetime + ) + ).order_by(FutureKlineMin.trade_datetime).all() + + if len(cached_records) < 10: + try: + adapter = self._get_adapter() + if adapter: + sdk_data = adapter.get_kline([code], start_date, end_date, period) + + if code in sdk_data and not sdk_data[code].empty: + self._save_min_kline(code, sdk_data[code], period) + + cached_records = self.db.query(FutureKlineMin).filter( + and_( + FutureKlineMin.code == code, + FutureKlineMin.period_type == period, + FutureKlineMin.trade_datetime >= start_datetime, + FutureKlineMin.trade_datetime <= end_datetime + ) + ).order_by(FutureKlineMin.trade_datetime).all() + except Exception as e: + logger.error(f"从SDK获取{code}分钟数据失败: {str(e)}") + + return [ + { + "trade_datetime": r.trade_datetime.isoformat(), + "open": float(r.open), + "high": float(r.high), + "low": float(r.low), + "close": float(r.close), + "volume": int(r.volume), + "amount": float(r.amount), + "settle": float(r.settle) if r.settle else None, + "open_interest": int(r.open_interest) if r.open_interest else None + } + for r in cached_records + ] + + def _save_daily_kline(self, code: str, df: pd.DataFrame): + """保存期货日线数据到数据库""" + if df.empty: + return + + for idx, row in df.iterrows(): + trade_date = idx if isinstance(idx, date) else parse_date(str(idx)) + + existing = self.db.query(FutureKlineDaily).filter( + and_( + FutureKlineDaily.code == code, + FutureKlineDaily.trade_date == trade_date + ) + ).first() + + if existing: + existing.open = float(row.get("open", 0)) + existing.high = float(row.get("high", 0)) + existing.low = float(row.get("low", 0)) + existing.close = float(row.get("close", 0)) + existing.volume = int(row.get("volume", 0)) + existing.amount = float(row.get("amount", 0)) + existing.settle = float(row.get("settle")) if pd.notna(row.get("settle")) else None + existing.open_interest = int(row.get("open_interest")) if pd.notna(row.get("open_interest")) else None + else: + record = FutureKlineDaily( + code=code, + 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)), + settle=float(row.get("settle")) if pd.notna(row.get("settle")) else None, + open_interest=int(row.get("open_interest")) if pd.notna(row.get("open_interest")) else None + ) + self.db.add(record) + + self.db.commit() + + def _save_min_kline(self, code: str, df: pd.DataFrame, period: str): + """保存期货分钟线数据到数据库""" + if df.empty: + return + + from datetime import datetime + + for idx, row in df.iterrows(): + trade_datetime = idx if isinstance(idx, datetime) else datetime.fromisoformat(str(idx)) + + existing = self.db.query(FutureKlineMin).filter( + and_( + FutureKlineMin.code == code, + FutureKlineMin.period_type == period, + FutureKlineMin.trade_datetime == trade_datetime + ) + ).first() + + if not existing: + record = FutureKlineMin( + code=code, + period_type=period, + trade_datetime=trade_datetime, + 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)), + settle=float(row.get("settle")) if pd.notna(row.get("settle")) else None, + open_interest=int(row.get("open_interest")) if pd.notna(row.get("open_interest")) else None + ) + self.db.add(record) + + self.db.commit() + + def get_kline_chart_data( + self, + code: str, + start_date: date, + end_date: date, + period: str = "daily" + ) -> dict: + """获取K线图数据(ECharts格式)""" + kline_data = self.get_kline([code], start_date, end_date, period) + data = kline_data.get(code, []) + + if not data: + return { + "categoryData": [], + "values": [], + "volumes": [] + } + + category_data = [] + values = [] + volumes = [] + + for i, item in enumerate(data): + date_key = item.get("trade_date") or item.get("trade_datetime", "")[:10] + category_data.append(date_key) + + values.append([ + item["open"], + item["close"], + item["low"], + item["high"], + item["volume"] + ]) + + sign = 1 if item["close"] >= item["open"] else -1 + volumes.append([i, item["volume"], sign]) + + return { + "categoryData": category_data, + "values": values, + "volumes": volumes + } + + def get_cache_status(self, code: str, period: str = "daily") -> dict: + """获取代码缓存状态""" + if period == "daily": + query = self.db.query(FutureKlineDaily).filter(FutureKlineDaily.code == code) + count = query.count() + min_date = query.order_by(FutureKlineDaily.trade_date).first() + max_date = query.order_by(FutureKlineDaily.trade_date.desc()).first() + + return { + "code": code, + "security_type": "future", + "period_type": period, + "record_count": count, + "min_date": format_date(min_date.trade_date) if min_date else None, + "max_date": format_date(max_date.trade_date) if max_date else None + } + else: + query = self.db.query(FutureKlineMin).filter( + FutureKlineMin.code == code, + FutureKlineMin.period_type == period + ) + count = query.count() + + return { + "code": code, + "security_type": "future", + "period_type": period, + "record_count": count, + "min_date": None, + "max_date": None + } diff --git a/backend/app/services/realtime_service.py b/backend/app/services/realtime_service.py new file mode 100644 index 0000000..12580d4 --- /dev/null +++ b/backend/app/services/realtime_service.py @@ -0,0 +1,204 @@ +""" +实时数据服务 +""" +import asyncio +import logging +from typing import Dict, Set, List, Callable, Optional +from datetime import datetime, timedelta +from sqlalchemy.orm import Session +from fastapi import WebSocket + +from app.models.realtime import RealtimeSnapshot +from app.services.base_data_service import BaseDataService +from app.config import settings + +logger = logging.getLogger(__name__) + + +class RealtimeManager: + """实时数据管理器(单例)""" + _instance = None + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._initialized = False + return cls._instance + + def __init__(self): + if self._initialized: + return + + self.subscribers: Dict[str, Set[WebSocket]] = {} + self.code_callbacks: Dict[str, List[Callable]] = {} + self._adapter = None + self._initialized = True + self._lock = asyncio.Lock() + + async def subscribe(self, websocket: WebSocket, codes: List[str]): + """ + 客户端订阅实时数据 + + Args: + websocket: WebSocket连接 + codes: 代码列表 + """ + await websocket.accept() + + async with self._lock: + for code in codes: + if code not in self.subscribers: + self.subscribers[code] = set() + # 启动SDK订阅 + await self._start_sdk_subscription(code) + + self.subscribers[code].add(websocket) + + logger.info(f"WebSocket {id(websocket)} 订阅了: {codes}") + + async def unsubscribe(self, websocket: WebSocket, codes: List[str] = None): + """ + 取消订阅 + + Args: + websocket: WebSocket连接 + codes: 代码列表,None表示取消所有 + """ + async with self._lock: + codes_to_remove = codes if codes else list(self.subscribers.keys()) + + for code in codes_to_remove: + if code in self.subscribers: + self.subscribers[code].discard(websocket) + + # 如果没有订阅者了,取消SDK订阅 + if not self.subscribers[code]: + del self.subscribers[code] + await self._stop_sdk_subscription(code) + + logger.info(f"WebSocket {id(websocket)} 取消订阅") + + async def _start_sdk_subscription(self, code: str): + """启动SDK订阅""" + # 这里需要实现实际的SDK订阅逻辑 + # 由于SDK的实时订阅是同步的回调,需要在后台线程中运行 + logger.info(f"开始SDK订阅: {code}") + + async def _stop_sdk_subscription(self, code: str): + """停止SDK订阅""" + logger.info(f"停止SDK订阅: {code}") + + def on_sdk_data(self, code: str, data: dict): + """ + SDK数据回调 + + Args: + code: 代码 + data: 数据字典 + """ + # 保存到数据库 + # self._save_snapshot(code, data) + + # 推送给所有订阅者 + if code in self.subscribers: + message = { + "type": "snapshot", + "code": code, + "data": data, + "timestamp": datetime.utcnow().isoformat() + } + + # 异步推送 + for ws in self.subscribers[code]: + asyncio.create_task(self._send_to_ws(ws, message)) + + async def _send_to_ws(self, websocket: WebSocket, message: dict): + """发送消息到WebSocket""" + try: + await websocket.send_json(message) + except Exception as e: + logger.error(f"发送WebSocket消息失败: {str(e)}") + # 从订阅列表中移除 + await self.unsubscribe(websocket) + + def _save_snapshot(self, db: Session, code: str, data: dict): + """保存快照到数据库""" + try: + expires_at = datetime.utcnow() + timedelta(days=settings.CACHE_AUTO_CLEANUP_DAYS) + + snapshot = RealtimeSnapshot( + code=code, + security_type=data.get("security_type", "stock"), + trade_time=datetime.fromisoformat(data.get("trade_time", datetime.utcnow().isoformat())), + pre_close=data.get("pre_close"), + last=data.get("last"), + open=data.get("open"), + high=data.get("high"), + low=data.get("low"), + close=data.get("close"), + volume=data.get("volume"), + amount=data.get("amount"), + expires_at=expires_at + ) + + db.add(snapshot) + db.commit() + except Exception as e: + logger.error(f"保存快照失败: {str(e)}") + + +# 全局实时数据管理器实例 +realtime_manager = RealtimeManager() + + +class RealtimeService: + """实时数据服务""" + + def __init__(self, db: Session): + self.db = db + self.base_service = BaseDataService(db) + self.manager = realtime_manager + + def get_latest_snapshot(self, codes: List[str]) -> Dict[str, dict]: + """ + 获取最新快照数据 + + Args: + codes: 代码列表 + + Returns: + 快照数据字典 + """ + result = {} + + for code in codes: + # 查询最新的快照 + snapshot = self.db.query(RealtimeSnapshot).filter( + RealtimeSnapshot.code == code + ).order_by(RealtimeSnapshot.trade_time.desc()).first() + + if snapshot: + result[code] = { + "code": snapshot.code, + "trade_time": snapshot.trade_time.isoformat(), + "pre_close": float(snapshot.pre_close) if snapshot.pre_close else None, + "last": float(snapshot.last) if snapshot.last else None, + "open": float(snapshot.open) if snapshot.open else None, + "high": float(snapshot.high) if snapshot.high else None, + "low": float(snapshot.low) if snapshot.low else None, + "close": float(snapshot.close) if snapshot.close else None, + "volume": int(snapshot.volume) if snapshot.volume else None, + "amount": float(snapshot.amount) if snapshot.amount else None + } + else: + result[code] = None + + return result + + async def subscribe_websocket(self, websocket: WebSocket, codes: List[str]): + """订阅WebSocket""" + await self.manager.subscribe(websocket, codes) + + async def unsubscribe_websocket(self, websocket: WebSocket, codes: List[str] = None): + """取消WebSocket订阅""" + await self.manager.unsubscribe(websocket, codes) diff --git a/backend/app/services/sdk_manager.py b/backend/app/services/sdk_manager.py new file mode 100644 index 0000000..0eca3af --- /dev/null +++ b/backend/app/services/sdk_manager.py @@ -0,0 +1,154 @@ +""" +SDK连接管理器 - 保持SDK登录状态,避免重复登录 +""" +import threading +import time +from typing import Optional, Dict, Any +from app.services.amazing_data_adapter import AmazingDataAdapter +from app.models.config import SDKConfig +from app.db.session import SessionLocal +import logging + +logger = logging.getLogger(__name__) + + +class SDKConnectionManager: + """ + SDK连接管理器 + + - 保持SDK登录状态,避免重复登录 + - 登录成功后,在释放前所有请求都不用重复登录 + - 支持多配置管理 + """ + + _instance = None + _lock = threading.Lock() + + def __new__(cls): + if cls._instance is None: + with cls._lock: + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._connections: Dict[int, AmazingDataAdapter] = {} + cls._instance._connection_locks: Dict[int, threading.Lock] = {} + cls._instance._last_activity: Dict[int, float] = {} + return cls._instance + + def get_connection(self, config_id: int) -> Optional[AmazingDataAdapter]: + """ + 获取SDK连接 + + Args: + config_id: 配置ID + + Returns: + AmazingDataAdapter实例,如果连接失败返回None + """ + if config_id not in self._connection_locks: + self._connection_locks[config_id] = threading.Lock() + + with self._connection_locks[config_id]: + if config_id in self._connections: + adapter = self._connections[config_id] + if adapter.is_connected(): + self._last_activity[config_id] = time.time() + return adapter + else: + del self._connections[config_id] + + db = SessionLocal() + try: + config = db.query(SDKConfig).filter(SDKConfig.id == config_id).first() + if not config: + logger.warning(f"SDK配置不存在: {config_id}") + return None + + adapter = AmazingDataAdapter({ + "username": config.username, + "password": config.password, + "host": config.host, + "port": config.port, + "local_path": config.local_path or "./amazing_data_cache/" + }) + + if adapter.connect(): + self._connections[config_id] = adapter + self._last_activity[config_id] = time.time() + logger.info(f"SDK连接成功: config_id={config_id}") + return adapter + else: + logger.warning(f"SDK连接失败: config_id={config_id}") + return None + finally: + db.close() + + def release_connection(self, config_id: int): + """ + 释放SDK连接 + + Args: + config_id: 配置ID + """ + if config_id not in self._connection_locks: + return + + with self._connection_locks[config_id]: + if config_id in self._connections: + adapter = self._connections[config_id] + try: + adapter.disconnect() + logger.info(f"SDK连接已释放: config_id={config_id}") + except Exception as e: + logger.warning(f"释放SDK连接时出错: {e}") + del self._connections[config_id] + if config_id in self._last_activity: + del self._last_activity[config_id] + + def get_default_connection(self) -> Optional[AmazingDataAdapter]: + """ + 获取默认SDK连接 + + Returns: + AmazingDataAdapter实例 + """ + db = SessionLocal() + try: + config = db.query(SDKConfig).filter(SDKConfig.is_default == True).first() + if not config: + config = db.query(SDKConfig).first() + if config: + return self.get_connection(config.id) + return None + finally: + db.close() + + def release_all(self): + """释放所有SDK连接""" + for config_id in list(self._connections.keys()): + self.release_connection(config_id) + + def get_status(self, config_id: int) -> Dict[str, Any]: + """ + 获取连接状态 + + Args: + config_id: 配置ID + + Returns: + 状态信息 + """ + if config_id in self._connections: + adapter = self._connections[config_id] + return { + "connected": adapter.is_connected(), + "last_activity": self._last_activity.get(config_id, 0), + "config_id": config_id + } + return { + "connected": False, + "last_activity": 0, + "config_id": config_id + } + + +sdk_manager = SDKConnectionManager() \ No newline at end of file diff --git a/backend/app/services/stock_service.py b/backend/app/services/stock_service.py new file mode 100644 index 0000000..6096086 --- /dev/null +++ b/backend/app/services/stock_service.py @@ -0,0 +1,348 @@ +""" +股票数据服务 +""" +from typing import List, Optional, Dict +from datetime import date +from sqlalchemy.orm import Session +from sqlalchemy import and_ +import pandas as pd +import logging + +from app.models.stock import StockKlineDaily, StockKlineMin +from app.services.sdk_manager import sdk_manager +from app.services.base_data_service import BaseDataService +from app.utils.date_utils import parse_date, format_date, get_market_from_code +from app.utils.data_utils import dataframe_to_dict_list, merge_kline_data + +logger = logging.getLogger(__name__) + + +class StockService: + """股票数据服务""" + + def __init__(self, db: Session): + self.db = db + self.base_service = BaseDataService(db) + + def _get_adapter(self): + """获取SDK适配器(使用连接管理器)""" + return sdk_manager.get_default_connection() + + def get_kline( + self, + codes: List[str], + start_date: date, + end_date: date, + period: str = "daily" + ) -> Dict[str, List[dict]]: + """ + 获取股票K线数据(带缓存) + + Args: + codes: 代码列表 + start_date: 开始日期 + end_date: 结束日期 + period: 周期 (daily, min1, min5, min15, min30, min60) + + Returns: + 字典 {code: [kline_data]} + """ + result = {} + + for code in codes: + try: + if period == "daily": + data = self._get_daily_kline_with_cache(code, start_date, end_date) + else: + data = self._get_min_kline_with_cache(code, start_date, end_date, period) + result[code] = data + except Exception as e: + logger.error(f"获取{code}的K线数据失败: {str(e)}") + result[code] = [] + + return result + + def _get_daily_kline_with_cache( + self, + code: str, + start_date: date, + end_date: date + ) -> List[dict]: + """获取日线数据(带缓存)""" + # 1. 查询本地缓存 + cached_records = self.db.query(StockKlineDaily).filter( + and_( + StockKlineDaily.code == code, + StockKlineDaily.trade_date >= start_date, + StockKlineDaily.trade_date <= end_date + ) + ).order_by(StockKlineDaily.trade_date).all() + + # 2. 检查数据完整性 + cached_dates = {r.trade_date for r in cached_records} + expected_dates = set(self.base_service.get_trading_calendar( + get_market_from_code(code), + start_date, + end_date + )) + + missing_dates = expected_dates - cached_dates + + # 3. 如果有缺失,从SDK获取 + if missing_dates: + try: + adapter = self._get_adapter() + if adapter: + sdk_data = adapter.get_kline([code], start_date, end_date, "daily") + + if code in sdk_data and not sdk_data[code].empty: + # 保存到数据库 + self._save_daily_kline(code, sdk_data[code]) + + # 重新查询 + cached_records = self.db.query(StockKlineDaily).filter( + and_( + StockKlineDaily.code == code, + StockKlineDaily.trade_date >= start_date, + StockKlineDaily.trade_date <= end_date + ) + ).order_by(StockKlineDaily.trade_date).all() + except Exception as e: + logger.error(f"从SDK获取{code}数据失败: {str(e)}") + + # 4. 转换为字典列表 + return [ + { + "trade_date": format_date(r.trade_date), + "open": float(r.open), + "high": float(r.high), + "low": float(r.low), + "close": float(r.close), + "volume": int(r.volume), + "amount": float(r.amount) + } + for r in cached_records + ] + + def _get_min_kline_with_cache( + self, + code: str, + start_date: date, + end_date: date, + period: str + ) -> List[dict]: + """获取分钟线数据(带缓存)""" + from datetime import datetime + + start_datetime = datetime.combine(start_date, datetime.min.time()) + end_datetime = datetime.combine(end_date, datetime.max.time()) + + # 1. 查询本地缓存 + cached_records = self.db.query(StockKlineMin).filter( + and_( + StockKlineMin.code == code, + StockKlineMin.period_type == period, + StockKlineMin.trade_datetime >= start_datetime, + StockKlineMin.trade_datetime <= end_datetime + ) + ).order_by(StockKlineMin.trade_datetime).all() + + # 2. 如果数据较少,尝试从SDK获取 + if len(cached_records) < 10: + try: + adapter = self._get_adapter() + if adapter: + sdk_data = adapter.get_kline([code], start_date, end_date, period) + + if code in sdk_data and not sdk_data[code].empty: + self._save_min_kline(code, sdk_data[code], period) + + # 重新查询 + cached_records = self.db.query(StockKlineMin).filter( + and_( + StockKlineMin.code == code, + StockKlineMin.period_type == period, + StockKlineMin.trade_datetime >= start_datetime, + StockKlineMin.trade_datetime <= end_datetime + ) + ).order_by(StockKlineMin.trade_datetime).all() + except Exception as e: + logger.error(f"从SDK获取{code}分钟数据失败: {str(e)}") + + return [ + { + "trade_datetime": r.trade_datetime.isoformat(), + "open": float(r.open), + "high": float(r.high), + "low": float(r.low), + "close": float(r.close), + "volume": int(r.volume), + "amount": float(r.amount) + } + for r in cached_records + ] + + def _save_daily_kline(self, code: str, df: pd.DataFrame): + """保存日线数据到数据库""" + if df.empty: + return + + for idx, row in df.iterrows(): + kline_time = row.get("kline_time") + if kline_time is None: + continue + + trade_date = kline_time.date() if hasattr(kline_time, 'date') else parse_date(str(kline_time)[:10]) + + existing = self.db.query(StockKlineDaily).filter( + and_( + StockKlineDaily.code == code, + StockKlineDaily.trade_date == trade_date + ) + ).first() + + if existing: + existing.open = float(row.get("open", 0)) + existing.high = float(row.get("high", 0)) + existing.low = float(row.get("low", 0)) + existing.close = float(row.get("close", 0)) + existing.volume = int(row.get("volume", 0)) + existing.amount = float(row.get("amount", 0)) + else: + record = StockKlineDaily( + code=code, + 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)) + ) + self.db.add(record) + + self.db.commit() + + def _save_min_kline(self, code: str, df: pd.DataFrame, period: str): + """保存分钟线数据到数据库""" + if df.empty: + return + + from datetime import datetime + + for idx, row in df.iterrows(): + kline_time = row.get("kline_time") + if kline_time is None: + continue + + trade_datetime = kline_time if isinstance(kline_time, datetime) else datetime.fromisoformat(str(kline_time)) + + existing = self.db.query(StockKlineMin).filter( + and_( + StockKlineMin.code == code, + StockKlineMin.period_type == period, + StockKlineMin.trade_datetime == trade_datetime + ) + ).first() + + if not existing: + record = StockKlineMin( + code=code, + period_type=period, + trade_datetime=trade_datetime, + 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)) + ) + self.db.add(record) + + self.db.commit() + + def get_kline_chart_data( + self, + code: str, + start_date: date, + end_date: date, + period: str = "daily" + ) -> dict: + """ + 获取K线图数据(ECharts格式) + + Returns: + { + "categoryData": ["2024-01-02", ...], + "values": [[open, close, low, high, volume], ...], + "volumes": [[index, volume, sign], ...] + } + """ + kline_data = self.get_kline([code], start_date, end_date, period) + data = kline_data.get(code, []) + + if not data: + return { + "categoryData": [], + "values": [], + "volumes": [] + } + + category_data = [] + values = [] + volumes = [] + + for i, item in enumerate(data): + date_key = item.get("trade_date") or item.get("trade_datetime", "")[:10] + category_data.append(date_key) + + # ECharts candlestick format: [open, close, low, high] + values.append([ + item["open"], + item["close"], + item["low"], + item["high"], + item["volume"] + ]) + + # Volume with color sign + sign = 1 if item["close"] >= item["open"] else -1 + volumes.append([i, item["volume"], sign]) + + return { + "categoryData": category_data, + "values": values, + "volumes": volumes + } + + def get_cache_status(self, code: str, period: str = "daily") -> dict: + """获取代码缓存状态""" + if period == "daily": + query = self.db.query(StockKlineDaily).filter(StockKlineDaily.code == code) + count = query.count() + min_date = query.order_by(StockKlineDaily.trade_date).first() + max_date = query.order_by(StockKlineDaily.trade_date.desc()).first() + + return { + "code": code, + "security_type": "stock", + "period_type": period, + "record_count": count, + "min_date": format_date(min_date.trade_date) if min_date else None, + "max_date": format_date(max_date.trade_date) if max_date else None + } + else: + query = self.db.query(StockKlineMin).filter( + StockKlineMin.code == code, + StockKlineMin.period_type == period + ) + count = query.count() + + return { + "code": code, + "security_type": "stock", + "period_type": period, + "record_count": count, + "min_date": None, + "max_date": None + } diff --git a/backend/app/services/test_service.py b/backend/app/services/test_service.py new file mode 100644 index 0000000..d28d696 --- /dev/null +++ b/backend/app/services/test_service.py @@ -0,0 +1,268 @@ +""" +测试中心服务 +""" +import time +import logging +from typing import List, Dict, Optional +from datetime import date, datetime, timedelta +from sqlalchemy.orm import Session + +from app.models.test import APITestLog +from app.services.base_data_service import BaseDataService +from app.services.stock_service import StockService +from app.services.future_service import FutureService +from app.services.finance_service import FinanceService + +logger = logging.getLogger(__name__) + + +class TestService: + """测试服务""" + + # 测试端点定义 + TEST_ENDPOINTS = [ + # 基础数据 + {"category": "base_data", "name": "获取代码列表", "endpoint": "/api/v1/base/codes", "method": "GET", "params": {"security_type": "EXTRA_STOCK_A"}}, + {"category": "base_data", "name": "获取交易日历", "endpoint": "/api/v1/base/calendar", "method": "GET", "params": {"market": "SH", "start_date": "20240101", "end_date": "20241231"}}, + + # 股票数据 + {"category": "stock", "name": "获取股票K线", "endpoint": "/api/v1/stock/kline", "method": "GET", "params": {"codes": "000001.SZ", "start_date": "20240101", "end_date": "20241231", "period": "daily"}}, + {"category": "stock", "name": "获取股票K线图", "endpoint": "/api/v1/stock/kline/000001.SZ/chart", "method": "GET", "params": {"start_date": "20240101", "end_date": "20241231", "period": "daily"}}, + + # 期货数据 + {"category": "future", "name": "获取期货K线", "endpoint": "/api/v1/future/kline", "method": "GET", "params": {"codes": "IF2501.CFE", "start_date": "20240101", "end_date": "20241231", "period": "daily"}}, + {"category": "future", "name": "获取期货K线图", "endpoint": "/api/v1/future/kline/IF2501.CFE/chart", "method": "GET", "params": {"start_date": "20240101", "end_date": "20241231", "period": "daily"}}, + + # 财务数据 + {"category": "finance", "name": "获取资产负债表", "endpoint": "/api/v1/finance/balance-sheet", "method": "GET", "params": {"codes": "000001.SZ", "start_date": "20240930", "end_date": "20240930"}}, + {"category": "finance", "name": "获取现金流量表", "endpoint": "/api/v1/finance/cash-flow", "method": "GET", "params": {"codes": "000001.SZ", "start_date": "20240930", "end_date": "20240930"}}, + {"category": "finance", "name": "获取利润表", "endpoint": "/api/v1/finance/income", "method": "GET", "params": {"codes": "000001.SZ", "start_date": "20240930", "end_date": "20240930"}}, + + # 实时数据 + {"category": "realtime", "name": "获取最新快照", "endpoint": "/api/v1/realtime/snapshot", "method": "GET", "params": {"codes": "000001.SZ"}}, + + # 缓存管理 + {"category": "cache", "name": "获取缓存任务列表", "endpoint": "/api/v1/cache/tasks", "method": "GET", "params": {"page": 1, "page_size": 20}}, + ] + + def __init__(self, db: Session): + self.db = db + self.base_service = BaseDataService(db) + self.stock_service = StockService(db) + self.future_service = FutureService(db) + self.finance_service = FinanceService(db) + + def get_categories(self) -> List[Dict]: + """获取测试分类""" + categories = set(endpoint["category"] for endpoint in self.TEST_ENDPOINTS) + category_names = { + "base_data": "基础数据", + "stock": "股票数据", + "future": "期货数据", + "realtime": "实时数据", + "finance": "财务数据", + "shareholder": "股东数据", + "margin": "融资融券", + "index": "指数数据", + "etf": "ETF数据", + "kzz": "可转债数据", + "cache": "缓存管理" + } + + return [ + {"key": cat, "name": category_names.get(cat, cat)} + for cat in sorted(categories) + ] + + def get_endpoints(self, category: str = None) -> List[Dict]: + """获取测试端点列表""" + if category: + return [ep for ep in self.TEST_ENDPOINTS if ep["category"] == category] + return self.TEST_ENDPOINTS + + def run_test(self, endpoint: str, method: str, params: dict) -> Dict: + """ + 运行单个测试 + + Args: + endpoint: 端点路径 + method: HTTP方法 + params: 请求参数 + + Returns: + 测试结果 + """ + start_time = time.time() + + try: + # 根据端点调用相应的服务方法 + result = self._call_endpoint(endpoint, method, params) + + execution_time = int((time.time() - start_time) * 1000) + + return { + "success": True, + "endpoint": endpoint, + "method": method, + "status_code": 200, + "execution_time_ms": execution_time, + "response_data": result, + "error_message": None + } + + except Exception as e: + execution_time = int((time.time() - start_time) * 1000) + logger.error(f"测试失败 {endpoint}: {str(e)}") + + return { + "success": False, + "endpoint": endpoint, + "method": method, + "status_code": 500, + "execution_time_ms": execution_time, + "response_data": None, + "error_message": str(e) + } + + def _call_endpoint(self, endpoint: str, method: str, params: dict): + """调用端点对应的服务方法""" + from app.utils.date_utils import parse_date + + # 股票数据接口 + if "/stock/kline" in endpoint and "/chart" in endpoint: + code = endpoint.split("/")[-2] + start_date = parse_date(params.get("start_date", "20240101")) + end_date = parse_date(params.get("end_date", "20241231")) + period = params.get("period", "daily") + return self.stock_service.get_kline_chart_data(code, start_date, end_date, period) + + elif "/stock/kline" in endpoint: + codes = params.get("codes", "").split(",") + start_date = parse_date(params.get("start_date", "20240101")) + end_date = parse_date(params.get("end_date", "20241231")) + period = params.get("period", "daily") + return self.stock_service.get_kline(codes, start_date, end_date, period) + + # 期货数据接口 + elif "/future/kline" in endpoint and "/chart" in endpoint: + code = endpoint.split("/")[-2] + start_date = parse_date(params.get("start_date", "20240101")) + end_date = parse_date(params.get("end_date", "20241231")) + period = params.get("period", "daily") + return self.future_service.get_kline_chart_data(code, start_date, end_date, period) + + elif "/future/kline" in endpoint: + codes = params.get("codes", "").split(",") + start_date = parse_date(params.get("start_date", "20240101")) + end_date = parse_date(params.get("end_date", "20241231")) + period = params.get("period", "daily") + return self.future_service.get_kline(codes, start_date, end_date, period) + + # 财务数据接口 + elif "/finance/balance-sheet" in endpoint: + codes = params.get("codes", "").split(",") + start_date = parse_date(params.get("start_date", "20240930")) + end_date = parse_date(params.get("end_date", "20240930")) + return self.finance_service.get_balance_sheet(codes, start_date, end_date) + + elif "/finance/cash-flow" in endpoint: + codes = params.get("codes", "").split(",") + start_date = parse_date(params.get("start_date", "20240930")) + end_date = parse_date(params.get("end_date", "20240930")) + return self.finance_service.get_cash_flow(codes, start_date, end_date) + + elif "/finance/income" in endpoint: + codes = params.get("codes", "").split(",") + start_date = parse_date(params.get("start_date", "20240930")) + end_date = parse_date(params.get("end_date", "20240930")) + return self.finance_service.get_income_statement(codes, start_date, end_date) + + # 基础数据接口 + elif "/base/codes" in endpoint: + security_type = params.get("security_type", "EXTRA_STOCK_A") + return {"codes": self.base_service.get_code_list(security_type)[:10]} + + elif "/base/calendar" in endpoint: + market = params.get("market", "SH") + start_date = parse_date(params.get("start_date", "20240101")) + end_date = parse_date(params.get("end_date", "20241231")) + calendar = self.base_service.get_trading_calendar(market, start_date, end_date) + return {"calendar": [d.isoformat() for d in calendar[:10]]} + + # 其他接口 + else: + return {"message": "测试通过"} + + def run_all_tests(self, categories: List[str] = None) -> Dict: + """ + 运行全部测试 + + Args: + categories: 测试分类列表,None表示全部 + + Returns: + 测试结果汇总 + """ + endpoints = self.get_endpoints() + + if categories: + endpoints = [ep for ep in endpoints if ep["category"] in categories] + + results = [] + passed = 0 + failed = 0 + + for endpoint in endpoints: + result = self.run_test( + endpoint["endpoint"], + endpoint["method"], + endpoint.get("params", {}) + ) + results.append(result) + + if result["success"]: + passed += 1 + else: + failed += 1 + + return { + "total": len(results), + "passed": passed, + "failed": failed, + "results": results + } + + def log_test(self, test_name: str, api_category: str, api_endpoint: str, + request_method: str, request_params: dict, response_data: dict, + status_code: int, execution_time_ms: int, is_success: bool, + error_message: str = None): + """记录测试日志""" + log = APITestLog( + test_name=test_name, + api_category=api_category, + api_endpoint=api_endpoint, + request_method=request_method, + request_params=request_params, + response_data=response_data, + status_code=status_code, + execution_time_ms=execution_time_ms, + is_success=is_success, + error_message=error_message + ) + self.db.add(log) + self.db.commit() + + def get_test_history(self, page: int = 1, page_size: int = 20) -> Dict: + """获取测试历史""" + query = self.db.query(APITestLog).order_by(APITestLog.created_at.desc()) + + total = query.count() + logs = query.offset((page - 1) * page_size).limit(page_size).all() + + return { + "items": logs, + "total": total, + "page": page, + "page_size": page_size, + "total_pages": (total + page_size - 1) // page_size + } diff --git a/backend/app/static/index.html b/backend/app/static/index.html new file mode 100644 index 0000000..c0498c3 --- /dev/null +++ b/backend/app/static/index.html @@ -0,0 +1,163 @@ + + + + + + AmazingData金融数据服务平台 + + + +
+

AmazingData

+

金融数据服务平台

+ +
+
系统状态
+
+ 后端服务 + 检查中... +
+
+ API版本 + - +
+
+ 数据库 + SQLite (演示模式) +
+
+ +
+

功能特性

+
    +
  • 完整SDK接口封装
  • +
  • 智能数据缓存
  • +
  • 实时数据订阅
  • +
  • 缺失数据检测
  • +
  • 批量缓存管理
  • +
  • 可视化K线图
  • +
  • 完整测试中心
  • +
+
+ + 查看API文档 + + +
+ + + + diff --git a/backend/app/utils/__init__.py b/backend/app/utils/__init__.py new file mode 100644 index 0000000..308adc9 --- /dev/null +++ b/backend/app/utils/__init__.py @@ -0,0 +1,14 @@ +# 工具模块 +from app.utils.date_utils import parse_date, format_date, get_trading_days_between +from app.utils.data_utils import deduplicate_dataframe, compare_kline_data +from app.utils.validators import validate_code_format, validate_date_range + +__all__ = [ + "parse_date", + "format_date", + "get_trading_days_between", + "deduplicate_dataframe", + "compare_kline_data", + "validate_code_format", + "validate_date_range", +] diff --git a/backend/app/utils/data_utils.py b/backend/app/utils/data_utils.py new file mode 100644 index 0000000..a80fc38 --- /dev/null +++ b/backend/app/utils/data_utils.py @@ -0,0 +1,141 @@ +""" +数据处理工具模块 +""" +from typing import List, Dict, Tuple, Optional +import pandas as pd +import numpy as np + + +def deduplicate_dataframe( + df: pd.DataFrame, + existing_df: pd.DataFrame, + core_columns: List[str] = None, + tolerance: float = 0.0001 +) -> pd.DataFrame: + """ + 去重DataFrame + + Args: + df: 新数据 + existing_df: 已存在的数据 + core_columns: 核心比较列 + tolerance: 差异容忍度 + + Returns: + 需要去重的数据 + """ + if core_columns is None: + core_columns = ["open", "high", "low", "close", "volume"] + + if existing_df.empty: + return df + + # 获取已存在的索引 + existing_indices = set(existing_df.index) + + to_keep = [] + for idx, row in df.iterrows(): + if idx not in existing_indices: + to_keep.append(idx) + else: + # 比较核心字段 + existing_row = existing_df.loc[idx] + is_same = all( + abs(row[col] - existing_row[col]) < tolerance + for col in core_columns + if col in row and col in existing_row + ) + if not is_same: + to_keep.append(idx) + + return df.loc[to_keep] + + +def compare_kline_data( + new_data: Dict, + existing_data: Dict, + tolerance: float = 0.0001 +) -> bool: + """ + 比较K线数据是否相同 + + Args: + new_data: 新数据 + existing_data: 已存在数据 + tolerance: 差异容忍度 + + Returns: + 是否相同 + """ + core_fields = ["open", "high", "low", "close", "volume"] + + for field in core_fields: + if field not in new_data or field not in existing_data: + return False + if abs(new_data[field] - existing_data[field]) >= tolerance: + return False + + return True + + +def dataframe_to_dict_list(df: pd.DataFrame) -> List[Dict]: + """将DataFrame转换为字典列表""" + if df.empty: + return [] + + # 重置索引以便包含日期信息 + df_reset = df.reset_index() + + # 转换列名 + df_reset.columns = [str(col).lower() for col in df_reset.columns] + + # 转换为字典列表 + records = df_reset.to_dict("records") + + # 处理数值类型 + for record in records: + for key, value in record.items(): + if isinstance(value, (np.integer, np.floating)): + record[key] = float(value) if isinstance(value, np.floating) else int(value) + elif pd.isna(value): + record[key] = None + + return records + + +def merge_kline_data( + cached_data: pd.DataFrame, + sdk_data: pd.DataFrame +) -> pd.DataFrame: + """合并K线数据(SDK数据优先)""" + if cached_data.empty: + return sdk_data + + if sdk_data.empty: + return cached_data + + # 合并数据,SDK数据优先 + combined = pd.concat([cached_data, sdk_data]) + combined = combined[~combined.index.duplicated(keep="last")] + combined = combined.sort_index() + + return combined + + +def calculate_data_completeness( + actual_count: int, + expected_count: int +) -> float: + """计算数据完整度""" + if expected_count == 0: + return 1.0 + return actual_count / expected_count + + +def detect_missing_periods( + existing_dates: List, + expected_dates: List +) -> List[Tuple]: + """检测缺失的时间段""" + missing = set(expected_dates) - set(existing_dates) + return sorted(list(missing)) diff --git a/backend/app/utils/date_utils.py b/backend/app/utils/date_utils.py new file mode 100644 index 0000000..45a1ee5 --- /dev/null +++ b/backend/app/utils/date_utils.py @@ -0,0 +1,76 @@ +""" +日期工具模块 +""" +from datetime import date, datetime, timedelta +from typing import List, Optional + + +def parse_date(date_str: str) -> date: + """解析日期字符串 (YYYYMMDD 或 YYYY-MM-DD)""" + if len(date_str) == 8: + return datetime.strptime(date_str, "%Y%m%d").date() + elif len(date_str) == 10: + return datetime.strptime(date_str, "%Y-%m-%d").date() + else: + raise ValueError(f"无效的日期格式: {date_str}") + + +def format_date(d: date, format_str: str = "%Y-%m-%d") -> str: + """格式化日期""" + return d.strftime(format_str) + + +def format_datetime(dt: datetime, format_str: str = "%Y-%m-%d %H:%M:%S") -> str: + """格式化日期时间""" + return dt.strftime(format_str) + + +def get_market_from_code(code: str) -> str: + """从代码获取市场""" + if code.endswith(".SH"): + return "SH" + elif code.endswith(".SZ"): + return "SZ" + elif code.endswith(".BJ"): + return "BJ" + elif code.endswith(".CFE"): + return "CFE" + else: + return "SH" # 默认上海 + + +def get_trading_days_between( + start_date: date, + end_date: date, + calendar: Optional[List[date]] = None +) -> List[date]: + """获取两个日期之间的交易日列表""" + if calendar: + return [d for d in calendar if start_date <= d <= end_date] + + # 如果没有提供交易日历,简单排除周末 + trading_days = [] + current = start_date + while current <= end_date: + if current.weekday() < 5: # 周一到周五 + trading_days.append(current) + current += timedelta(days=1) + + return trading_days + + +def get_default_date_range(days: int = 365) -> tuple: + """获取默认日期范围""" + end_date = date.today() + start_date = end_date - timedelta(days=days) + return start_date, end_date + + +def int_to_date(date_int: int) -> date: + """将整数日期(YYYYMMDD)转换为date对象""" + return datetime.strptime(str(date_int), "%Y%m%d").date() + + +def date_to_int(d: date) -> int: + """将date对象转换为整数日期(YYYYMMDD)""" + return int(d.strftime("%Y%m%d")) diff --git a/backend/app/utils/validators.py b/backend/app/utils/validators.py new file mode 100644 index 0000000..3d34dc1 --- /dev/null +++ b/backend/app/utils/validators.py @@ -0,0 +1,79 @@ +""" +验证器模块 +""" +import re +from datetime import date +from typing import Tuple, Optional + + +def validate_code_format(code: str) -> Tuple[bool, str]: + """ + 验证代码格式 + + Returns: + (是否有效, 错误信息) + """ + if not code: + return False, "代码不能为空" + + # 股票代码格式: 000001.SZ, 600000.SH + stock_pattern = r"^\d{6}\.(SZ|SH|BJ)$" + + # 期货代码格式: IF2501.CFE + future_pattern = r"^[A-Z]{1,2}\d{4}\.CFE$" + + if re.match(stock_pattern, code): + return True, "" + + if re.match(future_pattern, code): + return True, "" + + return False, f"无效的代码格式: {code}" + + +def validate_date_range(start_date: date, end_date: date) -> Tuple[bool, str]: + """ + 验证日期范围 + + Returns: + (是否有效, 错误信息) + """ + if start_date > end_date: + return False, "开始日期不能晚于结束日期" + + # 检查日期范围是否合理(不超过5年) + from datetime import timedelta + if (end_date - start_date).days > 365 * 5: + return False, "日期范围不能超过5年" + + return True, "" + + +def validate_period_type(period: str) -> Tuple[bool, str]: + """ + 验证周期类型 + + Returns: + (是否有效, 错误信息) + """ + valid_periods = ["daily", "min1", "min5", "min15", "min30", "min60"] + + if period not in valid_periods: + return False, f"无效的周期类型: {period},有效值: {', '.join(valid_periods)}" + + return True, "" + + +def validate_security_type(security_type: str) -> Tuple[bool, str]: + """ + 验证证券类型 + + Returns: + (是否有效, 错误信息) + """ + valid_types = ["stock", "future", "index", "etf", "kzz"] + + if security_type not in valid_types: + return False, f"无效的证券类型: {security_type},有效值: {', '.join(valid_types)}" + + return True, "" diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..abb232d --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,37 @@ +# Web框架 +fastapi==0.104.1 +uvicorn[standard]==0.24.0 +python-multipart==0.0.6 +websockets==12.0 + +# 数据库 +sqlalchemy==2.0.23 +psycopg2-binary==2.9.9 +aiosqlite==0.19.0 +alembic==1.12.1 +redis==5.0.1 + +# 认证 +pyjwt==2.8.0 +bcrypt==4.1.1 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 + +# 数据处理 +pandas==2.1.3 +numpy==1.26.2 + +# 任务调度 +apscheduler==3.10.4 + +# 配置管理 +pydantic==2.5.0 +pydantic-settings==2.1.0 +python-dotenv==1.0.0 + +# 工具 +python-dateutil==2.8.2 +httpx==0.25.2 + +# AmazingData SDK (本地安装) +# AmazingData-1.0.24-py3-none-any.whl diff --git a/backend/static/index.html b/backend/static/index.html new file mode 100644 index 0000000..c0498c3 --- /dev/null +++ b/backend/static/index.html @@ -0,0 +1,163 @@ + + + + + + AmazingData金融数据服务平台 + + + +
+

AmazingData

+

金融数据服务平台

+ +
+
系统状态
+
+ 后端服务 + 检查中... +
+
+ API版本 + - +
+
+ 数据库 + SQLite (演示模式) +
+
+ +
+

功能特性

+
    +
  • 完整SDK接口封装
  • +
  • 智能数据缓存
  • +
  • 实时数据订阅
  • +
  • 缺失数据检测
  • +
  • 批量缓存管理
  • +
  • 可视化K线图
  • +
  • 完整测试中心
  • +
+
+ + 查看API文档 + + +
+ + + + diff --git a/backend/test_sdk_connection.py b/backend/test_sdk_connection.py new file mode 100644 index 0000000..2b9399c --- /dev/null +++ b/backend/test_sdk_connection.py @@ -0,0 +1,53 @@ +""" +AmazingData SDK 连接测试脚本 +""" +import sys +import time + +def test_sdk_connection(): + from app.db.session import SessionLocal + from app.models.config import SDKConfig + from app.services.amazing_data_adapter import AmazingDataAdapter + + db = SessionLocal() + config = db.query(SDKConfig).filter(SDKConfig.id == 1).first() + + if not config: + print("No SDK config found with id=1") + return False + + print(f"Testing SDK connection with config:") + print(f" Username: {config.username}") + print(f" Host: {config.host}") + print(f" Port: {config.port}") + + adapter = AmazingDataAdapter({ + "username": config.username, + "password": config.password, + "host": config.host, + "port": config.port, + "local_path": config.local_path or "./amazing_data_cache/" + }) + + start = time.time() + print("\nConnecting to SDK...") + + try: + success = adapter.connect() + elapsed = time.time() - start + + if success: + print(f"✓ SDK connection successful! (took {elapsed:.2f}s)") + adapter.disconnect() + print("✓ Disconnected successfully") + return True + else: + print(f"✗ SDK connection failed! (took {elapsed:.2f}s)") + return False + except Exception as e: + print(f"✗ Error: {type(e).__name__}: {e}") + return False + +if __name__ == "__main__": + success = test_sdk_connection() + sys.exit(0 if success else 1) \ No newline at end of file diff --git a/backend/xyzs/AmazingData/AmazingData-1.0.30-cp310-none-any.whl b/backend/xyzs/AmazingData/AmazingData-1.0.30-cp310-none-any.whl new file mode 100644 index 0000000..3e25648 Binary files /dev/null and b/backend/xyzs/AmazingData/AmazingData-1.0.30-cp310-none-any.whl differ diff --git a/backend/xyzs/AmazingData/AmazingData-1.0.30-cp311-none-any.whl b/backend/xyzs/AmazingData/AmazingData-1.0.30-cp311-none-any.whl new file mode 100644 index 0000000..f6c8adc Binary files /dev/null and b/backend/xyzs/AmazingData/AmazingData-1.0.30-cp311-none-any.whl differ diff --git a/backend/xyzs/AmazingData/AmazingData-1.0.30-cp312-none-any.whl b/backend/xyzs/AmazingData/AmazingData-1.0.30-cp312-none-any.whl new file mode 100644 index 0000000..bd45391 Binary files /dev/null and b/backend/xyzs/AmazingData/AmazingData-1.0.30-cp312-none-any.whl differ diff --git a/backend/xyzs/AmazingData/AmazingData-1.0.30-cp313-none-any.whl b/backend/xyzs/AmazingData/AmazingData-1.0.30-cp313-none-any.whl new file mode 100644 index 0000000..3c641fc Binary files /dev/null and b/backend/xyzs/AmazingData/AmazingData-1.0.30-cp313-none-any.whl differ diff --git a/backend/xyzs/AmazingData/AmazingData-1.0.30-cp38-none-any.whl b/backend/xyzs/AmazingData/AmazingData-1.0.30-cp38-none-any.whl new file mode 100644 index 0000000..ec2c7e8 Binary files /dev/null and b/backend/xyzs/AmazingData/AmazingData-1.0.30-cp38-none-any.whl differ diff --git a/backend/xyzs/AmazingData/AmazingData-1.0.30-cp39-none-any.whl b/backend/xyzs/AmazingData/AmazingData-1.0.30-cp39-none-any.whl new file mode 100644 index 0000000..25523fb Binary files /dev/null and b/backend/xyzs/AmazingData/AmazingData-1.0.30-cp39-none-any.whl differ diff --git a/backend/xyzs/AmazingDataAdapter_接口文档.md b/backend/xyzs/AmazingDataAdapter_接口文档.md new file mode 100644 index 0000000..c6aa3da --- /dev/null +++ b/backend/xyzs/AmazingDataAdapter_接口文档.md @@ -0,0 +1,790 @@ +# AmazingDataAdapter 接口文档 + +基于银河证券星耀数智量化平台 SDK 的 Python 适配器接口文档 + +--- + +## 目录 + +1. [快速开始](#快速开始) +2. [枚举类型](#枚举类型) +3. [接口列表](#接口列表) + - [基础数据接口](#基础数据接口) + - [历史行情接口](#历史行情接口) + - [财务数据接口](#财务数据接口) + - [股东股本接口](#股东股本接口) + - [融资融券接口](#融资融券接口) + - [交易异动接口](#交易异动接口) + - [指数数据接口](#指数数据接口) + - [ETF数据接口](#etf数据接口) + - [可转债数据接口](#可转债数据接口) + +--- + +## 快速开始 + +```python +import asyncio +from app.adapters.amazingdata_adapter import AmazingDataAdapter, SecurityType, Market + +async def main(): + # 1. 创建适配器 + adapter = AmazingDataAdapter() + + # 2. 连接数据源 + await adapter.connect({ + "username": "your_username", + "password": "your_password", + "host": "your_host", + "port": 8600, + "local_path": "./amazing_data_cache/", + "use_local_cache": True + }) + + # 3. 获取数据 + klines = await adapter.fetch_klines( + symbol="000001.SZ", + start="20240101", + end="20241231", + freq="1d" + ) + print(f"获取到 {len(klines)} 条K线数据") + + # 4. 断开连接 + await adapter.close() + +asyncio.run(main()) +``` + +--- + +## 枚举类型 + +### SecurityType - 证券类型 + +| 枚举值 | 说明 | +|--------|------| +| `SecurityType.STOCK_A` | 沪深A股 | +| `SecurityType.STOCK_A_SH_SZ` | 沪深A股(沪深) | +| `SecurityType.INDEX_A` | 沪深指数 | +| `SecurityType.ETF` | ETF | +| `SecurityType.FUTURE` | 期货 | +| `SecurityType.KZZ` | 可转债 | +| `SecurityType.GLRA` | 逆回购 | +| `SecurityType.HKT` | 港股通 | +| `SecurityType.ETF_OP` | ETF期权 | + +### Market - 市场 + +| 枚举值 | 说明 | +|--------|------| +| `Market.SH` | 上海 | +| `Market.SZ` | 深圳 | +| `Market.BJ` | 北京 | + +--- + +## 接口列表 + +### 基础数据接口 + +#### 1. fetch_klines - 获取历史K线 + +```python +async def fetch_klines( + self, + symbol: str, + start: str, + end: str, + freq: str +) -> List[KLineData] +``` + +**参数说明:** + +| 参数 | 类型 | 说明 | +|------|------|------| +| symbol | str | 标的代码,如 "000001.SZ" | +| start | str | 开始日期,格式 YYYYMMDD | +| end | str | 结束日期,格式 YYYYMMDD | +| freq | str | 周期:1m/5m/15m/30m/60m/1d/1w/1M | + +**返回数据 (KLineData):** + +| 字段 | 类型 | 说明 | +|------|------|------| +| symbol | str | 标的代码 | +| time | int | Unix时间戳 | +| open | float | 开盘价 | +| high | float | 最高价 | +| low | float | 最低价 | +| close | float | 收盘价 | +| volume | int | 成交量 | +| amount | float | 成交金额 | +| trade_date | str | 交易日 (YYYY-MM-DD) | +| is_limit_up | bool | 是否涨停 | +| is_limit_down | bool | 是否跌停 | +| total_market_cap | float | 总市值(元) | +| float_market_cap | float | 流通市值(元) | +| inst_holding_ratio | float | 机构持仓占比(%) | +| trading_days | int | 可交易日数 | + +--- + +#### 2. fetch_symbols - 获取标的列表 + +```python +async def fetch_symbols( + self, + asset_type: str +) -> List[SymbolInfo] +``` + +**参数说明:** + +| 参数 | 类型 | 说明 | +|------|------|------| +| asset_type | str | 资产类型:stock/futures | + +**返回数据 (SymbolInfo):** + +| 字段 | 类型 | 说明 | +|------|------|------| +| symbol_id | str | 标的代码 | +| name | str | 标的名称 | +| exchange | str | 交易所 | +| underlying | str | 期货品种代码 | +| contract_month | str | 合约月份 | + +--- + +#### 3. fetch_trading_calendar - 获取交易日历 + +```python +async def fetch_trading_calendar( + self, + exchange: str, + start: str, + end: str +) -> List[TradeCalData] +``` + +**参数说明:** + +| 参数 | 类型 | 说明 | +|------|------|------| +| exchange | str | 交易所代码:SH/SZ | +| start | str | 开始日期 YYYYMMDD | +| end | str | 结束日期 YYYYMMDD | + +--- + +#### 4. get_code_info - 获取证券基本信息 + +```python +async def get_code_info( + self, + security_type: SecurityType = SecurityType.STOCK_A +) -> pd.DataFrame +``` + +**返回字段:** + +| 字段 | 说明 | +|------|------| +| symbol | 证券简称 | +| security_status | 产品状态标志 | +| pre_close | 昨收价 | +| high_limited | 涨停价 | +| low_limited | 跌停价 | +| price_tick | 最小价格变动单位 | + +--- + +#### 5. get_trading_calendar - 获取交易日历(列表) + +```python +async def get_trading_calendar( + self, + market: Market = Market.SH +) -> List[int] +``` + +**返回:** 交易日列表,格式 [20240102, 20240103, ...] + +--- + +#### 6. get_adj_factor - 获取单次复权因子 + +```python +async def get_adj_factor( + self, + codes: List[str], + is_local: Optional[bool] = None +) -> pd.DataFrame +``` + +**参数说明:** + +| 参数 | 类型 | 说明 | +|------|------|------| +| codes | List[str] | 股票代码列表 | +| is_local | bool | 是否使用本地缓存 | + +**返回:** DataFrame (index: 日期, columns: 股票代码) + +--- + +#### 7. get_backward_factor - 获取后复权因子 + +```python +async def get_backward_factor( + self, + codes: List[str], + is_local: Optional[bool] = None +) -> pd.DataFrame +``` + +--- + +### 历史行情接口 + +#### 8. get_snapshot - 获取历史快照 + +```python +async def get_snapshot( + self, + codes: List[str], + start_date: str, + end_date: str +) -> Dict[str, pd.DataFrame] +``` + +**说明:** 获取Level-1行情快照数据 + +--- + +### 财务数据接口 + +#### 9. get_balance_sheet - 资产负债表 + +```python +async def get_balance_sheet( + self, + codes: List[str], + start_date: Optional[str] = None, + end_date: Optional[str] = None, + is_local: Optional[bool] = None +) -> Dict[str, pd.DataFrame] +``` + +**主要字段:** + +| 字段 | 说明 | +|------|------| +| TOTAL_ASSETS | 资产总计 | +| TOTAL_CUR_ASSETS | 流动资产合计 | +| TOTAL_NONCUR_ASSETS | 非流动资产合计 | +| TOTAL_LIAB | 负债合计 | +| TOT_SHARE_EQUITY_INCL_MIN_INT | 股东权益合计 | +| CURRENCY_CAP | 货币资金 | +| NOTES_RECEIVABLE | 应收票据 | +| ACCT_RECEIVABLE | 应收账款 | +| INV | 存货 | + +--- + +#### 10. get_cash_flow - 现金流量表 + +```python +async def get_cash_flow( + self, + codes: List[str], + start_date: Optional[str] = None, + end_date: Optional[str] = None, + is_local: Optional[bool] = None +) -> Dict[str, pd.DataFrame] +``` + +**主要字段:** + +| 字段 | 说明 | +|------|------| +| NET_CASH_FLOWS_OPERA_ACT | 经营活动现金流净额 | +| NET_CASH_FLOWS_INV_ACT | 投资活动现金流净额 | +| NET_CASH_FLOWS_FIN_ACT | 筹资活动现金流净额 | +| NET_INCR_CASH_AND_CASH_EQU | 现金及现金等价物净增加额 | + +--- + +#### 11. get_income_statement - 利润表 + +```python +async def get_income_statement( + self, + codes: List[str], + start_date: Optional[str] = None, + end_date: Optional[str] = None, + is_local: Optional[bool] = None +) -> Dict[str, pd.DataFrame] +``` + +**主要字段:** + +| 字段 | 说明 | +|------|------| +| TOT_OPERA_REV | 营业总收入 | +| OPERA_REV | 营业收入 | +| TOT_OPERA_COST | 营业总成本 | +| OPERA_PROFIT | 营业利润 | +| TOTAL_PROFIT | 利润总额 | +| NET_PRO_INCL_MIN_INT_INC | 净利润 | +| BASIC_EPS | 基本每股收益 | +| DILUTED_EPS | 稀释每股收益 | +| RD_EXP | 研发费用 | + +--- + +#### 12. get_profit_express - 业绩快报 + +```python +async def get_profit_express( + self, + codes: List[str], + start_date: Optional[str] = None, + end_date: Optional[str] = None, + is_local: Optional[bool] = None +) -> pd.DataFrame +``` + +**主要字段:** + +| 字段 | 说明 | +|------|------| +| TOTAL_ASSETS | 总资产 | +| NET_PRO_EXCL_MIN_INT_INC | 净利润 | +| TOT_OPERA_REV | 营业总收入 | +| TOTAL_PROFIT | 利润总额 | +| OPERA_PROFIT | 营业利润 | +| EPS_BASIC | 基本每股收益 | +| ROE_WEIGHTED | 净资产收益率-加权 | +| YOY_GR_NET_PROFIT_PARENT | 同比增长率 | + +--- + +#### 13. get_profit_notice - 业绩预告 + +```python +async def get_profit_notice( + self, + codes: List[str], + start_date: Optional[str] = None, + end_date: Optional[str] = None, + is_local: Optional[bool] = None +) -> pd.DataFrame +``` + +**主要字段:** + +| 字段 | 说明 | +|------|------| +| P_TYPECODE | 业绩预告类型代码 | +| P_CHANGE_MAX | 预告净利润变动幅度上限 | +| P_CHANGE_MIN | 预告净利润变动幅度下限 | +| NET_PROFIT_MAX | 预告净利润上限(万元) | +| NET_PROFIT_MIN | 预告净利润下限(万元) | +| P_REASON | 业绩变动原因 | + +--- + +### 股东股本接口 + +#### 14. get_top10_shareholders - 十大股东 + +```python +async def get_top10_shareholders( + self, + codes: List[str], + start_date: Optional[str] = None, + end_date: Optional[str] = None, + is_local: Optional[bool] = None +) -> pd.DataFrame +``` + +**主要字段:** + +| 字段 | 说明 | +|------|------| +| HOLDER_NAME | 股东名称 | +| HOLDER_QUANTITY | 持股数 | +| HOLDER_PCT | 持股比例(%) | +| HOLDER_HOLDER_CATEGORY | 股东性质(1:个人, 2:公司) | +| FLOAT_QTY | 流通股数量 | + +--- + +#### 15. get_shareholder_count - 股东户数 + +```python +async def get_shareholder_count( + self, + codes: List[str], + start_date: Optional[str] = None, + end_date: Optional[str] = None, + is_local: Optional[bool] = None +) -> pd.DataFrame +``` + +**主要字段:** + +| 字段 | 说明 | +|------|------| +| HOLDER_TOTAL_NUM | A股、B股、H股、境外股的总户数 | +| HOLDER_NUM | A股股东户数 | + +--- + +#### 16. get_equity_structure - 股本结构 + +```python +async def get_equity_structure( + self, + codes: List[str], + start_date: Optional[str] = None, + end_date: Optional[str] = None, + is_local: Optional[bool] = None +) -> pd.DataFrame +``` + +**主要字段:** + +| 字段 | 说明 | +|------|------| +| TOT_SHARE | 总股本(万股) | +| FLOAT_SHARE | 流通股(万股) | +| FLOAT_A_SHARE | 流通A股(万股) | +| RESTRICTED_A_SHARE | 限售A股(万股) | +| TOT_RESTRICTED_SHARE | 限售股合计 | + +--- + +### 融资融券接口 + +#### 17. get_margin_summary - 融资融券汇总 + +```python +async def get_margin_summary( + self, + start_date: Optional[str] = None, + end_date: Optional[str] = None, + is_local: Optional[bool] = None +) -> pd.DataFrame +``` + +**主要字段:** + +| 字段 | 说明 | +|------|------| +| TRADE_DATE | 交易日期 | +| SUM_BORROW_MONEY_BALANCE | 融资余额(元) | +| SUM_PURCH_WITH_BORROW_MONEY | 融资买入额(元) | +| SUM_REPAYMENT_OF_BORROW_MONEY | 融资偿还额(元) | +| SUM_SEC_LENDING_BALANCE | 融券余额(元) | +| SUM_SALES_OF_BORROWED_SEC | 融券卖出量 | +| SUM_MARGIN_TRADE_BALANCE | 融资融券余额(元) | + +--- + +#### 18. get_margin_detail - 个股融资融券明细 + +```python +async def get_margin_detail( + self, + codes: List[str], + start_date: Optional[str] = None, + end_date: Optional[str] = None, + is_local: Optional[bool] = None +) -> Dict[str, pd.DataFrame] +``` + +**主要字段:** + +| 字段 | 说明 | +|------|------| +| BORROW_MONEY_BALANCE | 融资余额 | +| PURCH_WITH_BORROW_MONEY | 融资买入额 | +| REPAYMENT_OF_BORROW_MONEY | 融资偿还额 | +| SEC_LENDING_BALANCE | 融券余额 | +| SALES_OF_BORROWED_SEC | 融券卖出量 | + +--- + +### 交易异动接口 + +#### 19. get_longhu_bang - 龙虎榜数据 + +```python +async def get_longhu_bang( + self, + codes: List[str], + start_date: Optional[str] = None, + end_date: Optional[str] = None, + is_local: Optional[bool] = None +) -> pd.DataFrame +``` + +**主要字段:** + +| 字段 | 说明 | +|------|------| +| TRADE_DATE | 交易日期 | +| REASON_TYPE_NAME | 上榜原因 | +| CHANGE_RANGE | 涨跌幅(%) | +| TRADER_NAME | 营业部名称 | +| BUY_AMOUNT | 买入金额(元) | +| SELL_AMOUNT | 卖出金额(元) | +| FLOW_MARK | 买卖表示(1买入, 2卖出) | + +--- + +#### 20. get_block_trading - 大宗交易 + +```python +async def get_block_trading( + self, + codes: List[str], + start_date: Optional[str] = None, + end_date: Optional[str] = None, + is_local: Optional[bool] = None +) -> pd.DataFrame +``` + +**主要字段:** + +| 字段 | 说明 | +|------|------| +| TRADE_DATE | 交易日期 | +| B_SHARE_PRICE | 成交价(元) | +| B_SHARE_VOLUME | 成交量(万股) | +| B_SHARE_AMOUNT | 成交金额(万元) | +| B_BUYER_NAME | 买方营业部名称 | +| B_SELLER_NAME | 卖方营业部名称 | + +--- + +### 指数数据接口 + +#### 21. get_index_constituents - 指数成分股 + +```python +async def get_index_constituents( + self, + codes: List[str], + is_local: Optional[bool] = None +) -> Dict[str, pd.DataFrame] +``` + +**支持指数:** + +| 代码 | 名称 | +|------|------| +| 000016.SH | 上证50 | +| 000300.SH | 沪深300 | +| 000905.SH | 中证500 | +| 000906.SH | 中证800 | +| 000852.SH | 中证1000 | + +**返回字段:** + +| 字段 | 说明 | +|------|------| +| INDEX_CODE | 指数代码 | +| CON_CODE | 成分股代码 | +| INDATE | 纳入日期 | +| OUTDATE | 剔除日期 | +| INDEX_NAME | 指数名称 | + +--- + +#### 22. get_index_weights - 成分股权重 + +```python +async def get_index_weights( + self, + codes: List[str], + start_date: Optional[str] = None, + end_date: Optional[str] = None, + is_local: Optional[bool] = None +) -> Dict[str, pd.DataFrame] +``` + +**返回字段:** + +| 字段 | 说明 | +|------|------| +| INDEX_CODE | 指数代码 | +| CON_CODE | 标的代码 | +| TRADE_DATE | 生效日期 | +| WEIGHT | 权重(%) | +| CLOSE | 收盘价 | + +--- + +### ETF数据接口 + +#### 23. get_etf_pcf - ETF申赎数据 + +```python +async def get_etf_pcf( + self, + codes: List[str] +) -> Tuple[pd.DataFrame, Dict[str, pd.DataFrame]] +``` + +**返回:** (etf_info, etf_constituents) + +**etf_info字段:** + +| 字段 | 说明 | +|------|------| +| creation_redemption_unit | 每个篮子对应的ETF份数 | +| max_cash_ratio | 最大现金替代比例 | +| creation | 是否允许申购 | +| redemption | 是否允许赎回 | + +**etf_constituents字段:** + +| 字段 | 说明 | +|------|------| +| underlying_symbol | 成份证券简称 | +| component_share | 成份证券数量 | +| substitute_flag | 现金替代标志 | + +--- + +#### 24. get_fund_share - 基金份额 + +```python +async def get_fund_share( + self, + codes: List[str], + start_date: Optional[str] = None, + end_date: Optional[str] = None, + is_local: Optional[bool] = None +) -> Dict[str, pd.DataFrame] +``` + +**主要字段:** + +| 字段 | 说明 | +|------|------| +| FUND_SHARE | 基金份额(万份) | +| TOTAL_SHARE | 基金总份额(万份) | +| FLOAT_SHARE | 流通份额(万份) | +| CHANGE_REASON | 份额变动原因 | + +--- + +### 可转债数据接口 + +#### 25. get_kzz_issuance - 可转债发行数据 + +```python +async def get_kzz_issuance( + self, + codes: List[str], + is_local: Optional[bool] = None +) -> Dict[str, pd.DataFrame] +``` + +**主要字段:** + +| 字段 | 说明 | +|------|------| +| STOCK_CODE | 正股代码 | +| LISTED_DATE | 上市日期 | +| PLAN_SCHEDULE | 方案进度 | +| CLAUSE_INI_CONV_PRICE | 初始转换价格 | +| LIST_ISSUE_SIZE | 发行规模(万元) | +| LIST_ISSUE_QUANTITY | 发行数量(万张) | +| TERM_YEAR | 借款期限(年) | +| COUPON_RATE | 利率(%) | + +--- + +## 使用示例 + +### 示例1:获取股票K线并判断是否涨停 + +```python +import asyncio +from app.adapters.amazingdata_adapter import AmazingDataAdapter + +async def main(): + adapter = AmazingDataAdapter() + await adapter.connect({ + "username": "xxx", "password": "xxx", + "host": "xxx", "port": 8600 + }) + + # 获取K线 + klines = await adapter.fetch_klines("000001.SZ", "20240101", "20241231", "1d") + + for k in klines: + print(f"日期: {k.trade_date}, 收盘: {k.close}, " + f"涨停: {k.is_limit_up}, 跌停: {k.is_limit_down}") + + await adapter.close() + +asyncio.run(main()) +``` + +### 示例2:获取财务报表 + +```python +# 获取资产负债表 +balance = await adapter.get_balance_sheet( + codes=["000001.SZ", "600000.SH"], + start_date="20240930", + end_date="20240930" +) + +for code, df in balance.items(): + print(f"{code} 总资产: {df['TOTAL_ASSETS'].values[0]}") +``` + +### 示例3:获取指数成分股 + +```python +# 沪深300成分股 +constituents = await adapter.get_index_constituents(["000300.SH"]) +df = constituents["000300.SH"] +print(f"成分股数量: {len(df)}") +print(df[["CON_CODE", "INDATE"]].head()) +``` + +### 示例4:获取龙虎榜数据 + +```python +longhu = await adapter.get_longhu_bang( + codes=["000001.SZ"], + start_date="20240101", + end_date="20241231" +) +print(longhu[["TRADE_DATE", "REASON_TYPE_NAME", "BUY_AMOUNT", "SELL_AMOUNT"]]) +``` + +--- + +## 注意事项 + +1. **连接管理**: 使用前先调用 `connect()`,使用后调用 `close()` +2. **日期格式**: 支持 `YYYYMMDD`、`"YYYY-MM-DD"` 或 `date` 对象 +3. **本地缓存**: 默认启用,可设置 `is_local=False` 强制从服务器获取 +4. **批量处理**: 大量数据建议分批获取,每批 50-100 个代码 +5. **错误处理**: 连接断开会抛出 `RuntimeError`,需做好异常处理 + +--- + +**文档版本**: 1.0 +**更新日期**: 2024-03-11 diff --git a/backend/xyzs/AmazingData_完整文档.md b/backend/xyzs/AmazingData_完整文档.md new file mode 100644 index 0000000..27dff21 --- /dev/null +++ b/backend/xyzs/AmazingData_完整文档.md @@ -0,0 +1,922 @@ +# AmazingData 数据源适配器 - 完整文档 + +## 目录 +1. [适配器代码](#一适配器代码) +2. [接口详细说明](#二接口详细说明) +3. [使用示例](#三使用示例) +4. [数据结构说明](#四数据结构说明) + +--- + +## 一、适配器代码 + +### 1. 主适配器代码 (amazing_data_adapter.py) + +```python +""" +AmazingData 数据源适配器 +基于银河证券星耀数智量化平台 SDK 的封装 +提供统一、简洁的金融数据获取接口 +""" + +import pandas as pd +from typing import List, Dict, Optional, Union, Tuple +from datetime import datetime, date +from dataclasses import dataclass +from enum import Enum +import logging + +# 配置日志 +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) + + +class SecurityType(Enum): + """证券类型枚举""" + STOCK_A = "EXTRA_STOCK_A" # 沪深A股 + STOCK_A_SH_SZ = "EXTRA_STOCK_A_SH_SZ" # 沪深A股(沪深) + INDEX_A = "EXTRA_INDEX_A" # 沪深指数 + ETF = "EXTRA_ETF" # ETF + FUTURE = "EXTRA_FUTURE" # 期货 + KZZ = "EXTRA_KZZ" # 可转债 + GLRA = "EXTRA_GLRA" # 逆回购 + HKT = "EXTRA_HKT" # 港股通 + ETF_OP = "EXTRA_ETF_OP" # ETF期权 + + +class Market(Enum): + """市场枚举""" + SH = "SH" # 上海 + SZ = "SZ" # 深圳 + BJ = "BJ" # 北京 + + +class Period(Enum): + """周期枚举""" + MIN1 = "min1" + MIN5 = "min5" + MIN15 = "min15" + MIN30 = "min30" + MIN60 = "min60" + DAILY = "daily" + WEEKLY = "weekly" + MONTHLY = "monthly" + + +@dataclass +class DataSourceConfig: + """数据源配置""" + username: str + password: str + host: str + port: int + local_path: str = "./amazing_data_cache/" + use_local_cache: bool = True + + +class AmazingDataAdapter: + """ + AmazingData 数据源适配器 + 封装银河证券星耀数智 SDK,提供统一的数据获取接口 + """ + + def __init__(self, config: DataSourceConfig): + self.config = config + self._ad = None + self._base_data = None + self._market_data = None + self._info_data = None + self._calendar = None + self._is_logged_in = False + + def connect(self) -> bool: + """连接到数据源""" + try: + import AmazingData as ad + self._ad = ad + + ad.login( + username=self.config.username, + password=self.config.password, + host=self.config.host, + port=self.config.port + ) + + self._base_data = ad.BaseData() + self._info_data = ad.InfoData() + self._calendar = self._base_data.get_calendar() + self._market_data = ad.MarketData(self._calendar) + + self._is_logged_in = True + logger.info("成功连接到 AmazingData 数据源") + return True + + except Exception as e: + logger.error(f"连接失败: {e}") + return False + + def disconnect(self): + """断开连接""" + if self._is_logged_in and self._ad: + try: + self._ad.logout(self.config.username) + logger.info("已断开与 AmazingData 的连接") + except Exception as e: + logger.warning(f"断开连接时出错: {e}") + self._is_logged_in = False + + # ==================== 基础数据接口 ==================== + + def get_code_list(self, security_type: SecurityType = SecurityType.STOCK_A) -> List[str]: + """ + 获取代码列表 + + Args: + security_type: 证券类型,可选 STOCK_A, ETF, FUTURE, KZZ, INDEX_A 等 + + Returns: + 证券代码列表,如 ['000001.SZ', '600000.SH', ...] + """ + self._check_login() + + if security_type == SecurityType.FUTURE: + return self._base_data.get_future_code_list(security_type=security_type.value) + elif security_type == SecurityType.ETF_OP: + return self._base_data.get_option_code_list(security_type=security_type.value) + else: + return self._base_data.get_code_list(security_type=security_type.value) + + def get_code_info(self, security_type: SecurityType = SecurityType.STOCK_A) -> pd.DataFrame: + """ + 获取证券信息 + + Returns: + DataFrame 包含字段: + - symbol: 证券简称 + - security_status: 产品状态标志 + - pre_close: 昨收价 + - high_limited: 涨停价 + - low_limited: 跌停价 + - price_tick: 最小价格变动单位 + """ + self._check_login() + return self._base_data.get_code_info(security_type=security_type.value) + + def get_trading_calendar(self, market: Market = Market.SH) -> List[int]: + """ + 获取交易日历 + + Returns: + 交易日列表,格式为 [20240102, 20240103, ...] + """ + self._check_login() + return self._base_data.get_calendar(market=market.value) + + def get_adj_factor(self, codes: List[str], is_local: Optional[bool] = None) -> pd.DataFrame: + """ + 获取单次复权因子 + + Args: + codes: 股票代码列表 + is_local: 是否使用本地缓存 + + Returns: + DataFrame (index: 日期, columns: 股票代码) + """ + self._check_login() + is_local = is_local if is_local is not None else self.config.use_local_cache + return self._base_data.get_adj_factor( + code_list=codes, local_path=self.config.local_path, is_local=is_local + ) + + def get_backward_factor(self, codes: List[str], is_local: Optional[bool] = None) -> pd.DataFrame: + """ + 获取后复权因子 + + Returns: + DataFrame (index: 日期, columns: 股票代码) + """ + self._check_login() + is_local = is_local if is_local is not None else self.config.use_local_cache + return self._base_data.get_backward_factor( + code_list=codes, local_path=self.config.local_path, is_local=is_local + ) + + # ==================== 历史行情数据接口 ==================== + + def get_kline(self, codes: Union[str, List[str]], + start_date: Union[str, int, date], + end_date: Union[str, int, date], + period: Period = Period.DAILY) -> Dict[str, pd.DataFrame]: + """ + 获取历史K线数据 + + Args: + codes: 证券代码或列表,如 '000001.SZ' 或 ['000001.SZ', '600000.SH'] + start_date: 开始日期,支持 '20240101' 或 20240101 或 date对象 + end_date: 结束日期 + period: K线周期,可选 MIN1/MIN5/MIN15/MIN30/MIN60/DAILY/WEEKLY/MONTHLY + + Returns: + Dict[代码, DataFrame],DataFrame包含字段: + - open: 开盘价 + - high: 最高价 + - low: 最低价 + - close: 收盘价 + - volume: 成交量 + - amount: 成交金额 + """ + self._check_login() + if isinstance(codes, str): + codes = [codes] + start_date = self._format_date(start_date) + end_date = self._format_date(end_date) + + return self._market_data.query_kline( + code_list=codes, begin_date=start_date, end_date=end_date, + period=getattr(self._ad.constant.Period, period.value).value + ) + + def get_snapshot(self, codes: Union[str, List[str]], + start_date: Union[str, int, date], + end_date: Union[str, int, date]) -> Dict[str, pd.DataFrame]: + """ + 获取历史快照数据 + + Returns: + Dict[代码, DataFrame],包含Level-1行情快照 + """ + self._check_login() + if isinstance(codes, str): + codes = [codes] + start_date = self._format_date(start_date) + end_date = self._format_date(end_date) + + return self._market_data.query_snapshot( + code_list=codes, begin_date=start_date, end_date=end_date + ) + + # ==================== 财务数据接口 ==================== + + def get_balance_sheet(self, codes: List[str], + start_date: Optional[Union[str, int, date]] = None, + end_date: Optional[Union[str, int, date]] = None, + is_local: Optional[bool] = None) -> Dict[str, pd.DataFrame]: + """ + 获取资产负债表 + + Args: + codes: 股票代码列表 + start_date: 开始报告期 (如 20240930 表示2024年三季报) + end_date: 结束报告期 + + Returns: + Dict[代码, DataFrame],主要字段包括: + - TOTAL_ASSETS: 资产总计 + - TOTAL_CUR_ASSETS: 流动资产合计 + - TOTAL_NONCUR_ASSETS: 非流动资产合计 + - TOTAL_LIAB: 负债合计 + - TOTAL_CUR_LIAB: 流动负债合计 + - TOT_SHARE_EQUITY_INCL_MIN_INT: 股东权益合计 + - CURRENCY_CAP: 货币资金 + - NOTES_RECEIVABLE: 应收票据 + - ACCT_RECEIVABLE: 应收账款 + - INV: 存货 + - FIX_ASSETS: 固定资产 + - NOTES_PAYABLE: 应付票据 + - ACCT_PAYABLE: 应付账款 + - ST_BORROWING: 短期借款 + - LT_LOAN: 长期借款 + """ + return self._get_financial_data('get_balance_sheet', codes, start_date, end_date, is_local) + + def get_cash_flow(self, codes: List[str], + start_date: Optional[Union[str, int, date]] = None, + end_date: Optional[Union[str, int, date]] = None, + is_local: Optional[bool] = None) -> Dict[str, pd.DataFrame]: + """ + 获取现金流量表 + + 主要字段: + - NET_CASH_FLOWS_OPERA_ACT: 经营活动现金流净额 + - NET_CASH_FLOWS_INV_ACT: 投资活动现金流净额 + - NET_CASH_FLOWS_FIN_ACT: 筹资活动现金流净额 + - NET_INCR_CASH_AND_CASH_EQU: 现金及现金等价物净增加额 + - CASH_RECP_SG_AND_RS: 销售商品提供劳务收到的现金 + - CASH_PAY_GOODS_SERVICES: 购买商品接受劳务支付的现金 + """ + return self._get_financial_data('get_cash_flow', codes, start_date, end_date, is_local) + + def get_income_statement(self, codes: List[str], + start_date: Optional[Union[str, int, date]] = None, + end_date: Optional[Union[str, int, date]] = None, + is_local: Optional[bool] = None) -> Dict[str, pd.DataFrame]: + """ + 获取利润表 + + 主要字段: + - TOT_OPERA_REV: 营业总收入 + - OPERA_REV: 营业收入 + - TOT_OPERA_COST: 营业总成本 + - OPERA_PROFIT: 营业利润 + - TOTAL_PROFIT: 利润总额 + - NET_PRO_INCL_MIN_INT_INC: 净利润 + - BASIC_EPS: 基本每股收益 + - DILUTED_EPS: 稀释每股收益 + - RD_EXP: 研发费用 + - LESS_SELLING_EXP: 销售费用 + - LESS_ADMIN_EXP: 管理费用 + - LESS_FIN_EXP: 财务费用 + """ + return self._get_financial_data('get_income', codes, start_date, end_date, is_local) + + def get_profit_express(self, codes: List[str], + start_date: Optional[Union[str, int, date]] = None, + end_date: Optional[Union[str, int, date]] = None, + is_local: Optional[bool] = None) -> pd.DataFrame: + """ + 获取业绩快报 + + 主要字段: + - TOTAL_ASSETS: 总资产 + - NET_PRO_EXCL_MIN_INT_INC: 净利润 + - TOT_OPERA_REV: 营业总收入 + - TOTAL_PROFIT: 利润总额 + - OPERA_PROFIT: 营业利润 + - EPS_BASIC: 基本每股收益 + - ROE_WEIGHTED: 净资产收益率-加权 + - YOY_GR_NET_PROFIT_PARENT: 同比增长率:归属母公司股东的净利润 + """ + self._check_login() + is_local = is_local if is_local is not None else self.config.use_local_cache + return self._info_data.get_profit_express( + code_list=codes, local_path=self.config.local_path, is_local=is_local, + begin_date=self._format_date(start_date) if start_date else None, + end_date=self._format_date(end_date) if end_date else None + ) + + def get_profit_notice(self, codes: List[str], + start_date: Optional[Union[str, int, date]] = None, + end_date: Optional[Union[str, int, date]] = None, + is_local: Optional[bool] = None) -> pd.DataFrame: + """ + 获取业绩预告 + + 主要字段: + - P_TYPECODE: 业绩预告类型代码 + - P_CHANGE_MAX: 预告净利润变动幅度上限 + - P_CHANGE_MIN: 预告净利润变动幅度下限 + - NET_PROFIT_MAX: 预告净利润上限(万元) + - NET_PROFIT_MIN: 预告净利润下限(万元) + - P_REASON: 业绩变动原因 + """ + self._check_login() + is_local = is_local if is_local is not None else self.config.use_local_cache + return self._info_data.get_profit_notice( + code_list=codes, local_path=self.config.local_path, is_local=is_local, + begin_date=self._format_date(start_date) if start_date else None, + end_date=self._format_date(end_date) if end_date else None + ) + + # ==================== 股东股本数据接口 ==================== + + def get_top10_shareholders(self, codes: List[str], + start_date: Optional[Union[str, int, date]] = None, + end_date: Optional[Union[str, int, date]] = None, + is_local: Optional[bool] = None) -> pd.DataFrame: + """ + 获取十大股东数据 + + 主要字段: + - HOLDER_NAME: 股东名称 + - HOLDER_QUANTITY: 持股数 + - HOLDER_PCT: 持股比例(%) + - HOLDER_HOLDER_CATEGORY: 股东性质(1:个人, 2:公司) + - FLOAT_QTY: 流通股数量 + """ + self._check_login() + is_local = is_local if is_local is not None else self.config.use_local_cache + return self._info_data.get_share_holder( + code_list=codes, local_path=self.config.local_path, is_local=is_local, + begin_date=self._format_date(start_date) if start_date else None, + end_date=self._format_date(end_date) if end_date else None + ) + + def get_shareholder_count(self, codes: List[str], + start_date: Optional[Union[str, int, date]] = None, + end_date: Optional[Union[str, int, date]] = None, + is_local: Optional[bool] = None) -> pd.DataFrame: + """ + 获取股东户数数据 + + 主要字段: + - HOLDER_TOTAL_NUM: A股、B股、H股、境外股的总户数 + - HOLDER_NUM: A股股东户数 + """ + self._check_login() + is_local = is_local if is_local is not None else self.config.use_local_cache + return self._info_data.get_holder_num( + code_list=codes, local_path=self.config.local_path, is_local=is_local, + begin_date=self._format_date(start_date) if start_date else None, + end_date=self._format_date(end_date) if end_date else None + ) + + def get_equity_structure(self, codes: List[str], + start_date: Optional[Union[str, int, date]] = None, + end_date: Optional[Union[str, int, date]] = None, + is_local: Optional[bool] = None) -> pd.DataFrame: + """ + 获取股本结构数据 + + 主要字段: + - TOT_SHARE: 总股本(万股) + - FLOAT_SHARE: 流通股(万股) + - FLOAT_A_SHARE: 流通A股(万股) + - RESTRICTED_A_SHARE: 限售A股(万股) + - TOT_RESTRICTED_SHARE: 限售股合计 + """ + self._check_login() + is_local = is_local if is_local is not None else self.config.use_local_cache + return self._info_data.get_equity_structure( + code_list=codes, local_path=self.config.local_path, is_local=is_local, + begin_date=self._format_date(start_date) if start_date else None, + end_date=self._format_date(end_date) if end_date else None + ) + + # ==================== 融资融券数据接口 ==================== + + def get_margin_summary(self, + start_date: Optional[Union[str, int, date]] = None, + end_date: Optional[Union[str, int, date]] = None, + is_local: Optional[bool] = None) -> pd.DataFrame: + """ + 获取融资融券成交汇总 + + 主要字段: + - TRADE_DATE: 交易日期 + - SUM_BORROW_MONEY_BALANCE: 融资余额(元) + - SUM_PURCH_WITH_BORROW_MONEY: 融资买入额(元) + - SUM_REPAYMENT_OF_BORROW_MONEY: 融资偿还额(元) + - SUM_SEC_LENDING_BALANCE: 融券余额(元) + - SUM_SALES_OF_BORROWED_SEC: 融券卖出量 + - SUM_MARGIN_TRADE_BALANCE: 融资融券余额(元) + """ + self._check_login() + is_local = is_local if is_local is not None else self.config.use_local_cache + return self._info_data.get_margin_summary( + local_path=self.config.local_path, is_local=is_local, + begin_date=self._format_date(start_date) if start_date else None, + end_date=self._format_date(end_date) if end_date else None + ) + + def get_margin_detail(self, codes: List[str], + start_date: Optional[Union[str, int, date]] = None, + end_date: Optional[Union[str, int, date]] = None, + is_local: Optional[bool] = None) -> Dict[str, pd.DataFrame]: + """ + 获取融资融券交易明细 + + 主要字段: + - BORROW_MONEY_BALANCE: 融资余额 + - PURCH_WITH_BORROW_MONEY: 融资买入额 + - REPAYMENT_OF_BORROW_MONEY: 融资偿还额 + - SEC_LENDING_BALANCE: 融券余额 + - SALES_OF_BORROWED_SEC: 融券卖出量 + """ + self._check_login() + is_local = is_local if is_local is not None else self.config.use_local_cache + return self._info_data.get_margin_detail( + code_list=codes, local_path=self.config.local_path, is_local=is_local, + begin_date=self._format_date(start_date) if start_date else None, + end_date=self._format_date(end_date) if end_date else None + ) + + # ==================== 交易异动数据接口 ==================== + + def get_longhu_bang(self, codes: List[str], + start_date: Optional[Union[str, int, date]] = None, + end_date: Optional[Union[str, int, date]] = None, + is_local: Optional[bool] = None) -> pd.DataFrame: + """ + 获取龙虎榜数据 + + 主要字段: + - TRADE_DATE: 交易日期 + - REASON_TYPE_NAME: 上榜原因 + - CHANGE_RANGE: 涨跌幅(%) + - TRADER_NAME: 营业部名称 + - BUY_AMOUNT: 买入金额(元) + - SELL_AMOUNT: 卖出金额(元) + - FLOW_MARK: 买卖表示(1买入, 2卖出) + """ + self._check_login() + is_local = is_local if is_local is not None else self.config.use_local_cache + return self._info_data.get_long_hu_bang( + code_list=codes, local_path=self.config.local_path, is_local=is_local, + begin_date=self._format_date(start_date) if start_date else None, + end_date=self._format_date(end_date) if end_date else None + ) + + def get_block_trading(self, codes: List[str], + start_date: Optional[Union[str, int, date]] = None, + end_date: Optional[Union[str, int, date]] = None, + is_local: Optional[bool] = None) -> pd.DataFrame: + """ + 获取大宗交易数据 + + 主要字段: + - TRADE_DATE: 交易日期 + - B_SHARE_PRICE: 成交价(元) + - B_SHARE_VOLUME: 成交量(万股) + - B_SHARE_AMOUNT: 成交金额(万元) + - B_BUYER_NAME: 买方营业部名称 + - B_SELLER_NAME: 卖方营业部名称 + """ + self._check_login() + is_local = is_local if is_local is not None else self.config.use_local_cache + return self._info_data.get_block_trading( + code_list=codes, local_path=self.config.local_path, is_local=is_local, + begin_date=self._format_date(start_date) if start_date else None, + end_date=self._format_date(end_date) if end_date else None + ) + + # ==================== 指数数据接口 ==================== + + def get_index_constituents(self, codes: List[str], + is_local: Optional[bool] = None) -> Dict[str, pd.DataFrame]: + """ + 获取指数成分股 + + Args: + codes: 指数代码列表,如 ['000300.SH', '000905.SH'] + + Returns: + Dict[指数代码, DataFrame] + + DataFrame字段: + - INDEX_CODE: 指数代码 + - CON_CODE: 成分股代码 + - INDATE: 纳入日期 + - OUTDATE: 剔除日期 + - INDEX_NAME: 指数名称 + """ + self._check_login() + is_local = is_local if is_local is not None else self.config.use_local_cache + return self._info_data.get_index_constituent( + code_list=codes, local_path=self.config.local_path, is_local=is_local + ) + + def get_index_weights(self, codes: List[str], + start_date: Optional[Union[str, int, date]] = None, + end_date: Optional[Union[str, int, date]] = None, + is_local: Optional[bool] = None) -> Dict[str, pd.DataFrame]: + """ + 获取指数成分股权重 + + 支持指数: + - 000016.SH: 上证50 + - 000300.SH: 沪深300 + - 000905.SH: 中证500 + - 000906.SH: 中证800 + - 000852.SH: 中证1000 + + DataFrame字段: + - INDEX_CODE: 指数代码 + - CON_CODE: 标的代码 + - TRADE_DATE: 生效日期 + - WEIGHT: 权重(%) + - CLOSE: 收盘价 + """ + self._check_login() + is_local = is_local if is_local is not None else self.config.use_local_cache + return self._info_data.get_index_weight( + code_list=codes, local_path=self.config.local_path, is_local=is_local, + begin_date=self._format_date(start_date) if start_date else None, + end_date=self._format_date(end_date) if end_date else None + ) + + # ==================== ETF数据接口 ==================== + + def get_etf_pcf(self, codes: List[str]) -> Tuple[pd.DataFrame, Dict[str, pd.DataFrame]]: + """ + 获取ETF申赎数据 + + Returns: + (etf_info, etf_constituents) + + etf_info字段: + - creation_redemption_unit: 每个篮子对应的ETF份数 + - max_cash_ratio: 最大现金替代比例 + - creation: 是否允许申购 + - redemption: 是否允许赎回 + + etf_constituents字段: + - underlying_symbol: 成份证券简称 + - component_share: 成份证券数量 + - substitute_flag: 现金替代标志 + """ + self._check_login() + return self._base_data.get_etf_pcf(code_list=codes) + + def get_fund_share(self, codes: List[str], + start_date: Optional[Union[str, int, date]] = None, + end_date: Optional[Union[str, int, date]] = None, + is_local: Optional[bool] = None) -> Dict[str, pd.DataFrame]: + """ + 获取基金份额数据 + + 主要字段: + - FUND_SHARE: 基金份额(万份) + - TOTAL_SHARE: 基金总份额(万份) + - FLOAT_SHARE: 流通份额(万份) + - CHANGE_REASON: 份额变动原因 + """ + self._check_login() + is_local = is_local if is_local is not None else self.config.use_local_cache + return self._info_data.get_fund_share( + code_list=codes, local_path=self.config.local_path, is_local=is_local, + begin_date=self._format_date(start_date) if start_date else None, + end_date=self._format_date(end_date) if end_date else None + ) + + # ==================== 可转债数据接口 ==================== + + def get_kzz_issuance(self, codes: List[str], + is_local: Optional[bool] = None) -> Dict[str, pd.DataFrame]: + """ + 获取可转债发行数据 + + 主要字段: + - STOCK_CODE: 正股代码 + - LISTED_DATE: 上市日期 + - PLAN_SCHEDULE: 方案进度 + - CLAUSE_INI_CONV_PRICE: 初始转换价格 + - LIST_ISSUE_SIZE: 发行规模(万元) + - LIST_ISSUE_QUANTITY: 发行数量(万张) + - TERM_YEAR: 借款期限(年) + - COUPON_RATE: 利率(%) + """ + self._check_login() + is_local = is_local if is_local is not None else self.config.use_local_cache + return self._info_data.get_kzz_issuance( + code_list=codes, local_path=self.config.local_path, is_local=is_local + ) + + # ==================== 辅助方法 ==================== + + def _check_login(self): + if not self._is_logged_in: + raise RuntimeError("未连接到数据源,请先调用 connect()") + + def _format_date(self, d: Union[str, int, date]) -> int: + if isinstance(d, int): + return d + elif isinstance(d, str): + return int(d.replace("-", "").replace("/", "")) + elif isinstance(d, date): + return int(d.strftime("%Y%m%d")) + else: + raise ValueError(f"不支持的日期格式: {d}") + + def _get_financial_data(self, method: str, codes: List[str], + start_date: Optional[Union[str, int, date]] = None, + end_date: Optional[Union[str, int, date]] = None, + is_local: Optional[bool] = None) -> Dict[str, pd.DataFrame]: + self._check_login() + is_local = is_local if is_local is not None else self.config.use_local_cache + method = getattr(self._info_data, method) + return method( + code_list=codes, local_path=self.config.local_path, is_local=is_local, + begin_date=self._format_date(start_date) if start_date else None, + end_date=self._format_date(end_date) if end_date else None + ) + + +def create_adapter(username: str, password: str, host: str, port: int, + local_path: str = "./amazing_data_cache/", + use_local_cache: bool = True) -> AmazingDataAdapter: + """快速创建适配器实例""" + config = DataSourceConfig( + username=username, password=password, host=host, port=port, + local_path=local_path, use_local_cache=use_local_cache + ) + return AmazingDataAdapter(config) +``` + +--- + +## 二、接口详细说明 + +### 1. 基础数据接口 + +| 接口名 | 功能 | 返回类型 | 主要字段 | +|--------|------|----------|----------| +| `get_code_list` | 获取代码列表 | `List[str]` | 代码列表 | +| `get_code_info` | 获取证券信息 | `DataFrame` | symbol, pre_close, high_limited, low_limited | +| `get_trading_calendar` | 获取交易日历 | `List[int]` | 交易日列表 | +| `get_adj_factor` | 单次复权因子 | `DataFrame` | index=日期, columns=代码 | +| `get_backward_factor` | 后复权因子 | `DataFrame` | index=日期, columns=代码 | + +### 2. 历史行情接口 + +| 接口名 | 功能 | 返回类型 | 主要字段 | +|--------|------|----------|----------| +| `get_kline` | K线数据 | `Dict[str, DataFrame]` | open, high, low, close, volume, amount | +| `get_snapshot` | 历史快照 | `Dict[str, DataFrame]` | Level-1行情数据 | + +**Period 周期选项:** +- `Period.MIN1` / `Period.MIN5` / `Period.MIN15` / `Period.MIN30` / `Period.MIN60` +- `Period.DAILY` / `Period.WEEKLY` / `Period.MONTHLY` + +### 3. 财务数据接口 + +| 接口名 | 功能 | 返回类型 | 主要字段 | +|--------|------|----------|----------| +| `get_balance_sheet` | 资产负债表 | `Dict[str, DataFrame]` | TOTAL_ASSETS, TOTAL_LIAB, TOT_SHARE_EQUITY | +| `get_cash_flow` | 现金流量表 | `Dict[str, DataFrame]` | NET_CASH_FLOWS_OPERA_ACT, NET_CASH_FLOWS_INV_ACT | +| `get_income_statement` | 利润表 | `Dict[str, DataFrame]` | TOT_OPERA_REV, NET_PRO_INCL_MIN_INT_INC, BASIC_EPS | +| `get_profit_express` | 业绩快报 | `DataFrame` | ROE_WEIGHTED, YOY_GR_NET_PROFIT_PARENT | +| `get_profit_notice` | 业绩预告 | `DataFrame` | P_CHANGE_MAX, P_CHANGE_MIN, NET_PROFIT_MAX | + +### 4. 股东股本接口 + +| 接口名 | 功能 | 返回类型 | 主要字段 | +|--------|------|----------|----------| +| `get_top10_shareholders` | 十大股东 | `DataFrame` | HOLDER_NAME, HOLDER_QUANTITY, HOLDER_PCT | +| `get_shareholder_count` | 股东户数 | `DataFrame` | HOLDER_TOTAL_NUM, HOLDER_NUM | +| `get_equity_structure` | 股本结构 | `DataFrame` | TOT_SHARE, FLOAT_SHARE, RESTRICTED_A_SHARE | + +### 5. 融资融券接口 + +| 接口名 | 功能 | 返回类型 | 主要字段 | +|--------|------|----------|----------| +| `get_margin_summary` | 融资融券汇总 | `DataFrame` | SUM_BORROW_MONEY_BALANCE, SUM_MARGIN_TRADE_BALANCE | +| `get_margin_detail` | 个股融资融券 | `Dict[str, DataFrame]` | BORROW_MONEY_BALANCE, SEC_LENDING_BALANCE | + +### 6. 交易异动接口 + +| 接口名 | 功能 | 返回类型 | 主要字段 | +|--------|------|----------|----------| +| `get_longhu_bang` | 龙虎榜 | `DataFrame` | TRADER_NAME, BUY_AMOUNT, SELL_AMOUNT, FLOW_MARK | +| `get_block_trading` | 大宗交易 | `DataFrame` | B_SHARE_PRICE, B_SHARE_VOLUME, B_BUYER_NAME | + +### 7. 指数数据接口 + +| 接口名 | 功能 | 返回类型 | 主要字段 | +|--------|------|----------|----------| +| `get_index_constituents` | 指数成分股 | `Dict[str, DataFrame]` | INDEX_CODE, CON_CODE, INDATE, OUTDATE | +| `get_index_weights` | 成分股权重 | `Dict[str, DataFrame]` | INDEX_CODE, CON_CODE, WEIGHT | + +**支持的指数代码:** +- `000016.SH` - 上证50 +- `000300.SH` - 沪深300 +- `000905.SH` - 中证500 +- `000906.SH` - 中证800 +- `000852.SH` - 中证1000 + +### 8. ETF数据接口 + +| 接口名 | 功能 | 返回类型 | 主要字段 | +|--------|------|----------|----------| +| `get_etf_pcf` | ETF申赎数据 | `Tuple[DataFrame, Dict]` | creation_redemption_unit, component_share | +| `get_fund_share` | 基金份额 | `Dict[str, DataFrame]` | FUND_SHARE, TOTAL_SHARE | + +### 9. 可转债数据接口 + +| 接口名 | 功能 | 返回类型 | 主要字段 | +|--------|------|----------|----------| +| `get_kzz_issuance` | 可转债发行 | `Dict[str, DataFrame]` | CLAUSE_INI_CONV_PRICE, LIST_ISSUE_SIZE, COUPON_RATE | + +--- + +## 三、使用示例 + +### 基础使用 + +```python +from amazing_data_adapter import create_adapter, SecurityType, Period + +# 创建并连接 +adapter = create_adapter( + username='your_username', + password='your_password', + host='your_host', + port=8080 +) + +if adapter.connect(): + # 获取A股列表 + codes = adapter.get_code_list(SecurityType.STOCK_A) + print(f"A股数量: {len(codes)}") + + # 获取平安银行K线 + kline = adapter.get_kline( + codes=['000001.SZ'], + start_date='20240101', + end_date='20241231', + period=Period.DAILY + ) + print(kline['000001.SZ'].head()) + + adapter.disconnect() +``` + +### 获取财务数据 + +```python +# 获取资产负债表 +balance = adapter.get_balance_sheet( + codes=['000001.SZ', '600000.SH'], + start_date=20240930, + end_date=20240930 +) + +for code, df in balance.items(): + print(f"{code} 总资产: {df['TOTAL_ASSETS'].values[0]}") +``` + +### 获取指数成分股 + +```python +# 沪深300成分股 +constituents = adapter.get_index_constituents(['000300.SH']) +df = constituents['000300.SH'] +print(f"成分股数量: {len(df)}") +print(df[['CON_CODE', 'INDATE']].head()) +``` + +### 批量处理 + +```python +# 分批获取数据避免超时 +all_codes = adapter.get_code_list(SecurityType.STOCK_A) +batch_size = 50 + +for i in range(0, 100, batch_size): # 只取前100只演示 + batch = all_codes[i:i+batch_size] + data = adapter.get_balance_sheet(batch) + print(f"已处理 {i+len(batch)} 只股票") +``` + +--- + +## 四、数据结构说明 + +### K线数据字段 + +| 字段名 | 类型 | 说明 | +|--------|------|------| +| open | float | 开盘价 | +| high | float | 最高价 | +| low | float | 最低价 | +| close | float | 收盘价 | +| volume | int | 成交量(股) | +| amount | float | 成交金额(元) | + +### 资产负债表关键字段 + +| 字段名 | 说明 | +|--------|------| +| TOTAL_ASSETS | 资产总计 | +| TOTAL_CUR_ASSETS | 流动资产合计 | +| TOTAL_NONCUR_ASSETS | 非流动资产合计 | +| TOTAL_LIAB | 负债合计 | +| TOT_SHARE_EQUITY_INCL_MIN_INT | 股东权益合计(含少数股东) | +| CURRENCY_CAP | 货币资金 | +| NOTES_RECEIVABLE | 应收票据 | +| ACCT_RECEIVABLE | 应收账款 | +| INV | 存货 | + +### 利润表关键字段 + +| 字段名 | 说明 | +|--------|------| +| TOT_OPERA_REV | 营业总收入 | +| OPERA_PROFIT | 营业利润 | +| TOTAL_PROFIT | 利润总额 | +| NET_PRO_INCL_MIN_INT_INC | 净利润(含少数股东) | +| BASIC_EPS | 基本每股收益 | +| DILUTED_EPS | 稀释每股收益 | + +### 现金流量表关键字段 + +| 字段名 | 说明 | +|--------|------| +| NET_CASH_FLOWS_OPERA_ACT | 经营活动现金流净额 | +| NET_CASH_FLOWS_INV_ACT | 投资活动现金流净额 | +| NET_CASH_FLOWS_FIN_ACT | 筹资活动现金流净额 | +| NET_INCR_CASH_AND_CASH_EQU | 现金及现金等价物净增加额 | + +--- + +## 五、注意事项 + +1. **连接管理**: 使用前先调用 `connect()`,使用后调用 `disconnect()` +2. **日期格式**: 支持 `20240101`、`"2024-01-01"` 或 `date` 对象 +3. **本地缓存**: 默认启用,可设置 `is_local=False` 强制从服务器获取 +4. **批量处理**: 大量数据建议分批获取,每批 50-100 个代码 +5. **错误处理**: 连接断开会抛出 `RuntimeError`,需做好异常处理 + +--- + +**文件保存位置**: `/root/.openclaw/workspace/amazing_data_adapter.py` diff --git a/backend/xyzs/AmazingData开发手册.md b/backend/xyzs/AmazingData开发手册.md new file mode 100644 index 0000000..0392b7e --- /dev/null +++ b/backend/xyzs/AmazingData开发手册.md @@ -0,0 +1,1383 @@ +# 中国银河证券星耀数智AmazingData 开发手册 + +**文档版本**: V1.0.24 +**Python SDK 版本**: V1.0.24 +**创建时间**: 2025年7月10日 +**最新发布日期**: 2025年12月16日 + +--- + +## 目录 + +1. [版本说明](#1版本说明) +2. [功能介绍](#2功能介绍) +3. [Python开发指南](#3python开发指南) +4. [附录](#4附录) +5. [免责声明](#5免责声明) + +--- + +## 1.版本说明 + +### 1.1 文档管理信息表 + +| 主题 | 中国银河证券星耀数智AmazingData 开发手册 | +|------|----------------------------------------| +| 文档版本 | V1.0.24 | +| Python SDK 版本 | V1.0.24 | +| 创建时间 | 2025年7月10日 | +| 最新发布日期 | 2025年12月16日 | + +--- + +## 2.功能介绍 + +本文档是tgw的SDK开发指南,包含了对API接口的说明以及示例,用于指引开发人员通过tgw金融数据功能接口进行数据接收和查询的开发。如需参考或使用本项目,需要提前联系官方获取权限。 + +### 2.1 金融数据服务 + +金融数据功能,是指用户使用C++、Python以及其他本功能可支持的程序设计语言或用户端页面,获取公司通过对证券交易所等渠道的公开信息加工而成的行情数据、金融资讯数据等金融数据的功能。 + +### 2.2 数据详情 + +#### 1) 行情数据 + +| 行情数据 | 品种 | 数据类型 | 数据起点 | 说明 | 是否支持实时订阅 | +|---------|------|---------|---------|------|----------------| +| 股票 | Level-1快照、K线数据 | 上交所、深交所、北交所 | 2013年至今 | - | 是 | +| 指数 | Level-1快照、K线数据 | 上交所、深交所、北交所 | - | - | 是 | +| 债券 | Level-1快照、K线数据 | 上交所、深交所 | - | - | 是 | +| 场内基金 | Level-1快照、K线数据 | 上交所、深交所 | - | - | 是 | +| 期权 | Level-1快照、K线数据 | 深交所ETF期权、上交所ETF期权 | 2015年至今 | - | 是 | +| 港股通 | 港股通行情快照 | 上交所、深交所 | 2023年至今 | - | 是 | +| 期货 | Level-1快照、K线数据 | 中金所 | 2010年4月至今 | - | 是 | + +#### 2) 基础数据 +- 每日最新证券信息(交易日早上9点前更新) +- 复权因子 +- 每日最新代码表(交易日早上9点前更新) +- 历史代码表 +- 交易日历 + +#### 3) 财务数据 +- 资产负债表 +- 现金流量表 +- 利润表 +- 业绩快报 +- 业绩预告 + +#### 4) 股东股本数据 +- 十大股东数据 +- 股东户数 +- 股本结构 +- 股权冻结/质押 +- 限售股解禁 + +#### 5) 股东权益数据 +- 分红数据 +- 配股数据 + +#### 6) 融资融券数据 +- 融资融券成交汇总 +- 融资融券交易明细 + +#### 7) 交易异动数据 +- 龙虎榜 +- 大宗交易 + +--- + +## 3.Python开发指南 + +### 3.1 SDK版本与下载 + +#### 3.1.1 wheel文件版本 + +| wheel文件名 | 操作系统 | Python版本 | +|------------|---------|-----------| +| tgw-1.*.*-py3-none-any.whl | Linux/Windows | Python 3.8/3.9/3.10/3.11/3.12/3.13 | +| AmazingData-1.*.*-cp38-none-any.whl | Linux/Windows | Python 3.8/3.9/3.10/3.11/3.12/3.13 | + +#### 3.1.2 wheel文件下载路径 + +1. 银河网盘 +2. 公众号"中国银河证券星耀数智"路径:"业务介绍"——"安装包下载" + +### 3.2 SDK运行环境 + +#### 3.2.1 Linux推荐运行环境配置 + +| 类型 | 最低配置 | 推荐配置 | +|------|---------|---------| +| 处理器 | 2.10GHz, 4核 | 2.10GHz, 8核 | +| 内存 | DDR4 4GB | DDR4 4GB | +| 硬盘 | 200G机械硬盘/SSD | 480G机械硬盘/SSD | +| 网卡 | 普通网卡 | 普通万兆网卡 | +| 操作系统 | REDHAT 7.2/7.4/7.6 | REDHAT 7.2/7.4/7.6 | + +#### 3.2.2 Windows推荐运行环境配置 + +| 类型 | 最低配置 | 推荐配置 | +|------|---------|---------| +| 处理器 | 2.60GHz, 4核 | 2.60GHz, 8核 | +| 内存 | DDR4 4GB | DDR4 4GB | +| 硬盘 | 200G机械硬盘/SSD | 480G机械硬盘/SSD | +| 网卡 | 普通网卡 | 普通万兆网卡 | +| 操作系统 | Windows 10(64位) | Windows 10(64位) | + +### 3.3 SDK安装 + +#### 3.3.1 tgw安装 +```bash +pip install +``` + +#### 3.3.2 AmazingData安装 +选择对应的Python版本: +```bash +pip install +``` + +### 3.4 Python开发步骤 + +登录AmazingData之后,实现数据获取。 + +#### 3.4.1 登录AmazingData + +**注意**: +1. 所有数据接口调用前,必须登录 +2. import AmazingData库,填写账号、密码、ip/port等信息,调用登录API + +```python +import AmazingData as ad +``` + +#### 3.4.2 调用数据接口 + +##### 3.4.2.1 查询接口调用 + +步骤: +1. 登录API +2. 实例化对应的数据查询类 +3. 调用查询数据接口,获取数据 + +```python +# 第一步:登录API +import AmazingData as ad +ad.login(username='username', password='password', host='***.***.***.***', port=****) + +# 第二步:实例化对应的数据查询类 +base_data_object = ad.BaseData() + +# 第三步:调用查询数据接口,获取数据 +code_list = base_data_object.get_code_list(security_type='EXTRA_STOCK_A') +``` + +##### 3.4.2.2 订阅接口调用 + +步骤: +1. 登录API +2. 实例化对应的数据查询类 +3. 实例化数据订阅类 +4. 用装饰器装饰回调函数,接收订阅数据 +5. 订阅数据执行 + +```python +# 第一步:登录API +import AmazingData as ad +ad.login(username='username', password='password', host='***.***.***.***', port=****) + +# 第二步:输入标的代码列表 +base_data_object = ad.BaseData() +etf_code_list = base_data_object.get_code_list(security_type='EXTRA_ETF') + +# 第三步:实例化数据订阅类 +sub_data = ad.SubscribeData() + +# 第四步:用装饰器装饰回调函数,接收订阅数据 +@sub_data.register(code_list=etf_code_list, period=ad.constant.Period.snapshot.value) +def onSnapshot(data: Union[ad.constant.Snapshot, ad.constant.SnapshotIndex], period): + print(period, data) + +# 第五步:订阅数据执行 +sub_data.run() +``` + +### 3.5 数据接口详细 + +#### 3.5.1 基础接口 + +##### 3.5.1.1 登录 + +**函数接口**: `login` + +**功能描述**: API登录 + +**输入参数**: + +| 参数 | 数据类型 | 必选 | 解释 | +|------|---------|------|------| +| username | str | 是 | 账号 | +| password | str | 是 | 密码 | +| host | str | 是 | 服务器IP | +| port | int | 是 | 服务器端口号 | + +**示例代码**: +```python +import AmazingData as ad +ad.login(username='username', password='password', host='***.***.***.***', port=****) +``` + +**注意**:SDK的账号、密码、ip和端口号需联系您的开户营业部申请开通权限之后获取。 + +##### 3.5.1.2 登出 + +**函数接口**: `logout` + +**功能描述**: API退出登录链接,必须在登录状态下才可使用;正常使用情况下,无需使用此接口 + +| 名称 | 类型 | 说明 | +|------|------|------| +| username | str | 用户名 | + +##### 3.5.1.3 更新密码 + +**函数接口**: `update_password` + +**功能描述**: 更新密码接口,必须先登录才能修改密码 + +| 名称 | 类型 | 说明 | +|------|------|------| +| username | str | 用户名 | +| old_password | str | 旧密码 | +| new_password | str | 新密码 | + +#### 3.5.2 基础数据 + +##### 3.5.2.1 每日最新证券信息 + +**函数接口**: `get_code_info` + +**功能描述**: 获取每日最新证券信息,交易日早上9点前更新当日最新 + +**输入参数**: + +| 参数 | 数据类型 | 必选 | 解释 | +|------|---------|------|------| +| security_type | str | 否 | 代码类型security_type(见附录),默认为EXTRA_STOCK_A | + +**输出参数**: + +| 参数 | 数据类型 | 解释 | +|------|---------|------| +| code_info | DataFrame | index为股票代码,column包含:symbol(证券简称)、security_status、pre_close、high_limited、low_limited、price_tick | + +**示例代码**: +```python +import AmazingData as ad +ad.login(username='username', password='password', host='***.***.***.***', port=****) +base_data_object = ad.BaseData() +code_info = base_data_object.get_code_info(security_type='EXTRA_ETF') +``` + +##### 3.5.2.2 每日最新代码表(沪深北) + +**函数接口**: `get_code_list` + +**功能描述**: 获取代码表(每日最新),此接口无法获取历史代码表。交易日早上9点前更新。 + +**输入参数**: + +| 参数 | 数据类型 | 必选 | 解释 | +|------|---------|------|------| +| security_type | str | 否 | 代码类型,默认为EXTRA_STOCK_A | + +**输出参数**: + +| 返回值 | 数据类型 | 解释 | +|--------|---------|------| +| code_list | list | 证券代码 | + +**示例代码**: +```python +import AmazingData as ad +ad.login(username='username', password='password', host='***.***.***.***', port=****) +base_data_object = ad.BaseData() +code_list = base_data_object.get_code_list(security_type='EXTRA_STOCK_A') +``` + +##### 3.5.2.3 每日最新代码表(期货交易所) + +**函数接口**: `get_future_code_list` + +**功能描述**: 获取代码表(每日最新),此接口无法获取历史代码表。交易日早上9点前更新。 + +**输入参数**: + +| 参数 | 数据类型 | 必选 | 解释 | +|------|---------|------|------| +| security_type | str | 是 | 代码类型,默认为ZJ_FUTURE(期货,中金所) | + +**输出参数**: + +| 返回值 | 数据类型 | 解释 | +|--------|---------|------| +| code_list | list | 证券代码 | + +##### 3.5.2.4 每日最新代码表(期权) + +**函数接口**: `get_option_code_list` + +**功能描述**: 获取代码表(每日最新),此接口无法获取历史代码表。交易日早上9点前更新。 + +**输入参数**: + +| 参数 | 数据类型 | 必选 | 解释 | +|------|---------|------|------| +| security_type | str | 是 | 代码类型,默认为EXTRA_ETF_OP(ETF期权) | + +**输出参数**: + +| 返回值 | 数据类型 | 解释 | +|--------|---------|------| +| code_list | list | 证券代码 | + +##### 3.5.2.5 复权因子(后复权因子) + +**函数接口**: `get_backward_factor` + +**功能描述**: 获取复权因子数据并本地存储 + +**输入参数**: + +| 参数 | 数据类型 | 必选 | 解释 | +|------|---------|------|------| +| code_list | list[str] | 是 | 代码列表,支持股票、ETF | +| local_path | str | 是 | 本地存储复权因子数据的文件夹地址 | +| is_local | bool | 是 | 是否使用本地存储的数据,默认为True | + +**输出参数**: + +| 参数 | 数据类型 | 解释 | +|------|---------|------| +| backward_factor | DataFrame | index为交易日期,column为股票代码 | + +**示例代码**: +```python +import AmazingData as ad +ad.login(username='username', password='password', host='***.***.***.***', port=****) +base_data_object = ad.BaseData() +code_list = base_data_object.get_code_list(security_type='EXTRA_STOCK_A') +backward_factor = base_data_object.get_backward_factor( + code_list, + local_path='D://AmazingData_local_data//', + is_local=False +) +``` + +##### 3.5.2.6 复权因子(单次复权因子) + +**函数接口**: `get_adj_factor` + +**功能描述**: 获取单次复权因子数据并本地存储 + +**输入参数**: 同后复权因子 + +**输出参数**: + +| 参数 | 数据类型 | 解释 | +|------|---------|------| +| adj_factor | DataFrame | index为交易日期,column为股票代码 | + +##### 3.5.2.7 历史代码表 + +**函数接口**: `get_hist_code_list` + +**功能描述**: 获取历史代码表,先检查本地数据,再从服务端补充 + +**输入参数**: + +| 参数 | 数据类型 | 必选 | 解释 | +|------|---------|------|------| +| security_type | str | 是 | 默认为"EXTRA_STOCK_A_SH_SZ" | +| start_date | int | 是 | 开始时间,闭区间 | +| end_date | int | 是 | 结束时间,闭区间 | +| local_path | str | 是 | 本地存储数据的路径 | + +**输出参数**: + +| 返回值 | 数据类型 | 解释 | +|--------|---------|------| +| code_list | List[str] | 证券代码 | + +##### 3.5.2.8 交易日历 + +**函数接口**: `get_calendar` + +**功能描述**: 获取交易所的交易日历 + +**输入参数**: + +| 参数 | 数据类型 | 必选 | 解释 | +|------|---------|------|------| +| data_type | str | 否 | 选择返回数据的类型,默认为str | +| market | str | 否 | 选择市场,默认为SH(上海) | + +**输出参数**: + +| 返回值 | 数据类型 | 解释 | +|--------|---------|------| +| calendar | List[int] | 日期 | + +##### 3.5.2.9 证券基础信息 + +**函数接口**: `get_stock_basic` + +**功能描述**: 获取指定股票列表的证券基础数据 + +**输入参数**: + +| 参数 | 数据类型 | 必选 | 解释 | +|------|---------|------|------| +| code_list | list[str] | 是 | 支持沪深北三个交易所的代码列表 | + +**输出参数**: + +| 返回值 | 数据类型 | 解释 | +|--------|---------|------| +| stock_basic | DataFrame | 包含中英文名称、上市日期、退市日期、上市板块等信息 | + +##### 3.5.2.10 历史证券信息 + +**函数接口**: `get_history_stock_status` + +**功能描述**: 获取历史证券数据,包含涨跌停、st、除权除息等信息 + +**输入参数**: + +| 参数 | 数据类型 | 必选 | 解释 | +|------|---------|------|------| +| code_list | list[str] | 是 | 支持沪深A的代码列表 | +| local_path | str | 是 | 本地存储数据的路径 | +| is_local | bool | 否 | 默认为True | +| begin_date | int | 否 | 交易日 | +| end_date | int | 否 | 交易日 | + +##### 3.5.2.11 北交所新旧代码对照表 + +**函数接口**: `get_bj_code_mapping` + +**功能描述**: 获取北交所的存量上市公司股票新旧代码对照表 + +**输入参数**: + +| 参数 | 数据类型 | 必选 | 解释 | +|------|---------|------|------| +| local_path | str | 是 | 本地存储数据的路径 | +| is_local | bool | 否 | 默认为True | + +--- + +#### 3.5.3 实时行情数据 + +实时行情订阅接口使用步骤: +1. 实例化AmazingData的SubscribeData +2. 回调函数的装饰器传入code_list和period两个参数 +3. 回调函数中获取数据 + +##### 3.5.3.1 指数实时快照 + +**函数接口**: `onSnapshotIndex` + +**输入参数**: + +| 参数 | 数据类型 | 必选 | 解释 | +|------|---------|------|------| +| code_list | list:[str] | 是 | 支持北交所、上交所、深交所的指数 | +| period | Period | 是 | Period.snapshot.value | + +##### 3.5.3.2 股票实时快照 + +**函数接口**: `onSnapshot` + +**输入参数**: + +| 参数 | 数据类型 | 必选 | 解释 | +|------|---------|------|------| +| code_list | list:[str] | 是 | 支持北交所、上交所、深交所的股票 | +| period | Period | 是 | Period.snapshot.value | + +##### 3.5.3.3 逆回购实时快照 + +**函数接口**: `onSnapshotGlra` + +**输入参数**: + +| 参数 | 数据类型 | 必选 | 解释 | +|------|---------|------|------| +| code_list | list:[str] | 是 | 支持上交所、深交所的逆回购代码 | +| period | Period | 是 | Period.snapshot.value | + +##### 3.5.3.4 期货实时快照 + +**函数接口**: `onSnapshotFuture` + +**输入参数**: + +| 参数 | 数据类型 | 必选 | 解释 | +|------|---------|------|------| +| code_list | list:[str] | 是 | 支持中金所 | +| period | Period | 是 | Period.snapshotfuture.value | + +##### 3.5.3.5 ETF实时快照 + +**函数接口**: `onSnapshotEtf` + +**输入参数**: + +| 参数 | 数据类型 | 必选 | 解释 | +|------|---------|------|------| +| code_list | list:[str] | 是 | 支持上交所、深交所的ETF | +| period | Period | 是 | Period.snapshot.value | + +##### 3.5.3.6 可转债实时快照 + +**函数接口**: `onSnapshotKzz` + +**输入参数**: + +| 参数 | 数据类型 | 必选 | 解释 | +|------|---------|------|------| +| code_list | list:[str] | 是 | 支持上交所、深交所的可转债 | +| period | Period | 是 | Period.snapshot.value | + +##### 3.5.3.7 港股通实时快照 + +**函数接口**: `onSnapshotHkt` + +**输入参数**: + +| 参数 | 数据类型 | 必选 | 解释 | +|------|---------|------|------| +| code_list | list:[str] | 是 | 支持上交所、深交所的港股通 | +| period | Period | 是 | Period.snapshotHKT.value | + +##### 3.5.3.8 ETF期权实时快照 + +**函数接口**: `onSnapshotOption` + +**输入参数**: + +| 参数 | 数据类型 | 必选 | 解释 | +|------|---------|------|------| +| code_list | list:[str] | 是 | 支持上交所、深交所的ETF期权 | +| period | Period | 是 | Period.snapshotoption.value | + +##### 3.5.3.9 实时K线 + +**函数接口**: `OnKLine` + +**输入参数**: + +| 参数 | 数据类型 | 必选 | 解释 | +|------|---------|------|------| +| code_list | list:[str] | 是 | 支持多品种 | +| period | Period | 是 | Period | + +--- + +#### 3.5.4 历史行情数据 + +##### 3.5.4.1 历史快照 + +**函数接口**: `query_snapshot` + +**功能描述**: 快照数据的历史数据查询接口 + +**输入参数**: + +| 参数 | 数据类型 | 必选 | 解释 | +|------|---------|------|------| +| code_list | list:[str] | 是 | 支持多品种 | +| begin_date | int | 是 | 开始日期 | +| end_date | int | 是 | 结束日期 | +| begin_time | int | 否 | 开始时间 | +| end_time | int | 否 | 结束时间 | + +##### 3.5.4.2 历史K线 + +**函数接口**: `query_kline` + +**功能描述**: K线数据的历史数据查询接口 + +**输入参数**: + +| 参数 | 数据类型 | 必选 | 解释 | +|------|---------|------|------| +| code_list | list:[str] | 是 | 支持多品种 | +| begin_date | int | 是 | 开始日期 | +| end_date | int | 是 | 结束日期 | +| period | Period | 是 | 数据周期 | +| begin_time | int | 否 | 开始时间 | +| end_time | int | 否 | 结束时间 | + +--- + +#### 3.5.5 财务数据 + +##### 3.5.5.1 资产负债表 + +**函数接口**: `get_balance_sheet` + +**功能描述**: 获取上市公司的资产负债表数据 + +##### 3.5.5.2 现金流量表 + +**函数接口**: `get_cash_flow` + +**功能描述**: 获取上市公司的现金流量表数据 + +##### 3.5.5.3 利润表 + +**函数接口**: `get_income` + +**功能描述**: 获取上市公司的利润表数据 + +##### 3.5.5.4 业绩快报 + +**函数接口**: `get_profit_express` + +**功能描述**: 获取上市公司的业绩快报数据 + +##### 3.5.5.5 业绩预告 + +**函数接口**: `get_profit_notice` + +**功能描述**: 获取上市公司的业绩预告数据 + +--- + +#### 3.5.6 股东股本数据 + +##### 3.5.6.1 十大股东数据 + +**函数接口**: `get_share_holder` + +##### 3.5.6.2 股东户数 + +**函数接口**: `get_holder_num` + +##### 3.5.6.3 股本结构 + +**函数接口**: `get_equity_structure` + +##### 3.5.6.4 股权冻结/质押 + +**函数接口**: `get_equity_pledge_freeze` + +##### 3.5.6.5 限售股解禁 + +**函数接口**: `get_equity_restricted` + +--- + +#### 3.5.7 股东权益数据 + +##### 3.5.7.1 分红数据 + +**函数接口**: `get_dividend` + +##### 3.5.7.2 配股数据 + +**函数接口**: `get_right_issue` + +--- + +#### 3.5.8 融资融券数据 + +##### 3.5.8.1 融资融券成交汇总 + +**函数接口**: `get_margin_summary` + +##### 3.5.8.2 融资融券交易明细 + +**函数接口**: `get_margin_detail` + +--- + +#### 3.5.9 交易异动数据 + +##### 3.5.9.1 龙虎榜 + +**函数接口**: `get_long_hu_bang` + +##### 3.5.9.2 大宗交易 + +**函数接口**: `get_block_trading` + +--- + +#### 3.5.10 期权数据 + +##### 3.5.10.1 期权基本资料 + +**函数接口**: `get_option_basic_info` + +##### 3.5.10.2 期权标准合约属性 + +**函数接口**: `get_option_std_ctr_specs` + +##### 3.5.10.3 期权月合约属性变动 + +**函数接口**: `get_option_mon_ctr_specs` + +--- + +#### 3.5.11 ETF数据 + +##### 3.5.11.1 ETF每日最新申赎数据 + +**函数接口**: `get_etf_pcf` + +##### 3.5.11.2 ETF基金份额 + +**函数接口**: `get_fund_share` + +##### 3.5.11.3 ETF每日收盘IOPV + +**函数接口**: `get_fund_iopv` + +--- + +#### 3.5.12 交易所指数数据 + +##### 3.5.12.1 交易所指数成分股 + +**函数接口**: `get_index_constituent` + +##### 3.5.12.2 交易所指数成分股日权重 + +**函数接口**: `get_index_weight` + +--- + +#### 3.5.13 行业指数数据 + +##### 3.5.13.1 行业指数基本信息 + +**函数接口**: `get_industry_base_info` + +##### 3.5.13.2 行业指数成分股 + +**函数接口**: `get_industry_constituent` + +##### 3.5.13.3 行业指数成分股日权重 + +**函数接口**: `get_industry_weight` + +##### 3.5.13.4 行业指数日行情 + +**函数接口**: `get_industry_daily` + +--- + +#### 3.5.14 可转债数据 + +##### 3.5.14.1 可转债发行 + +**函数接口**: `get_kzz_issuance` + +##### 3.5.14.2 可转债份额 + +**函数接口**: `get_kzz_share` + +##### 3.5.14.3 可转债转股数据 + +**函数接口**: `get_kzz_conv` + +##### 3.5.14.4 可转债转股变动数据 + +**函数接口**: `get_kzz_conv_change` + +##### 3.5.14.5 可转债修正数据 + +**函数接口**: `get_kzz_corr` + +##### 3.5.14.6 可转债赎回数据 + +**函数接口**: `get_kzz_call` + +##### 3.5.14.7 可转债回售数据 + +**函数接口**: `get_kzz_put` + +##### 3.5.14.8 可转债回售赎回条款 + +**函数接口**: `get_kzz_put_call_item` + +##### 3.5.14.9 可转债回售条款执行说明 + +**函数接口**: `get_kzz_put_explanation` + +##### 3.5.14.10 可转债赎回条款执行说明 + +**函数接口**: `get_kzz_call_explanation` + +##### 3.5.14.11 可转债停复牌信息 + +**函数接口**: `get_kzz_suspend` + +--- + +#### 3.5.15 国债收益率数据 + +##### 3.5.15.1 国债收益率 + +**函数接口**: `get_treasury_yield` + +**功能描述**: 获取指定期限的国债收益率数据 + +**输入参数**: + +| 参数 | 数据类型 | 必选 | 解释 | +|------|---------|------|------| +| term_list | list[str] | 是 | 支持:'m3','m6','y1','y2','y3','y5','y7','y10','y30' | +| local_path | str | 是 | 本地存储路径 | +| is_local | bool | 否 | 默认为True | +| begin_date | int | 否 | 开始日期 | +| end_date | int | 否 | 结束日期 | + +--- + +### 3.6 金融算子详细 + +#### 3.6.1 数学函数 + +数学函数用于基本的数学运算,包括三角函数、对数、取整等操作。所有函数返回pd.Series类型。 + +**函数列表**: + +| 序号 | 函数名称 | 函数用法 | +|------|---------|---------| +| 1 | ABS | ABS(X)为X的绝对值 | +| 2 | ACOS | ACOS(X)返回X的反余弦值 | +| 3 | ASIN | ASIN(X)返回X的反正弦值 | +| 4 | ATAN | ATAN(X)返回X的反正切值 | +| 5 | BETWEEN | BETWEEN(A,B,C)表示A处于B和C之间时返回1 | +| 6 | CEILING | CEILING(x)沿数值增大方向最接近的整数 | +| 7 | COS | COS(X)返回X的余弦值 | +| 8 | EXP | EXP(X)返回e的X次幂 | +| 9 | FLOOR | FLOOR(x)沿数值减小方向最接近的整数 | +| 10 | FRACPART | FRACPART(X)返回X的小数部分 | +| 11 | IF | IF(X,A,B)若X不为0则返回A,否则返回B | +| 12 | INTPART | INTPART(x)沿绝对值减小方向最接近的整数 | +| 13 | LN | LN(X)以e为底的对数 | +| 14 | LOG | LOG(X)以10为底的对数 | +| 15 | MAX | MAX(A,B,C...)返回较大值 | +| 16 | MIN | MIN(A,B,C...)返回较小值 | +| 17 | MOD | MOD(M,N)返回M关于N的模 | +| 18 | POW | POW(A,B)返回A的B次幂 | +| 19 | RAND | RAND(a,b)返回范围在[a,b]的随机整数 | +| 20 | ROUND | ROUND(X,N)返回X四舍五入到N位小数 | +| 21 | SIGN | SIGN(X)返回X的符号 | +| 22 | SIN | SIN(X)返回X的正弦值 | +| 23 | SQRT | SQRT(X)为X的平方根 | +| 24 | TAN | TAN(X)返回X的正切值 | + +#### 3.6.2 统计函数 + +统计函数用于计算时序数据的统计指标。 + +**函数列表**: + +| 序号 | 函数名称 | 函数用法 | +|------|---------|---------| +| 1 | AVEDEV | AVEDEV(X,N)返回X在N周期内的平均绝对偏差 | +| 2 | BETA | BETA(X,BENCHMARK,N)返回贝塔系数 | +| 3 | BETAEX | BETAEX(X,Y,N)返回相关放大系数 | +| 4 | COVAR | COVAR(X,Y,N)返回协方差 | +| 5 | DEVSQ | DEVSQ(X,N)返回数据偏差平方和 | +| 6 | FORCAST | FORCAST(X,N)返回线性回归预测值 | +| 7 | KURTOSIS | KURTOSIS(X,N)计算峰度 | +| 8 | MEAN | MEAN(X,N)计算平均值 | +| 9 | MEDIAN | MEDIAN(X,N)计算中位数 | +| 10 | QUANTILE | QUANTILE(X,N,M)计算分位数 | +| 11 | RELATE | RELATE(X,Y,N)返回相关系数 | +| 12 | SKEW | SKEW(X,N)计算偏度 | +| 13 | SLOPE | SLOPE(X,N)返回线性回归斜率 | +| 14 | STD | STD(X,N)返回估算标准差 | +| 15 | STDDEV | STDDEV(X,N)返回标准偏差 | +| 16 | STDP | STDP(X,N)返回总体标准差 | +| 17 | VAR | VAR(X,N)返回估算样本方差 | +| 18 | VARP | VARP(X,N)返回总体样本方差 | + +#### 3.6.3 时序函数 + +时序函数用于时间序列数据的处理。 + +**函数列表**: + +| 序号 | 函数名称 | 函数用法 | +|------|---------|---------| +| 1 | AMA | AMA(X,A)自适应系数A必须小于1 | +| 2 | BARSLAST | BARSLAST(X)上一次X不为False到现在的周期数 | +| 3 | BARSLASTCOUNT | BARSLASTCOUNT(X)统计连续满足X条件的周期数 | +| 4 | BARSLASTS | BARSLASTS(X,N)X倒数第N满足到现在的周期数 | +| 5 | BARSNEXT | BARSNEXT(X)下一次X不为0到现在的周期数 | +| 6 | BARSSINCE | BARSSINCE(X)第一次X不为0到现在的周期数 | +| 7 | BARSSINCEN | BARSSINCEN(X,N)N周期内第一次X不为0到现在的周期数 | +| 8 | BARSTATUS | BARSTATUS(X)返回数据位置信息 | +| 9 | COUNT | COUNT(X,N)统计N周期中满足X条件的周期数 | +| 10 | CROSS | CROSS(A,B)当A从下方向上穿过B时返回1 | +| 11 | CUMSUM | CUMSUM(X)从第一个有效值开始累计求和 | +| 12 | CURRBARSCOUNT | CURRBARSCOUNT(X)从最新一根K线倒数编号 | +| 13 | DMA | DMA(X,A)求X的动态移动平均 | +| 14 | DOWNNDAY | DOWNNDAY(CLOSE,M)表示连跌M个周期 | +| 15 | EMA | EMA(X,N)X的N日指数移动平均 | +| 16 | EVERY | EVERY(X,N)表示N日内一直满足条件X | +| 17 | EXIST | EXIST(X,N)表示N日内是否存在满足条件X | +| 18 | EXISTR | EXISTR(X,A,B)表示从前A日内到前B日内是否存在满足条件X | +| 19 | EXPMEMA | EXPMEMA(X,N)X的N日指数平滑移动平均 | +| 20 | FILTER | FILTER(X,N)X满足条件后,将其后N周期内的数据置为0 | +| 21 | FILTERX | FILTERX(X,N)X满足条件后,将其前N周期内的数据置为0 | +| 22 | HHV | HHV(X,N)求N周期内X最高值 | +| 23 | HHVBARS | HHVBARS(X,N)求N周期内X最高值到当前周期数 | +| 24 | HHVLLV | HHVLLV(X,T,N1,N2)阶段最高最低值 | +| 25 | HOD | HOD(X,N)求当前X数据是N周期内的第几个高值 | +| 26 | LAST | LAST(X,A,B)表示从前A日到前B日内一直满足条件X | +| 27 | LLV | LLV(X,N)求N周期内X最低值 | +| 28 | LLVBARS | LLVBARS(X,N)求N周期内X最低值到当前周期数 | +| 29 | LOD | LOD(X,N)求当前X数据是N周期内的第几个低值 | +| 30 | LONGCROSS | LONGCROSS(A,B,N)A在N周期内都小于B,本周期从下方向上穿过B时返回1 | +| 31 | MA | MA(X,N)X的N日简单移动平均 | +| 32 | MEMA | MEMA(X,N)X的N日平滑移动平均 | +| 33 | MULAR | MULAR(X,N)统计N周期中X的乘积 | +| 34 | NDAY | NDAY(CLOSE,OPEN,3)表示连续3日收阳线 | +| 35 | RANGE | RANGE(A,B,C)A在B和C范围之间 | +| 36 | REF | REF(X,A)引用A周期前的X值 | +| 37 | REFV | REFV(X,A)引用A周期前的X值(平滑处理) | +| 38 | REFX | REFX(X,A)引用A周期后的X值 | +| 39 | REFXV | REFXV(X,A)引用A周期后的X值(平滑处理) | +| 40 | REVERSE | REVERSE(X)返回-X | +| 41 | SAR | SAR(HIGH,LOW,CLOSE,N,STEP,MAXAF)抛物线转向指标 | +| 42 | SHIFT | SHIFT(A,N)获取A的N个交易日前的值 | +| 43 | SMA | SMA(X,N,M)X的N日移动平均,M为权重 | +| 44 | SUM | SUM(X,N)统计N周期中X的总和 | +| 45 | SUMBARS | SUMBARS(X,A)将X向前累加直到大于等于A | +| 46 | SUMBARSX | SUMBARSX(X,A)将X向前累加直到大于等于A | +| 47 | TMA | TMA(X,A,B)A和B必须小于1 | +| 48 | TOTALBARSCOUNT | TOTALBARSCOUNT(X)从第一根K线开始编号 | +| 49 | TR | TR(HIGH,LOW,CLOSE)求真实波幅 | +| 50 | UPNDAY | UPNDAY(CLOSE,M)表示连涨M个周期 | +| 51 | WMA | WMA(X,N)X的N日加权移动平均 | + +#### 3.6.4 截面函数 + +截面函数用于计算同一交易日内多个标的之间的统计指标。 + +**函数列表**: + +| 序号 | 函数名称 | 函数用法 | +|------|---------|---------| +| 1 | CSCORR | CSCORR(X,Y)返回每交易日两个指标的当日相关度 | +| 2 | CSCOUNT | CSCOUNT(X)统计交易日截面的标的个数 | +| 3 | CSCOV | CSCOV(X,Y)返回每交易日两个指标的当日协方差 | +| 4 | CSDEMEAN | CSDEMEAN(X)对每个交易日的截面数据减去均值 | +| 5 | CSMAX | CSMAX(X)计算交易日截面指标的最大值 | +| 6 | CSMEAN | CSMEAN(X)计算交易日截面指标的平均值 | +| 7 | CSMEDIAN | CSMEDIAN(X)计算交易日截面指标的中位数 | +| 8 | CSMIN | CSMIN(X)计算交易日截面指标的最小值 | +| 9 | CSNORMALIZE | CSNORMALIZE(X)对每个交易日的截面数据进行归一化到[0,1] | +| 10 | CSPCTRANK | CSPCTRANK(X)计算交易日截面指标的百分位排名 | +| 11 | CSQUANTILE | CSQUANTILE(X,N)计算交易日截面指标的分位数N | +| 12 | CSRANK | CSRANK(X,B)计算交易日截面指标的排名 | +| 13 | CSSTD | CSSTD(X)计算交易日截面指标的标准差 | +| 14 | CSSUM | CSSUM(X)计算交易日截面指标的求和 | +| 15 | CSVAR | CSVAR(X)计算交易日截面指标的方差 | +| 16 | CSZSCORE | CSZSCORE(X)对每个交易日的截面数据进行Z-score标准化 | + +--- + +## 4.附录 + +### 4.1 字段取值说明 + +#### 4.1.1 代码类型security_type(沪深北) + +| 数据类型 | 枚举值 | 说明 | +|---------|--------|------| +| str | EXTRA_STOCK_A | 上交所A股、深交所A股和北交所的股票列表 | +| str | SH_A | 上交所A股的股票列表 | +| str | SZ_A | 深交所A股的股票列表 | +| str | BJ_A | 北交所的股票列表 | +| str | EXTRA_STOCK_A_SH_SZ | 上交所A股和深交所A股的股票列表 | +| str | EXTRA_INDEX_A_SH_SZ | 上交所和深交所指数列表 | +| str | EXTRA_INDEX_A | 上交所、深交所和北交所的指数列表 | +| str | SH_INDEX | 上交所指数列表 | +| str | SZ_INDEX | 深交所指数列表 | +| str | BJ_INDEX | 北交所的指数列表 | +| str | SH_ETF | 上交所的ETF列表 | +| str | SZ_ETF | 深交所的ETF列表 | +| str | EXTRA_ETF | 上交所、深交所的ETF列表 | +| str | SH_KZZ | 上交所的可转债列表 | +| str | SZ_KZZ | 深交所的可转债列表 | +| str | EXTRA_KZZ | 上交所、深交所的可转债列表 | +| str | SH_HKT | 沪港通 | +| str | SZ_HKT | 深港通 | +| str | EXTRA_HKT | 沪深港通 | +| str | SH_GLRA | 上交所逆回购 | +| str | SZ_GLRA | 深交所逆回购 | +| str | EXTRA_GLRA | 沪深逆回购 | + +#### 4.1.2 代码类型security_type(期货交易所) + +| 数据类型 | 枚举值 | 说明 | +|---------|--------|------| +| str | ZJ_FUTURE | 期货,包含中金所 | + +#### 4.1.3 代码类型security_type(期权) + +| 数据类型 | 枚举值 | 说明 | +|---------|--------|------| +| str | EXTRA_ETF_OP | ETF期权,上交所/深交所 | +| str | SH_OPTION | ETF期权,包含上交所 | +| str | SZ_OPTION | ETF期权,包含深交所 | + +#### 4.1.4 市场类型market + +| 数据类型 | 枚举值 | 说明 | +|---------|--------|------| +| str | SH | 上交所 | +| str | SZ | 深交所 | +| str | BJ | 北交所 | +| str | CFE | 中金所 | +| str | SHN | 沪港通 | +| str | SZN | 深港通 | +| str | HK | 港交所 | + +#### 4.1.5 交易阶段代码trading_phase_code + +**上海现货快照交易状态**: +- 第0位:'S'表示启动(开市前)时段,'C'表示开盘集合竞价时段,'T'表示连续交易时段,'E'表示闭市时段,'P'表示产品停牌 +- 第1位:'0'表示此产品不可正常交易,'1'表示此产品可正常交易 +- 第2位:'0'表示未上市,'1'表示已上市 +- 第3位:'0'表示此产品在当前时段不接受进行新订单申报,'1'表示可接受 + +**深圳现货快照交易状态**: +- 第0位:'S'=启动,'O'=开盘集合竞价,'T'=连续竞价,'B'=休市,'C'=收盘集合竞价,'E'=已闭市,'H'=临时停牌,'A'=盘后交易,'V'=波动性中断 +- 第1位:'0'=正常状态,'1'=全天停牌 + +**港股股票行情交易状态**: +- '1'表示正常交易,'2'表示停牌,'3'表示复牌 + +#### 4.1.6 产品状态标志security_status + +| 状态 | 标志 | 说明 | +|------|------|------| +| 停牌 | 1 | 深交所、北交所 | +| 除权 | 2 | 上交所、深交所、北交所 | +| 除息 | 3 | 上交所、深交所、北交所 | +| 风险警示 | 4 | 上交所、深交所、北交所 | +| 退市整理期 | 5 | 上交所、深交所、北交所 | +| 上市首日 | 6 | 上交所、深交所、北交所 | +| 公司再融资 | 7 | 深交所 | +| 恢复上市首日 | 8 | 深交所、北交所 | +| 网络投票 | 9 | 深交所 | +| 增发股份上市 | 10 | 深交所 | +| 合约调整 | 11 | 深交所 | +| 暂停上市后协议转让 | 12 | 深交所 | +| 实施双转单调整 | 13 | 深交所 | +| 特定债券转让 | 14 | 深交所、北交所 | +| 上市初期 | 15 | 深圳有效 | +| 退市整理期首日 | 16 | 深交所、北交所 | + +#### 4.1.7 数据周期Period + +| 数据类型 | 枚举值 | 说明 | +|---------|--------|------| +| int | Period.min1.value | 1分钟线 | +| int | Period.min3.value | 3分钟线 | +| int | Period.min5.value | 5分钟线 | +| int | Period.min10.value | 10分钟线 | +| int | Period.min15.value | 15分钟线 | +| int | Period.min30.value | 30分钟线 | +| int | Period.min60.value | 60分钟线 | +| int | Period.min120.value | 120分钟线 | +| int | Period.day.value | 日线 | +| int | Period.week.value | 周线 | +| int | Period.month.value | 月线 | +| int | Period.season.value | 季度线 | +| int | Period.year.value | 年线 | + +#### 4.1.8 报告期名称REPORT_TYPE + +| 报告期类型代码 | 报告期月份 | +|---------------|-----------| +| 1 | 3月 | +| 2 | 6月 | +| 3 | 9月 | +| 4 | 12月 | + +#### 4.1.9 报表类型代码表STATEMENT_TYPE + +| 报表类型代码 | 报表类型 | 备注 | +|-------------|---------|------| +| 1 | 合并报表 | 涵盖母公司的财务报表数据,为最新报表 | +| 2 | 合并报表(单季度) | 合并报表(单季度)=合并报表(本期)-合并报表(上一季) | +| 3 | 合并报表(单季度调整) | 合并报表(单季度调整)=合并报表(本期调整)-合并报表(上一季调整) | +| 4 | 合并报表(调整) | 本年度公布上年同期的财务报表数据 | +| 5 | 合并报表(更正前) | 出更正公告后,把合并报表的记录修改为合并报表(更正前) | +| 6 | 母公司报表 | 该公司母公司的财务报表数据 | +| 7 | 母公司报表(单季度) | 母公司报表(单季度)=母公司报表(本期)-母公司报表(上一季) | +| 8 | 母公司报表(单季度调整) | 母公司报表(单季度调整)=母公司报表(本期调整)-母公司报表(上一季调整) | +| 9 | 母公司报表(调整) | 该公司母公司的本年度公布上年同期的财务报表数据 | +| 10 | 母公司报表(更正前) | 未调整之前的原始财务报表数据 | + +#### 4.1.10 股票分红进度代码表DIV_PROGRESS + +| 分红进度描述 | 进度代码 | +|-------------|---------| +| 董事会预案 | 1 | +| 股东大会通过 | 2 | +| 实施 | 3 | +| 未通过 | 4 | +| 停止实施 | 12 | +| 股东提议 | 17 | +| 董事会预案预披露 | 19 | + +分红实施进程:股东提议 → 董事会预案 → 股东大会 → 实施 + +#### 4.1.11 股票配股进度代码表PROGRESS + +| 配股进度描述 | 进度代码 | +|-------------|---------| +| 董事会预案 | 1 | +| 股东大会通过 | 2 | +| 实施 | 3 | +| 未通过 | 4 | +| 证监会核准 | 5 | +| 达成转让意向 | 6 | +| 签署转让协议 | 7 | +| 国资委批准 | 8 | +| 商务部批准 | 9 | +| 过户 | 10 | +| 延期实施 | 11 | +| 停止实施 | 12 | +| 分红方案待定 | 13 | +| 传闻 | 14 | +| 证监会受理 | 15 | +| 传闻被否认 | 16 | +| 股东提议 | 17 | +| 保监会批复 | 18 | +| 董事会预案预披露 | 19 | +| 发审委通过 | 20 | +| 发审委未通过 | 21 | +| 股东大会未通过 | 22 | +| 银监会批准 | 23 | +| 证监会恢复审核 | 24 | +| 预发行 | 25 | +| 提交注册 | 26 | + +### 4.2 数据结构说明 + +#### 4.2.1 Level-1快照Snapshot + +| 数据类型 | 字段名称 | 说明 | +|---------|---------|------| +| str | code | 证券代码+市场 | +| datetime | trade_time | 交易所行情数据时间 | +| float | pre_close | 昨收价 | +| float | last | 最新价 | +| float | open | 开盘价 | +| float | high | 最高价 | +| float | low | 最低价 | +| float | close | 收盘价 | +| float | volume | 成交总量 | +| float | amount | 成交总金额 | +| float | num_trades | 成交笔数 | +| float | high_limited | 涨停价 | +| float | low_limited | 跌停价 | +| float | ask_price1-5 | 卖1-5档价格 | +| int | ask_volume1-5 | 卖1-5档量 | +| float | bid_price1-5 | 买1-5档价格 | +| int | bid_volume1-5 | 买1-5档量 | +| float | iopv | 净值估产(仅基金品种有效) | +| str | trading_phase_code | 交易阶段代码 | + +#### 4.2.2 ETF期权快照SnapshotOption + +| 数据类型 | 字段名称 | 说明 | +|---------|---------|------| +| str | code | 证券代码+市场 | +| datetime | trade_time | 交易所行情数据时间 | +| str | trading_phase_code | 交易阶段代码 | +| int | total_long_position | 总持仓量 | +| float | volume | 成交总量 | +| float | amount | 成交总金额 | +| float | pre_close | 昨收价 | +| float | pre_settle | 上次结算价 | +| float | auction_price | 动态参考价(仅上海有效) | +| int | auction_volume | 虚拟匹配数量(仅上海有效) | +| float | last | 最新价 | +| float | open | 开盘价 | +| float | high | 最高价 | +| float | low | 最低价 | +| float | close | 收盘价 | +| float | settle | 本次结算价 | +| float | high_limited | 涨停价 | +| float | low_limited | 跌停价 | +| float | ask_price1-5 | 卖1-5档价格 | +| int | ask_volume1-5 | 卖1-5档量 | +| float | bid_price1-5 | 买1-5档价格 | +| int | bid_volume1-5 | 买1-5档量 | +| str | contract_type | 合约类别 | +| int | expire_date | 到期日 | +| str | underlying_security_code | 标的代码 | +| float | exercise_price | 行权价 | + +#### 4.2.3 期货快照SnapshotFuture + +| 数据类型 | 字段名称 | 说明 | +|---------|---------|------| +| str | code | 证券代码+市场 | +| datetime | trade_time | 交易所行情数据时间 | +| str | action_day | 业务日期 | +| str | trading_day | 交易日期 | +| float | pre_close | 昨收价 | +| float | pre_settle | 上次结算价 | +| int | pre_open_interest | 昨持仓量 | +| int | open_interest | 持仓量 | +| float | last | 最新价 | +| float | open | 开盘价 | +| float | high | 最高价 | +| float | low | 最低价 | +| float | close | 收盘价 | +| float | volume | 成交总量 | +| float | amount | 成交总金额 | +| float | high_limited | 涨停价 | +| float | low_limited | 跌停价 | +| float | ask_price1-5 | 卖1-5档价格 | +| int | ask_volume1-5 | 卖1-5档量 | +| float | bid_price1-5 | 买1-5档价格 | +| int | bid_volume1-5 | 买1-5档量 | +| float | average_price | 当日均价 | +| float | settle | 本次结算价 | + +#### 4.2.4 指数快照SnapshotIndex + +| 数据类型 | 字段名称 | 说明 | +|---------|---------|------| +| str | code | 证券代码+市场 | +| datetime | trade_time | 交易所行情数据时间 | +| float | last | 最新价 | +| float | pre_close | 前收盘价 | +| float | open | 今开盘价 | +| float | high | 最高价 | +| float | low | 最低价 | +| float | close | 收盘价(仅上海有效) | +| int | volume | 成交总量 | +| float | amount | 成交总金额 | + +#### 4.2.5 港股通快照SnapshotHKT + +| 数据类型 | 字段名称 | 说明 | +|---------|---------|------| +| str | code | 证券代码+市场 | +| datetime | trade_time | 交易所行情数据时间 | +| float | pre_close | 昨收价 | +| float | last | 最新价 | +| float | high | 最高价 | +| float | low | 最低价 | +| float | volume | 成交总量 | +| float | amount | 成交总金额 | +| float | nominal_price | 暗盘价 | +| float | ref_price | 参考价 | +| float | bid_price_limit_up | 买盘上限价 | +| float | bid_price_limit_down | 买盘下限价 | +| float | offer_price_limit_up | 卖盘上限价 | +| float | offer_price_limit_down | 卖盘下限价 | +| float | high_limited | 冷静期价格上限 | +| float | low_limited | 冷静期价格下限 | +| float | ask_price1-5 | 卖1-5档价格 | +| int | ask_volume1-5 | 卖1-5档量 | +| float | bid_price1-5 | 买1-5档价格 | +| int | bid_volume1-5 | 买1-5档量 | +| str | trading_phase_code | 交易阶段代码 | + +#### 4.2.6 K线Kline + +| 数据类型 | 字段名称 | 说明 | +|---------|---------|------| +| str | code | 证券代码+市场 | +| datetime | kline_time | 交易所行情数据时间 | +| float | open | 今开盘价 | +| float | high | 最高价 | +| float | low | 最低价 | +| float | close | 收盘价 | +| int | volume | 成交总量 | +| float | amount | 成交总金额 | + +### 4.3 相关算法说明 + +#### 4.3.1 K线算法说明 + +**集合竞价的处理**: +对于分钟K线,开盘集合竞价数据的成交量包含在当日第一根K线,收盘集合竞价数据的成交量包含在当日最后一根K线。 + +**前推算法**: +9:30的1分钟K线,计算的是9:30:00.000~9:30:59.999期间的K线。9:35的5分钟K线,计算的是9:35:00.000~9:39:59.999期间的K线。 + +### 4.4 本地数据缓存方案说明 + +**应用场景**: +1. 接口取全量历史时间区间的数据:查询接口包含local_path和is_local两个参数,支持本地缓存方案,本地保存全量历史数据,且每次调用接口默认增量更新本地数据 +2. 接口取指定时间区间的数据:查询接口包含begin_date和end_date两个参数,仅从服务器获取数据,不本地缓存数据 + +**函数入参说明**: +- local_path和is_local为参数组1,begin_date和end_date为参数组2 +- 一个参数组内的参数必须同时使用 +- 两个参数组需独立使用 + +**本地存储文件说明**: +文件格式为hdf5格式 + +**本地存储空间说明**: +本地存储空间,不同的数据类型和标的范围,所需空间不同。建议本地存储空间在500GB以上。 + +--- + +## 5.免责声明 + +为了使客户更好地了解使用中国银河证券股份有限公司(以下简称"本公司")星耀数智服务平台(以下简称"本平台")的相关风险,根据相关法律、行政法规、部门规章、自律组织规则和监管规定,特提供风险揭示书,请客户务必详细阅读并充分理解以下风险: + +1. **数据准确性风险**:本公司使用外购或者自有的数据源作为基础数据进行数据加工、计算和分析,但并不能保证数据的及时性、准确性、真实性和完整性。 + +2. **网络传输风险**:由于计算机故障以及互联网数据传输等原因,数据传输可能会出现中断、停顿、延迟、数据错误等情况;因特网和移动通讯网络遭到黑客恶意攻击、您的网络终端设备及软件系统受到非法攻击或病毒感染、您的网络终端设备及软件系统与本平台不兼容、因电脑的故障或互联网故障引起的中断和错误等,都可能会造成数据传输故障,由此导致的损失由您自行承担。 + +3. **投资建议免责声明**:本平台所提供的信息数据等全部内容仅供参考,投资者须自行确认自己具备理解相关信息数据内容的专业能力,保持自身的独立判断,任何情况下本平台提供的内容不构成对投资者的投资建议,据此操作的一切风险和损失由投资者自行承担,本公司不对任何人因参考上述内容造成的直接或间接损失或与此有关的其他损失承担任何责任。 + +4. **账号安全责任**:您使用本平台过程中,凡使用您本人的用户名和密码,针对平台账号进行的操作均视为您亲自办理,由此所产生的一切后果由您承担。本公司提醒您加强账号、密码等信息的保护工作,不得出借他人使用,并建议您定期修改密码、增强密码强度、防止密码泄露、及时查询交易记录、防止用于网上交易的计算机或手机终端感染木马、病毒等。 + +5. **不可抗力风险**:由于地震、水灾、火灾等不可抗力因素或者无法控制和不可预测的系统故障、设备故障、通讯故障、电力故障、网络故障及其它因素,可能使本平台非正常运行甚至瘫痪,出现信息异常或信息传递异常等情况,由此产生的损失将由您承担。 + +6. **系统更新风险**:本公司可能不时更新或升级本平台,您应按照本公司的技术要求在规定的时间内配合做好更新或升级工作;因您未按本公司通知要求进行变更、升级的,由此发生的任何损失由您自行承担。 + +7. **服务终止风险**:如果本公司依据自身判断认为您违反本平台相关的国家法律法规、规范性文件,以及证券交易所、行业协会等自律组织的规则和要求,且不按法律法规或乙方要求及时纠正的,或影响本公司信息系统安全运行的,或监管机构、交易所、行业自律组织对本平台提出监管要求或相关业务规则发生变化,可能导致本平台的服务形式发生变化或本公司决定完全停止提供该项服务的,本公司有权立即停止您使用本平台,并且不承担任何责任,由此产生的任何损失由您承担。 + +8. **数据源局限性**:本公司在遵守国家相关法律、法规、规章及自律组织规则、监管政策前提下,尽力为客户提供高速、完整、准确的金融数据服务,但因受制于数据来源、技术能力等多种因素影响,本公司不保证数据源的及时性、准确性或者完整性,因数据源的遗漏、错误、丢失、延迟、中断而可能造成的损失将由您承担,本公司不承担任何责任。 + +9. **操作风险**:本平台的相关用户文档仅供您操作参考,如您对于本平台的使用不熟悉,可能因操作不当造成本平台出现非正常现象,上述风险可能导致发生的损失应由您自身承担,本公司不承担任何责任。 + +10. **信息更新责任**:您申请使用本平台前应如实填写相关信息和资料,使用过程中信息资料发生变更应及时告知本公司,因您未及时、准确、完整地提供或变更相关信息和资料,导致本公司不能及时、有效地为您提供服务,或导致本公司依据不准确、不完整的信息提供服务,由此可能造成的损失由您自行承担。 + +11. **权限管理**:对于客户未及时更新信息,或者不再符合本平台使用条件,或本平台权限期限到期,或存在重大风险隐患,公司认为不适合使用星耀数智服务平台时,公司可关闭客户的系统相关权限,由此导致的损失由您自行承担。 + +12. **知识产权保护**:本公司开发的本平台及本平台提供的相关数据知识产权归本公司所有。本公司为您开通本平台账号后,仅供您个人使用,如您把本平台提供的全部或部分资料和数据以任何形式转移、出售和公开给任何第三人,或因您未采取必要和合适的措施保护本平台提供的资料和数据的知识产权而造成数据资料信息泄露给任何第三人,本公司有权暂停或终止您使用本平台,由此导致的损失由您自行承担。 + +13. **风险认知**:本免责声明无法揭示您使用本平台及通过本平台从事投资交易的所有风险,故您在使用本平台之前,应全面了解相关法律法规及有关规定,对您自身的经济承受能力、风险承受能力、投资目标、风险控制能力等综合考虑,作出客观判断,对投资交易作仔细的研究。 + +--- + +**文档结束** + +*中国银河证券星耀数智量化平台* diff --git a/backend/xyzs/AmazingData开发手册.pdf b/backend/xyzs/AmazingData开发手册.pdf new file mode 100644 index 0000000..c2f94fc Binary files /dev/null and b/backend/xyzs/AmazingData开发手册.pdf differ diff --git a/backend/xyzs/README_AmazingDataAdapter.md b/backend/xyzs/README_AmazingDataAdapter.md new file mode 100644 index 0000000..b1bedfe --- /dev/null +++ b/backend/xyzs/README_AmazingDataAdapter.md @@ -0,0 +1,268 @@ +# AmazingData 数据源适配器 + +基于中国银河证券星耀数智量化平台 SDK 的封装,提供统一、简洁的金融数据获取接口。 + +## 功能特性 + +- **简洁的API设计**: 封装复杂的SDK接口,提供直观的数据获取方法 +- **类型安全**: 使用Python类型注解,IDE友好的代码提示 +- **灵活的配置**: 支持本地缓存、参数自定义等配置选项 +- **全面的数据覆盖**: 支持行情、财务、股本、融资融券等多类金融数据 + +## 安装依赖 + +```bash +# 安装 AmazingData SDK (需从银河证券获取) +pip install tgw-1.*.*-py3-none-any.whl +pip install AmazingData-1.*.*-cp3x-none-any.whl + +# 安装其他依赖 +pip install pandas +``` + +## 快速开始 + +```python +from amazing_data_adapter import create_adapter, SecurityType, Period + +# 1. 创建适配器 +adapter = create_adapter( + username='your_username', + password='your_password', + host='your_host', + port=8080, + local_path='./data_cache/', # 本地缓存路径 + use_local_cache=True # 是否使用本地缓存 +) + +# 2. 连接数据源 +if adapter.connect(): + # 3. 获取数据 + codes = adapter.get_code_list(SecurityType.STOCK_A) + kline = adapter.get_kline( + codes=['000001.SZ'], + start_date='20240101', + end_date='20241231', + period=Period.DAILY + ) + + # 4. 断开连接 + adapter.disconnect() +``` + +## 功能模块 + +### 1. 基础数据 + +| 方法 | 说明 | +|------|------| +| `get_code_list(security_type)` | 获取代码列表 | +| `get_code_info(security_type)` | 获取证券基本信息 | +| `get_trading_calendar(market)` | 获取交易日历 | +| `get_adj_factor(codes)` | 获取单次复权因子 | +| `get_backward_factor(codes)` | 获取后复权因子 | + +### 2. 历史行情数据 + +| 方法 | 说明 | +|------|------| +| `get_kline(codes, start_date, end_date, period)` | 获取K线数据 | +| `get_snapshot(codes, start_date, end_date)` | 获取历史快照 | + +**支持的周期 (Period)**: +- `Period.MIN1` - 1分钟 +- `Period.MIN5` - 5分钟 +- `Period.MIN15` - 15分钟 +- `Period.MIN30` - 30分钟 +- `Period.MIN60` - 60分钟 +- `Period.DAILY` - 日线 +- `Period.WEEKLY` - 周线 +- `Period.MONTHLY` - 月线 + +### 3. 财务数据 + +| 方法 | 说明 | +|------|------| +| `get_balance_sheet(codes, start_date, end_date)` | 资产负债表 | +| `get_cash_flow(codes, start_date, end_date)` | 现金流量表 | +| `get_income_statement(codes, start_date, end_date)` | 利润表 | +| `get_profit_express(codes, start_date, end_date)` | 业绩快报 | +| `get_profit_notice(codes, start_date, end_date)` | 业绩预告 | + +### 4. 股东股本数据 + +| 方法 | 说明 | +|------|------| +| `get_top10_shareholders(codes, start_date, end_date)` | 十大股东 | +| `get_shareholder_count(codes, start_date, end_date)` | 股东户数 | +| `get_equity_structure(codes, start_date, end_date)` | 股本结构 | + +### 5. 融资融券数据 + +| 方法 | 说明 | +|------|------| +| `get_margin_summary(start_date, end_date)` | 融资融券汇总 | +| `get_margin_detail(codes, start_date, end_date)` | 个股融资融券明细 | + +### 6. 交易异动数据 + +| 方法 | 说明 | +|------|------| +| `get_longhu_bang(codes, start_date, end_date)` | 龙虎榜数据 | +| `get_block_trading(codes, start_date, end_date)` | 大宗交易 | + +### 7. 指数数据 + +| 方法 | 说明 | +|------|------| +| `get_index_constituents(codes)` | 指数成分股 | +| `get_index_weights(codes, start_date, end_date)` | 成分股权重 | + +**支持的指数**: +- `000016.SH` - 上证50 +- `000300.SH` - 沪深300 +- `000905.SH` - 中证500 +- `000906.SH` - 中证800 +- `000852.SH` - 中证1000 + +### 8. ETF数据 + +| 方法 | 说明 | +|------|------| +| `get_etf_pcf(codes)` | ETF申赎数据 | +| `get_fund_share(codes, start_date, end_date)` | 基金份额 | + +### 9. 可转债数据 + +| 方法 | 说明 | +|------|------| +| `get_kzz_issuance(codes)` | 可转债发行数据 | + +## 证券类型枚举 + +```python +from amazing_data_adapter import SecurityType + +SecurityType.STOCK_A # 沪深A股 +SecurityType.STOCK_A_SH_SZ # 沪深A股(沪深) +SecurityType.INDEX_A # 沪深指数 +SecurityType.ETF # ETF +SecurityType.FUTURE # 期货 +SecurityType.KZZ # 可转债 +SecurityType.GLRA # 逆回购 +SecurityType.HKT # 港股通 +SecurityType.ETF_OP # ETF期权 +``` + +## 使用示例 + +### 获取历史K线数据 + +```python +# 获取多只股票日线数据 +kline_data = adapter.get_kline( + codes=['000001.SZ', '600000.SH'], + start_date='20240101', + end_date='20241231', + period=Period.DAILY +) + +for code, df in kline_data.items(): + print(f"{code}: {len(df)} 条数据") + print(df.head()) +``` + +### 获取财务报表 + +```python +# 获取资产负债表 +balance_sheet = adapter.get_balance_sheet( + codes=['000001.SZ', '600000.SH'], + start_date=20240101, + end_date=20241231 +) + +for code, df in balance_sheet.items(): + print(f"\n{code} 资产负债表:") + print(df[['REPORTING_PERIOD', 'TOTAL_ASSETS', 'TOTAL_CUR_ASSETS']]) +``` + +### 获取指数成分股 + +```python +# 获取沪深300成分股 +constituents = adapter.get_index_constituents(['000300.SH']) +df = constituents['000300.SH'] +print(f"沪深300成分股数量: {len(df)}") +print(df[['CON_CODE', 'INDATE', 'INDEX_NAME']].head()) +``` + +### 批量数据处理 + +```python +# 获取所有A股代码 +all_codes = adapter.get_code_list(SecurityType.STOCK_A) + +# 分批处理避免超时 +batch_size = 50 +for i in range(0, len(all_codes), batch_size): + batch_codes = all_codes[i:i+batch_size] + data = adapter.get_balance_sheet(batch_codes) + # 处理数据... +``` + +### 结合复权因子计算真实价格 + +```python +# 获取K线和复权因子 +kline = adapter.get_kline(['000001.SZ'], '20240101', '20241231') +adj_factor = adapter.get_backward_factor(['000001.SZ']) + +df = kline['000001.SZ'] +# 合并并计算复权价格 +df['trade_date'] = df.index.strftime('%Y%m%d').astype(int) +df = df.merge(adj_factor[['000001.SZ']].reset_index(), + left_on='trade_date', right_on='index') +df['adj_close'] = df['close'] * df['000001.SZ'] +``` + +## 数据缓存 + +适配器支持本地数据缓存,可大幅提升重复查询的速度: + +```python +adapter = create_adapter( + username='xxx', + password='xxx', + host='xxx', + port=8080, + local_path='./my_data_cache/', # 缓存目录 + use_local_cache=True # 默认启用缓存 +) + +# 强制从服务器获取最新数据 +adapter.get_kline(codes, start_date, end_date, is_local=False) +``` + +## 注意事项 + +1. **账号权限**: 使用本适配器需要先向中国银河证券申请开通星耀数智平台权限 + +2. **日期格式**: 支持多种日期格式: + - `int`: 20240101 + - `str`: "2024-01-01" 或 "20240101" + - `date`: datetime.date(2024, 1, 1) + +3. **错误处理**: 所有方法在连接断开会抛出 `RuntimeError`,建议在外层做好异常处理 + +4. **资源释放**: 使用完毕后请调用 `adapter.disconnect()` 断开连接 + +## 文件说明 + +- `amazing_data_adapter.py` - 适配器主代码 +- `amazing_data_examples.py` - 详细使用示例 +- `README.md` - 本文档 + +## API参考 + +详细的SDK接口文档请参考银河证券提供的《AmazingData开发手册》。 diff --git a/backend/xyzs/amazing_data_adapter.py b/backend/xyzs/amazing_data_adapter.py new file mode 100644 index 0000000..633052b --- /dev/null +++ b/backend/xyzs/amazing_data_adapter.py @@ -0,0 +1,832 @@ +""" +AmazingData 数据源适配器 +基于银河证券星耀数智量化平台 SDK 的封装 +提供统一、简洁的金融数据获取接口 +""" + +import pandas as pd +from typing import List, Dict, Optional, Union, Tuple +from datetime import datetime, date +from dataclasses import dataclass +from enum import Enum +import logging + +# 配置日志 +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) + + +class SecurityType(Enum): + """证券类型枚举""" + STOCK_A = "EXTRA_STOCK_A" # 沪深A股 + STOCK_A_SH_SZ = "EXTRA_STOCK_A_SH_SZ" # 沪深A股(沪深) + INDEX_A = "EXTRA_INDEX_A" # 沪深指数 + ETF = "EXTRA_ETF" # ETF + FUTURE = "EXTRA_FUTURE" # 期货 + KZZ = "EXTRA_KZZ" # 可转债 + GLRA = "EXTRA_GLRA" # 逆回购 + HKT = "EXTRA_HKT" # 港股通 + ETF_OP = "EXTRA_ETF_OP" # ETF期权 + + +class Market(Enum): + """市场枚举""" + SH = "SH" # 上海 + SZ = "SZ" # 深圳 + BJ = "BJ" # 北京 + + +class Period(Enum): + """周期枚举""" + MIN1 = "min1" + MIN5 = "min5" + MIN15 = "min15" + MIN30 = "min30" + MIN60 = "min60" + DAILY = "daily" + WEEKLY = "weekly" + MONTHLY = "monthly" + + +@dataclass +class DataSourceConfig: + """数据源配置""" + username: str + password: str + host: str + port: int + local_path: str = "./amazing_data_cache/" + use_local_cache: bool = True + + +class AmazingDataAdapter: + """ + AmazingData 数据源适配器 + + 封装银河证券星耀数智 SDK,提供统一的数据获取接口 + """ + + def __init__(self, config: DataSourceConfig): + """ + 初始化适配器 + + Args: + config: 数据源配置 + """ + self.config = config + self._ad = None + self._base_data = None + self._market_data = None + self._info_data = None + self._calendar = None + self._is_logged_in = False + + def connect(self) -> bool: + """ + 连接到数据源 + + Returns: + bool: 是否连接成功 + """ + try: + import AmazingData as ad + self._ad = ad + + # 登录 + ad.login( + username=self.config.username, + password=self.config.password, + host=self.config.host, + port=self.config.port + ) + + # 初始化数据类 + self._base_data = ad.BaseData() + self._info_data = ad.InfoData() + self._calendar = self._base_data.get_calendar() + self._market_data = ad.MarketData(self._calendar) + + self._is_logged_in = True + logger.info("成功连接到 AmazingData 数据源") + return True + + except Exception as e: + logger.error(f"连接失败: {e}") + return False + + def disconnect(self): + """断开连接""" + if self._is_logged_in and self._ad: + try: + self._ad.logout(self.config.username) + logger.info("已断开与 AmazingData 的连接") + except Exception as e: + logger.warning(f"断开连接时出错: {e}") + self._is_logged_in = False + + # ==================== 基础数据接口 ==================== + + def get_code_list(self, security_type: SecurityType = SecurityType.STOCK_A) -> List[str]: + """ + 获取代码列表 + + Args: + security_type: 证券类型 + + Returns: + 证券代码列表 + """ + self._check_login() + + if security_type == SecurityType.FUTURE: + return self._base_data.get_future_code_list(security_type=security_type.value) + elif security_type == SecurityType.ETF_OP: + return self._base_data.get_option_code_list(security_type=security_type.value) + else: + return self._base_data.get_code_list(security_type=security_type.value) + + def get_code_info(self, security_type: SecurityType = SecurityType.STOCK_A) -> pd.DataFrame: + """ + 获取证券信息 + + Args: + security_type: 证券类型 + + Returns: + DataFrame 包含证券基本信息 + """ + self._check_login() + return self._base_data.get_code_info(security_type=security_type.value) + + def get_trading_calendar(self, market: Market = Market.SH) -> List[int]: + """ + 获取交易日历 + + Args: + market: 市场 + + Returns: + 交易日列表 (YYYYMMDD 格式) + """ + self._check_login() + return self._base_data.get_calendar(market=market.value) + + def get_adj_factor(self, codes: List[str], + is_local: Optional[bool] = None) -> pd.DataFrame: + """ + 获取复权因子(单次复权) + + Args: + codes: 股票代码列表 + is_local: 是否使用本地缓存 + + Returns: + DataFrame (index: 日期, columns: 股票代码) + """ + self._check_login() + is_local = is_local if is_local is not None else self.config.use_local_cache + + return self._base_data.get_adj_factor( + code_list=codes, + local_path=self.config.local_path, + is_local=is_local + ) + + def get_backward_factor(self, codes: List[str], + is_local: Optional[bool] = None) -> pd.DataFrame: + """ + 获取后复权因子 + + Args: + codes: 股票代码列表 + is_local: 是否使用本地缓存 + + Returns: + DataFrame (index: 日期, columns: 股票代码) + """ + self._check_login() + is_local = is_local if is_local is not None else self.config.use_local_cache + + return self._base_data.get_backward_factor( + code_list=codes, + local_path=self.config.local_path, + is_local=is_local + ) + + # ==================== 历史行情数据接口 ==================== + + def get_kline(self, + codes: Union[str, List[str]], + start_date: Union[str, int, date], + end_date: Union[str, int, date], + period: Period = Period.DAILY) -> Dict[str, pd.DataFrame]: + """ + 获取历史K线数据 + + Args: + codes: 证券代码或代码列表 + start_date: 开始日期 + end_date: 结束日期 + period: K线周期 + + Returns: + Dict[代码, DataFrame],DataFrame包含OHLCV等字段 + """ + self._check_login() + + if isinstance(codes, str): + codes = [codes] + + start_date = self._format_date(start_date) + end_date = self._format_date(end_date) + + # 获取K线数据 + kline_dict = self._market_data.query_kline( + code_list=codes, + begin_date=start_date, + end_date=end_date, + period=getattr(self._ad.constant.Period, period.value).value + ) + + return kline_dict + + def get_snapshot(self, + codes: Union[str, List[str]], + start_date: Union[str, int, date], + end_date: Union[str, int, date]) -> Dict[str, pd.DataFrame]: + """ + 获取历史快照数据(tick级别) + + Args: + codes: 证券代码或代码列表 + start_date: 开始日期 + end_date: 结束日期 + + Returns: + Dict[代码, DataFrame] + """ + self._check_login() + + if isinstance(codes, str): + codes = [codes] + + start_date = self._format_date(start_date) + end_date = self._format_date(end_date) + + snapshot_dict = self._market_data.query_snapshot( + code_list=codes, + begin_date=start_date, + end_date=end_date + ) + + return snapshot_dict + + # ==================== 财务数据接口 ==================== + + def get_balance_sheet(self, + codes: List[str], + start_date: Optional[Union[str, int, date]] = None, + end_date: Optional[Union[str, int, date]] = None, + is_local: Optional[bool] = None) -> Dict[str, pd.DataFrame]: + """ + 获取资产负债表 + + Args: + codes: 股票代码列表 + start_date: 开始报告期 + end_date: 结束报告期 + is_local: 是否使用本地缓存 + + Returns: + Dict[代码, DataFrame] + """ + return self._get_financial_data( + 'get_balance_sheet', codes, start_date, end_date, is_local + ) + + def get_cash_flow(self, + codes: List[str], + start_date: Optional[Union[str, int, date]] = None, + end_date: Optional[Union[str, int, date]] = None, + is_local: Optional[bool] = None) -> Dict[str, pd.DataFrame]: + """ + 获取现金流量表 + + Args: + codes: 股票代码列表 + start_date: 开始报告期 + end_date: 结束报告期 + is_local: 是否使用本地缓存 + + Returns: + Dict[代码, DataFrame] + """ + return self._get_financial_data( + 'get_cash_flow', codes, start_date, end_date, is_local + ) + + def get_income_statement(self, + codes: List[str], + start_date: Optional[Union[str, int, date]] = None, + end_date: Optional[Union[str, int, date]] = None, + is_local: Optional[bool] = None) -> Dict[str, pd.DataFrame]: + """ + 获取利润表 + + Args: + codes: 股票代码列表 + start_date: 开始报告期 + end_date: 结束报告期 + is_local: 是否使用本地缓存 + + Returns: + Dict[代码, DataFrame] + """ + return self._get_financial_data( + 'get_income', codes, start_date, end_date, is_local + ) + + def get_profit_express(self, + codes: List[str], + start_date: Optional[Union[str, int, date]] = None, + end_date: Optional[Union[str, int, date]] = None, + is_local: Optional[bool] = None) -> pd.DataFrame: + """ + 获取业绩快报 + + Args: + codes: 股票代码列表 + start_date: 开始报告期 + end_date: 结束报告期 + is_local: 是否使用本地缓存 + + Returns: + DataFrame + """ + self._check_login() + is_local = is_local if is_local is not None else self.config.use_local_cache + + return self._info_data.get_profit_express( + code_list=codes, + local_path=self.config.local_path, + is_local=is_local, + begin_date=self._format_date(start_date) if start_date else None, + end_date=self._format_date(end_date) if end_date else None + ) + + def get_profit_notice(self, + codes: List[str], + start_date: Optional[Union[str, int, date]] = None, + end_date: Optional[Union[str, int, date]] = None, + is_local: Optional[bool] = None) -> pd.DataFrame: + """ + 获取业绩预告 + + Args: + codes: 股票代码列表 + start_date: 开始报告期 + end_date: 结束报告期 + is_local: 是否使用本地缓存 + + Returns: + DataFrame + """ + self._check_login() + is_local = is_local if is_local is not None else self.config.use_local_cache + + return self._info_data.get_profit_notice( + code_list=codes, + local_path=self.config.local_path, + is_local=is_local, + begin_date=self._format_date(start_date) if start_date else None, + end_date=self._format_date(end_date) if end_date else None + ) + + # ==================== 股东股本数据接口 ==================== + + def get_top10_shareholders(self, + codes: List[str], + start_date: Optional[Union[str, int, date]] = None, + end_date: Optional[Union[str, int, date]] = None, + is_local: Optional[bool] = None) -> pd.DataFrame: + """ + 获取十大股东数据 + + Args: + codes: 股票代码列表 + start_date: 开始日期 + end_date: 结束日期 + is_local: 是否使用本地缓存 + + Returns: + DataFrame + """ + self._check_login() + is_local = is_local if is_local is not None else self.config.use_local_cache + + return self._info_data.get_share_holder( + code_list=codes, + local_path=self.config.local_path, + is_local=is_local, + begin_date=self._format_date(start_date) if start_date else None, + end_date=self._format_date(end_date) if end_date else None + ) + + def get_shareholder_count(self, + codes: List[str], + start_date: Optional[Union[str, int, date]] = None, + end_date: Optional[Union[str, int, date]] = None, + is_local: Optional[bool] = None) -> pd.DataFrame: + """ + 获取股东户数数据 + + Args: + codes: 股票代码列表 + start_date: 开始日期 + end_date: 结束日期 + is_local: 是否使用本地缓存 + + Returns: + DataFrame + """ + self._check_login() + is_local = is_local if is_local is not None else self.config.use_local_cache + + return self._info_data.get_holder_num( + code_list=codes, + local_path=self.config.local_path, + is_local=is_local, + begin_date=self._format_date(start_date) if start_date else None, + end_date=self._format_date(end_date) if end_date else None + ) + + def get_equity_structure(self, + codes: List[str], + start_date: Optional[Union[str, int, date]] = None, + end_date: Optional[Union[str, int, date]] = None, + is_local: Optional[bool] = None) -> pd.DataFrame: + """ + 获取股本结构数据 + + Args: + codes: 股票代码列表 + start_date: 开始日期 + end_date: 结束日期 + is_local: 是否使用本地缓存 + + Returns: + DataFrame + """ + self._check_login() + is_local = is_local if is_local is not None else self.config.use_local_cache + + return self._info_data.get_equity_structure( + code_list=codes, + local_path=self.config.local_path, + is_local=is_local, + begin_date=self._format_date(start_date) if start_date else None, + end_date=self._format_date(end_date) if end_date else None + ) + + # ==================== 融资融券数据接口 ==================== + + def get_margin_summary(self, + start_date: Optional[Union[str, int, date]] = None, + end_date: Optional[Union[str, int, date]] = None, + is_local: Optional[bool] = None) -> pd.DataFrame: + """ + 获取融资融券成交汇总 + + Args: + start_date: 开始日期 + end_date: 结束日期 + is_local: 是否使用本地缓存 + + Returns: + DataFrame + """ + self._check_login() + is_local = is_local if is_local is not None else self.config.use_local_cache + + return self._info_data.get_margin_summary( + local_path=self.config.local_path, + is_local=is_local, + begin_date=self._format_date(start_date) if start_date else None, + end_date=self._format_date(end_date) if end_date else None + ) + + def get_margin_detail(self, + codes: List[str], + start_date: Optional[Union[str, int, date]] = None, + end_date: Optional[Union[str, int, date]] = None, + is_local: Optional[bool] = None) -> Dict[str, pd.DataFrame]: + """ + 获取融资融券交易明细 + + Args: + codes: 股票代码列表 + start_date: 开始日期 + end_date: 结束日期 + is_local: 是否使用本地缓存 + + Returns: + Dict[代码, DataFrame] + """ + self._check_login() + is_local = is_local if is_local is not None else self.config.use_local_cache + + return self._info_data.get_margin_detail( + code_list=codes, + local_path=self.config.local_path, + is_local=is_local, + begin_date=self._format_date(start_date) if start_date else None, + end_date=self._format_date(end_date) if end_date else None + ) + + # ==================== 交易异动数据接口 ==================== + + def get_longhu_bang(self, + codes: List[str], + start_date: Optional[Union[str, int, date]] = None, + end_date: Optional[Union[str, int, date]] = None, + is_local: Optional[bool] = None) -> pd.DataFrame: + """ + 获取龙虎榜数据 + + Args: + codes: 股票代码列表 + start_date: 开始日期 + end_date: 结束日期 + is_local: 是否使用本地缓存 + + Returns: + DataFrame + """ + self._check_login() + is_local = is_local if is_local is not None else self.config.use_local_cache + + return self._info_data.get_long_hu_bang( + code_list=codes, + local_path=self.config.local_path, + is_local=is_local, + begin_date=self._format_date(start_date) if start_date else None, + end_date=self._format_date(end_date) if end_date else None + ) + + def get_block_trading(self, + codes: List[str], + start_date: Optional[Union[str, int, date]] = None, + end_date: Optional[Union[str, int, date]] = None, + is_local: Optional[bool] = None) -> pd.DataFrame: + """ + 获取大宗交易数据 + + Args: + codes: 股票代码列表 + start_date: 开始日期 + end_date: 结束日期 + is_local: 是否使用本地缓存 + + Returns: + DataFrame + """ + self._check_login() + is_local = is_local if is_local is not None else self.config.use_local_cache + + return self._info_data.get_block_trading( + code_list=codes, + local_path=self.config.local_path, + is_local=is_local, + begin_date=self._format_date(start_date) if start_date else None, + end_date=self._format_date(end_date) if end_date else None + ) + + # ==================== 指数数据接口 ==================== + + def get_index_constituents(self, + codes: List[str], + is_local: Optional[bool] = None) -> Dict[str, pd.DataFrame]: + """ + 获取指数成分股 + + Args: + codes: 指数代码列表 + is_local: 是否使用本地缓存 + + Returns: + Dict[指数代码, DataFrame] + """ + self._check_login() + is_local = is_local if is_local is not None else self.config.use_local_cache + + return self._info_data.get_index_constituent( + code_list=codes, + local_path=self.config.local_path, + is_local=is_local + ) + + def get_index_weights(self, + codes: List[str], + start_date: Optional[Union[str, int, date]] = None, + end_date: Optional[Union[str, int, date]] = None, + is_local: Optional[bool] = None) -> Dict[str, pd.DataFrame]: + """ + 获取指数成分股权重 + 支持:上证50(000016.SH)、沪深300(000300.SH)、中证500(000905.SH)、 + 中证800(000906.SH)、中证1000(000852.SH) + + Args: + codes: 指数代码列表 + start_date: 开始日期 + end_date: 结束日期 + is_local: 是否使用本地缓存 + + Returns: + Dict[指数代码, DataFrame] + """ + self._check_login() + is_local = is_local if is_local is not None else self.config.use_local_cache + + return self._info_data.get_index_weight( + code_list=codes, + local_path=self.config.local_path, + is_local=is_local, + begin_date=self._format_date(start_date) if start_date else None, + end_date=self._format_date(end_date) if end_date else None + ) + + # ==================== ETF数据接口 ==================== + + def get_etf_pcf(self, codes: List[str]) -> Tuple[pd.DataFrame, Dict[str, pd.DataFrame]]: + """ + 获取ETF申赎数据 + + Args: + codes: ETF代码列表 + + Returns: + (etf_info, etf_constituents) + """ + self._check_login() + return self._base_data.get_etf_pcf(code_list=codes) + + def get_fund_share(self, + codes: List[str], + start_date: Optional[Union[str, int, date]] = None, + end_date: Optional[Union[str, int, date]] = None, + is_local: Optional[bool] = None) -> Dict[str, pd.DataFrame]: + """ + 获取基金份额数据 + + Args: + codes: ETF代码列表 + start_date: 开始日期 + end_date: 结束日期 + is_local: 是否使用本地缓存 + + Returns: + Dict[代码, DataFrame] + """ + self._check_login() + is_local = is_local if is_local is not None else self.config.use_local_cache + + return self._info_data.get_fund_share( + code_list=codes, + local_path=self.config.local_path, + is_local=is_local, + begin_date=self._format_date(start_date) if start_date else None, + end_date=self._format_date(end_date) if end_date else None + ) + + # ==================== 可转债数据接口 ==================== + + def get_kzz_issuance(self, + codes: List[str], + is_local: Optional[bool] = None) -> Dict[str, pd.DataFrame]: + """ + 获取可转债发行数据 + + Args: + codes: 可转债代码列表 + is_local: 是否使用本地缓存 + + Returns: + Dict[代码, DataFrame] + """ + self._check_login() + is_local = is_local if is_local is not None else self.config.use_local_cache + + return self._info_data.get_kzz_issuance( + code_list=codes, + local_path=self.config.local_path, + is_local=is_local + ) + + # ==================== 辅助方法 ==================== + + def _check_login(self): + """检查是否已登录""" + if not self._is_logged_in: + raise RuntimeError("未连接到数据源,请先调用 connect()") + + def _format_date(self, d: Union[str, int, date]) -> int: + """统一日期格式为 YYYYMMDD""" + if isinstance(d, int): + return d + elif isinstance(d, str): + return int(d.replace("-", "").replace("/", "")) + elif isinstance(d, date): + return int(d.strftime("%Y%m%d")) + else: + raise ValueError(f"不支持的日期格式: {d}") + + def _get_financial_data(self, method: str, codes: List[str], + start_date: Optional[Union[str, int, date]] = None, + end_date: Optional[Union[str, int, date]] = None, + is_local: Optional[bool] = None) -> Dict[str, pd.DataFrame]: + """通用财务数据获取方法""" + self._check_login() + is_local = is_local if is_local is not None else self.config.use_local_cache + + method = getattr(self._info_data, method) + return method( + code_list=codes, + local_path=self.config.local_path, + is_local=is_local, + begin_date=self._format_date(start_date) if start_date else None, + end_date=self._format_date(end_date) if end_date else None + ) + + +# ==================== 便捷函数 ==================== + +def create_adapter(username: str, password: str, host: str, port: int, + local_path: str = "./amazing_data_cache/", + use_local_cache: bool = True) -> AmazingDataAdapter: + """ + 快速创建适配器实例 + + Args: + username: 用户名 + password: 密码 + host: 服务器地址 + port: 服务器端口 + local_path: 本地缓存路径 + use_local_cache: 是否使用本地缓存 + + Returns: + AmazingDataAdapter 实例 + """ + config = DataSourceConfig( + username=username, + password=password, + host=host, + port=port, + local_path=local_path, + use_local_cache=use_local_cache + ) + return AmazingDataAdapter(config) + + +# ==================== 使用示例 ==================== + +if __name__ == "__main__": + # 示例代码 + print(""" +# 使用示例: + +# 1. 创建适配器 +adapter = create_adapter( + username='your_username', + password='your_password', + host='your_host', + port=your_port +) + +# 2. 连接数据源 +if adapter.connect(): + # 3. 获取沪深A股代码列表 + codes = adapter.get_code_list(SecurityType.STOCK_A) + print(f"获取到 {len(codes)} 只股票") + + # 4. 获取历史K线数据 + kline_data = adapter.get_kline( + codes=['000001.SZ', '600000.SH'], + start_date='20240101', + end_date='20241231', + period=Period.DAILY + ) + + # 5. 获取财务数据 + balance_sheet = adapter.get_balance_sheet( + codes=['000001.SZ', '600000.SH'], + start_date='20240101', + end_date='20241231' + ) + + # 6. 获取指数成分股 + constituents = adapter.get_index_constituents(['000300.SH']) # 沪深300 + + # 7. 断开连接 + adapter.disconnect() +""") diff --git a/backend/xyzs/amazing_data_examples.py b/backend/xyzs/amazing_data_examples.py new file mode 100644 index 0000000..fb78b8e --- /dev/null +++ b/backend/xyzs/amazing_data_examples.py @@ -0,0 +1,379 @@ +""" +AmazingData 数据源适配器 - 使用示例 + +本文件展示了如何使用 AmazingDataAdapter 获取各类金融数据 +""" + +from amazing_data_adapter import ( + AmazingDataAdapter, + DataSourceConfig, + SecurityType, + Market, + Period, + create_adapter +) +import pandas as pd + + +def demo_basic_usage(): + """基础使用示例""" + + # 方式1: 使用配置对象创建 + config = DataSourceConfig( + username='your_username', + password='your_password', + host='your_host', + port=8080, + local_path='./amazing_data_cache/', + use_local_cache=True + ) + adapter = AmazingDataAdapter(config) + + # 方式2: 使用便捷函数创建 + # adapter = create_adapter( + # username='your_username', + # password='your_password', + # host='your_host', + # port=8080 + # ) + + # 连接数据源 + if not adapter.connect(): + print("连接失败") + return + + try: + # ============== 基础数据 ============== + print("\n=== 基础数据 ===") + + # 获取沪深A股代码列表 + stock_codes = adapter.get_code_list(SecurityType.STOCK_A) + print(f"沪深A股数量: {len(stock_codes)}") + + # 获取ETF代码列表 + etf_codes = adapter.get_code_list(SecurityType.ETF) + print(f"ETF数量: {len(etf_codes)}") + + # 获取期货代码列表 + future_codes = adapter.get_code_list(SecurityType.FUTURE) + print(f"期货数量: {len(future_codes)}") + + # 获取证券基本信息 + stock_info = adapter.get_code_info(SecurityType.STOCK_A) + print(f"\n证券信息列: {stock_info.columns.tolist()}") + print(stock_info.head()) + + # 获取交易日历 + calendar = adapter.get_trading_calendar(Market.SH) + print(f"\n交易日数量: {len(calendar)}") + print(f"最近交易日: {calendar[-5:]}") + + # ============== 历史行情数据 ============== + print("\n=== 历史行情数据 ===") + + # 获取日K线数据 + sample_codes = stock_codes[:5] # 取前5只股票作为示例 + kline_data = adapter.get_kline( + codes=sample_codes, + start_date='20240101', + end_date='20241231', + period=Period.DAILY + ) + + for code, df in list(kline_data.items())[:2]: + print(f"\n{code} K线数据:") + print(df.head()) + + # 获取分钟K线 + min_kline = adapter.get_kline( + codes=['000001.SZ'], + start_date='20241201', + end_date='20241231', + period=Period.MIN60 + ) + + # ============== 复权因子 ============== + print("\n=== 复权因子 ===") + + adj_factor = adapter.get_adj_factor( + codes=['000001.SZ', '600000.SH'], + is_local=False # 强制从服务器获取最新数据 + ) + print("单次复权因子:") + print(adj_factor.head()) + + backward_factor = adapter.get_backward_factor( + codes=['000001.SZ', '600000.SH'] + ) + print("\n后复权因子:") + print(backward_factor.head()) + + # ============== 财务数据 ============== + print("\n=== 财务数据 ===") + + # 获取资产负债表 + balance_sheet = adapter.get_balance_sheet( + codes=['000001.SZ', '600000.SH'], + start_date=20240101, + end_date=20241231 + ) + + for code, df in balance_sheet.items(): + print(f"\n{code} 资产负债表字段:") + print(df.columns.tolist()[:10]) # 显示前10个字段 + + # 获取现金流量表 + cash_flow = adapter.get_cash_flow( + codes=['000001.SZ'], + start_date=20240101, + end_date=20241231 + ) + + # 获取利润表 + income = adapter.get_income_statement( + codes=['000001.SZ'], + start_date=20240101, + end_date=20241231 + ) + + # 获取业绩快报 + profit_express = adapter.get_profit_express( + codes=['000001.SZ', '600000.SH'] + ) + print(f"\n业绩快报:\n{profit_express.head()}") + + # ============== 股东股本数据 ============== + print("\n=== 股东股本数据 ===") + + # 获取十大股东 + top10_holders = adapter.get_top10_shareholders( + codes=['000001.SZ'], + start_date=20240101, + end_date=20241231 + ) + print(f"十大股东:\n{top10_holders.head()}") + + # 获取股东户数 + holder_count = adapter.get_shareholder_count( + codes=['000001.SZ'], + start_date=20240101, + end_date=20241231 + ) + print(f"\n股东户数:\n{holder_count.head()}") + + # 获取股本结构 + equity_structure = adapter.get_equity_structure( + codes=['000001.SZ'] + ) + print(f"\n股本结构:\n{equity_structure.head()}") + + # ============== 融资融券数据 ============== + print("\n=== 融资融券数据 ===") + + # 获取融资融券汇总 + margin_summary = adapter.get_margin_summary( + start_date=20240101, + end_date=20241231 + ) + print(f"融资融券汇总:\n{margin_summary.head()}") + + # 获取个股融资融券明细 + margin_detail = adapter.get_margin_detail( + codes=['000001.SZ'], + start_date=20241201, + end_date=20241231 + ) + + # ============== 交易异动数据 ============== + print("\n=== 交易异动数据 ===") + + # 获取龙虎榜 + longhu = adapter.get_longhu_bang( + codes=['000001.SZ'], + start_date=20241201, + end_date=20241231 + ) + print(f"龙虎榜数据:\n{longhu.head()}") + + # 获取大宗交易 + block_trade = adapter.get_block_trading( + codes=['000001.SZ'], + start_date=20241201, + end_date=20241231 + ) + print(f"\n大宗交易:\n{block_trade.head()}") + + # ============== 指数数据 ============== + print("\n=== 指数数据 ===") + + # 获取指数成分股 (沪深300) + index_constituents = adapter.get_index_constituents(['000300.SH']) + for code, df in index_constituents.items(): + print(f"\n{code} 成分股数量: {len(df)}") + print(df.head()) + + # 获取指数成分股权重 + index_weights = adapter.get_index_weights( + codes=['000300.SH', '000905.SH'], # 沪深300和中证500 + start_date=20241201, + end_date=20241231 + ) + + # ============== ETF数据 ============== + print("\n=== ETF数据 ===") + + # 获取ETF申赎数据 + etf_info, etf_constituents = adapter.get_etf_pcf(['510050.SH']) + print(f"ETF基本信息:\n{etf_info.head()}") + + # 获取基金份额 + fund_share = adapter.get_fund_share( + codes=['510050.SH'], + start_date=20240101, + end_date=20241231 + ) + + # ============== 可转债数据 ============== + print("\n=== 可转债数据 ===") + + # 获取可转债发行数据 + kzz_codes = adapter.get_code_list(SecurityType.KZZ) + print(f"可转债数量: {len(kzz_codes)}") + + if kzz_codes: + kzz_issuance = adapter.get_kzz_issuance(kzz_codes[:3]) + for code, df in list(kzz_issuance.items())[:1]: + print(f"\n{code} 发行信息:\n{df.head()}") + + finally: + # 断开连接 + adapter.disconnect() + + +def demo_data_processing(): + """数据处理示例 - 展示如何加工获取的数据""" + + adapter = create_adapter( + username='your_username', + password='your_password', + host='your_host', + port=8080 + ) + + if not adapter.connect(): + return + + try: + # 获取平安银行(000001.SZ)的日K线数据 + kline_data = adapter.get_kline( + codes=['000001.SZ'], + start_date='20240101', + end_date='20241231', + period=Period.DAILY + ) + + df = kline_data['000001.SZ'] + + # 计算技术指标 + # 1. 移动平均线 + df['MA5'] = df['close'].rolling(window=5).mean() + df['MA10'] = df['close'].rolling(window=10).mean() + df['MA20'] = df['close'].rolling(window=20).mean() + + # 2. 涨跌幅 + df['return'] = df['close'].pct_change() + + # 3. 波动率 (20日) + df['volatility'] = df['return'].rolling(window=20).std() * (252 ** 0.5) + + # 4. 成交量移动平均 + df['volume_MA5'] = df['volume'].rolling(window=5).mean() + + print("加工后的数据:") + print(df[['close', 'MA5', 'MA10', 'MA20', 'return', 'volatility']].tail(10)) + + # 获取复权因子并计算复权价格 + adj_factor = adapter.get_backward_factor(['000001.SZ']) + + # 合并K线和复权因子 + df['trade_date'] = df.index.strftime('%Y%m%d').astype(int) + df = df.merge( + adj_factor[['000001.SZ']].reset_index(), + left_on='trade_date', + right_on='index', + how='left' + ) + df = df.rename(columns={'000001.SZ': 'adj_factor'}) + + # 计算后复权价格 + df['adj_close'] = df['close'] * df['adj_factor'] + + print("\n复权后的价格:") + print(df[['close', 'adj_factor', 'adj_close']].tail(10)) + + finally: + adapter.disconnect() + + +def demo_batch_processing(): + """批量处理示例""" + + adapter = create_adapter( + username='your_username', + password='your_password', + host='your_host', + port=8080 + ) + + if not adapter.connect(): + return + + try: + # 获取沪深300成分股 + index_constituents = adapter.get_index_constituents(['000300.SH']) + hs300_codes = index_constituents['000300.SH']['CON_CODE'].tolist() + + print(f"沪深300成分股数量: {len(hs300_codes)}") + + # 批量获取财务数据 (分批处理避免超时) + batch_size = 50 + all_balance_sheets = {} + + for i in range(0, len(hs300_codes), batch_size): + batch_codes = hs300_codes[i:i+batch_size] + print(f"处理第 {i//batch_size + 1} 批,共 {len(batch_codes)} 只股票") + + batch_data = adapter.get_balance_sheet( + codes=batch_codes, + start_date=20240930, + end_date=20240930 + ) + all_balance_sheets.update(batch_data) + + # 合并所有数据 + combined_data = [] + for code, df in all_balance_sheets.items(): + if not df.empty: + df['code'] = code + combined_data.append(df) + + if combined_data: + result_df = pd.concat(combined_data, ignore_index=True) + print(f"\n合并后的数据形状: {result_df.shape}") + print(result_df[['code', 'REPORTING_PERIOD', 'TOTAL_ASSETS', 'TOTAL_CUR_ASSETS']].head()) + + finally: + adapter.disconnect() + + +if __name__ == "__main__": + print("=" * 60) + print("AmazingData 数据源适配器 - 使用示例") + print("=" * 60) + + # 注:实际运行前请替换用户名、密码和服务器地址 + print("\n提示: 请先在代码中替换 username, password, host, port 为实际值") + + # demo_basic_usage() + # demo_data_processing() + # demo_batch_processing() diff --git a/backend/xyzs/tgw-1.0.8.5-py3-none-any.whl b/backend/xyzs/tgw-1.0.8.5-py3-none-any.whl new file mode 100644 index 0000000..f6a8a02 Binary files /dev/null and b/backend/xyzs/tgw-1.0.8.5-py3-none-any.whl differ diff --git a/database/init.sql b/database/init.sql new file mode 100644 index 0000000..75cca6e --- /dev/null +++ b/database/init.sql @@ -0,0 +1,729 @@ +-- AmazingData 金融数据服务平台 - 数据库初始化脚本 +-- PostgreSQL 15+ +-- 执行命令: psql -U postgres -d amazing_data -f init.sql + +-- ============================================ +-- 1. 创建扩展 +-- ============================================ +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; +CREATE EXTENSION IF NOT EXISTS "pg_trgm"; -- 用于模糊搜索 + +-- ============================================ +-- 2. 用户表 +-- ============================================ +CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + username VARCHAR(50) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + is_active BOOLEAN DEFAULT TRUE, + is_superuser BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +COMMENT ON TABLE users IS '系统用户表'; +COMMENT ON COLUMN users.password_hash IS 'bcrypt加密的密码'; + +-- 创建默认管理员用户 (密码: admin123, 请在生产环境修改) +-- 密码通过Python bcrypt生成: bcrypt.hashpw('admin123'.encode(), bcrypt.gensalt()) +INSERT INTO users (username, password_hash, is_superuser) +VALUES ('admin', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/X4.VTtYA.qGZvKG6G', TRUE) +ON CONFLICT (username) DO NOTHING; + +-- ============================================ +-- 3. SDK配置表 +-- ============================================ +CREATE TABLE IF NOT EXISTS sdk_configs ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + username VARCHAR(100) NOT NULL, + password VARCHAR(255) NOT NULL, + host VARCHAR(100) NOT NULL, + port INTEGER NOT NULL DEFAULT 8080, + local_path VARCHAR(255) DEFAULT './amazing_data_cache/', + is_active BOOLEAN DEFAULT TRUE, + is_default BOOLEAN DEFAULT FALSE, + description TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +COMMENT ON TABLE sdk_configs IS 'AmazingData SDK配置表'; +COMMENT ON COLUMN sdk_configs.password IS 'SDK登录密码,建议加密存储'; + +-- 确保只有一个默认配置 +CREATE OR REPLACE FUNCTION ensure_single_default_sdk() +RETURNS TRIGGER AS $$ +BEGIN + IF NEW.is_default THEN + UPDATE sdk_configs SET is_default = FALSE WHERE id != NEW.id; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_ensure_single_default_sdk ON sdk_configs; +CREATE TRIGGER trg_ensure_single_default_sdk + BEFORE INSERT OR UPDATE ON sdk_configs + FOR EACH ROW + EXECUTE FUNCTION ensure_single_default_sdk(); + +-- ============================================ +-- 4. 系统配置表 +-- ============================================ +CREATE TABLE IF NOT EXISTS system_configs ( + id SERIAL PRIMARY KEY, + config_key VARCHAR(100) UNIQUE NOT NULL, + config_value TEXT NOT NULL, + description TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +COMMENT ON TABLE system_configs IS '系统配置键值表'; + +-- 插入默认配置 +INSERT INTO system_configs (config_key, config_value, description) VALUES +('cache.default_period', 'daily', '默认K线周期'), +('cache.default_days', '365', '默认查询天数'), +('cache.auto_cleanup_days', '7', '实时数据自动清理天数'), +('cache.batch_size', '100', '批量缓存每次处理代码数'), +('cache.missing_threshold', '0.1', '数据缺失判断阈值(10%)'), +('realtime.subscribe_interval', '1000', '实时订阅推送间隔(ms)'), +('jwt.expire_hours', '24', 'JWT Token过期时间(小时)') +ON CONFLICT (config_key) DO NOTHING; + +-- ============================================ +-- 5. 股票代码信息表 +-- ============================================ +CREATE TABLE IF NOT EXISTS stock_info ( + id SERIAL PRIMARY KEY, + code VARCHAR(20) UNIQUE NOT NULL, + symbol VARCHAR(100) NOT NULL, + security_status INTEGER, + pre_close DECIMAL(12, 4), + high_limited DECIMAL(12, 4), + low_limited DECIMAL(12, 4), + price_tick DECIMAL(10, 4), + exchange VARCHAR(10), -- SH, SZ, BJ + industry VARCHAR(50), + list_date DATE, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +COMMENT ON TABLE stock_info IS '股票基础信息表'; + +CREATE INDEX IF NOT EXISTS idx_stock_info_code ON stock_info(code); +CREATE INDEX IF NOT EXISTS idx_stock_info_exchange ON stock_info(exchange); +CREATE INDEX IF NOT EXISTS idx_stock_info_symbol ON stock_info USING gin(symbol gin_trgm_ops); + +-- ============================================ +-- 6. 期货代码信息表 +-- ============================================ +CREATE TABLE IF NOT EXISTS future_info ( + id SERIAL PRIMARY KEY, + code VARCHAR(20) UNIQUE NOT NULL, + symbol VARCHAR(100) NOT NULL, + underlying VARCHAR(20), -- 标的代码 + contract_month VARCHAR(10), + pre_close DECIMAL(12, 4), + high_limited DECIMAL(12, 4), + low_limited DECIMAL(12, 4), + price_tick DECIMAL(10, 4), + exchange VARCHAR(10) DEFAULT 'CFE', -- CFE: 中金所 + list_date DATE, + expire_date DATE, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +COMMENT ON TABLE future_info IS '期货基础信息表'; + +CREATE INDEX IF NOT EXISTS idx_future_info_code ON future_info(code); +CREATE INDEX IF NOT EXISTS idx_future_info_underlying ON future_info(underlying); + +-- ============================================ +-- 7. 股票日线数据表 +-- ============================================ +CREATE TABLE IF NOT EXISTS stock_kline_daily ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(20) NOT NULL, + trade_date DATE NOT NULL, + open DECIMAL(12, 4) NOT NULL, + high DECIMAL(12, 4) NOT NULL, + low DECIMAL(12, 4) NOT NULL, + close DECIMAL(12, 4) NOT NULL, + volume BIGINT NOT NULL, + amount DECIMAL(18, 4) NOT NULL, + adj_factor DECIMAL(12, 6), -- 复权因子 + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + UNIQUE(code, trade_date) +); + +COMMENT ON TABLE stock_kline_daily IS '股票日线K线数据'; + +CREATE INDEX IF NOT EXISTS idx_stock_daily_code_date ON stock_kline_daily(code, trade_date); +CREATE INDEX IF NOT EXISTS idx_stock_daily_trade_date ON stock_kline_daily(trade_date); +CREATE INDEX IF NOT EXISTS idx_stock_daily_code ON stock_kline_daily(code); + +-- 分区表(按年分区,可选) +-- 如果需要分区,可以创建子表 +-- CREATE TABLE stock_kline_daily_2024 PARTITION OF stock_kline_daily +-- FOR VALUES FROM ('2024-01-01') TO ('2025-01-01'); + +-- ============================================ +-- 8. 股票分钟数据表 +-- ============================================ +CREATE TABLE IF NOT EXISTS stock_kline_min ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(20) NOT NULL, + period_type VARCHAR(10) NOT NULL CHECK (period_type IN ('min1', 'min5', 'min15', 'min30', 'min60')), + trade_datetime TIMESTAMP NOT NULL, + open DECIMAL(12, 4) NOT NULL, + high DECIMAL(12, 4) NOT NULL, + low DECIMAL(12, 4) NOT NULL, + close DECIMAL(12, 4) NOT NULL, + volume BIGINT NOT NULL, + amount DECIMAL(18, 4) NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + UNIQUE(code, period_type, trade_datetime) +); + +COMMENT ON TABLE stock_kline_min IS '股票分钟K线数据'; + +CREATE INDEX IF NOT EXISTS idx_stock_min_code_period_datetime ON stock_kline_min(code, period_type, trade_datetime); +CREATE INDEX IF NOT EXISTS idx_stock_min_trade_datetime ON stock_kline_min(trade_datetime); + +-- ============================================ +-- 9. 期货日线数据表 +-- ============================================ +CREATE TABLE IF NOT EXISTS future_kline_daily ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(20) NOT NULL, + trade_date DATE NOT NULL, + open DECIMAL(12, 4) NOT NULL, + high DECIMAL(12, 4) NOT NULL, + low DECIMAL(12, 4) NOT NULL, + close DECIMAL(12, 4) NOT NULL, + volume BIGINT NOT NULL, + amount DECIMAL(18, 4) NOT NULL, + settle DECIMAL(12, 4), + open_interest BIGINT, + pre_settle DECIMAL(12, 4), + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + UNIQUE(code, trade_date) +); + +COMMENT ON TABLE future_kline_daily IS '期货日线K线数据'; + +CREATE INDEX IF NOT EXISTS idx_future_daily_code_date ON future_kline_daily(code, trade_date); +CREATE INDEX IF NOT EXISTS idx_future_daily_trade_date ON future_kline_daily(trade_date); + +-- ============================================ +-- 10. 期货分钟数据表 +-- ============================================ +CREATE TABLE IF NOT EXISTS future_kline_min ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(20) NOT NULL, + period_type VARCHAR(10) NOT NULL CHECK (period_type IN ('min1', 'min5', 'min15', 'min30', 'min60')), + trade_datetime TIMESTAMP NOT NULL, + open DECIMAL(12, 4) NOT NULL, + high DECIMAL(12, 4) NOT NULL, + low DECIMAL(12, 4) NOT NULL, + close DECIMAL(12, 4) NOT NULL, + volume BIGINT NOT NULL, + amount DECIMAL(18, 4) NOT NULL, + settle DECIMAL(12, 4), + open_interest BIGINT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + UNIQUE(code, period_type, trade_datetime) +); + +COMMENT ON TABLE future_kline_min IS '期货分钟K线数据'; + +CREATE INDEX IF NOT EXISTS idx_future_min_code_period_datetime ON future_kline_min(code, period_type, trade_datetime); + +-- ============================================ +-- 11. 指数日线数据表 +-- ============================================ +CREATE TABLE IF NOT EXISTS index_kline_daily ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(20) NOT NULL, + trade_date DATE NOT NULL, + open DECIMAL(12, 4), + high DECIMAL(12, 4), + low DECIMAL(12, 4), + close DECIMAL(12, 4), + volume BIGINT, + amount DECIMAL(18, 4), + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + UNIQUE(code, trade_date) +); + +COMMENT ON TABLE index_kline_daily IS '指数日线数据'; + +CREATE INDEX IF NOT EXISTS idx_index_daily_code_date ON index_kline_daily(code, trade_date); + +-- ============================================ +-- 12. 实时快照数据表 (TTL 7天) +-- ============================================ +CREATE TABLE IF NOT EXISTS realtime_snapshot ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(20) NOT NULL, + security_type VARCHAR(20) NOT NULL, -- stock, future, index, etf, kzz, option + trade_time TIMESTAMP NOT NULL, + pre_close DECIMAL(12, 4), + last DECIMAL(12, 4), + open DECIMAL(12, 4), + high DECIMAL(12, 4), + low DECIMAL(12, 4), + close DECIMAL(12, 4), + volume BIGINT, + amount DECIMAL(18, 4), + -- 盘口数据 + ask_price1 DECIMAL(12, 4), + ask_price2 DECIMAL(12, 4), + ask_price3 DECIMAL(12, 4), + ask_price4 DECIMAL(12, 4), + ask_price5 DECIMAL(12, 4), + ask_volume1 INTEGER, + ask_volume2 INTEGER, + ask_volume3 INTEGER, + ask_volume4 INTEGER, + ask_volume5 INTEGER, + bid_price1 DECIMAL(12, 4), + bid_price2 DECIMAL(12, 4), + bid_price3 DECIMAL(12, 4), + bid_price4 DECIMAL(12, 4), + bid_price5 DECIMAL(12, 4), + bid_volume1 INTEGER, + bid_volume2 INTEGER, + bid_volume3 INTEGER, + bid_volume4 INTEGER, + bid_volume5 INTEGER, + -- 期货特有字段 + settle DECIMAL(12, 4), + open_interest BIGINT, + pre_settle DECIMAL(12, 4), + average_price DECIMAL(12, 4), + -- 状态 + trading_phase_code VARCHAR(10), + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP WITH TIME ZONE NOT NULL -- 过期时间 +); + +COMMENT ON TABLE realtime_snapshot IS '实时快照数据,自动清理7天前数据'; + +CREATE INDEX IF NOT EXISTS idx_snapshot_code_time ON realtime_snapshot(code, trade_time); +CREATE INDEX IF NOT EXISTS idx_snapshot_expires ON realtime_snapshot(expires_at); + +-- 创建自动清理过期数据的触发器 +CREATE OR REPLACE FUNCTION auto_cleanup_expired_snapshots() +RETURNS void AS $$ +BEGIN + DELETE FROM realtime_snapshot + WHERE expires_at < NOW(); +END; +$$ LANGUAGE plpgsql; + +-- ============================================ +-- 13. 历史快照数据表 +-- ============================================ +CREATE TABLE IF NOT EXISTS history_snapshot ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(20) NOT NULL, + security_type VARCHAR(20) NOT NULL, + trade_date DATE NOT NULL, + trade_time TIME NOT NULL, + pre_close DECIMAL(12, 4), + last DECIMAL(12, 4), + open DECIMAL(12, 4), + high DECIMAL(12, 4), + low DECIMAL(12, 4), + close DECIMAL(12, 4), + volume BIGINT, + amount DECIMAL(18, 4), + ask_price1 DECIMAL(12, 4), + ask_volume1 INTEGER, + bid_price1 DECIMAL(12, 4), + bid_volume1 INTEGER, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + UNIQUE(code, trade_date, trade_time) +); + +COMMENT ON TABLE history_snapshot IS '历史快照数据'; + +CREATE INDEX IF NOT EXISTS idx_hist_snapshot_code_date ON history_snapshot(code, trade_date); + +-- ============================================ +-- 14. 财务数据表 - 资产负债表 +-- ============================================ +CREATE TABLE IF NOT EXISTS finance_balance_sheet ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(20) NOT NULL, + report_date DATE NOT NULL, + report_type INTEGER, -- 1:年报, 2:中报, 3:季报 + statement_type INTEGER, -- 报表类型 + -- 资产 + total_assets DECIMAL(18, 4), + total_cur_assets DECIMAL(18, 4), + total_noncur_assets DECIMAL(18, 4), + currency_cap DECIMAL(18, 4), + notes_receivable DECIMAL(18, 4), + acct_receivable DECIMAL(18, 4), + inventory DECIMAL(18, 4), + fix_assets DECIMAL(18, 4), + -- 负债 + total_liab DECIMAL(18, 4), + total_cur_liab DECIMAL(18, 4), + total_noncur_liab DECIMAL(18, 4), + notes_payable DECIMAL(18, 4), + acct_payable DECIMAL(18, 4), + st_borrowing DECIMAL(18, 4), + lt_loan DECIMAL(18, 4), + -- 权益 + tot_share_equity DECIMAL(18, 4), + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + UNIQUE(code, report_date, statement_type) +); + +COMMENT ON TABLE finance_balance_sheet IS '资产负债表'; + +CREATE INDEX IF NOT EXISTS idx_balance_code_date ON finance_balance_sheet(code, report_date); + +-- ============================================ +-- 15. 财务数据表 - 现金流量表 +-- ============================================ +CREATE TABLE IF NOT EXISTS finance_cash_flow ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(20) NOT NULL, + report_date DATE NOT NULL, + report_type INTEGER, + statement_type INTEGER, + net_cash_flows_opera_act DECIMAL(18, 4), + net_cash_flows_inv_act DECIMAL(18, 4), + net_cash_flows_fin_act DECIMAL(18, 4), + net_incr_cash_and_cash_equ DECIMAL(18, 4), + cash_recp_sg_and_rs DECIMAL(18, 4), + cash_pay_goods_services DECIMAL(18, 4), + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + UNIQUE(code, report_date, statement_type) +); + +COMMENT ON TABLE finance_cash_flow IS '现金流量表'; + +CREATE INDEX IF NOT EXISTS idx_cashflow_code_date ON finance_cash_flow(code, report_date); + +-- ============================================ +-- 16. 财务数据表 - 利润表 +-- ============================================ +CREATE TABLE IF NOT EXISTS finance_income ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(20) NOT NULL, + report_date DATE NOT NULL, + report_type INTEGER, + statement_type INTEGER, + tot_opera_rev DECIMAL(18, 4), + opera_rev DECIMAL(18, 4), + tot_opera_cost DECIMAL(18, 4), + opera_profit DECIMAL(18, 4), + total_profit DECIMAL(18, 4), + net_pro_incl_min_int_inc DECIMAL(18, 4), + basic_eps DECIMAL(12, 6), + diluted_eps DECIMAL(12, 6), + rd_exp DECIMAL(18, 4), + selling_exp DECIMAL(18, 4), + admin_exp DECIMAL(18, 4), + fin_exp DECIMAL(18, 4), + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + UNIQUE(code, report_date, statement_type) +); + +COMMENT ON TABLE finance_income IS '利润表'; + +CREATE INDEX IF NOT EXISTS idx_income_code_date ON finance_income(code, report_date); + +-- ============================================ +-- 17. 交易日历表 +-- ============================================ +CREATE TABLE IF NOT EXISTS trading_calendar ( + id SERIAL PRIMARY KEY, + market VARCHAR(10) NOT NULL, -- SH, SZ, BJ, CFE + trade_date DATE NOT NULL, + is_trading_day BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + UNIQUE(market, trade_date) +); + +COMMENT ON TABLE trading_calendar IS '交易日历'; + +CREATE INDEX IF NOT EXISTS idx_calendar_market_date ON trading_calendar(market, trade_date); +CREATE INDEX IF NOT EXISTS idx_calendar_date ON trading_calendar(trade_date); + +-- ============================================ +-- 18. 缓存任务表 +-- ============================================ +CREATE TABLE IF NOT EXISTS cache_tasks ( + id SERIAL PRIMARY KEY, + task_name VARCHAR(200) NOT NULL, + task_type VARCHAR(50) NOT NULL CHECK (task_type IN ('detect_missing', 'cache_data', 'sync_data')), + security_type VARCHAR(20) NOT NULL, -- stock, future, index + period_type VARCHAR(10), -- daily, min1, min5, etc. + start_date DATE NOT NULL, + end_date DATE NOT NULL, + code_list TEXT, -- 逗号分隔的代码列表,NULL表示全部 + status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'running', 'completed', 'failed', 'cancelled')), + progress DECIMAL(5, 2) DEFAULT 0, -- 0-100 + total_count INTEGER DEFAULT 0, + success_count INTEGER DEFAULT 0, + error_count INTEGER DEFAULT 0, + error_message TEXT, + created_by INTEGER REFERENCES users(id), + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + started_at TIMESTAMP WITH TIME ZONE, + completed_at TIMESTAMP WITH TIME ZONE +); + +COMMENT ON TABLE cache_tasks IS '数据缓存任务'; + +CREATE INDEX IF NOT EXISTS idx_cache_tasks_status ON cache_tasks(status); +CREATE INDEX IF NOT EXISTS idx_cache_tasks_created ON cache_tasks(created_at); + +-- ============================================ +-- 19. 缓存任务详情表 +-- ============================================ +CREATE TABLE IF NOT EXISTS cache_task_details ( + id BIGSERIAL PRIMARY KEY, + task_id INTEGER NOT NULL REFERENCES cache_tasks(id) ON DELETE CASCADE, + code VARCHAR(20) NOT NULL, + trade_date DATE NOT NULL, + expected_count INTEGER DEFAULT 0, + actual_count INTEGER DEFAULT 0, + is_missing BOOLEAN DEFAULT FALSE, + status VARCHAR(20) DEFAULT 'pending', -- pending, success, failed, skipped + error_message TEXT, + processed_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +COMMENT ON TABLE cache_task_details IS '缓存任务详细记录'; + +CREATE INDEX IF NOT EXISTS idx_cache_details_task ON cache_task_details(task_id); +CREATE INDEX IF NOT EXISTS idx_cache_details_code ON cache_task_details(code); + +-- ============================================ +-- 20. API测试日志表 +-- ============================================ +CREATE TABLE IF NOT EXISTS api_test_logs ( + id BIGSERIAL PRIMARY KEY, + test_name VARCHAR(200) NOT NULL, + api_category VARCHAR(50) NOT NULL, -- base_data, stock, future, realtime, finance, etc. + api_endpoint VARCHAR(200) NOT NULL, + request_method VARCHAR(10) NOT NULL, + request_params JSONB, + response_data JSONB, + status_code INTEGER, + execution_time_ms INTEGER, + is_success BOOLEAN DEFAULT FALSE, + error_message TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +COMMENT ON TABLE api_test_logs IS 'API接口测试日志'; + +CREATE INDEX IF NOT EXISTS idx_test_logs_category ON api_test_logs(api_category); +CREATE INDEX IF NOT EXISTS idx_test_logs_endpoint ON api_test_logs(api_endpoint); +CREATE INDEX IF NOT EXISTS idx_test_logs_created ON api_test_logs(created_at); + +-- ============================================ +-- 21. 数据同步日志表 +-- ============================================ +CREATE TABLE IF NOT EXISTS data_sync_logs ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(20) NOT NULL, + security_type VARCHAR(20) NOT NULL, + period_type VARCHAR(10), + start_date DATE, + end_date DATE, + record_count INTEGER DEFAULT 0, + source VARCHAR(50) DEFAULT 'sdk', -- sdk, manual, import + status VARCHAR(20) DEFAULT 'success', + error_message TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +COMMENT ON TABLE data_sync_logs IS '数据同步日志'; + +CREATE INDEX IF NOT EXISTS idx_sync_logs_code ON data_sync_logs(code); +CREATE INDEX IF NOT EXISTS idx_sync_logs_created ON data_sync_logs(created_at); + +-- ============================================ +-- 22. 创建视图 - 数据缓存状态概览 +-- ============================================ +CREATE OR REPLACE VIEW v_cache_status AS +SELECT + code, + 'stock' as security_type, + 'daily' as period_type, + COUNT(*) as record_count, + MIN(trade_date) as min_date, + MAX(trade_date) as max_date +FROM stock_kline_daily +GROUP BY code +UNION ALL +SELECT + code, + 'future' as security_type, + 'daily' as period_type, + COUNT(*) as record_count, + MIN(trade_date) as min_date, + MAX(trade_date) as max_date +FROM future_kline_daily +GROUP BY code; + +COMMENT ON VIEW v_cache_status IS '数据缓存状态概览视图'; + +-- ============================================ +-- 23. 创建函数 - 获取交易日列表 +-- ============================================ +CREATE OR REPLACE FUNCTION get_trading_days( + p_market VARCHAR(10), + p_start_date DATE, + p_end_date DATE +) +RETURNS TABLE (trade_date DATE) AS $$ +BEGIN + RETURN QUERY + SELECT tc.trade_date + FROM trading_calendar tc + WHERE tc.market = p_market + AND tc.trade_date BETWEEN p_start_date AND p_end_date + AND tc.is_trading_day = TRUE + ORDER BY tc.trade_date; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION get_trading_days IS '获取指定区间的交易日列表'; + +-- ============================================ +-- 24. 创建函数 - 计算数据缺失率 +-- ============================================ +CREATE OR REPLACE FUNCTION calculate_missing_ratio( + p_code VARCHAR(20), + p_security_type VARCHAR(20), + p_period_type VARCHAR(10), + p_start_date DATE, + p_end_date DATE +) +RETURNS TABLE ( + expected_count INTEGER, + actual_count INTEGER, + missing_count INTEGER, + missing_ratio DECIMAL(5, 4) +) AS $$ +DECLARE + v_expected INTEGER; + v_actual INTEGER; + v_market VARCHAR(10); +BEGIN + -- 确定市场 + v_market := CASE + WHEN p_security_type = 'future' THEN 'CFE' + WHEN RIGHT(p_code, 3) = '.SH' THEN 'SH' + WHEN RIGHT(p_code, 3) = '.SZ' THEN 'SZ' + WHEN RIGHT(p_code, 3) = '.BJ' THEN 'BJ' + ELSE 'SH' + END; + + -- 计算期望数据条数 + SELECT COUNT(*) INTO v_expected + FROM get_trading_days(v_market, p_start_date, p_end_date); + + -- 计算实际数据条数 + IF p_security_type = 'stock' AND p_period_type = 'daily' THEN + SELECT COUNT(*) INTO v_actual + FROM stock_kline_daily + WHERE code = p_code AND trade_date BETWEEN p_start_date AND p_end_date; + ELSIF p_security_type = 'future' AND p_period_type = 'daily' THEN + SELECT COUNT(*) INTO v_actual + FROM future_kline_daily + WHERE code = p_code AND trade_date BETWEEN p_start_date AND p_end_date; + ELSE + v_actual := 0; + END IF; + + RETURN QUERY + SELECT + v_expected, + v_actual, + GREATEST(0, v_expected - v_actual), + CASE + WHEN v_expected > 0 THEN (v_expected - v_actual)::DECIMAL / v_expected + ELSE 0 + END; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION calculate_missing_ratio IS '计算指定代码在指定区间的数据缺失率'; + +-- ============================================ +-- 25. 创建触发器 - 自动更新updated_at +-- ============================================ +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- 为用户表创建触发器 +DROP TRIGGER IF EXISTS trg_users_updated_at ON users; +CREATE TRIGGER trg_users_updated_at + BEFORE UPDATE ON users + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +-- 为SDK配置表创建触发器 +DROP TRIGGER IF EXISTS trg_sdk_configs_updated_at ON sdk_configs; +CREATE TRIGGER trg_sdk_configs_updated_at + BEFORE UPDATE ON sdk_configs + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +-- 为系统配置表创建触发器 +DROP TRIGGER IF EXISTS trg_system_configs_updated_at ON system_configs; +CREATE TRIGGER trg_system_configs_updated_at + BEFORE UPDATE ON system_configs + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +-- ============================================ +-- 26. 创建分区表(可选,用于大数据量场景) +-- ============================================ +-- 如果需要按月分区,可以使用以下命令创建分区表结构 +-- 注意:这需要PostgreSQL 10+ + +/* +-- 股票分钟数据按月分区示例 +CREATE TABLE stock_kline_min_partitioned ( + LIKE stock_kline_min INCLUDING ALL +) PARTITION BY RANGE (trade_datetime); + +-- 创建各月分区 +CREATE TABLE stock_kline_min_202401 PARTITION OF stock_kline_min_partitioned + FOR VALUES FROM ('2024-01-01') TO ('2024-02-01'); +CREATE TABLE stock_kline_min_202402 PARTITION OF stock_kline_min_partitioned + FOR VALUES FROM ('2024-02-01') TO ('2024-03-01'); +-- ... 更多分区 +*/ + +-- ============================================ +-- 初始化完成 +-- ============================================ +SELECT 'Database initialization completed successfully!' as status; diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..fb82c44 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,64 @@ +version: '3.8' + +services: + postgres: + image: postgres:15-alpine + container_name: amazing_data_postgres + environment: + POSTGRES_DB: amazing_data + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + volumes: + - postgres_data:/var/lib/postgresql/data + - ../database/init.sql:/docker-entrypoint-initdb.d/init.sql + ports: + - "5432:5432" + networks: + - amazing_data_network + + redis: + image: redis:7-alpine + container_name: amazing_data_redis + ports: + - "6379:6379" + networks: + - amazing_data_network + + backend: + build: + context: ../backend + dockerfile: Dockerfile + container_name: amazing_data_backend + environment: + DATABASE_URL: postgresql://postgres:postgres@postgres:5432/amazing_data + REDIS_URL: redis://redis:6379/0 + SECRET_KEY: your-secret-key-change-in-production + DEBUG: "false" + ports: + - "8000:8000" + depends_on: + - postgres + - redis + networks: + - amazing_data_network + restart: unless-stopped + + frontend: + build: + context: ../frontend + dockerfile: Dockerfile + container_name: amazing_data_frontend + ports: + - "80:80" + depends_on: + - backend + networks: + - amazing_data_network + restart: unless-stopped + +volumes: + postgres_data: + +networks: + amazing_data_network: + driver: bridge diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..7abba4c --- /dev/null +++ b/docs/API.md @@ -0,0 +1,577 @@ +# AmazingData 金融数据服务平台 - API接口文档 + +## 基础信息 + +- **Base URL**: `http://localhost:8000/api/v1` +- **Content-Type**: `application/json` +- **认证方式**: JWT Bearer Token + +## 认证相关 + +### 1. 用户登录 +```http +POST /auth/login +Content-Type: application/json + +{ + "username": "admin", + "password": "admin123" +} +``` + +**响应**: +```json +{ + "code": 200, + "message": "success", + "data": { + "access_token": "eyJhbGciOiJIUzI1NiIs...", + "token_type": "bearer", + "expires_in": 86400 + } +} +``` + +### 2. 获取当前用户 +```http +GET /auth/me +Authorization: Bearer {token} +``` + +## 配置管理 + +### 3. 获取SDK配置列表 +```http +GET /configs/sdk +Authorization: Bearer {token} +``` + +### 4. 创建SDK配置 +```http +POST /configs/sdk +Authorization: Bearer {token} +Content-Type: application/json + +{ + "name": "银河证券生产环境", + "username": "your_username", + "password": "your_password", + "host": "xxx.xxx.xxx.xxx", + "port": 8080, + "local_path": "./amazing_data_cache/", + "is_default": true +} +``` + +### 5. 测试SDK连接 +```http +POST /configs/sdk/{id}/test +Authorization: Bearer {token} +``` + +## 基础数据 + +### 6. 获取代码列表 +```http +GET /base/codes?security_type=EXTRA_STOCK_A +Authorization: Bearer {token} +``` + +**参数**: +- `security_type`: 证券类型 + - `EXTRA_STOCK_A` - 沪深A股 + - `EXTRA_FUTURE` - 期货 + - `EXTRA_ETF` - ETF + - `EXTRA_INDEX_A` - 指数 + +### 7. 获取证券信息 +```http +GET /base/codes/{code}/info +Authorization: Bearer {token} +``` + +### 8. 获取交易日历 +```http +GET /base/calendar?market=SH&start_date=20240101&end_date=20241231 +Authorization: Bearer {token} +``` + +## 股票数据 + +### 9. 获取股票K线数据 +```http +GET /stock/kline?codes=000001.SZ&start_date=20240101&end_date=20241231&period=daily +Authorization: Bearer {token} +``` + +**参数**: +- `codes`: 股票代码,多个用逗号分隔 +- `start_date`: 开始日期 (YYYYMMDD) +- `end_date`: 结束日期 (YYYYMMDD) +- `period`: 周期 (daily, min1, min5, min15, min30, min60) + +**响应**: +```json +{ + "code": 200, + "message": "success", + "data": { + "000001.SZ": [ + { + "trade_date": "2024-01-02", + "open": 10.50, + "high": 10.80, + "low": 10.40, + "close": 10.65, + "volume": 1234567, + "amount": 12845678.90 + } + ] + } +} +``` + +### 10. 获取股票K线图数据 +```http +GET /stock/kline/{code}/chart?start_date=20240101&end_date=20241231&period=daily +Authorization: Bearer {token} +``` + +**响应** (ECharts格式): +```json +{ + "code": 200, + "message": "success", + "data": { + "categoryData": ["2024-01-02", "2024-01-03", ...], + "values": [ + [10.50, 10.80, 10.40, 10.65, 1234567], + [10.65, 10.70, 10.50, 10.60, 987654], + ... + ], + "volumes": [ + [0, 1234567, 1], + [1, 987654, -1], + ... + ] + } +} +``` + +### 11. 批量获取股票K线 +```http +POST /stock/kline/batch +Authorization: Bearer {token} +Content-Type: application/json + +{ + "codes": ["000001.SZ", "600000.SH"], + "start_date": "20240101", + "end_date": "20241231", + "period": "daily" +} +``` + +## 期货数据 + +### 12. 获取期货K线数据 +```http +GET /future/kline?codes=IF2501.CFE&start_date=20240101&end_date=20241231&period=daily +Authorization: Bearer {token} +``` + +### 13. 获取期货K线图数据 +```http +GET /future/kline/{code}/chart?start_date=20240101&end_date=20241231&period=daily +Authorization: Bearer {token} +``` + +## 实时数据 + +### 14. 获取最新快照 +```http +GET /realtime/snapshot?codes=000001.SZ,600000.SH +Authorization: Bearer {token} +``` + +### 15. 开始实时订阅 +```http +POST /realtime/subscribe +Authorization: Bearer {token} +Content-Type: application/json + +{ + "codes": ["000001.SZ", "600000.SH"], + "types": ["snapshot"], + "callback_url": "ws://localhost:8000/api/v1/realtime/stream" +} +``` + +### 16. WebSocket实时数据流 +``` +WS /realtime/stream?codes=000001.SZ,600000.SH&types=snapshot +Authorization: Bearer {token} +``` + +**消息格式**: +```json +{ + "type": "snapshot", + "code": "000001.SZ", + "data": { + "trade_time": "2025-01-15T10:30:00", + "last": 10.50, + "open": 10.30, + "high": 10.60, + "low": 10.20, + "volume": 1234567, + "amount": 12845678.90 + } +} +``` + +## 财务数据 + +### 17. 获取资产负债表 +```http +GET /finance/balance-sheet?codes=000001.SZ&start_date=20240930&end_date=20240930 +Authorization: Bearer {token} +``` + +**响应**: +```json +{ + "code": 200, + "message": "success", + "data": { + "000001.SZ": [ + { + "report_date": "2024-09-30", + "total_assets": 123456789012.34, + "total_cur_assets": 98765432109.87, + "total_liab": 87654321098.76, + "tot_share_equity": 35802467913.58 + } + ] + } +} +``` + +### 18. 获取现金流量表 +```http +GET /finance/cash-flow?codes=000001.SZ&start_date=20240930&end_date=20240930 +Authorization: Bearer {token} +``` + +### 19. 获取利润表 +```http +GET /finance/income?codes=000001.SZ&start_date=20240930&end_date=20240930 +Authorization: Bearer {token} +``` + +### 20. 获取业绩快报 +```http +GET /finance/profit-express?codes=000001.SZ&start_date=20240930&end_date=20240930 +Authorization: Bearer {token} +``` + +### 21. 获取业绩预告 +```http +GET /finance/profit-notice?codes=000001.SZ&start_date=20240930&end_date=20240930 +Authorization: Bearer {token} +``` + +## 股东数据 + +### 22. 获取十大股东 +```http +GET /shareholder/top10?codes=000001.SZ&start_date=20240930&end_date=20240930 +Authorization: Bearer {token} +``` + +### 23. 获取股东户数 +```http +GET /shareholder/count?codes=000001.SZ&start_date=20240930&end_date=20240930 +Authorization: Bearer {token} +``` + +### 24. 获取股本结构 +```http +GET /shareholder/equity?codes=000001.SZ&start_date=20240930&end_date=20240930 +Authorization: Bearer {token} +``` + +## 融资融券 + +### 25. 获取融资融券汇总 +```http +GET /margin/summary?start_date=20240101&end_date=20241231 +Authorization: Bearer {token} +``` + +### 26. 获取融资融券明细 +```http +GET /margin/detail?codes=000001.SZ&start_date=20240101&end_date=20241231 +Authorization: Bearer {token} +``` + +## 指数数据 + +### 27. 获取指数成分股 +```http +GET /index/constituents?codes=000300.SH +Authorization: Bearer {token} +``` + +### 28. 获取指数权重 +```http +GET /index/weights?codes=000300.SH&start_date=20240101&end_date=20241231 +Authorization: Bearer {token} +``` + +## ETF数据 + +### 29. 获取ETF申赎数据 +```http +GET /etf/pcf?codes=510050.SH +Authorization: Bearer {token} +``` + +### 30. 获取ETF份额 +```http +GET /etf/share?codes=510050.SH&start_date=20240101&end_date=20241231 +Authorization: Bearer {token} +``` + +## 可转债数据 + +### 31. 获取可转债发行数据 +```http +GET /kzz/issuance?codes=128XXX.SZ +Authorization: Bearer {token} +``` + +## 缓存管理 + +### 32. 检测缺失数据 +```http +POST /cache/detect-missing +Authorization: Bearer {token} +Content-Type: application/json + +{ + "security_type": "stock", + "period_type": "daily", + "start_date": "20240101", + "end_date": "20241231", + "code_list": ["000001.SZ", "600000.SH"] +} +``` + +**响应**: +```json +{ + "code": 200, + "message": "success", + "data": { + "task_id": 1, + "total_codes": 2, + "missing_codes": [ + { + "code": "000001.SZ", + "missing_dates": [ + { + "date": "2024-06-01", + "expected": 1, + "actual": 0, + "missing_ratio": 1.0 + } + ] + } + ] + } +} +``` + +### 33. 批量缓存数据 +```http +POST /cache/batch-cache +Authorization: Bearer {token} +Content-Type: application/json + +{ + "security_type": "stock", + "period_type": "daily", + "start_date": "20240101", + "end_date": "20241231", + "code_list": ["000001.SZ", "600000.SH"] +} +``` + +### 34. 获取缓存任务列表 +```http +GET /cache/tasks?page=1&page_size=20 +Authorization: Bearer {token} +``` + +### 35. 获取缓存任务详情 +```http +GET /cache/tasks/{task_id} +Authorization: Bearer {token} +``` + +### 36. 取消缓存任务 +```http +DELETE /cache/tasks/{task_id} +Authorization: Bearer {token} +``` + +### 37. 获取代码缓存状态 +```http +GET /cache/status/{code}?security_type=stock&period_type=daily +Authorization: Bearer {token} +``` + +**响应**: +```json +{ + "code": 200, + "message": "success", + "data": { + "code": "000001.SZ", + "security_type": "stock", + "period_type": "daily", + "record_count": 242, + "min_date": "2024-01-02", + "max_date": "2024-12-31", + "missing_ratio": 0.0 + } +} +``` + +## 测试中心 + +### 38. 获取测试分类 +```http +GET /test/categories +Authorization: Bearer {token} +``` + +**响应**: +```json +{ + "code": 200, + "message": "success", + "data": [ + {"key": "base_data", "name": "基础数据"}, + {"key": "stock", "name": "股票数据"}, + {"key": "future", "name": "期货数据"}, + {"key": "realtime", "name": "实时数据"}, + {"key": "finance", "name": "财务数据"}, + {"key": "shareholder", "name": "股东数据"}, + {"key": "margin", "name": "融资融券"}, + {"key": "index", "name": "指数数据"}, + {"key": "etf", "name": "ETF数据"}, + {"key": "kzz", "name": "可转债数据"} + ] +} +``` + +### 39. 获取接口列表 +```http +GET /test/endpoints?category=stock +Authorization: Bearer {token} +``` + +### 40. 执行单个接口测试 +```http +POST /test/run +Authorization: Bearer {token} +Content-Type: application/json + +{ + "endpoint": "/api/v1/stock/kline", + "method": "GET", + "params": { + "codes": "000001.SZ", + "start_date": "20240101", + "end_date": "20241231", + "period": "daily" + } +} +``` + +### 41. 执行全部接口测试 +```http +POST /test/run-all +Authorization: Bearer {token} +Content-Type: application/json + +{ + "categories": ["stock", "future", "finance"] +} +``` + +### 42. 获取测试历史 +```http +GET /test/history?page=1&page_size=20 +Authorization: Bearer {token} +``` + +## 错误码 + +| 错误码 | 说明 | +|--------|------| +| 200 | 成功 | +| 400 | 参数错误 | +| 401 | 未授权 | +| 403 | 禁止访问 | +| 404 | 资源不存在 | +| 500 | 服务器内部错误 | +| 1001 | SDK连接失败 | +| 1002 | 数据不存在 | +| 1003 | 任务执行失败 | + +## 数据类型说明 + +### K线数据结构 + +| 字段 | 类型 | 说明 | +|------|------|------| +| trade_date | string | 交易日期 (YYYY-MM-DD) | +| trade_datetime | string | 交易时间 (YYYY-MM-DD HH:MM:SS) | +| open | number | 开盘价 | +| high | number | 最高价 | +| low | number | 最低价 | +| close | number | 收盘价 | +| volume | number | 成交量 | +| amount | number | 成交金额 | + +### 期货K线额外字段 + +| 字段 | 类型 | 说明 | +|------|------|------| +| settle | number | 结算价 | +| open_interest | number | 持仓量 | + +### 快照数据结构 + +| 字段 | 类型 | 说明 | +|------|------|------| +| code | string | 证券代码 | +| trade_time | string | 交易时间 | +| last | number | 最新价 | +| open | number | 开盘价 | +| high | number | 最高价 | +| low | number | 最低价 | +| volume | number | 成交量 | +| amount | number | 成交金额 | +| ask_price1-5 | number | 卖1-5价格 | +| ask_volume1-5 | number | 卖1-5数量 | +| bid_price1-5 | number | 买1-5价格 | +| bid_volume1-5 | number | 买1-5数量 | + +--- + +**文档版本**: 1.0 +**更新日期**: 2025年 diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..c8e328a --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,27 @@ +# 前端Dockerfile +FROM node:18-alpine AS builder + +WORKDIR /app + +# 复制package.json +COPY package.json ./ +RUN npm install + +# 复制源代码 +COPY . . + +# 构建 +RUN npm run build + +# 生产环境 +FROM nginx:alpine + +# 复制构建产物 +COPY --from=builder /app/dist /usr/share/nginx/html + +# 复制nginx配置 +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..deeb028 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + AmazingData金融数据服务平台 + + +
+ + + diff --git a/frontend/nginx-local.conf b/frontend/nginx-local.conf new file mode 100644 index 0000000..d952eb1 --- /dev/null +++ b/frontend/nginx-local.conf @@ -0,0 +1,17 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + location /api { + proxy_pass http://host.docker.internal:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } +} \ No newline at end of file diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..b6cdb47 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,17 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + location /api { + proxy_pass http://backend:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..9fa76f0 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2060 @@ +{ + "name": "amazing-data-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "amazing-data-frontend", + "version": "1.0.0", + "dependencies": { + "@element-plus/icons-vue": "^2.1.0", + "axios": "^1.6.2", + "dayjs": "^1.11.10", + "echarts": "^5.4.3", + "element-plus": "^2.4.4", + "js-cookie": "^3.0.5", + "pinia": "^2.1.7", + "vue": "^3.3.8", + "vue-echarts": "^6.6.1", + "vue-router": "^4.2.5" + }, + "devDependencies": { + "@types/js-cookie": "^3.0.6", + "@types/node": "^20.10.0", + "@vitejs/plugin-vue": "^4.5.0", + "typescript": "^5.3.2", + "vite": "^5.0.4", + "vue-tsc": "^1.8.22" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/parser/node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", + "integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@element-plus/icons-vue": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz", + "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@popperjs/core": { + "name": "@sxzz/popperjs-es", + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz", + "integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/js-cookie": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz", + "integrity": "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/node": { + "version": "20.19.39", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", + "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.20", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", + "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-4.6.2.tgz", + "integrity": "sha512-kqf7SGFoG+80aZG6Pf+gsZIVvGSCKE98JbiWqcCV9cThtg91Jav0yvYFC9Zb+jKetNGF6ZKeoaxgZfND21fWKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.0.0 || ^5.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-1.11.1.tgz", + "integrity": "sha512-dOcNn3i9GgZAcJt43wuaEykSluAuOkQgzni1cuxLxTV0nJKanQztp7FxyswdRILaKH+P2XZMPRp2S4MV/pElCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "1.11.1" + } + }, + "node_modules/@volar/source-map": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-1.11.1.tgz", + "integrity": "sha512-hJnOnwZ4+WT5iupLRnuzbULZ42L7BWWPMmruzwtLhJfpDVoZLjNBxHDi2sY2bgZXCKlpU5XcsMFoYrsQmPhfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "muggle-string": "^0.3.1" + } + }, + "node_modules/@volar/typescript": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-1.11.1.tgz", + "integrity": "sha512-iU+t2mas/4lYierSnoFOeRFQUhAEMgsFuQxoxvwn5EdQopw43j+J27a4lt9LMInx1gLJBC6qL14WYGlgymaSMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "1.11.1", + "path-browserify": "^1.0.1" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.32.tgz", + "integrity": "sha512-4x74Tbtqnda8s/NSD6e1Dr5p1c8HdMU5RWSjMSUzb8RTcUQqevDCxVAitcLBKT+ie3o0Dl9crc/S/opJM7qBGQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/shared": "3.5.32", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.32.tgz", + "integrity": "sha512-ybHAu70NtiEI1fvAUz3oXZqkUYEe5J98GjMDpTGl5iHb0T15wQYLR4wE3h9xfuTNA+Cm2f4czfe8B4s+CCH57Q==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.32.tgz", + "integrity": "sha512-8UYUYo71cP/0YHMO814TRZlPuUUw3oifHuMR7Wp9SNoRSrxRQnhMLNlCeaODNn6kNTJsjFoQ/kqIj4qGvya4Xg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/compiler-core": "3.5.32", + "@vue/compiler-dom": "3.5.32", + "@vue/compiler-ssr": "3.5.32", + "@vue/shared": "3.5.32", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.8", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.32.tgz", + "integrity": "sha512-Gp4gTs22T3DgRotZ8aA/6m2jMR+GMztvBXUBEUOYOcST+giyGWJ4WvFd7QLHBkzTxkfOt8IELKNdpzITLbA2rw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/language-core": { + "version": "1.8.27", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-1.8.27.tgz", + "integrity": "sha512-L8Kc27VdQserNaCUNiSFdDl9LWT24ly8Hpwf1ECy3aFb9m6bDhBGQYOujDm21N7EW3moKIOKEanQwe1q5BK+mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "~1.11.1", + "@volar/source-map": "~1.11.1", + "@vue/compiler-dom": "^3.3.0", + "@vue/shared": "^3.3.0", + "computeds": "^0.0.1", + "minimatch": "^9.0.3", + "muggle-string": "^0.3.1", + "path-browserify": "^1.0.1", + "vue-template-compiler": "^2.7.14" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.32.tgz", + "integrity": "sha512-/ORasxSGvZ6MN5gc+uE364SxFdJ0+WqVG0CENXaGW58TOCdrAW76WWaplDtECeS1qphvtBZtR+3/o1g1zL4xPQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.32.tgz", + "integrity": "sha512-pDrXCejn4UpFDFmMd27AcJEbHaLemaE5o4pbb7sLk79SRIhc6/t34BQA7SGNgYtbMnvbF/HHOftYBgFJtUoJUQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.32.tgz", + "integrity": "sha512-1CDVv7tv/IV13V8Nip1k/aaObVbWqRlVCVezTwx3K07p7Vxossp5JU1dcPNhJk3w347gonIUT9jQOGutyJrSVQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.32", + "@vue/runtime-core": "3.5.32", + "@vue/shared": "3.5.32", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.32.tgz", + "integrity": "sha512-IOjm2+JQwRFS7W28HNuJeXQle9KdZbODFY7hFGVtnnghF51ta20EWAZJHX+zLGtsHhaU6uC9BGPV52KVpYryMQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.32", + "@vue/shared": "3.5.32" + }, + "peerDependencies": { + "vue": "3.5.32" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.32.tgz", + "integrity": "sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg==", + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-12.0.0.tgz", + "integrity": "sha512-C12RukhXiJCbx4MGhjmd/gH52TjJsc3G0E0kQj/kb19H3Nt6n1CA4DRWuTdWWcaFRdlTe0npWDS942mvacvNBw==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.20", + "@vueuse/metadata": "12.0.0", + "@vueuse/shared": "12.0.0", + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/metadata": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-12.0.0.tgz", + "integrity": "sha512-Yzimd1D3sjxTDOlF05HekU5aSGdKjxhuhRFHA7gDWLn57PRbBIh+SF5NmjhJ0WRgF3my7T8LBucyxdFJjIfRJQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-12.0.0.tgz", + "integrity": "sha512-3i6qtcq2PIio5i/vVYidkkcgvmTjCqrf26u+Fd4LhnbBmIT6FN8y6q/GJERp8lfcB9zVEfjdV0Br0443qZuJpw==", + "license": "MIT", + "dependencies": { + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", + "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/computeds": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/computeds/-/computeds-0.0.1.tgz", + "integrity": "sha512-7CEBgcMjVmitjYo5q8JTJVra6X5mQ20uTThdK+0kR7UEaDrAWEQcRiBtWJzga4eRpP6afNwwLsX2SET2JhVB1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.20", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "license": "MIT" + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/echarts": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-5.6.0.tgz", + "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "5.6.1" + } + }, + "node_modules/element-plus": { + "version": "2.13.7", + "resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.13.7.tgz", + "integrity": "sha512-XdHATFZOyzVFL1DaHQ90IOJQSg9UnSAV+bhDW+YB5UoZ0Hxs50mwqjqfwXkuwpSag+VXXizVcErBR6Movo5daw==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^4.2.0", + "@element-plus/icons-vue": "^2.3.2", + "@floating-ui/dom": "^1.0.1", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.7", + "@types/lodash": "^4.17.20", + "@types/lodash-es": "^4.17.12", + "@vueuse/core": "12.0.0", + "async-validator": "^4.2.5", + "dayjs": "^1.11.19", + "lodash": "^4.17.23", + "lodash-es": "^4.17.23", + "lodash-unified": "^1.0.3", + "memoize-one": "^6.0.0", + "normalize-wheel-es": "^1.2.0", + "vue-component-type-helpers": "^3.2.4" + }, + "peerDependencies": { + "vue": "^3.3.0" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, + "node_modules/lodash-unified": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/lodash-unified/-/lodash-unified-1.0.3.tgz", + "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==", + "license": "MIT", + "peerDependencies": { + "@types/lodash-es": "*", + "lodash": "*", + "lodash-es": "*" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/muggle-string": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.3.1.tgz", + "integrity": "sha512-ckmWDJjphvd/FvZawgygcUeQCxzvohjFO5RxTjj4eq8kw359gFF3E1brjfI+viLMxss5JrHTDRHZvu2/tuy0Qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/normalize-wheel-es": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz", + "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==", + "license": "BSD-3-Clause" + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/pinia": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz", + "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss": { + "version": "8.5.9", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", + "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/resize-detector": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/resize-detector/-/resize-detector-0.3.0.tgz", + "integrity": "sha512-R/tCuvuOHQ8o2boRP6vgx8hXCCy87H1eY9V5imBYeVNyNVpuL9ciReSccLj2gDcax9+2weXy3bc8Vv+NRXeEvQ==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.32.tgz", + "integrity": "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.32", + "@vue/compiler-sfc": "3.5.32", + "@vue/runtime-dom": "3.5.32", + "@vue/server-renderer": "3.5.32", + "@vue/shared": "3.5.32" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-component-type-helpers": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.2.6.tgz", + "integrity": "sha512-O02tnvIfOQVmnvoWwuSydwRoHjZVt8UEBR+2p4rT35p8GAy5VTlWP8o5qXfJR/GWCN0nVZoYWsVUvx2jwgdBmQ==", + "license": "MIT" + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-echarts": { + "version": "6.7.3", + "resolved": "https://registry.npmjs.org/vue-echarts/-/vue-echarts-6.7.3.tgz", + "integrity": "sha512-vXLKpALFjbPphW9IfQPOVfb1KjGZ/f8qa/FZHi9lZIWzAnQC1DgnmEK3pJgEkyo6EP7UnX6Bv/V3Ke7p+qCNXA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "resize-detector": "^0.3.0", + "vue-demi": "^0.13.11" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.5", + "@vue/runtime-core": "^3.0.0", + "echarts": "^5.4.1", + "vue": "^2.6.12 || ^3.1.1" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + }, + "@vue/runtime-core": { + "optional": true + } + } + }, + "node_modules/vue-echarts/node_modules/vue-demi": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.13.11.tgz", + "integrity": "sha512-IR8HoEEGM65YY3ZJYAjMlKygDQn25D5ajNFNoKh9RSDMQtlzCxtfQjdQgv9jjK+m3377SsJXY8ysq8kLCZL25A==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-template-compiler": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/vue-template-compiler/-/vue-template-compiler-2.7.16.tgz", + "integrity": "sha512-AYbUWAJHLGGQM7+cNTELw+KsOG9nl2CnSv467WobS5Cv9uk3wFcnr1Etsz2sEIHEZvw1U+o9mRlEO6QbZvUPGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/vue-tsc": { + "version": "1.8.27", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-1.8.27.tgz", + "integrity": "sha512-WesKCAZCRAbmmhuGl3+VrdWItEvfoFIPXOvUJkjULi+x+6G/Dy69yO3TBRJDr9eUlmsNAwVmxsNZxvHKzbkKdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "~1.11.1", + "@vue/language-core": "1.8.27", + "semver": "^7.5.4" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": "*" + } + }, + "node_modules/zrender": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz", + "integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..71fbb32 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,31 @@ +{ + "name": "amazing-data-frontend", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "vue": "^3.3.8", + "vue-router": "^4.2.5", + "pinia": "^2.1.7", + "axios": "^1.6.2", + "element-plus": "^2.4.4", + "echarts": "^5.4.3", + "vue-echarts": "^6.6.1", + "@element-plus/icons-vue": "^2.1.0", + "dayjs": "^1.11.10", + "js-cookie": "^3.0.5" + }, + "devDependencies": { + "@types/node": "^20.10.0", + "@types/js-cookie": "^3.0.6", + "@vitejs/plugin-vue": "^4.5.0", + "typescript": "^5.3.2", + "vite": "^5.0.4", + "vue-tsc": "^1.8.22" + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..1ba353f --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,26 @@ + + + + + diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts new file mode 100644 index 0000000..05108bc --- /dev/null +++ b/frontend/src/api/auth.ts @@ -0,0 +1,13 @@ +import request from '@/utils/request' + +export const login = (data: { username: string; password: string }) => { + return request.post('/auth/login', data) +} + +export const getUserInfo = () => { + return request.get('/auth/me') +} + +export const logout = () => { + return request.post('/auth/logout') +} diff --git a/frontend/src/api/cache.ts b/frontend/src/api/cache.ts new file mode 100644 index 0000000..af4c491 --- /dev/null +++ b/frontend/src/api/cache.ts @@ -0,0 +1,40 @@ +import request from '@/utils/request' + +export const detectMissingData = (data: { + security_type: string + period_type: string + start_date: string + end_date: string + code_list: string[] +}) => { + return request.post('/cache/detect-missing', data) +} + +export const batchCacheData = (data: { + security_type: string + period_type: string + start_date: string + end_date: string + code_list: string[] +}) => { + return request.post('/cache/batch-cache', data) +} + +export const getCacheTasks = (params?: { page?: number; page_size?: number }) => { + return request.get('/cache/tasks', { params }) +} + +export const getCacheTask = (taskId: number) => { + return request.get(`/cache/tasks/${taskId}`) +} + +export const cancelCacheTask = (taskId: number) => { + return request.delete(`/cache/tasks/${taskId}`) +} + +export const getCacheStatus = ( + code: string, + params: { security_type: string; period_type: string } +) => { + return request.get(`/cache/status/${code}`, { params }) +} diff --git a/frontend/src/api/config.ts b/frontend/src/api/config.ts new file mode 100644 index 0000000..390b3df --- /dev/null +++ b/frontend/src/api/config.ts @@ -0,0 +1,25 @@ +import request from '@/utils/request' + +export const getSDKConfigs = () => { + return request.get('/configs/sdk') +} + +export const createSDKConfig = (data: any) => { + return request.post('/configs/sdk', data) +} + +export const updateSDKConfig = (id: number, data: any) => { + return request.put(`/configs/sdk/${id}`, data) +} + +export const deleteSDKConfig = (id: number) => { + return request.delete(`/configs/sdk/${id}`) +} + +export const testSDKConfig = (id: number) => { + return request.post(`/configs/sdk/${id}/test`) +} + +export const setDefaultConfig = (id: number) => { + return request.post(`/configs/sdk/${id}/set-default`) +} diff --git a/frontend/src/api/finance.ts b/frontend/src/api/finance.ts new file mode 100644 index 0000000..2e1a417 --- /dev/null +++ b/frontend/src/api/finance.ts @@ -0,0 +1,25 @@ +import request from '@/utils/request' + +export const getBalanceSheet = (params: { + codes: string + start_date: string + end_date: string +}) => { + return request.get('/finance/balance-sheet', { params }) +} + +export const getCashFlow = (params: { + codes: string + start_date: string + end_date: string +}) => { + return request.get('/finance/cash-flow', { params }) +} + +export const getIncome = (params: { + codes: string + start_date: string + end_date: string +}) => { + return request.get('/finance/income', { params }) +} diff --git a/frontend/src/api/future.ts b/frontend/src/api/future.ts new file mode 100644 index 0000000..079748d --- /dev/null +++ b/frontend/src/api/future.ts @@ -0,0 +1,21 @@ +import request from '@/utils/request' + +export const getFutureKline = (params: { + codes: string + start_date: string + end_date: string + period?: string +}) => { + return request.get('/future/kline', { params }) +} + +export const getFutureKlineChart = ( + code: string, + params: { + start_date: string + end_date: string + period?: string + } +) => { + return request.get(`/future/kline/${code}/chart`, { params }) +} diff --git a/frontend/src/api/stock.ts b/frontend/src/api/stock.ts new file mode 100644 index 0000000..dcae6bd --- /dev/null +++ b/frontend/src/api/stock.ts @@ -0,0 +1,30 @@ +import request from '@/utils/request' + +export const getStockKline = (params: { + codes: string + start_date: string + end_date: string + period?: string +}) => { + return request.get('/stock/kline', { params }) +} + +export const getStockKlineChart = ( + code: string, + params: { + start_date: string + end_date: string + period?: string + } +) => { + return request.get(`/stock/kline/${code}/chart`, { params }) +} + +export const batchGetStockKline = (data: { + codes: string[] + start_date: string + end_date: string + period?: string +}) => { + return request.post('/stock/kline/batch', data) +} diff --git a/frontend/src/api/test.ts b/frontend/src/api/test.ts new file mode 100644 index 0000000..9de8bce --- /dev/null +++ b/frontend/src/api/test.ts @@ -0,0 +1,29 @@ +import request from '@/utils/request' + +export const getTestCategories = () => { + return request.get('/test/categories') +} + +export const getTestEndpoints = (category?: string) => { + return request.get('/test/endpoints', { params: { category } }) +} + +export const runSingleTest = (data: { + endpoint: string + method: string + params?: Record +}) => { + return request.post('/test/run', data) +} + +export const runAllTests = (data: { categories: string[] }) => { + return request.post('/test/run-all', data) +} + +export const getTestHistory = (params?: { page?: number; page_size?: number }) => { + return request.get('/test/history', { params }) +} + +export const getTestDetail = (testId: number) => { + return request.get(`/test/history/${testId}`) +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..9e3a977 --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,22 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import ElementPlus from 'element-plus' +import * as ElementPlusIconsVue from '@element-plus/icons-vue' +import 'element-plus/dist/index.css' +import zhCn from 'element-plus/dist/locale/zh-cn.mjs' + +import App from './App.vue' +import router from './router' + +const app = createApp(App) + +// 注册所有图标 +for (const [key, component] of Object.entries(ElementPlusIconsVue)) { + app.component(key, component) +} + +app.use(createPinia()) +app.use(router) +app.use(ElementPlus, { locale: zhCn }) + +app.mount('#app') diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts new file mode 100644 index 0000000..623c1ed --- /dev/null +++ b/frontend/src/router/index.ts @@ -0,0 +1,73 @@ +import { createRouter, createWebHistory } from 'vue-router' +import { useUserStore } from '@/store/user' + +const routes = [ + { + path: '/login', + name: 'Login', + component: () => import('@/views/Login.vue'), + meta: { public: true } + }, + { + path: '/', + name: 'Layout', + component: () => import('@/views/Layout.vue'), + redirect: '/dashboard', + children: [ + { + path: 'dashboard', + name: 'Dashboard', + component: () => import('@/views/Dashboard.vue'), + meta: { title: '首页', icon: 'HomeFilled' } + }, + { + path: 'data-query', + name: 'DataQuery', + component: () => import('@/views/DataQuery/index.vue'), + meta: { title: '数据查询', icon: 'DataLine' } + }, + { + path: 'config', + name: 'ConfigManager', + component: () => import('@/views/ConfigManager.vue'), + meta: { title: '配置管理', icon: 'Setting' } + }, + { + path: 'cache', + name: 'CacheManager', + component: () => import('@/views/CacheManager/index.vue'), + meta: { title: '缓存管理', icon: 'Box' } + }, + { + path: 'test', + name: 'TestCenter', + component: () => import('@/views/TestCenter/index.vue'), + meta: { title: '测试中心', icon: 'CircleCheck' } + } + ] + } +] + +const router = createRouter({ + history: createWebHistory(), + routes +}) + +// 路由守卫 +router.beforeEach((to, from, next) => { + const userStore = useUserStore() + + if (to.meta.public) { + next() + return + } + + if (!userStore.token) { + next('/login') + return + } + + next() +}) + +export default router diff --git a/frontend/src/shims-vue.d.ts b/frontend/src/shims-vue.d.ts new file mode 100644 index 0000000..2b97bd9 --- /dev/null +++ b/frontend/src/shims-vue.d.ts @@ -0,0 +1,5 @@ +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent<{}, {}, any> + export default component +} diff --git a/frontend/src/store/user.ts b/frontend/src/store/user.ts new file mode 100644 index 0000000..2418bb8 --- /dev/null +++ b/frontend/src/store/user.ts @@ -0,0 +1,44 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import Cookies from 'js-cookie' + +const TOKEN_KEY = 'amazing_data_token' + +export const useUserStore = defineStore('user', () => { + // State + const token = ref(Cookies.get(TOKEN_KEY) || '') + const userInfo = ref(null) + + // Getters + const isLoggedIn = computed(() => !!token.value) + + // Actions + const setToken = (newToken: string) => { + token.value = newToken + Cookies.set(TOKEN_KEY, newToken, { expires: 1 }) + } + + const clearToken = () => { + token.value = '' + userInfo.value = null + Cookies.remove(TOKEN_KEY) + } + + const setUserInfo = (info: any) => { + userInfo.value = info + } + + const logout = () => { + clearToken() + } + + return { + token, + userInfo, + isLoggedIn, + setToken, + clearToken, + setUserInfo, + logout + } +}) diff --git a/frontend/src/utils/request.ts b/frontend/src/utils/request.ts new file mode 100644 index 0000000..98993f7 --- /dev/null +++ b/frontend/src/utils/request.ts @@ -0,0 +1,52 @@ +import axios from 'axios' +import { ElMessage } from 'element-plus' +import { useUserStore } from '@/store/user' + +// 创建axios实例 +const request = axios.create({ + baseURL: '/api/v1', + timeout: 30000 +}) + +// 请求拦截器 +request.interceptors.request.use( + (config) => { + const userStore = useUserStore() + if (userStore.token) { + config.headers.Authorization = `Bearer ${userStore.token}` + } + return config + }, + (error) => { + return Promise.reject(error) + } +) + +// 响应拦截器 +request.interceptors.response.use( + (response) => { + const res = response.data + + if (res.code !== 200) { + ElMessage.error(res.message || '请求失败') + + // 401未授权,跳转到登录页 + if (res.code === 401) { + const userStore = useUserStore() + userStore.logout() + window.location.href = '/login' + } + + return Promise.reject(new Error(res.message)) + } + + return res + }, + (error) => { + const message = error.response?.data?.message || error.message || '网络错误' + ElMessage.error(message) + return Promise.reject(error) + } +) + +export default request diff --git a/frontend/src/views/CacheManager/DetectMissing.vue b/frontend/src/views/CacheManager/DetectMissing.vue new file mode 100644 index 0000000..14ea354 --- /dev/null +++ b/frontend/src/views/CacheManager/DetectMissing.vue @@ -0,0 +1,191 @@ + + + + + diff --git a/frontend/src/views/CacheManager/TaskManager.vue b/frontend/src/views/CacheManager/TaskManager.vue new file mode 100644 index 0000000..1972e65 --- /dev/null +++ b/frontend/src/views/CacheManager/TaskManager.vue @@ -0,0 +1,211 @@ + + + + + diff --git a/frontend/src/views/CacheManager/index.vue b/frontend/src/views/CacheManager/index.vue new file mode 100644 index 0000000..0c1f533 --- /dev/null +++ b/frontend/src/views/CacheManager/index.vue @@ -0,0 +1,26 @@ + + + + + diff --git a/frontend/src/views/ConfigManager.vue b/frontend/src/views/ConfigManager.vue new file mode 100644 index 0000000..7eef9e1 --- /dev/null +++ b/frontend/src/views/ConfigManager.vue @@ -0,0 +1,261 @@ + + + + + diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue new file mode 100644 index 0000000..96f4bc9 --- /dev/null +++ b/frontend/src/views/Dashboard.vue @@ -0,0 +1,179 @@ + + + + + diff --git a/frontend/src/views/DataQuery/BatchQuery.vue b/frontend/src/views/DataQuery/BatchQuery.vue new file mode 100644 index 0000000..6d01c66 --- /dev/null +++ b/frontend/src/views/DataQuery/BatchQuery.vue @@ -0,0 +1,176 @@ + + + + + diff --git a/frontend/src/views/DataQuery/KlineQuery.vue b/frontend/src/views/DataQuery/KlineQuery.vue new file mode 100644 index 0000000..252eb3e --- /dev/null +++ b/frontend/src/views/DataQuery/KlineQuery.vue @@ -0,0 +1,275 @@ + + + + + diff --git a/frontend/src/views/DataQuery/index.vue b/frontend/src/views/DataQuery/index.vue new file mode 100644 index 0000000..cbb1a07 --- /dev/null +++ b/frontend/src/views/DataQuery/index.vue @@ -0,0 +1,26 @@ + + + + + diff --git a/frontend/src/views/Layout.vue b/frontend/src/views/Layout.vue new file mode 100644 index 0000000..5804af3 --- /dev/null +++ b/frontend/src/views/Layout.vue @@ -0,0 +1,127 @@ + + + + + diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue new file mode 100644 index 0000000..83cb1aa --- /dev/null +++ b/frontend/src/views/Login.vue @@ -0,0 +1,144 @@ + + + + + diff --git a/frontend/src/views/TestCenter/ApiTest.vue b/frontend/src/views/TestCenter/ApiTest.vue new file mode 100644 index 0000000..b398d81 --- /dev/null +++ b/frontend/src/views/TestCenter/ApiTest.vue @@ -0,0 +1,205 @@ + + + + + diff --git a/frontend/src/views/TestCenter/TestHistory.vue b/frontend/src/views/TestCenter/TestHistory.vue new file mode 100644 index 0000000..9359d8f --- /dev/null +++ b/frontend/src/views/TestCenter/TestHistory.vue @@ -0,0 +1,164 @@ + + + + + diff --git a/frontend/src/views/TestCenter/index.vue b/frontend/src/views/TestCenter/index.vue new file mode 100644 index 0000000..9941a0e --- /dev/null +++ b/frontend/src/views/TestCenter/index.vue @@ -0,0 +1,26 @@ + + + + + diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..964ccad --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "preserve", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..42872c5 --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..b792935 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import { resolve } from 'path' + +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': resolve(__dirname, 'src') + } + }, + server: { + port: 3000, + proxy: { + '/api': { + target: 'http://localhost:8000', + changeOrigin: true + } + } + }, + build: { + outDir: 'dist', + assetsDir: 'assets' + } +})