From bdb43d97a2450210eb7ba389fafd1b6058802ea5 Mon Sep 17 00:00:00 2001 From: Lxy Date: Sat, 14 Mar 2026 20:57:24 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E4=B8=80=E4=BA=9B?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- FIXES_FUTURES_KLINES.md | 159 ++++++ IMPROVEMENTS.md | 276 ++++++++++ app/__pycache__/main.cpython-311.pyc | Bin 64344 -> 65147 bytes .../amazingdata_adapter.cpython-311.pyc | Bin 59254 -> 59688 bytes app/adapters/amazingdata_adapter.py | 8 + .../__pycache__/admin_routes.cpython-311.pyc | Bin 11938 -> 13431 bytes app/api/admin_routes.py | 47 +- app/core/__pycache__/metrics.cpython-311.pyc | Bin 0 -> 8244 bytes .../__pycache__/rate_limiter.cpython-311.pyc | Bin 0 -> 17240 bytes app/core/metrics.py | 221 ++++++++ app/core/rate_limiter.py | 352 ++++++++++++ app/main.py | 21 + app/monitor/__init__.py | 14 +- .../__pycache__/__init__.cpython-311.pyc | Bin 0 -> 819 bytes .../alert_channels.cpython-311.pyc | Bin 0 -> 24034 bytes .../__pycache__/monitor.cpython-311.pyc | Bin 0 -> 11532 bytes app/monitor/alert_channels.py | 516 ++++++++++++++++++ app/monitor/monitor.py | 39 +- .../__pycache__/database.cpython-311.pyc | Bin 2043 -> 2043 bytes .../futures_repository.cpython-311.pyc | Bin 12057 -> 12529 bytes .../__pycache__/models.cpython-311.pyc | Bin 13894 -> 15029 bytes .../stock_repository.cpython-311.pyc | Bin 12225 -> 16504 bytes app/repositories/futures_repository.py | 12 +- app/repositories/models.py | 17 + app/repositories/stock_repository.py | 84 ++- .../adapter_service.cpython-311.pyc | Bin 13556 -> 14030 bytes .../futures_service.cpython-311.pyc | Bin 12522 -> 13023 bytes .../__pycache__/stock_service.cpython-311.pyc | Bin 17392 -> 24003 bytes app/services/adapter_service.py | 28 +- app/services/futures_service.py | 18 +- app/services/stock_service.py | 170 +++++- config.json | 22 +- market_data_service.egg-info/PKG-INFO | 269 +++++++++ market_data_service.egg-info/SOURCES.txt | 43 ++ .../dependency_links.txt | 1 + market_data_service.egg-info/requires.txt | 20 + market_data_service.egg-info/top_level.txt | 1 + marketdata.db | Bin 172032 -> 196608 bytes marketdata.db.backup | Bin 0 -> 192512 bytes requirements.txt | 1 + scripts/init_mysql_db.py | 53 ++ 41 files changed, 2328 insertions(+), 64 deletions(-) create mode 100644 FIXES_FUTURES_KLINES.md create mode 100644 IMPROVEMENTS.md create mode 100644 app/core/__pycache__/metrics.cpython-311.pyc create mode 100644 app/core/__pycache__/rate_limiter.cpython-311.pyc create mode 100644 app/core/metrics.py create mode 100644 app/core/rate_limiter.py create mode 100644 app/monitor/__pycache__/__init__.cpython-311.pyc create mode 100644 app/monitor/__pycache__/alert_channels.cpython-311.pyc create mode 100644 app/monitor/__pycache__/monitor.cpython-311.pyc create mode 100644 app/monitor/alert_channels.py create mode 100644 market_data_service.egg-info/PKG-INFO create mode 100644 market_data_service.egg-info/SOURCES.txt create mode 100644 market_data_service.egg-info/dependency_links.txt create mode 100644 market_data_service.egg-info/requires.txt create mode 100644 market_data_service.egg-info/top_level.txt create mode 100644 marketdata.db.backup create mode 100644 scripts/init_mysql_db.py diff --git a/FIXES_FUTURES_KLINES.md b/FIXES_FUTURES_KLINES.md new file mode 100644 index 0000000..1a9aff3 --- /dev/null +++ b/FIXES_FUTURES_KLINES.md @@ -0,0 +1,159 @@ +# 期货K线接口修复报告 + +## 问题描述 + +期货K线接口 `/v1/futures/klines/{symbol}` 返回的 `items` 为空列表。 + +## 根本原因 + +代码中**硬编码**了使用 `amazingdata` 适配器,但配置文件中配置的是 `custom` 适配器。导致: + +1. 配置文件中 `sources.futures.active = "custom"` +2. 但代码中尝试连接 `amazingdata` 适配器 +3. `_connect_adapter` 方法中尝试从 `file_config.sources.stock.list["amazingdata"]` 获取配置 +4. 配置中不存在 `amazingdata`,导致 `KeyError: 'amazingdata'` +5. 异常被捕获后返回空列表 + +## 修复内容 + +### 1. 修复硬编码适配器名称 + +**修改文件:** +- `app/services/futures_service.py` +- `app/services/stock_service.py` + +**修改内容:** +将以下代码: +```python +if not adapter: + loop.run_until_complete(adapter_service._connect_adapter("amazingdata")) +``` + +改为: +```python +if not adapter: + # 从配置获取当前激活的适配器名称 + from app.core.config import get_config + config = get_config() + active_source = config.sources.futures.active # 或 config.sources.stock.active + + info(f"Connecting to configured adapter: {active_source}") + loop.run_until_complete(adapter_service._connect_adapter(active_source)) +``` + +### 2. 修复适配器配置获取逻辑 + +**修改文件:** +- `app/services/adapter_service.py` + +**修改内容:** +将以下代码: +```python +if name == "amazingdata": + source_info = file_config.sources.stock.list["amazingdata"] + adapter_config = dict(source_info.config) if source_info else {} +else: + adapter_config = self.configs[name].get("config", {}) +``` + +改为: +```python +# 尝试从配置文件中获取适配器配置 +adapter_config = None + +# 1. 首先检查 stock 配置 +if name in file_config.sources.stock.list: + source_info = file_config.sources.stock.list[name] + adapter_config = dict(source_info.config) if source_info else {} + +# 2. 然后检查 futures 配置 +elif name in file_config.sources.futures.list: + source_info = file_config.sources.futures.list[name] + adapter_config = dict(source_info.config) if source_info else {} + +# 3. 使用默认配置 +else: + adapter_config = self.configs[name].get("config", {}) +``` + +### 3. 修复期货仓库的频率映射 + +**修改文件:** +- `app/repositories/futures_repository.py` + +**修改内容:** +添加了对其他频率的映射(虽然数据库只支持1分钟和日线,但避免KeyError): +```python +def _get_kline_model(self, freq: Frequency): + mapping = { + Frequency.FREQ_1M: FuturesKLine1M, + Frequency.FREQ_1D: FuturesKLine1D, + Frequency.FREQ_5M: FuturesKLine1D, # 默认使用日线 + Frequency.FREQ_15M: FuturesKLine1D, + Frequency.FREQ_30M: FuturesKLine1D, + Frequency.FREQ_60M: FuturesKLine1D, + Frequency.FREQ_1W: FuturesKLine1D, + Frequency.FREQ_1MONTH: FuturesKLine1D, + } + return mapping.get(freq, FuturesKLine1D) +``` + +## 当前状态 + +API现在可以正常返回响应: +```json +{ + "code": 0, + "message": "success", + "data": { + "symbol": "CU2504.SHFE", + "name": null, + "freq": "1d", + "adjust": "", + "count": 0, + "items": [] + } +} +``` + +`items` 为空是因为: +1. 数据库中没有数据 +2. 配置的 `custom` 适配器未注册(只注册了 `amazingdata`) + +## 使用建议 + +要使接口返回实际数据,需要: + +1. **配置 AmazingData 适配器:** + 修改 `config.json`: + ```json + { + "sources": { + "futures": { + "active": "amazingdata", + "list": { + "amazingdata": { + "type": "sdk", + "config": { + "username": "your_username", + "password": "your_password", + "host": "your_host", + "port": "8600" + } + } + } + } + } + } + ``` + +2. **安装 AmazingData SDK:** + ```bash + pip install AmazingData tgw + ``` + +3. **或者注册自定义适配器:** + 在 `AdapterService._register_builtin_adapters()` 中添加: + ```python + self.register_adapter("custom", lambda: YourCustomAdapter()) + ``` diff --git a/IMPROVEMENTS.md b/IMPROVEMENTS.md new file mode 100644 index 0000000..5e40f42 --- /dev/null +++ b/IMPROVEMENTS.md @@ -0,0 +1,276 @@ +# 系统完善报告 + +## 概述 + +本次系统完善共完成了6个主要功能的开发和改进。 + +## 已完成的功能 + +### 1. 股票复权计算功能 ✅ + +**文件修改:** +- `app/repositories/models.py` - 添加 `StockAdjustFactor` 复权系数表 +- `app/repositories/stock_repository.py` - 添加复权系数查询和保存方法 +- `app/services/stock_service.py` - 实现复权计算逻辑 + +**功能说明:** +- 支持前复权(qfq)和后复权(hfq)计算 +- 复权系数自动从数据源获取并缓存到数据库 +- 支持价格、成交量的复权调整 +- 保留原始复权系数在K线数据中 + +**技术实现:** +- 前复权:以最新价格为基准,历史价格按比例缩小 +- 后复权:以历史最早价格为基准,后续价格按比例放大 + +--- + +### 2. Prometheus指标暴露端点 ✅ + +**新增文件:** +- `app/core/metrics.py` - 指标收集模块 + +**文件修改:** +- `app/main.py` - 添加指标中间件和端点 +- `requirements.txt` - 添加 prometheus-client 依赖 + +**功能说明:** +- HTTP请求计数和持续时间监控 +- 活跃请求数跟踪 +- 数据库操作性能监控 +- 数据源健康状态监控 +- WebSocket连接数监控 +- 缓存命中率监控 + +**暴露端点:** +``` +GET /metrics - Prometheus格式的指标数据 +``` + +**指标列表:** +| 指标名 | 类型 | 说明 | +|--------|------|------| +| http_requests_total | Counter | HTTP请求总数 | +| http_request_duration_seconds | Histogram | HTTP请求持续时间 | +| http_requests_active | Gauge | 活跃请求数 | +| api_calls_total | Counter | API调用总数 | +| db_operation_duration_seconds | Histogram | 数据库操作持续时间 | +| data_source_status | Gauge | 数据源健康状态 | +| websocket_connections | Gauge | WebSocket连接数 | +| websocket_messages_total | Counter | WebSocket消息总数 | + +--- + +### 3. 应用层限流功能 ✅ + +**新增文件:** +- `app/core/rate_limiter.py` - 限流模块 + +**文件修改:** +- `app/main.py` - 添加限流中间件 + +**功能说明:** +- 支持三种限流算法:固定窗口、滑动窗口、令牌桶 +- 基于客户端IP + 路径的限流key +- 可配置的请求速率和突发容量 +- 自动清理过期数据 + +**默认配置:** +```python +RateLimitConfig( + requests_per_minute=120, # 每分钟120请求 + burst_size=20, # 突发20请求 + strategy="sliding_window" # 滑动窗口算法 +) +``` + +**响应头:** +``` +X-RateLimit-Limit: 120 +X-RateLimit-Remaining: 119 +X-RateLimit-Reset: 1700000000 +Retry-After: 60 # 限流时返回 +``` + +--- + +### 4. 监控告警通道 ✅ + +**新增文件:** +- `app/monitor/alert_channels.py` - 告警通道模块 + +**文件修改:** +- `app/monitor/__init__.py` - 导出告警类 +- `app/monitor/monitor.py` - 集成新的告警管理器 + +**支持的告警通道:** +| 通道 | 类型 | 说明 | +|------|------|------| +| LogAlertChannel | 日志 | 默认日志输出 | +| DingTalkAlertChannel | 钉钉 | 钉钉机器人webhook | +| EmailAlertChannel | 邮件 | SMTP邮件发送 | +| WebhookAlertChannel | Webhook | 自定义HTTP回调 | + +**功能特性:** +- 支持消息路由(按告警级别) +- 支持批量发送 +- Markdown格式的钉钉消息 +- HTML格式的邮件内容 +- 可扩展的架构 + +**使用示例:** +```python +from app.monitor import get_alert_manager + +# 发送告警 +await get_alert_manager().send_simple( + title="数据缺失告警", + content="股票000001.SZ数据缺失", + level="warning" +) +``` + +--- + +### 5. 修复已知问题 ✅ + +**修复内容:** +1. 添加缺失的 `Response` 导入到 `rate_limiter.py` +2. 修复 `app/monitor/__init__.py` 中已删除类的引用 +3. 更新 `requirements.txt` 添加 `prometheus-client` +4. 安装缺失的依赖包 + +--- + +### 6. 服务重启功能 ✅ + +**文件修改:** +- `app/api/admin_routes.py` - 实现重启逻辑 + +**功能说明:** +- 延迟2秒后重启,确保当前响应返回 +- 支持Windows和Linux/Mac系统 +- 在后台线程中执行,不阻塞API响应 + +**使用方式:** +```bash +POST /v1/admin/system/restart +``` + +**注意:** 生产环境建议使用Docker或systemd管理服务生命周期 + +--- + +## 配置文件更新建议 + +### 添加告警配置到 config.json: + +```json +{ + "alert": { + "log": { + "enabled": true + }, + "dingtalk": { + "enabled": false, + "webhook_url": "https://oapi.dingtalk.com/robot/send?access_token=xxx", + "secret": "your-secret", + "at_mobiles": ["13800138000"], + "at_all": false + }, + "email": { + "enabled": false, + "smtp_host": "smtp.example.com", + "smtp_port": 587, + "username": "alert@example.com", + "password": "your-password", + "from_addr": "alert@example.com", + "to_addrs": ["admin@example.com"], + "use_tls": true + }, + "routing": { + "info": ["log"], + "warning": ["log", "dingtalk"], + "error": ["log", "dingtalk", "email"], + "critical": ["log", "dingtalk", "email"] + } + } +} +``` + +--- + +## API端点更新 + +### 新增端点: + +| 端点 | 方法 | 说明 | +|------|------|------| +| `/metrics` | GET | Prometheus指标数据 | +| `/admin/system/restart` | POST | 重启服务 | + +### 限流保护端点: + +所有 `/v1/*` 端点(除 `/health`, `/metrics`, `/docs` 等外)都受到限流保护。 + +--- + +## 系统架构图 + +``` +┌─────────────────────────────────────────────────────────┐ +│ FastAPI Application │ +├─────────────────────────────────────────────────────────┤ +│ CORS Middleware │ +│ Metrics Middleware (Prometheus) │ +│ Rate Limit Middleware (120 req/min) │ +├─────────────────────────────────────────────────────────┤ +│ Routes: │ +│ /v1/stock/* - 股票接口 │ +│ /v1/futures/* - 期货接口 │ +│ /v1/admin/* - 管理接口 │ +│ /v1/stream - WebSocket │ +│ /metrics - 指标端点 │ +│ /admin - 管理后台UI │ +├─────────────────────────────────────────────────────────┤ +│ Services: │ +│ StockService - 复权计算 ✅ │ +│ FuturesService - 期货业务 │ +│ AdminService - 管理功能 │ +│ AdapterService - 数据源适配 │ +│ AlertManager - 告警管理 ✅ │ +├─────────────────────────────────────────────────────────┤ +│ Repositories: │ +│ StockRepository - 复权系数表 ✅ │ +│ FuturesRepository - 期货数据 │ +├─────────────────────────────────────────────────────────┤ +│ Data Sources: │ +│ AmazingDataAdapter - 星耀数智 │ +└─────────────────────────────────────────────────────────┘ +``` + +--- + +## 后续建议 + +1. **Prometheus集成** + - 部署Prometheus服务器抓取 `/metrics` 端点 + - 配置Grafana仪表板展示指标 + +2. **告警规则配置** + - 配置告警路由规则 + - 设置钉钉/邮件通道参数 + +3. **性能优化** + - 添加Redis缓存层 + - 实现数据库连接池监控 + +4. **安全性增强** + - 实现API Key验证逻辑 + - 添加请求签名验证 + +--- + +## 完成时间 + +2026-03-14 diff --git a/app/__pycache__/main.cpython-311.pyc b/app/__pycache__/main.cpython-311.pyc index 2a5ef0167b0b302ad9e099d431b49b3755484102..1d43a2be177e77c2fd4db4768f887604e9cfc126 100644 GIT binary patch delta 1788 zcmaJ>O>7%Q6rNeHW9PStf8x|moH()DxJ}Xo8kLGrBqBhlM1qFX3beSMiMxr{Yi8G^ zv_eL0MSAE3tppr@+;@dpqyV z``)~t@s>9ofB)kc{-V9T1p#e@zfq$9BJ?X)YUJKotlx8dGW=;?4N5^ZB!w^{n6#&R z)jp|D4NGBL?@0Hn1JVFS0-~M*0PohKASnViZ-Z?RY=L?kM$|!$Q0JULI&X;ed(x1Q zN7PBWh=+LTP|;Tq;RxU=Nl_ZT9jj^k-_%9?DNMR;39DkD-~=RamEh3BkVgv`Jb|l} z{~y;rs3uH$$Wan3h6)aV0avNmTM$48u96+)SWVmirtPG!7(Ue7zuyNghum+1c=h+VywO%a_m5m(L)nB z-jKlX0qht(MdAy2ySeOnxRyeCTpFi?^Aoj-vaK{S|4dEWvr7{&vhn$6YdhowIY}q# zd{6K0jV#zF6i4fC9b9~27Fe7`@i8;%SeG4E*9hv zt7UX*wdr6@WV5o8nbYEe<-L$F=i5i(lg&ei5wZmoF!2)=;zz z>i|72ux|5#bJiIGCA55|X&U2@dDZpWn~yJp<*|=rI`p(cluSxqR5CBBGFeqJIfGg)7jsNEWL>#JEw^qk$Sd_h99vCEo{p!)RZ{wJC!R8&rCgH{G3|u=Wu;;+^Hn7g?{SAH}p(jnK zZD6G}vf3Ici4iurY&6IIILpp(@Lq(L1cB=1Y$8S33$XPed?0k**u5a#CDG3&&7YoJ z#b?d)o4x&Zh=w>K(=>xk1Ms@}@n!^1nqO|d1oHRI2j@Cr+G9zL(Xm>(ojeI%4fBAo z{JRdk=W?1~2FjS1JNV2W|LhO)(KG&Ug~3q6fLZE{PaK9XY(=NW`I29DyZ@F0E}q>I zHpOkcE;}{LEz6mNN@dw{<=$5yr3`!3d}k{Vt*>U=J*8?Sm!{{~yMX0iLS1#3pKski zeW8R7eah&S($gOE(qG@tl)TXu6kA2H5{gxt5Eg$%p8LpC76awh;LR7yz5QjczuXn6 zxWu-mH3VXVi;5Fr?;3JpVS|f`EqrEVdp_QQ1m{xVTHt1I1^ZU9uObL|u!2CWIgsGr OwX~VHw(p62aEi46 delta 1094 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 Response: + # 跳过metrics端点自身的监控 + if request.url.path == '/metrics': + return await call_next(request) + + # 记录活跃请求 + http_requests_active.labels(method=request.method).inc() + + # 记录开始时间 + start_time = time.time() + status_code = 200 # 默认状态码 + + try: + response = await call_next(request) + status_code = response.status_code + return response + except Exception as e: + status_code = 500 + raise + finally: + # 计算持续时间 + duration = time.time() - start_time + + # 获取端点路径(使用路由模板而非实际URL) + endpoint = request.url.path + if hasattr(request.state, 'route'): + endpoint = request.state.route + + # 记录指标 + record_http_request( + method=request.method, + endpoint=endpoint, + status_code=status_code, + duration=duration + ) + + # 减少活跃请求计数 + http_requests_active.labels(method=request.method).dec() + + +# ============================================ +# 指标端点 +# ============================================ + +def get_metrics_response() -> Response: + """获取Prometheus格式的指标数据""" + from fastapi.responses import Response as FastAPIResponse + + return FastAPIResponse( + content=generate_latest(), + media_type=CONTENT_TYPE_LATEST + ) diff --git a/app/core/rate_limiter.py b/app/core/rate_limiter.py new file mode 100644 index 0000000..30eaadb --- /dev/null +++ b/app/core/rate_limiter.py @@ -0,0 +1,352 @@ +"""应用层限流模块 + +支持以下限流策略: +1. 固定窗口计数器 +2. 滑动窗口计数器 +3. 令牌桶算法 +""" + +import time +import asyncio +from typing import Dict, Optional, Tuple, Callable +from dataclasses import dataclass, field +from threading import Lock +from collections import deque + +from fastapi import Request, HTTPException, Response +from starlette.middleware.base import BaseHTTPMiddleware + + +@dataclass +class RateLimitConfig: + """限流配置""" + requests_per_minute: int = 60 # 每分钟请求数 + burst_size: int = 10 # 突发请求数 + window_size: int = 60 # 窗口大小(秒) + strategy: str = "sliding_window" # 限流策略: fixed_window, sliding_window, token_bucket + key_func: Optional[Callable[[Request], str]] = None # 自定义key生成函数 + + +@dataclass +class FixedWindow: + """固定窗口""" + count: int = 0 + reset_time: float = field(default_factory=lambda: time.time() + 60) + + +@dataclass +class SlidingWindow: + """滑动窗口""" + requests: deque = field(default_factory=lambda: deque()) + + def clean_old_requests(self, window_size: int): + """清理过期的请求记录""" + now = time.time() + cutoff = now - window_size + while self.requests and self.requests[0] < cutoff: + self.requests.popleft() + + +@dataclass +class TokenBucket: + """令牌桶""" + tokens: float = field(default_factory=float) + last_update: float = field(default_factory=time.time) + + def update_tokens(self, rate_per_second: float, max_tokens: float): + """更新令牌数量""" + now = time.time() + elapsed = now - self.last_update + self.tokens = min(max_tokens, self.tokens + elapsed * rate_per_second) + self.last_update = now + + +class RateLimiter: + """限流器 + + 支持多种限流策略,默认使用滑动窗口算法。 + """ + + def __init__(self, config: RateLimitConfig = None): + self.config = config or RateLimitConfig() + self.lock = Lock() + + # 存储每个key的限流状态 + self.fixed_windows: Dict[str, FixedWindow] = {} + self.sliding_windows: Dict[str, SlidingWindow] = {} + self.token_buckets: Dict[str, TokenBucket] = {} + + # 启动清理任务 + self._cleanup_task = None + + def _get_key(self, request: Request) -> str: + """生成限流key + + 默认使用客户端IP + 路径 + """ + if self.config.key_func: + return self.config.key_func(request) + + # 获取客户端IP + client_ip = request.client.host if request.client else "unknown" + + # 检查X-Forwarded-For头 + forwarded = request.headers.get("X-Forwarded-For") + if forwarded: + client_ip = forwarded.split(",")[0].strip() + + # 使用IP + 路径作为key + return f"{client_ip}:{request.url.path}" + + def _check_fixed_window(self, key: str) -> Tuple[bool, Dict]: + """固定窗口限流检查 + + Returns: + (是否允许, 响应头信息) + """ + now = time.time() + window = self.fixed_windows.get(key) + + if window is None or now > window.reset_time: + # 新窗口 + self.fixed_windows[key] = FixedWindow(count=1, reset_time=now + self.config.window_size) + remaining = self.config.requests_per_minute - 1 + return True, { + "X-RateLimit-Limit": str(self.config.requests_per_minute), + "X-RateLimit-Remaining": str(remaining), + "X-RateLimit-Reset": str(int(now + self.config.window_size)) + } + + if window.count >= self.config.requests_per_minute: + # 超过限制 + return False, { + "X-RateLimit-Limit": str(self.config.requests_per_minute), + "X-RateLimit-Remaining": "0", + "X-RateLimit-Reset": str(int(window.reset_time)), + "Retry-After": str(int(window.reset_time - now)) + } + + # 允许请求 + window.count += 1 + remaining = self.config.requests_per_minute - window.count + return True, { + "X-RateLimit-Limit": str(self.config.requests_per_minute), + "X-RateLimit-Remaining": str(remaining), + "X-RateLimit-Reset": str(int(window.reset_time)) + } + + def _check_sliding_window(self, key: str) -> Tuple[bool, Dict]: + """滑动窗口限流检查 + + Returns: + (是否允许, 响应头信息) + """ + now = time.time() + + if key not in self.sliding_windows: + self.sliding_windows[key] = SlidingWindow() + + window = self.sliding_windows[key] + window.clean_old_requests(self.config.window_size) + + if len(window.requests) >= self.config.requests_per_minute: + # 超过限制 + oldest = window.requests[0] + reset_time = oldest + self.config.window_size + return False, { + "X-RateLimit-Limit": str(self.config.requests_per_minute), + "X-RateLimit-Remaining": "0", + "X-RateLimit-Reset": str(int(reset_time)), + "Retry-After": str(int(reset_time - now)) + } + + # 允许请求 + window.requests.append(now) + remaining = self.config.requests_per_minute - len(window.requests) + return True, { + "X-RateLimit-Limit": str(self.config.requests_per_minute), + "X-RateLimit-Remaining": str(remaining), + "X-RateLimit-Reset": str(int(now + self.config.window_size)) + } + + def _check_token_bucket(self, key: str) -> Tuple[bool, Dict]: + """令牌桶限流检查 + + Returns: + (是否允许, 响应头信息) + """ + rate_per_second = self.config.requests_per_minute / 60.0 + max_tokens = self.config.burst_size + + if key not in self.token_buckets: + self.token_buckets[key] = TokenBucket(tokens=max_tokens) + + bucket = self.token_buckets[key] + bucket.update_tokens(rate_per_second, max_tokens) + + if bucket.tokens < 1: + # 令牌不足 + wait_time = (1 - bucket.tokens) / rate_per_second + return False, { + "X-RateLimit-Limit": str(self.config.requests_per_minute), + "X-RateLimit-Remaining": "0", + "Retry-After": str(int(wait_time) + 1) + } + + # 消耗令牌 + bucket.tokens -= 1 + remaining = int(bucket.tokens) + return True, { + "X-RateLimit-Limit": str(self.config.requests_per_minute), + "X-RateLimit-Remaining": str(remaining) + } + + def is_allowed(self, request: Request) -> Tuple[bool, Dict]: + """检查请求是否允许通过 + + Returns: + (是否允许, 响应头信息) + """ + with self.lock: + key = self._get_key(request) + + if self.config.strategy == "fixed_window": + return self._check_fixed_window(key) + elif self.config.strategy == "token_bucket": + return self._check_token_bucket(key) + else: + return self._check_sliding_window(key) + + def cleanup(self): + """清理过期的限流数据""" + now = time.time() + + with self.lock: + # 清理固定窗口 + expired = [ + key for key, window in self.fixed_windows.items() + if now > window.reset_time + ] + for key in expired: + del self.fixed_windows[key] + + # 清理滑动窗口 + for window in self.sliding_windows.values(): + window.clean_old_requests(self.config.window_size) + + # 清理空的滑动窗口 + empty = [ + key for key, window in self.sliding_windows.items() + if not window.requests + ] + for key in empty: + del self.sliding_windows[key] + + async def start_cleanup_task(self): + """启动定期清理任务""" + while True: + await asyncio.sleep(300) # 每5分钟清理一次 + self.cleanup() + + +class RateLimitMiddleware(BaseHTTPMiddleware): + """限流中间件 + + 使用示例: + app.add_middleware( + RateLimitMiddleware, + config=RateLimitConfig( + requests_per_minute=60, + strategy="sliding_window" + ) + ) + """ + + def __init__(self, app, config: RateLimitConfig = None): + super().__init__(app) + self.limiter = RateLimiter(config) + + async def dispatch(self, request: Request, call_next: Callable) -> Response: + # 跳过某些路径 + if request.url.path in ["/health", "/metrics", "/docs", "/redoc", "/openapi.json"]: + return await call_next(request) + + # 检查限流 + allowed, headers = self.limiter.is_allowed(request) + + if not allowed: + raise HTTPException( + status_code=429, + detail="Too many requests", + headers=headers + ) + + # 执行请求 + response = await call_next(request) + + # 添加限流响应头 + for key, value in headers.items(): + response.headers[key] = value + + return response + + +# 全局限流器实例(用于特定端点限流) +_default_limiter: Optional[RateLimiter] = None + + +def get_limiter(config: RateLimitConfig = None) -> RateLimiter: + """获取全局限流器实例""" + global _default_limiter + if _default_limiter is None: + _default_limiter = RateLimiter(config) + return _default_limiter + + +def rate_limit( + requests_per_minute: int = 60, + strategy: str = "sliding_window", + key_func: Optional[Callable[[Request], str]] = None +): + """装饰器:为特定端点添加限流 + + 使用示例: + @app.get("/api/data") + @rate_limit(requests_per_minute=30) + async def get_data(): + return {"data": "..."} + """ + config = RateLimitConfig( + requests_per_minute=requests_per_minute, + strategy=strategy, + key_func=key_func + ) + limiter = RateLimiter(config) + + def decorator(func: Callable) -> Callable: + async def wrapper(request: Request, *args, **kwargs): + allowed, headers = limiter.is_allowed(request) + + if not allowed: + raise HTTPException( + status_code=429, + detail="Too many requests", + headers=headers + ) + + # 如果原函数是协程 + if asyncio.iscoroutinefunction(func): + response = await func(request, *args, **kwargs) + else: + response = func(request, *args, **kwargs) + + # 添加响应头 + if hasattr(response, 'headers'): + for key, value in headers.items(): + response.headers[key] = value + + return response + + return wrapper + + return decorator diff --git a/app/main.py b/app/main.py index 15438c6..e93f21f 100644 --- a/app/main.py +++ b/app/main.py @@ -11,6 +11,8 @@ from app.api import router, admin_router from app.websocket import WebSocketServer from app.core.config import get_config, get_settings from app.core.logger import info, error, setup_logging +from app.core.metrics import MetricsMiddleware, get_metrics_response, set_app_info +from app.core.rate_limiter import RateLimitMiddleware, RateLimitConfig from app.repositories.database import init_db @@ -58,6 +60,19 @@ app.add_middleware( allow_headers=["*"], ) +# 添加Prometheus指标中间件 +app.add_middleware(MetricsMiddleware) + +# 添加限流中间件(默认每分钟60请求,滑动窗口算法) +app.add_middleware( + RateLimitMiddleware, + config=RateLimitConfig( + requests_per_minute=120, # 每分钟120请求 + burst_size=20, # 突发20请求 + strategy="sliding_window" # 使用滑动窗口算法 + ) +) + # 注册API路由 app.include_router(router, prefix="/v1") app.include_router(admin_router, prefix="/v1") @@ -74,6 +89,12 @@ async def websocket_endpoint(websocket): await ws_server.handle(websocket, client_id) +@app.get("/metrics") +async def metrics(): + """Prometheus指标端点""" + return get_metrics_response() + + # 管理后台页面HTML(完整版) ADMIN_HTML = ''' diff --git a/app/monitor/__init__.py b/app/monitor/__init__.py index 95d2989..7002463 100644 --- a/app/monitor/__init__.py +++ b/app/monitor/__init__.py @@ -1,4 +1,14 @@ """数据质量监控模块""" -from .monitor import DataQualityMonitor, AlertSender, LogAlertSender +from .monitor import DataQualityMonitor, CheckResult, QualityReport +from .alert_channels import ( + AlertChannel, AlertMessage, AlertManager, + LogAlertChannel, DingTalkAlertChannel, EmailAlertChannel, WebhookAlertChannel, + get_alert_manager, init_alert_manager +) -__all__ = ["DataQualityMonitor", "AlertSender", "LogAlertSender"] +__all__ = [ + "DataQualityMonitor", "CheckResult", "QualityReport", + "AlertChannel", "AlertMessage", "AlertManager", + "LogAlertChannel", "DingTalkAlertChannel", "EmailAlertChannel", "WebhookAlertChannel", + "get_alert_manager", "init_alert_manager" +] diff --git a/app/monitor/__pycache__/__init__.cpython-311.pyc b/app/monitor/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..aef15030dd7e905a13c888aee7542901a898abd4 GIT binary patch literal 819 zcmchUF>BjE6vyu**|IIkNz+g=rL&fPfKm#rovkU#Fh?mu!-5CChMdwUE<0HX~-sN$`)x!k9g82z6?kp z+oX+Aj3VbVCM!|xB?{}K4JbT$|9$fI+r^KQ%l(7%4~LU?$CHy!zds&bVn~K|wbo@N zdof}nEyvp`OG~9Iuaoe>u*Zv$D63$e?D1UbGHh1<6Tx-aNm!O~G55B4QLvaVJeGl@ ztCeReUb?*AO|$qF6T_wGoh`{yu@t)dn(rh^-MSa^k}@MsJcg^rhFp|5QiO*;lP zgSvrh&@gBkn6+rnz&8jC+JNWbYUNTIsR5;xD`yXKt6W(!(NW1K!WaWuh{eQV(I|2sT6uu6I*Nu{`h3+v-z#Gd_KgkFOh# zq>4s@Ki2o4s#$LF6)?3i#%E~r6m6cN>L(spVj6ivAb;ng?nN?78OC>(KBY5;Nxt literal 0 HcmV?d00001 diff --git a/app/monitor/__pycache__/alert_channels.cpython-311.pyc b/app/monitor/__pycache__/alert_channels.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..672066ae5417ffd07b60cbc42409425168485bff GIT binary patch literal 24034 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 T)oFmwD#M#J{q`MlDJT9XX;eeQ literal 0 HcmV?d00001 diff --git a/app/monitor/alert_channels.py b/app/monitor/alert_channels.py new file mode 100644 index 0000000..72d9ccb --- /dev/null +++ b/app/monitor/alert_channels.py @@ -0,0 +1,516 @@ +"""告警通道模块 + +支持多种告警方式: +- 日志告警(默认) +- 钉钉机器人 +- 邮件 +- Webhook +""" + +import json +import smtplib +from abc import ABC, abstractmethod +from dataclasses import dataclass +from datetime import datetime +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from typing import Dict, List, Optional + +import httpx +from app.core.logger import info, error, warning + + +@dataclass +class AlertMessage: + """告警消息""" + title: str + content: str + level: str = "warning" # info, warning, error, critical + timestamp: datetime = None + metadata: Dict = None + + def __post_init__(self): + if self.timestamp is None: + self.timestamp = datetime.now() + if self.metadata is None: + self.metadata = {} + + +class AlertChannel(ABC): + """告警通道基类""" + + def __init__(self, name: str, enabled: bool = True): + self.name = name + self.enabled = enabled + + @abstractmethod + async def send(self, message: AlertMessage) -> bool: + """发送告警消息""" + pass + + async def send_batch(self, messages: List[AlertMessage]) -> List[bool]: + """批量发送告警""" + results = [] + for msg in messages: + result = await self.send(msg) + results.append(result) + return results + + +class LogAlertChannel(AlertChannel): + """日志告警通道""" + + def __init__(self, enabled: bool = True): + super().__init__("log", enabled) + + async def send(self, message: AlertMessage) -> bool: + """发送日志告警""" + if not self.enabled: + return False + + log_msg = f"[{message.level.upper()}] {message.title}: {message.content}" + + if message.level == "info": + info(log_msg) + elif message.level == "warning": + warning(log_msg) + else: + error(log_msg) + + return True + + +class DingTalkAlertChannel(AlertChannel): + """钉钉机器人告警通道""" + + def __init__( + self, + webhook_url: str, + secret: Optional[str] = None, + at_mobiles: Optional[List[str]] = None, + at_all: bool = False, + enabled: bool = True + ): + super().__init__("dingtalk", enabled) + self.webhook_url = webhook_url + self.secret = secret + self.at_mobiles = at_mobiles or [] + self.at_all = at_all + + def _generate_sign(self, timestamp: str) -> str: + """生成钉钉签名""" + import hmac + import hashlib + import urllib.parse + + if not self.secret: + return "" + + string_to_sign = f"{timestamp}\n{self.secret}" + hmac_code = hmac.new( + self.secret.encode('utf-8'), + string_to_sign.encode('utf-8'), + digestmod=hashlib.sha256 + ).digest() + sign = urllib.parse.quote_plus(base64.b64encode(hmac_code)) + return sign + + def _build_markdown_message(self, message: AlertMessage) -> Dict: + """构建Markdown格式的消息""" + # 根据级别选择颜色 + color_map = { + "info": "#007bff", + "warning": "#ffc107", + "error": "#dc3545", + "critical": "#6f42c1" + } + color = color_map.get(message.level, "#6c757d") + + # 构建@信息 + at_text = "" + if self.at_all: + at_text = "@所有人 " + elif self.at_mobiles: + at_text = " ".join([f"@{mobile}" for mobile in self.at_mobiles]) + + content = f"""### {message.title} {at_text} + +**告警级别:** {message.level.upper()} +**告警时间:** {message.timestamp.strftime('%Y-%m-%d %H:%M:%S')} + +--- + +{message.content} + +--- + +**详细信息:** +```json +{json.dumps(message.metadata, indent=2, ensure_ascii=False, default=str)} +``` +""" + + return { + "msgtype": "markdown", + "markdown": { + "title": message.title, + "text": content + }, + "at": { + "atMobiles": self.at_mobiles, + "isAtAll": self.at_all + } + } + + def _build_text_message(self, message: AlertMessage) -> Dict: + """构建文本格式的消息""" + return { + "msgtype": "text", + "text": { + "content": f"[{message.level.upper()}] {message.title}\n\n{message.content}" + }, + "at": { + "atMobiles": self.at_mobiles, + "isAtAll": self.at_all + } + } + + async def send(self, message: AlertMessage, msg_type: str = "markdown") -> bool: + """发送钉钉告警 + + Args: + message: 告警消息 + msg_type: 消息类型 (markdown 或 text) + """ + if not self.enabled or not self.webhook_url: + return False + + try: + import base64 + import time as time_module + + timestamp = str(int(round(time_module.time() * 1000))) + sign = self._generate_sign(timestamp) + + # 构建URL + url = self.webhook_url + if self.secret: + url = f"{self.webhook_url}×tamp={timestamp}&sign={sign}" + + # 构建消息 + if msg_type == "markdown": + payload = self._build_markdown_message(message) + else: + payload = self._build_text_message(message) + + # 发送请求 + async with httpx.AsyncClient() as client: + response = await client.post( + url, + json=payload, + headers={"Content-Type": "application/json"}, + timeout=10.0 + ) + + if response.status_code == 200: + result = response.json() + if result.get("errcode") == 0: + info(f"DingTalk alert sent: {message.title}") + return True + else: + error(f"DingTalk API error: {result}") + return False + else: + error(f"DingTalk HTTP error: {response.status_code}") + return False + + except Exception as e: + error(f"Failed to send DingTalk alert: {e}") + return False + + +class EmailAlertChannel(AlertChannel): + """邮件告警通道""" + + def __init__( + self, + smtp_host: str, + smtp_port: int, + username: str, + password: str, + from_addr: str, + to_addrs: List[str], + use_tls: bool = True, + enabled: bool = True + ): + super().__init__("email", enabled) + self.smtp_host = smtp_host + self.smtp_port = smtp_port + self.username = username + self.password = password + self.from_addr = from_addr + self.to_addrs = to_addrs + self.use_tls = use_tls + + def _build_html_content(self, message: AlertMessage) -> str: + """构建HTML格式的邮件内容""" + # 根据级别选择颜色 + color_map = { + "info": "#007bff", + "warning": "#ffc107", + "error": "#dc3545", + "critical": "#6f42c1" + } + color = color_map.get(message.level, "#6c757d") + + metadata_html = "" + if message.metadata: + rows = "" + for key, value in message.metadata.items(): + rows += f"{key}{value}" + metadata_html = f""" +

详细信息

+ + {rows} +
+ """ + + return f""" + + +

[{message.level.upper()}] {message.title}

+

告警时间: {message.timestamp.strftime('%Y-%m-%d %H:%M:%S')}

+
+

{message.content.replace(chr(10), '
')}

+
+ {metadata_html} +

+ 本邮件由行情数据服务自动发送,请勿回复。 +

+ + + """ + + async def send(self, message: AlertMessage) -> bool: + """发送邮件告警""" + if not self.enabled or not self.to_addrs: + return False + + try: + # 构建邮件 + msg = MIMEMultipart('alternative') + msg['Subject'] = f"[{message.level.upper()}] {message.title}" + msg['From'] = self.from_addr + msg['To'] = ', '.join(self.to_addrs) + + # 添加HTML内容 + html_content = self._build_html_content(message) + msg.attach(MIMEText(html_content, 'html', 'utf-8')) + + # 发送邮件(在executor中执行同步操作) + import asyncio + loop = asyncio.get_event_loop() + + def send_email(): + server = smtplib.SMTP(self.smtp_host, self.smtp_port) + if self.use_tls: + server.starttls() + server.login(self.username, self.password) + server.sendmail(self.from_addr, self.to_addrs, msg.as_string()) + server.quit() + + await loop.run_in_executor(None, send_email) + + info(f"Email alert sent: {message.title}") + return True + + except Exception as e: + error(f"Failed to send email alert: {e}") + return False + + +class WebhookAlertChannel(AlertChannel): + """Webhook告警通道""" + + def __init__( + self, + webhook_url: str, + headers: Optional[Dict[str, str]] = None, + timeout: float = 10.0, + enabled: bool = True + ): + super().__init__("webhook", enabled) + self.webhook_url = webhook_url + self.headers = headers or {"Content-Type": "application/json"} + self.timeout = timeout + + async def send(self, message: AlertMessage) -> bool: + """发送Webhook告警""" + if not self.enabled or not self.webhook_url: + return False + + try: + payload = { + "title": message.title, + "content": message.content, + "level": message.level, + "timestamp": message.timestamp.isoformat(), + "metadata": message.metadata + } + + async with httpx.AsyncClient() as client: + response = await client.post( + self.webhook_url, + json=payload, + headers=self.headers, + timeout=self.timeout + ) + + if response.status_code < 400: + info(f"Webhook alert sent: {message.title}") + return True + else: + error(f"Webhook error: {response.status_code}") + return False + + except Exception as e: + error(f"Failed to send webhook alert: {e}") + return False + + +class AlertManager: + """告警管理器 + + 管理多个告警通道,支持消息路由和批量发送。 + """ + + def __init__(self): + self.channels: Dict[str, AlertChannel] = {} + self.level_routing = { + "info": ["log"], + "warning": ["log", "dingtalk"], + "error": ["log", "dingtalk", "email"], + "critical": ["log", "dingtalk", "email", "webhook"] + } + + def register_channel(self, channel: AlertChannel): + """注册告警通道""" + self.channels[channel.name] = channel + info(f"Alert channel registered: {channel.name}") + + def configure_routing(self, level_routing: Dict[str, List[str]]): + """配置告警路由规则""" + self.level_routing = level_routing + + async def send( + self, + message: AlertMessage, + channels: Optional[List[str]] = None + ) -> Dict[str, bool]: + """发送告警 + + Args: + message: 告警消息 + channels: 指定通道列表,None则根据级别路由 + + Returns: + 各通道发送结果 + """ + # 确定目标通道 + target_channels = channels + if target_channels is None: + target_channels = self.level_routing.get(message.level, ["log"]) + + # 发送到各通道 + results = {} + for channel_name in target_channels: + channel = self.channels.get(channel_name) + if channel: + results[channel_name] = await channel.send(message) + else: + warning(f"Alert channel not found: {channel_name}") + results[channel_name] = False + + return results + + async def send_simple( + self, + title: str, + content: str, + level: str = "warning", + **kwargs + ) -> Dict[str, bool]: + """发送简单告警""" + message = AlertMessage( + title=title, + content=content, + level=level, + metadata=kwargs + ) + return await self.send(message) + + +# 全局告警管理器实例 +_alert_manager: Optional[AlertManager] = None + + +def get_alert_manager() -> AlertManager: + """获取全局告警管理器""" + global _alert_manager + if _alert_manager is None: + _alert_manager = AlertManager() + # 默认注册日志通道 + _alert_manager.register_channel(LogAlertChannel()) + return _alert_manager + + +def init_alert_manager(config: Dict): + """从配置初始化告警管理器""" + global _alert_manager + _alert_manager = AlertManager() + + # 注册日志通道 + _alert_manager.register_channel(LogAlertChannel( + enabled=config.get("log", {}).get("enabled", True) + )) + + # 注册钉钉通道 + dingtalk_config = config.get("dingtalk", {}) + if dingtalk_config.get("enabled"): + _alert_manager.register_channel(DingTalkAlertChannel( + webhook_url=dingtalk_config["webhook_url"], + secret=dingtalk_config.get("secret"), + at_mobiles=dingtalk_config.get("at_mobiles", []), + at_all=dingtalk_config.get("at_all", False), + enabled=True + )) + + # 注册邮件通道 + email_config = config.get("email", {}) + if email_config.get("enabled"): + _alert_manager.register_channel(EmailAlertChannel( + smtp_host=email_config["smtp_host"], + smtp_port=email_config["smtp_port"], + username=email_config["username"], + password=email_config["password"], + from_addr=email_config["from_addr"], + to_addrs=email_config["to_addrs"], + use_tls=email_config.get("use_tls", True), + enabled=True + )) + + # 注册Webhook通道 + webhook_config = config.get("webhook", {}) + if webhook_config.get("enabled"): + _alert_manager.register_channel(WebhookAlertChannel( + webhook_url=webhook_config["webhook_url"], + headers=webhook_config.get("headers"), + timeout=webhook_config.get("timeout", 10.0), + enabled=True + )) + + # 配置路由规则 + if "routing" in config: + _alert_manager.configure_routing(config["routing"]) + + return _alert_manager diff --git a/app/monitor/monitor.py b/app/monitor/monitor.py index 8a0dd57..01d6e87 100644 --- a/app/monitor/monitor.py +++ b/app/monitor/monitor.py @@ -1,6 +1,5 @@ """数据质量监控 - 对应Go的internal/monitor/monitor.go""" import asyncio -from abc import ABC, abstractmethod from dataclasses import dataclass from datetime import datetime, timedelta from typing import List, Optional @@ -11,6 +10,7 @@ from sqlalchemy import text from app.repositories import StockRepository, FuturesRepository from app.models import Frequency from app.core.logger import info, error +from app.monitor.alert_channels import AlertManager, AlertMessage, get_alert_manager @dataclass @@ -37,23 +37,6 @@ class QualityReport: pass_rate: float -class AlertSender(ABC): - """告警发送接口""" - - @abstractmethod - def send_alert(self, title: str, content: str) -> bool: - """发送告警""" - pass - - -class LogAlertSender(AlertSender): - """日志告警发送器""" - - def send_alert(self, title: str, content: str) -> bool: - info(f"[ALERT] {title}: {content}") - return True - - class DataQualityMonitor: """数据质量监控""" @@ -62,12 +45,12 @@ class DataQualityMonitor: db: Session, stock_repo: StockRepository, futures_repo: FuturesRepository, - sender: Optional[AlertSender] = None + alert_manager: Optional[AlertManager] = None ): self.db = db self.stock_repo = stock_repo self.futures_repo = futures_repo - self.sender = sender or LogAlertSender() + self.alert_manager = alert_manager or get_alert_manager() async def daily_check(self, check_date: str): """每日数据质量检查""" @@ -156,11 +139,17 @@ class DataQualityMonitor: result.detail = f"Data missing: expected {expect_count}, actual {actual_count}" # 发送告警 - if self.sender: - self.sender.send_alert( - f"[{asset_type}] Data Missing Alert", - f"Symbol: {symbol}, Date: {check_date}, Expected: {expect_count}, Actual: {actual_count}" - ) + if self.alert_manager: + asyncio.create_task(self.alert_manager.send_simple( + title=f"[{asset_type.upper()}] 数据缺失告警", + content=f"标的: {symbol}, 日期: {check_date}, 期望: {expect_count}条, 实际: {actual_count}条", + level="warning", + asset_type=asset_type, + symbol=symbol, + check_date=check_date, + expect_count=expect_count, + actual_count=actual_count + )) except Exception as e: result.status = "fail" diff --git a/app/repositories/__pycache__/database.cpython-311.pyc b/app/repositories/__pycache__/database.cpython-311.pyc index 08eb722f525cecbfea74b5168d313d5b15bd72dc..84f5275b11ee3765edba6428a6113f99adbe369d 100644 GIT binary patch delta 20 acmey(|C^tCIWI340}xDB*}9SY13Lgf^act5 delta 20 acmey(|C^tCIWI340}!Ym-L#SW13Lggw+1`_ diff --git a/app/repositories/__pycache__/futures_repository.cpython-311.pyc b/app/repositories/__pycache__/futures_repository.cpython-311.pyc index c0ca7082a6a96031a5434693c061ad934c0e63ff..66e9f9e4cad43a7afec892cad7c629c3d117e9fe 100644 GIT binary patch delta 1360 zcmai!?@Lor7{_;}`{`V!GjOKPU*Oe3Z=z9H29*h^L&?-KZM8vjPVuy5g0+ki1;z51 z85tTRt|(J3`9cx#4+we@z8Lf(>|Qkpj0&O%I?uUorDa_>pYy%X`JU%F&l$V->OR_x zd9*>LP;79hgYhjLn*TaN zxoM8dpnGE(T`;_58P*rLes?&vSCs9_(Fn4jjwL^I7Bou|^+`UxW&H^WN$^WI`u1Cv z?@x%`z0#dYsdqx0A!6SXY2dlk)4_{fovZyLVtEK(ld3SS@=K{ByGCQ;~@#x*${PKWbPPg)KLmtjR z3iT-knxcxM<@I@`0WA}JNaf}PWBF9cHohtlA=^r=WhaWsS$$=>jb6t!#TTO@a>r{! z!h@@C46%RCiDO0<-#iEX$zuoCw(DZ_U79tMu&93(hRTF-<6{+HO|#=Y6am#GTcg*3 zPl49B9Br-5*pA@`!MSYN+30jRa1H4&<>gdC_%xsva0XBZ*hj(Xa02KG01r4zB$EwY z7K&qg!_g#JjLSilL}Rw1CQ@bgB1~SIOOc<%$A48rd0Z{T?VMEl*x70zz@mSt$A!sD z=fG4z!Np)RXjaw)3VM@G?0_t19>iF>d{BDB@)b+-oCmap9Ou=d8?&O0K zSJG89FT796Lh1{2^}Ldlhns}#kQIq$HB{Jr1RuJ!HVO%zOowNa?|K$79{)`!u^<_#Kb0#m9 z@nxJh`LoOqwh)l|pv@m;H!x{~oe&HX0E=R=Pm>uU6*Kvag1jhL71$YIou(k2p_3FP zw83T|Tnchzkr0R|0uq`W5a&W{p6skx$GCO#Aw_LQ#-z>fluj`*229?ms^I4aQjga$ zMc`101gQht?+#*lfNWre*pLJgNCpv}lhxIH7;kPaQL|-aD+Y=rGfloIA+q_jdJvOP zHb{j(kkDi)$^fzRC+ll@xPh5qSLA~Pn}EbE_VU!!?3BdHqGpf~!a_EPg&>n}F{a$& zNJ%V7jV~@K0$RLRD~9Pi!)9siLPkdK$-O!fdMO~i{2-zXM6iPhaOhNnSPdXT07P_w zi1f*qb*4$Pu^KLj{J;RD&TP38pIoCa!8mR6RDD@S`zmHe$qx+J Q$&X<1FF0h26o57Z0PvW$X8-^I diff --git a/app/repositories/__pycache__/models.cpython-311.pyc b/app/repositories/__pycache__/models.cpython-311.pyc index 953510e59b4f8cf6fa2094acd6d009b263390ba3..3c501d17479fc122078b8cec9a60d90c67bc60a5 100644 GIT binary patch delta 847 zcmX?>v$d3OIWI340}%X?-I}>tXCj{j*!eMR1J}vco3ZYOvR%sXkpOy9@|Iw5Nq%y+V@g(Oafw@Ea!GzssVLA1FPauUU%KMylHO-? zo1bsq{cP%n7YkS15)~;1sg6$sE00eDt1q7HCnR1k1JqP14kVuJTJmhhdRPlm{SP)?cBJ4qg1BjRnBd!A(#cYg|8@WW-v>JRLaPm*CmN1!I zBqtPd!6Wg4VA4gwq$`3+4PG|{#V3f(V4uOhfN6o_OujjM8$=H+%1&-DTFW?l qvZk>t_hO(3Bgna8%$oy^pE6k}@G-J|V8A41uzUoGe!(IIHX8u!@#gga delta 79 zcmdm5dMt-;IWI340}$}6SexmiIgw9-aneThiHvM1GQkX*vYT%)?l)le)0CNf$ZYN8 f5_3uJxj?myKwSKeY4Z&8r%aP$t+-i>?13Txobwh+ diff --git a/app/repositories/__pycache__/stock_repository.cpython-311.pyc b/app/repositories/__pycache__/stock_repository.cpython-311.pyc index 0754396527b6fe00194c916d52147e993ad6c96a..a5c1c13210c44570e508476a3b7af840419a506d 100644 GIT binary patch delta 4876 zcmbtYZ*UXW5q~G?^v{waOSa{|k}NQ`f^9JVmpGKhiDOa%ZZWi-5Qt-ZXOJ!1a3?{` z$dMt?IEkBJ&czNf!L$U-khBh_jcJ&MeC(Hu?Gc0LW-`Nv$R^WHD^KXOllDV*-w8@z`l96@{fy<3*=!`xoX+3+{#-F+$!0zpDt_DDmA5O8Au6(& zaQfqfGh9>=as|Fh+m%1`_xhH}O=^(Vt$7^ZMaPSY|Q>X6letm=Jyz^fMM zez=va>uNo4z}LM`R7210b_*`#t&nWy{n|n#pPTA8yI3GNDKK7lM|gjW#S5I6)Lp&el#!ZQF~gCh4!I6zh614?EC zJ%Lug(15f~?Csjy;}?ZSsXs5%h{lplejli4vM6`zdfpJ){Dp!;D%X!-%F9q&Tvo}d z^7TR^jEE`#w6b)^)e%Mmf2S>av8bzv6JEvzY|^)6BYiHYtXEWYZ4L;n9QXLhcoP=nL82vi>$WWE+Zjiop7ut^igQy1pkEBg- zO%)fGF^WqboZ992gDU((+^+?o$(x=c3bk3%mzt3a?eb{dzk;*-5vZwmBK2bc&<2(6 zAPxu!KLwDD-TdJeu~!-?dJNp>Qqk_XSp8Fm;?1gXFEUbPs+F4%!bSSSJl~1BZ1S3A z4Ru+)7!cY6zAipR_~m*OsPJ^X4%fqS;Z58=DWX;pu^*qoaI&8BfNfe}XGK;W%G%cI z7q!5UQ00>Mdki(ca1?ht2GE8!x!-g^U_AKt4nxQY38^@SQ=L#Hqv896Q^Tl=)2uM6 z;h5#4XW2eYNd3Sbm4t4hUU3sOx6&r8)KSp+6nZcTq=?&rk%G36rjyc-m}RAy=5cAa z!z>lBPf5p|MPV(+9w#Af1Gxz6uf%sD1j4j7SOsUl`_8A)cTX=2z7hTPnT3%L7sk%r z9eh3d{(x)C>E>x5mN(BNIh&v$ci?#0ifg|p`#&7D}* z%m;!(w<1~izR>Q}qeKTr?w+6c>y5#M-@N>!S;hK?U+1; zQuqD@op?mkH)x&jn6hus>)Jq%iH4qqN#NU)03(%T9|@5?&-O7PrX#@vbYh8+5lC2< z5S)>o&dKt0GaPo%M`?SAO^s~r$i^{a>LJx&1K3oxsBp)=e_6SjvN3rQ*_b?uSEJnA zQQ#MzM0gM35I`b;;=ro_M{1-cSBANjRuplqNa^m(up15`VpQoTF`}~?Q@}*hZ7~CJ zxyStT)iu|4&p5Z7<;UwUY@KyhM4S~f&WfAjBPU`yjoA_-2zOvV=YOc*CCCbv&ke)O2qpML!2_M6Q& zn{SjW{Q<*#X2Iy*(SxIVhj-7|o`KK!^Ap=A4o>X2uubVtK0m#Ex_P?!+E%3>9?;#f zWepdPl$|QO<*t~uZHU-5+_pVAXM6H1Q$fU3JW(@es-R@a#1H07)p2t3oT+k2r?DIF z5rDtzNt*qwl3VuG5&OE^_R2YX<*dCrVy~Vx)%pP5zI;0+#hCCUC;LaC8 zi&x;QG>EJud0oa;z?G@e5~-ebsKzzetH(TH6BKuTwoLRxaNrG`E> z#0s@>IXncywt9J)9b#KBW78IAt0EA`A7(S$XB)g4K!1w25EFtlLC=}B(*DYncZEGa zVlP6(xq?xLwmvN?Mf$+vl!i4{BLhktG|-_hOPtb1))Hn2r-g{13w{g>zW)4d^yAUg z5SR#M3F3c<<_`w*xPc*(r{oDGxQ73a=$aCmPJ{3?G(o_^GBjlh16GsQBy7cDB_bdd z4+sz(QWnrEgT^`<=90xlYHTF#?h$>E zJ$lPm;PPUWumM1nun=2LjMxk%O9%t?^EtWaGS6g=hGuh0BRQo5TkbgWh7XVQo$9+) zR5RaL%rjz=Jb=AtFGuBnng7SIi>Uo!U-sL_QJQEz>Jy|*Hs*Sj69~y`m zSf_1?0Ng`3X^cV8&m7i`q@79|-7;$}j!0Lt3iAs=t8MhE0f7y(9gP6*Fj9L#52KRa zDfUWx942^yb+UC>wPdP7Dxp54>PTK!Rfj=(MdwiCFC;!aBt8Zh(&B?@I2gu3PBo_9 z0mU2mz9u*X&x%(#WBm)KC;w|nlUh3@O9!PsNM=%Z^weNVS>VQPKOTvOPfUZ?M=u^* z_+V_|>iLDSH=_gZ-Tizx`u+z^J>7ibrU}%mM?g08--lfHzUR_c3QTBFP4i-#QYtS` z*!N#_J;A6WLHD`#W&^5g5w14-;i38wY%N>h#Kpx0qfa8MJ0{NflXI8|`V zwSG3eB9dNlJN=2d^e0mC%aU4UgYXZqXtX8sNY$LZYSvy8vDeHRYa_WFjQeBINLx))}gJE2f$jI6Gnb=E|jH8b$pzVkoLLA8*9 z)dB@58H~i5F%VEn1(#H{wvus6``4C)pNI1(@94XbvLN84rthw@+6OPRB6h6^uOqyH zkdA=099Y5E77)g;e;xsIf$$*$zKPNYD*r9yVHd#$Uoj7yzn}C^{cUX@6D}=ejd!f} z7z@8KEy>J|>5$SBdsfVVl#v)M1KeP4EDd{PIW#hPY|g~07=fQ@&GgP2zyajV0R4Fo z>a1+Wgcu|6Gx_RG&9&EJWG&EiU?osafO0o^3xvzkTV=m1qtTR}XZabw({F@7V}gEu mlQ0Dfz+Z^?($xO)97c+j8=p2cunatCWn7};_Z~4zrT8~STI#6) delta 1515 zcma)+-)|IE6vyYzboQ6sO`(hI?zFqJz(R*8lvXs_QYs*QP)Zu02BK`YooPFC`olYe zwLH*jG^Q~zsW7OV6KCtY^iVHd-)f#Zsd--|?oo5#H8@bY=)6d#tU8 zR>V)8HL;f6%?^rd*{SEc$;9ixE?_sX2WSwYXXZS2!D-Gf+K$H$iT>OHRt+a}cNjCn zvGx?}dl}N5kZLndU};?XgR3Y#-&$I77iN5oW~OQKDzv>~JwKZ=F+36ek+12m;P6P{ z127!~27x2M5O5S2CRiEXkMRq@=Bt$Ftn~lb`i){4ychdA0Yn|8Dd1KRaE^TOLqH#p zoS{RePrKX?w$7{6{v%G2FB*FuC4rxS$7Ar5as8&7wQ5G^3`W)6zRcY(DL|kjMM(g{N$qN^VcwAzMrKhl`+0#F{5_R^@Cm};DF%r|WQHa6%<`$ah! z3T14?m&K3r0dLvVPGkxk-zq#-wi0cQYlq@Q1ZU)xB?RMtC(`+c%QU`NPJAn6rkAQ8!rvSQ%n?Mz)Y+*YM z{B;(l20vgWrKp$h0 List[dict]: + """获取指定日期范围内的复权系数 + + Args: + symbol: 股票代码 + start_date: 开始日期 (YYYYMMDD) + end_date: 结束日期 (YYYYMMDD) + + Returns: + 复权系数列表,每项包含 trade_date, qfq_factor, hfq_factor + """ + # 转换日期格式 + start_fmt = f"{start_date[:4]}-{start_date[4:6]}-{start_date[6:]}" + end_fmt = f"{end_date[:4]}-{end_date[4:6]}-{end_date[6:]}" + + results = self.db.query(StockAdjustFactor).filter( + StockAdjustFactor.symbol_id == symbol, + StockAdjustFactor.trade_date >= start_fmt, + StockAdjustFactor.trade_date <= end_fmt + ).order_by(StockAdjustFactor.trade_date.asc()).all() + + return [ + { + "trade_date": r.trade_date, + "qfq_factor": float(r.qfq_factor) if r.qfq_factor else 1.0, + "hfq_factor": float(r.hfq_factor) if r.hfq_factor else 1.0 + } + for r in results + ] + + def save_adjust_factors(self, symbol: str, factors: List[dict]) -> None: + """保存复权系数 + + Args: + symbol: 股票代码 + factors: 复权系数列表,每项包含 trade_date, qfq_factor, hfq_factor + """ + for f in factors: + trade_date = f.get("trade_date") + + existing = self.db.query(StockAdjustFactor).filter( + StockAdjustFactor.symbol_id == symbol, + StockAdjustFactor.trade_date == trade_date + ).first() + + if existing: + existing.qfq_factor = f.get("qfq_factor", 1.0) + existing.hfq_factor = f.get("hfq_factor", 1.0) + else: + new_factor = StockAdjustFactor( + symbol_id=symbol, + trade_date=trade_date, + qfq_factor=f.get("qfq_factor", 1.0), + hfq_factor=f.get("hfq_factor", 1.0) + ) + self.db.add(new_factor) + + self.db.commit() + + def get_latest_adjust_factor(self, symbol: str) -> Optional[dict]: + """获取最新的复权系数 + + Returns: + 包含 qfq_factor 和 hfq_factor 的字典,如果没有则返回None + """ + result = self.db.query(StockAdjustFactor).filter( + StockAdjustFactor.symbol_id == symbol + ).order_by(StockAdjustFactor.trade_date.desc()).first() + + if result: + return { + "trade_date": result.trade_date, + "qfq_factor": float(result.qfq_factor) if result.qfq_factor else 1.0, + "hfq_factor": float(result.hfq_factor) if result.hfq_factor else 1.0 + } + return None diff --git a/app/services/__pycache__/adapter_service.cpython-311.pyc b/app/services/__pycache__/adapter_service.cpython-311.pyc index 4b707c024ab13ba10e52698728e247823776a076..95292a2ea2c527a8e98aa5e4f711b3c6d875eadb 100644 GIT binary patch delta 1430 zcmbVKT}&fY6rS7ZblOseDgE2hI&=#C8EE+tmxcv@Kp>^mT@(CCR6u0trnt5^o$kXr zxUW8KFgGzIZn7^MB{7>|s!8?1Ao}1^G}FyZc4K0UKB^Dhga#XWIl4Pp}KBQz;W8p@~xthDC z0nM~(YX+>^A0V!Y$ff@k^Z)y4Io^*UHw<^Wy`UZbVsS~fEcKCrqCUZ2V?Feg*5j$& zGN-Y))eCB*8IA|R%G}YQ)F>S5_R*Mwd?*0(H0M-YYW(4FGC5lg$ZaHh468>id3o7> zvLzhTe%-KcfzP2x#6%60> zZ^=j;x+^B`h>5a!Cs-AykT|u${lvR)%BlI}IqpqCZ;N2NmD+xHCxa5teP5`?=1^=7 zbN7-j=>sp%qvXQfqUfw*kCwjcn*-p_l7HYj v0Q03U239@x#;OVz%5UWv-_V?@j!6~W|z`y!GeoJ~$ delta 1058 zcmZ`%&rcIk5Z>4A(sn7^YPW@!x^%H@X`u)KErAMBAYg6$5uz9gkizn#H5Rfg9!LxS zfH8y@6B03SFhXKXAk~D6MyrW{Mq9E*!bQBwr6e3Uc<^mO5hE{?dEb2B%)FWGe!8<8 zqULjQNDQ6C%u;BWTcVybU^D)fQQ?YlBL0zm1+-DXleZOej_?3Zp0*Y8q*;zKC#H(3 z1X?Wk9b;Hj71qNF(qxK&5EfI5xrY*;9aT@6X*;its_$aL74h_;7FF}w!?vOwju++Y zj&O4Z_+3N8A)L|+M$!3qDNJ$}DgyW%xr-Mm5q63*!}%g3%~K)l37YrasUt zypGo&)lmJ38AVJ3FBna*i_15-51-*rM!`djH^6(G8|2EfWJNVRWya)f;SVl&B$>`6 z8)28j15EH|p%q@GweXcA7wD(BJO!_VX-5ZY6DXc76AIBQW0vb9p~!$Ocqcf9Zaydm zZM{R0{?I^;O(;hRp=lOrxNwwF$wB1ORw@c;ih};O= zN_GULK+o5};Cf&%6$nd#aMCS4Yg=SqXGs+;DR-;nZcQQ^7AG&yolDWKy)Cxrv2}r$ z*vhZ0XPxydt6vwU*k(z|HcwJ;*3EU*AI!Z!J@Px4%aC_s`n>=>`e`-i!`=! zD!a7`Un$ngxJubtty-zZWn2qn>@^|0N|SyszRFnr9(=XN;t4F`?zQV+8;Syh3CP$iQ`m4r9SM%PUdHOyZl`x| zaJzx8A-;e)!Jx2YMx2=Ofu$1tXf)zJm^hLVNdr+5`6AKGM2!-op6AwX?SfI`N&7p` zJv}eaIrskmx8HVsrP$t;BmuF~SaPZNK*g-BS1|0$ptlQ(>6=2$#7w{t;Q9>xT-1b- z0coab)^!H@>m_Q|%)yN0RnWK;@yRG^QSHeXp+JUaIfEt*)0oY|zKMd7nwg9d3R;r! zG+Ljwn2^>@&l^%9;%)RZ`|dhOpF9BA0@w<85U?H4z!2v`;SsV9B1_|Fw=$sihC@CF zZF8)1Cl=xptH!0o5}k>4=?jhulYD>H&@kUcwn&8%62 z&g9CPB}LlS;Ig!>!DVS%gG+M0V47y;J7c7S?%Y)`Z%sQNvwAUD}1MS7*k=Kv{^jet#n&D7{=7b%^AL@*Z z^*oA$QveYCNSJOgdd0-ghaNJ%?`Pa;JS z?*%*t&~tnoq9*_y3~}+VRXck_2g4~VY49VCko#C20oMf?EEQ8i5QnKM*V^8JEuV}18ANv~YtopQ0Xe#C|b6HJp?s5)>*llDa zTI`jQEQ|-~Tczzj*t~wuJ}z+ru$RO}xWq;h*&m@rWnO$W)>P)<@?K32+U>(}6Hy{V zeQLyKCmJ317UDhhtT&fg#U-!DbrO13GQ@=lv*tdflPH7a1ikHT#W!O0l_dszigs3& z<2&^Es;h4Ors((Rbv7mf4_DGpPSfGlwfJ{By?R`E4Q9;(uxB3;ct4VR04;!f0q_zh z@K1q^0S*I>0IUEII-vk~ixDtgatZ){N60F`X?lDO*|N!)bG~#DvFc*CUVX&r)m)uk z&3Ejdf3jzx1RBn#&N delta 2030 zcmbW2Yiv|S6vt=w?%uoi-hKA5+tSBwACz9~gBBE!QUtmb5h>C(1`TaWcWJx!#q4eR zx?52{fJ$n}i6jP<{a~uGCSq1Unuvi^zR^TOvN3@e`vs#^!zW{+bMAJ#ThK2~(%(6A zX6DT4-2cqpjoj~Zee859gtr^NTpPMq^@VFlu{;!DTIq04={6ilx2OPrJADmtX+zMO z88)N^O2{JrOE*dkDPe=xR6iBLTV6;l@VOcgq=W>^YNykinMX)ifF_5Z%0R_J=y7=5 z_6zwU z9GxU=+DSS|ri7DZl4>&Cacy;`)V`6vnW52vaG%}>yWC&WP4KIG6YTPI&{nwMIoA3# z--GQzv>^5(_931@?B_@cqhph-2)o;{D~G1~Bjfte*r?_Nw|6`Acx%8@>4y)!^$w4P zkaJ|!+ARB4V6d{&X34Fz?-Z7b^V^$+r8Do*B!@KY@cZ#&&QU!{-*& zmTqay#h|vb(RCGvzv7Q5YQF{|m0PS9F8t(j<=eDo|Mrkbu0M6q&CN|w2{i~eJ^3L` zxLHNf*KC|@BKdlCKBZB(ytzxmLd}EGgmkr7M!E(eT|;_v9jh3wCOSiDiHV#{b_IEGMyNXw6gb;;ieY4Z$O~RD*1Q*ZpI(}$Ecb0AAll} A^Z)<= diff --git a/app/services/__pycache__/stock_service.cpython-311.pyc b/app/services/__pycache__/stock_service.cpython-311.pyc index 63524a8626f7a966fe10a584da07b6f4de8f2b28..b49eea068217ccb5a4d9e48013803916e82d4055 100644 GIT binary patch delta 9590 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 N&F6o68!2Dwe*u7y{GtE= delta 3674 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 List: - """应用复权计算(TODO: 实现复权逻辑)""" - # 复权计算需要从数据库获取复权系数 - # 这里简化处理,直接返回原始数据 - return items + ) -> List[KLineItem]: + """应用复权计算 + + 复权原理: + - 前复权(qfq): 以最新价格为基准,将历史价格按比例缩小 + - 后复权(hfq): 以历史最早价格为基准,将后续价格按比例放大 + """ + if not items or adjust_type == AdjustType.NONE: + return items + + try: + # 获取日期范围 + start_date = items[0].time.strftime("%Y%m%d") + end_date = items[-1].time.strftime("%Y%m%d") + + # 从数据库获取复权系数 + factors = self.repository.get_adjust_factors(symbol, start_date, end_date) + + # 如果没有复权系数,尝试从适配器获取 + if not factors: + factors = self._fetch_adjust_factors_from_adapter(symbol, start_date, end_date) + if factors: + self.repository.save_adjust_factors(symbol, factors) + + # 将复权系数转换为字典,方便查找 + factor_map = {f["trade_date"]: f for f in factors} + + # 应用复权 + adjusted_items = [] + for item in items: + # 获取交易日期 + trade_date = getattr(item, 'trade_date', None) + if not trade_date and hasattr(item, 'time'): + trade_date = item.time.strftime("%Y-%m-%d") + + factor = factor_map.get(trade_date, {"qfq_factor": 1.0, "hfq_factor": 1.0}) + + # 根据复权类型选择系数 + if adjust_type == AdjustType.QFQ: + adj_factor = factor.get("qfq_factor", 1.0) + else: # HFQ + adj_factor = factor.get("hfq_factor", 1.0) + + # 应用复权系数到价格字段 + adjusted_item = KLineItem( + symbol=item.symbol, + time=item.time, + open=round(item.open * adj_factor, 4), + high=round(item.high * adj_factor, 4), + low=round(item.low * adj_factor, 4), + close=round(item.close * adj_factor, 4), + volume=item.volume, + amount=round(item.amount * adj_factor, 4) if item.amount else item.amount, + trade_date=getattr(item, 'trade_date', None), + is_limit_up=getattr(item, 'is_limit_up', None), + is_limit_down=getattr(item, 'is_limit_down', None), + total_market_cap=getattr(item, 'total_market_cap', None), + float_market_cap=getattr(item, 'float_market_cap', None), + inst_holding_ratio=getattr(item, 'inst_holding_ratio', None), + trading_days=getattr(item, 'trading_days', None), + adj_factor=adj_factor + ) + adjusted_items.append(adjusted_item) + + return adjusted_items + + except Exception as e: + error(f"Failed to apply adjust factor for {symbol}: {e}") + # 出错时返回原始数据 + return items + + def _fetch_adjust_factors_from_adapter( + self, + symbol: str, + start_date: str, + end_date: str + ) -> List[dict]: + """从适配器获取复权系数""" + try: + adapter_service = AdapterService() + adapter = adapter_service.get_active_adapter("stock") + + if not adapter: + error("No active adapter available for fetching adjust factors") + return [] + + # 检查适配器是否支持获取复权因子 + if not hasattr(adapter, 'get_adj_factor'): + return [] + + # 异步获取前复权因子 + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + qfq_df = loop.run_until_complete( + adapter.get_adj_factor([symbol]) + ) + hfq_df = loop.run_until_complete( + adapter.get_backward_factor([symbol]) + ) + finally: + loop.close() + + # 转换DataFrame为列表 + factors = [] + + # 处理日期格式 + for idx in qfq_df.index: + date_obj = idx if hasattr(idx, 'strftime') else datetime.strptime(str(idx), "%Y%m%d") + date_str = date_obj.strftime("%Y-%m-%d") + date_key = date_obj.strftime("%Y%m%d") + + # 只保留指定范围内的数据 + if not (start_date <= date_key <= end_date): + continue + + qfq_factor = float(qfq_df.loc[idx, symbol]) if symbol in qfq_df.columns else 1.0 + hfq_factor = float(hfq_df.loc[idx, symbol]) if symbol in hfq_df.columns else 1.0 + + # 确保复权系数有效 + if qfq_factor <= 0 or qfq_factor != qfq_factor: # 检查NaN + qfq_factor = 1.0 + if hfq_factor <= 0 or hfq_factor != hfq_factor: + hfq_factor = 1.0 + + factors.append({ + "trade_date": date_str, + "qfq_factor": qfq_factor, + "hfq_factor": hfq_factor + }) + + info(f"Fetched {len(factors)} adjust factors from adapter for {symbol}") + return factors + + except Exception as e: + error(f"Failed to fetch adjust factors from adapter: {e}") + return [] def list_symbols(self, req: SymbolListRequest) -> SymbolListData: """查询标的列表""" @@ -196,7 +334,13 @@ class StockService: # 确保适配器已连接 adapter = adapter_service.get_active_adapter("stock") if not adapter: - asyncio.run(adapter_service._connect_adapter("amazingdata")) + # 从配置获取当前激活的适配器名称 + from app.core.config import get_config + config = get_config() + active_source = config.sources.stock.active + + info(f"Connecting to configured adapter: {active_source}") + asyncio.run(adapter_service._connect_adapter(active_source)) adapter = adapter_service.get_active_adapter("stock") if not adapter: @@ -301,7 +445,13 @@ class StockService: # 确保适配器已连接 adapter = adapter_service.get_active_adapter("stock") if not adapter: - asyncio.run(adapter_service._connect_adapter("amazingdata")) + # 从配置获取当前激活的适配器名称 + from app.core.config import get_config + config = get_config() + active_source = config.sources.stock.active + + info(f"Connecting to configured adapter: {active_source}") + asyncio.run(adapter_service._connect_adapter(active_source)) adapter = adapter_service.get_active_adapter("stock") if not adapter: diff --git a/config.json b/config.json index 64e54ea..cf8ee62 100644 --- a/config.json +++ b/config.json @@ -20,32 +20,32 @@ }, "sources": { "stock": { - "active": "custom", + "active": "amazingdata", "list": { - "custom": { + "amazingdata": { "type": "sdk", "config": { - "username": "", - "password": "", - "host": "", - "port": "", - "local_path": "./custom_data_cache/", + "username": "11200008169", + "password": "11200008169@2026", + "host": "140.206.44.234", + "port": "8600", + "local_path": "./amazing_data_cache/", "use_local_cache": "true" } } } }, "futures": { - "active": "custom", + "active": "amazingdata", "list": { - "custom": { + "amazingdata": { "type": "sdk", "config": { "username": "", "password": "", "host": "", - "port": "", - "local_path": "./custom_data_cache/", + "port": "8600", + "local_path": "./amazing_data_cache/", "use_local_cache": "true" } } diff --git a/market_data_service.egg-info/PKG-INFO b/market_data_service.egg-info/PKG-INFO new file mode 100644 index 0000000..bb0d8bd --- /dev/null +++ b/market_data_service.egg-info/PKG-INFO @@ -0,0 +1,269 @@ +Metadata-Version: 2.4 +Name: market-data-service +Version: 1.0.0 +Summary: 统一行情数据服务 - Python实现 +Classifier: Development Status :: 4 - Beta +Classifier: Intended Audience :: Developers +Classifier: Programming Language :: Python :: 3 +Classifier: Programming Language :: Python :: 3.10 +Classifier: Programming Language :: Python :: 3.11 +Classifier: Programming Language :: Python :: 3.12 +Requires-Python: >=3.10 +Description-Content-Type: text/markdown +Requires-Dist: fastapi>=0.115.0 +Requires-Dist: uvicorn[standard]>=0.32.0 +Requires-Dist: python-socketio>=5.12.1 +Requires-Dist: websockets>=14.1 +Requires-Dist: sqlalchemy>=2.0.36 +Requires-Dist: psycopg2-binary>=2.9.10 +Requires-Dist: pandas>=2.2.3 +Requires-Dist: numpy>=2.1.3 +Requires-Dist: numba>=0.61.0 +Requires-Dist: scipy>=1.15.0 +Requires-Dist: pydantic>=2.10.0 +Requires-Dist: pydantic-settings>=2.6.1 +Requires-Dist: python-dotenv>=1.0.1 +Requires-Dist: PyYAML>=6.0.2 +Requires-Dist: httpx>=0.28.0 +Requires-Dist: apscheduler>=3.11.0 +Provides-Extra: dev +Requires-Dist: pytest>=8.3.4; extra == "dev" +Requires-Dist: pytest-asyncio>=0.24.0; extra == "dev" + +# 统一行情数据服务 - Python实现 + +Python版本的统一行情数据服务,所有接口和功能与Go版本保持一致。 + +## 特性 + +- **多周期K线支持**:1m/5m/15m/30m/60m/1d/1w/1month +- **股票复权支持**:前复权(qfq)/后复权(hfq) +- **数据源热切换**:支持Wind、Tushare等多个数据源动态切换 +- **双轨设计**:股票和期货接口独立,数据存储隔离 +- **WebSocket实时订阅**:支持实时行情推送 +- **数据质量监控**:自动检测数据缺失并告警 +- **交易日历**:支持查询股票和期货的交易日历 +- **期货合约查询**:根据品种获取可交易合约列表 + +## 技术栈 + +- **语言**: Python 3.10+ +- **Web框架**: FastAPI +- **WebSocket**: FastAPI原生WebSocket + python-socketio +- **数据库**: PostgreSQL 15+ (SQLAlchemy ORM) +- **数据源**: Tushare (首期支持) + +## 项目结构 + +``` +python_market_data_service/ +├── app/ +│ ├── __init__.py +│ ├── main.py # 主程序入口 +│ ├── api/ # API路由 +│ │ ├── __init__.py +│ │ ├── routes.py # 主要API路由 +│ │ └── admin_routes.py # 管理后台路由 +│ ├── core/ # 核心模块 +│ │ ├── __init__.py +│ │ ├── config.py # 配置管理 +│ │ ├── errors.py # 错误定义 +│ │ └── logger.py # 日志工具 +│ ├── models/ # 数据模型 +│ │ ├── __init__.py +│ │ ├── types.py # 基础类型 +│ │ └── admin_types.py # 管理后台类型 +│ ├── repositories/ # 数据访问层 +│ │ ├── __init__.py +│ │ ├── database.py # 数据库连接 +│ │ ├── models.py # 数据库模型 +│ │ ├── stock_repository.py +│ │ └── futures_repository.py +│ ├── services/ # 业务逻辑层 +│ │ ├── __init__.py +│ │ ├── stock_service.py +│ │ ├── futures_service.py +│ │ ├── admin_service.py +│ │ ├── config_service.py +│ │ ├── adapter_service.py +│ │ └── test_service.py +│ ├── adapters/ # 数据源适配器 +│ │ ├── __init__.py +│ │ ├── base.py # 适配器基类 +│ │ └── tushare_adapter.py +│ └── websocket/ # WebSocket服务 +│ ├── __init__.py +│ └── server.py +├── scripts/ +│ └── sync_data.py # 数据同步工具 +├── tests/ # 测试文件 +├── requirements.txt # 依赖列表 +├── pyproject.toml # 项目配置 +└── README.md # 本文件 +``` + +## 快速开始 + +### 1. 环境准备 + +- Python 3.10+ +- PostgreSQL 15+ +- Tushare Token (从 [Tushare官网](https://tushare.pro) 获取) + +### 2. 安装依赖 + +```bash +# 创建虚拟环境 +python -m venv venv + +# 激活虚拟环境 +# Windows: +venv\Scripts\activate +# Linux/Mac: +source venv/bin/activate + +# 安装依赖 +pip install -r requirements.txt + +# 安装Tushare(需单独安装) +pip install tushare +``` + +### 3. 配置环境变量 + +```bash +# Windows PowerShell +$env:TUSHARE_TOKEN="your_tushare_token" +$env:DATABASE_URL="postgresql://user:password@localhost:5432/marketdata" + +# Linux/Mac +export TUSHARE_TOKEN="your_tushare_token" +export DATABASE_URL="postgresql://user:password@localhost:5432/marketdata" +``` + +### 4. 初始化数据库 + +```bash +# 创建数据库(使用psql或pgAdmin) +createdb marketdata + +# 启动服务时会自动创建表结构 +``` + +### 5. 启动服务 + +```bash +# 开发模式 +python -m app.main + +# 或使用uvicorn +uvicorn app.main:app --reload --port 8080 +``` + +服务将启动在 `http://localhost:8080` + +- API文档: `http://localhost:8080/docs` +- 管理后台: `http://localhost:8080/admin` + +### 6. 同步基础数据 + +```bash +# 同步股票列表 +python scripts/sync_data.py --type stocks + +# 同步期货列表 +python scripts/sync_data.py --type futures + +# 同步交易日历 +python scripts/sync_data.py --type calendar --start 20240101 --end 20241231 + +# 同步K线数据 +python scripts/sync_data.py --type klines --symbol 000001.SZ --start 20240301 --end 20240307 --freq 1d +``` + +## API接口 + +### 股票接口 + +| 接口 | 方法 | 说明 | +|------|------|------| +| `/v1/stock/klines/:symbol` | GET | 查询K线数据 | +| `/v1/stock/symbols` | GET | 查询标的列表 | +| `/v1/stock/klines/batch` | POST | 批量查询K线 | +| `/v1/stock/trading-dates` | GET | 获取交易日历 | + +### 期货接口 + +| 接口 | 方法 | 说明 | +|------|------|------| +| `/v1/futures/klines/:symbol` | GET | 查询K线数据 | +| `/v1/futures/symbols` | GET | 查询标的列表 | +| `/v1/futures/klines/batch` | POST | 批量查询K线 | +| `/v1/futures/continuous/:underlying` | GET | 查询主力连续合约(预留) | +| `/v1/futures/trading-dates` | GET | 获取交易日历 | +| `/v1/futures/contracts` | GET | 获取品种合约列表 | + +### 管理接口 + +| 接口 | 方法 | 说明 | +|------|------|------| +| `/v1/admin/source/status` | GET | 获取数据源状态 | +| `/v1/admin/source/switch` | POST | 切换数据源 | +| `/v1/admin/backfill` | POST | 历史数据补录 | +| `/v1/admin/health` | GET | 健康检查 | + +### 管理后台 + +服务启动后,访问 `http://localhost:8080/admin` 进入管理后台。 + +### WebSocket实时订阅 + +**连接地址**: `ws://localhost:8080/v1/stream` + +**认证**: 连接时在Header中传递 `X-API-Key` + +**客户端消息**: +```json +// 订阅 +{ + "action": "subscribe", + "symbols": ["000001.SZ", "CU2504.SHFE"] +} + +// 取消订阅 +{ + "action": "unsubscribe", + "symbols": ["000001.SZ"] +} +``` + +**服务器消息**: +```json +// 订阅确认 +{ + "type": "ack", + "action": "subscribe", + "symbols": ["000001.SZ", "CU2504.SHFE"], + "ts": "2025-03-07T12:30:00Z" +} + +// 心跳 +{ + "type": "heartbeat", + "ts": "2025-03-07T12:30:30Z" +} +``` + +**限制**: 单连接最大订阅100个标的 + +## 与Go版本的主要区别 + +1. **Web框架**: Gin -> FastAPI +2. **ORM**: 原生SQL -> SQLAlchemy +3. **WebSocket**: Gorilla -> FastAPI原生 +4. **配置**: 文件+环境变量 -> Pydantic Settings +5. **API文档**: 自动生成Swagger/ReDoc + +## License + +MIT diff --git a/market_data_service.egg-info/SOURCES.txt b/market_data_service.egg-info/SOURCES.txt new file mode 100644 index 0000000..ef08a44 --- /dev/null +++ b/market_data_service.egg-info/SOURCES.txt @@ -0,0 +1,43 @@ +README.md +pyproject.toml +app/__init__.py +app/main.py +app/adapters/__init__.py +app/adapters/amazingdata_adapter.py +app/adapters/base.py +app/api/__init__.py +app/api/admin_routes.py +app/api/routes.py +app/core/__init__.py +app/core/config.py +app/core/errors.py +app/core/logger.py +app/core/metrics.py +app/core/rate_limiter.py +app/models/__init__.py +app/models/admin_types.py +app/models/types.py +app/monitor/__init__.py +app/monitor/alert_channels.py +app/monitor/monitor.py +app/repositories/__init__.py +app/repositories/database.py +app/repositories/futures_repository.py +app/repositories/models.py +app/repositories/stock_repository.py +app/services/__init__.py +app/services/adapter_service.py +app/services/admin_service.py +app/services/config_service.py +app/services/futures_service.py +app/services/stock_service.py +app/services/test_service.py +app/websocket/__init__.py +app/websocket/server.py +market_data_service.egg-info/PKG-INFO +market_data_service.egg-info/SOURCES.txt +market_data_service.egg-info/dependency_links.txt +market_data_service.egg-info/requires.txt +market_data_service.egg-info/top_level.txt +tests/test_xysz_adapter.py +tests/test_xysz_integration.py \ No newline at end of file diff --git a/market_data_service.egg-info/dependency_links.txt b/market_data_service.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/market_data_service.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/market_data_service.egg-info/requires.txt b/market_data_service.egg-info/requires.txt new file mode 100644 index 0000000..b5b0438 --- /dev/null +++ b/market_data_service.egg-info/requires.txt @@ -0,0 +1,20 @@ +fastapi>=0.115.0 +uvicorn[standard]>=0.32.0 +python-socketio>=5.12.1 +websockets>=14.1 +sqlalchemy>=2.0.36 +psycopg2-binary>=2.9.10 +pandas>=2.2.3 +numpy>=2.1.3 +numba>=0.61.0 +scipy>=1.15.0 +pydantic>=2.10.0 +pydantic-settings>=2.6.1 +python-dotenv>=1.0.1 +PyYAML>=6.0.2 +httpx>=0.28.0 +apscheduler>=3.11.0 + +[dev] +pytest>=8.3.4 +pytest-asyncio>=0.24.0 diff --git a/market_data_service.egg-info/top_level.txt b/market_data_service.egg-info/top_level.txt new file mode 100644 index 0000000..b80f0bd --- /dev/null +++ b/market_data_service.egg-info/top_level.txt @@ -0,0 +1 @@ +app diff --git a/marketdata.db b/marketdata.db index 457389d814fae1dd0ef9397e2e8d602d73a50029..23e36b2d2257e276f72083c14e0ae4349035a127 100644 GIT binary patch delta 2119 zcmZoTz}3*eGeKHVoq>VD0El5geWH%Bs5*mQybLQ)h>8CjNWy^sH~+Wk0u79!8#{CP zxqu?liaV)pU{xRwRl&s>5aj9W7!;}C?HZ||k(r{&rOCj+{BGibf78WQ zF|OunvEye4xx2%7P`KL`>h8?E;*$7`{G61`y!7~@#FET>xaTc&6ik4A2L&)lG9|IH zSi#dT#MRw3XtOw55F?W<&*TDTnaSR4ypzQ_MVOlzxF@e=k=)$Mp3cbJ$iOvu1GDU8 zZ_c|+wj5A(e3P$p3j@`$1Jy}wcI8TAocxZ*ck?T57RJfeydNj;<+t9f!#9y}+XO}y z0TV$+Ee8HJerDcaUJ;(DJVG3k*!eh2S+&?svn*$kVX|Pp$+(tLi|Zc494;5m^_&t6 za~PgZ78FQf<7i{(i#DCSky}nXF(s=M=)ttaKNh*j58P42!%|Xdg~SZ{S>enfaDTLGJxbW%|>x8cJV-V^s1$|1NScSfrq^C_n*BE?A@>Fe!jVikqX0fnSvG0=dP99ErGCVfv!AjFPlS u#-6O;nr^$&8b(dV>F3rlx^MSh%h=4w=B&&n4k=To1Itv6>0;{{UjYCW^|@*Q delta 438 zcmZo@;AuF(H9=ZXhJk@W3y5JrW}=R`y{NJ5(bH?Q@^k|utxVrGa! zNuefzZ32vp1@`@_iD~{oZ9S}9Bx zz`T}mA9Eg~J;NRbGmbax#^UbUjE#!Z1DhBnroUUk$TfZ63dX37dzctmINq?Xp5D{U zm@)l810&1y`+u3(r$6XpjG3;sj`8m1JZ2k4CR>5Y15u82&5WzDd4UCJsO;p8%#z#h{$|o--0u6AshN?@Rh3OV(s=SlZi(qCf0;C= Vi~eVNy~x2}k%Gda00l6a0053xdfWg2 diff --git a/marketdata.db.backup b/marketdata.db.backup new file mode 100644 index 0000000000000000000000000000000000000000..95458768fa5e98fad40930f2d7954b4029069d97 GIT binary patch literal 192512 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