From ce7b5fc133853a4cb2c64a19ae5c62c9e7fb8948 Mon Sep 17 00:00:00 2001 From: Lxy Date: Sun, 17 May 2026 22:24:41 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0json=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=AF=BC=E5=87=BA=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/__pycache__/__init__.cpython-311.pyc | Bin 221 -> 159 bytes app/__pycache__/config.cpython-311.pyc | Bin 1553 -> 1491 bytes app/__pycache__/database.cpython-311.pyc | Bin 1327 -> 1265 bytes app/__pycache__/main.cpython-311.pyc | Bin 5530 -> 5468 bytes app/__pycache__/models.cpython-311.pyc | Bin 4581 -> 4519 bytes app/__pycache__/schemas.cpython-311.pyc | Bin 6430 -> 6330 bytes app/api/__pycache__/__init__.cpython-311.pyc | Bin 225 -> 163 bytes app/api/__pycache__/config.cpython-311.pyc | Bin 14820 -> 14758 bytes app/api/__pycache__/data.cpython-311.pyc | Bin 9773 -> 9711 bytes app/api/__pycache__/tasks.cpython-311.pyc | Bin 10445 -> 10383 bytes .../__pycache__/__init__.cpython-311.pyc | Bin 230 -> 168 bytes .../__pycache__/cache.cpython-311.pyc | Bin 11804 -> 11742 bytes .../__pycache__/collector.cpython-311.pyc | Bin 3490 -> 3428 bytes .../__pycache__/scheduler.cpython-311.pyc | Bin 9831 -> 9769 bytes app/static/index.html | 50 ++++++++++++++++++ 15 files changed, 50 insertions(+) diff --git a/app/__pycache__/__init__.cpython-311.pyc b/app/__pycache__/__init__.cpython-311.pyc index 92086fc12541bc2cfb0af41b7afcaa74a56e70f5..5da7f5f3f41a50d113a9c4ee666050a7ac91e017 100644 GIT binary patch delta 65 zcmcc1IG>SsIWI340}$AqXK3L^l&s}|n? delta 106 zcmcc2J&}iZIWI340}!mv;mC^L$jilIlBb_oP@rF)UzDAhmky?j3lfu4i}h1d(-KQ_ zO7x2}5{pvfQxZ!O^$Uvfvr>~w^d}!@k`>iYDosmEEs8J5Ni0drFUkd*v)P_y3L^kB CyCj4F diff --git a/app/__pycache__/database.cpython-311.pyc b/app/__pycache__/database.cpython-311.pyc index 2e3b6db97c8c0f8597d10c729a3b8b31c6a37615..507bc0c55d334c693abcafac693b84698a29bb6a 100644 GIT binary patch delta 68 zcmZ3_^^uczIWI340}$AqxT7FS(OkzPn%w}WeKa2qLt{40O delta 155 zcmey!xt@!6IWI340}xEg;mE4o$a{dPzCb^*pg_MozbHF1FC9!57bGU97VD>^rX`l< zl;{^{Bo?K{rzDmn>K7E{XQd{W=;tODWv7;a#p09mbAUqmMezkWi6v?IMY;M(rDo|4* diff --git a/app/__pycache__/main.cpython-311.pyc b/app/__pycache__/main.cpython-311.pyc index 13e81c5c55614beccc74de27373790c0d9757e94..eef9d37935e797c5d51fe1bf890bb010709669e5 100644 GIT binary patch delta 69 zcmbQGeMgILIWI340}$Aq=Ra-$*9#Z9 delta 131 zcmcbkHA|asIWI340}!wmb7bA-+Q_HDnUs;^0QKtOZ0OSi?UNoz+&;q`8hzL{G#}RoWzo}{Gwd_q|&ss OR3tvg{LOKk|F{8_PcRn% diff --git a/app/__pycache__/models.cpython-311.pyc b/app/__pycache__/models.cpython-311.pyc index fc97c345f0654ba9be2b269d549c1c6e3acee6d9..9f5a968be7103867d3d43b6da36a5d0ff7eb6fff 100644 GIT binary patch delta 74 zcmaE=yj+=gIWI340}$Aq$a{^2@z&%gES4GuE>_~h$a{^2@z>-hESB|o`iTVv`sMjW*_nCiV7j;}A73Tx3p8eP9IGb}0KE=8oB#j- diff --git a/app/__pycache__/schemas.cpython-311.pyc b/app/__pycache__/schemas.cpython-311.pyc index 7cafdde1dd317392a05c2a139f7ae4a2da0a9481..0e2ed94b8537233e9179719ca1b2f53bcd580c7d 100644 GIT binary patch delta 47 zcmbPdw9AlpIWI340}$AqPJGCgjJijQrxF9h(H6}SfDZj)> z&mcY=DCCh}RGb=~w rVsaCUvQtaIV)4oOIY6QOqWFTG#FDi9qTHCI(zG<7ots5imWcoW@yj}h diff --git a/app/api/__pycache__/__init__.cpython-311.pyc b/app/api/__pycache__/__init__.cpython-311.pyc index 948dd229f28b7f4ec5b7a015b4e81353f89129d5..2b2a5c5892849c37daf390e1b31a1e289f2389c0 100644 GIT binary patch delta 69 zcmaFJxR{Z5IWI340}$AqhrzIWI340}$xsaAdJf98>@R delta 163 zcmZ2h{G^y~IWI340}yC;b7Z|z+sG%!R$ru_SWuu}o?n!mnU@ZxiwhEyQ;YReQqvMk zb4v7!GZKqZ<5Ln#67>s;^0QKtOZ0OSi?UNoz+&;q`8hzL{G#}RoWzo}{Gwd_q|&ss rR3tvg{KSGx{p9?-w9It9g32OkpcBA^B*W%hwq@dsw>FE&O_Bxx%4j)@ diff --git a/app/api/__pycache__/data.cpython-311.pyc b/app/api/__pycache__/data.cpython-311.pyc index c75b420c00984d3b5f824d4200c78d570a3cb476..0ca2fc996f8710a3210ea45e6aadf9d86e2c30eb 100644 GIT binary patch delta 80 zcmZ4M^WK|pIWI340}$Aqb*dNHZoR^o20SN3)a%KriZsdE+&iHHcC-#>*#x7Pdi8%!siSgz6McKs#iOH!k gNu_CNsYUSxIf*4{`9--gi3J5foEfv(m9s-006!BP(*OVf delta 162 zcmeAVJR8WjoR^o20SLOoIJ2B2Hu61YXS_A}6Z^~hLjA;o0{!y*qU_APbTD09keHlW zte=vamROooqF~vm6}|lpPN{eomv7Gi%-ta0Se_8#TVoxmZaqu uP%7G&y|Bo=2E>lIYq;!iG0O)N=`&r2vfEDr!FsXd?o diff --git a/app/services/__pycache__/__init__.cpython-311.pyc b/app/services/__pycache__/__init__.cpython-311.pyc index 0fe3f4ce0cbfcd1c8161b41b69d09810118be13c..ebac38bb89482c978e4f4472c396d47478251009 100644 GIT binary patch delta 74 zcmaFHxPp;)IWI340}$AqOwS>(@^9Y!w|GnSt=!wW(_!5w z9s7EAXMO>Sz>_s~<85aV3{;?@h*LHwEh_m24MCK93I+peBHNu0oL9x_C8I^w0j^Vw P^!K-ZA$@dPb-rJI!`?D? diff --git a/app/services/__pycache__/cache.cpython-311.pyc b/app/services/__pycache__/cache.cpython-311.pyc index 8c19002b5e670acbe07810661cdc199dc5d576e6..7732f0cde53ed5498c4236639ed30889cefccaae 100644 GIT binary patch delta 78 zcmbOeb1#~2IWI340}$Aq#Ot=3kvkh^NX@G^U}d|aY15o zYO#JwYFc7xPKkbTMq*KFd`e +
+ +
@@ -1805,12 +1811,14 @@ if (!res.ok) { showToast(data.detail || '未找到缓存数据', 'error'); + document.getElementById('btnExportData').disabled = true; return; } addLog(`查询成功: ${symbol}, 缓存 ${data.timeframes ? data.timeframes.length : 0} 个周期`, 'success'); currentQueryData = data; + document.getElementById('btnExportData').disabled = false; if (!data.timeframes || data.timeframes.length === 0) { document.getElementById('queryResult').innerHTML = '

暂无K线数据

'; @@ -1822,7 +1830,49 @@ } catch (e) { showToast(`查询失败: ${e.message}`, 'error'); + document.getElementById('btnExportData').disabled = true; + } + } + + function exportData() { + if (!currentQueryData || !currentQueryData.timeframes || currentQueryData.timeframes.length === 0) { + showToast('暂无可导出的数据', 'error'); + return; } + + const symbol = document.getElementById('querySymbol').value.trim() || 'unknown'; + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); + const filename = `${symbol}_多周期数据_${timestamp}.json`; + + const exportObj = { + symbol: currentQueryData.symbol || symbol, + type: currentQueryData.type || 'futures', + current_price: currentQueryData.current_price, + timestamp: currentQueryData.timestamp || new Date().toISOString(), + timeframes: {} + }; + + currentQueryData.timeframes.forEach(tf => { + exportObj.timeframes[tf.period] = tf.candles || []; + }); + + const jsonStr = JSON.stringify(exportObj, null, 2); + const blob = new Blob([jsonStr], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + const periodCount = currentQueryData.timeframes.length; + const totalCandles = currentQueryData.timeframes.reduce((sum, tf) => sum + (tf.candles ? tf.candles.length : 0), 0); + + addLog(`数据导出成功: ${filename}, ${periodCount} 个周期, ${totalCandles} 条K线`, 'success'); + showToast(`已导出 ${periodCount} 个周期数据`, 'success'); } function renderKlineChart(timeframe, symbol) { From 9c61205d5e25f6d35860b5b4ddbee0294c0e7790 Mon Sep 17 00:00:00 2001 From: Lxy Date: Sun, 17 May 2026 23:19:48 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0docker=E9=83=A8?= =?UTF-8?q?=E7=BD=B2=E5=8F=8A=E7=9B=B8=E5=85=B3=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 17 ++++ DOCKER_DEPLOY.md | 224 +++++++++++++++++++++++++++++++++++++++++++++ Dockerfile | 28 ++++++ docker-compose.yml | 23 +++++ requirements.txt | 1 + 5 files changed, 293 insertions(+) create mode 100644 .dockerignore create mode 100644 DOCKER_DEPLOY.md create mode 100644 Dockerfile create mode 100644 docker-compose.yml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..df5bb9d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +__pycache__/ +*.pyc +*.pyo +*.db +.git/ +.gitignore +*.md +.trae/ +.vscode/ +*.egg-info/ +dist/ +build/ +.eggs/ +*.egg +docker-compose.yml +Dockerfile +.dockerignore diff --git a/DOCKER_DEPLOY.md b/DOCKER_DEPLOY.md new file mode 100644 index 0000000..0e48975 --- /dev/null +++ b/DOCKER_DEPLOY.md @@ -0,0 +1,224 @@ +# 数据缓冲平台 Docker 部署文档 + +## 目录结构 + +``` +buffer_platform/ +├── app/ # 应用代码 +├── config/ # 配置文件 +├── data/ # 本地数据目录 +├── Dockerfile # Docker 镜像构建文件 +├── docker-compose.yml # Docker Compose 配置文件 +├── .dockerignore # Docker 构建忽略文件 +└── requirements.txt # Python 依赖 +``` + +## 环境要求 + +- Docker Desktop for Windows +- Docker Compose v3.8+ +- Windows 10/11 或 Windows Server + +## 快速部署 + +### 1. 构建并启动容器 + +```powershell +cd d:\alpha_workspace\buffer_platform +docker-compose up -d --build +``` + +### 2. 查看容器状态 + +```powershell +docker-compose ps +``` + +### 3. 查看日志 + +```powershell +# 查看实时日志 +docker-compose logs -f + +# 查看最近 100 行日志 +docker-compose logs --tail=100 +``` + +## 访问地址 + +| 服务 | 地址 | +|------|------| +| 前端页面 | http://localhost:9600/ui | +| API 文档 | http://localhost:9600/docs | +| 健康检查 | http://localhost:9600/api/v1/health | + +## 数据持久化 + +### 挂载路径 + +| 宿主机路径 | 容器路径 | 说明 | +|-----------|----------|------| +| `E:\docker_workspace\futures_datas` | `/app/data` | SQLite 数据库及缓存数据 | + +### 数据目录结构 + +容器启动后,`E:\docker_workspace\futures_datas` 目录将包含: + +``` +E:\docker_workspace\futures_datas\ +└── buffer.db # SQLite 数据库文件 +``` + +## 常用操作 + +### 停止服务 + +```powershell +docker-compose stop +``` + +### 启动服务 + +```powershell +docker-compose start +``` + +### 重启服务 + +```powershell +docker-compose restart +``` + +### 停止并删除容器 + +```powershell +docker-compose down +``` + +### 停止并删除容器及数据卷 + +> ⚠️ 警告:此操作将删除所有持久化数据! + +```powershell +docker-compose down -v +``` + +### 重新构建并启动 + +```powershell +docker-compose up -d --build +``` + +### 更新镜像 + +```powershell +# 拉取最新代码后 +docker-compose down +docker-compose build --no-cache +docker-compose up -d +``` + +## 环境变量配置 + +可在 `docker-compose.yml` 中修改以下环境变量: + +| 变量名 | 默认值 | 说明 | +|--------|--------|------| +| `BUFFER_DB_PATH` | `/app/data/buffer.db` | 数据库文件路径 | +| `BUFFER_HOST` | `0.0.0.0` | 服务监听地址 | +| `BUFFER_PORT` | `8600` | 容器内服务端口 | +| `CACHE_TTL` | `300` | 缓存过期时间(秒) | +| `BUFFER_LOG_LEVEL` | `INFO` | 日志级别 | +| `MAX_WORKERS` | `2` | 并发采集数 | + +## 端口修改 + +如需修改宿主机绑定端口,编辑 `docker-compose.yml`: + +```yaml +ports: + - "9600:8600" # 修改 9600 为其他端口 +``` + +## 数据备份 + +### 备份数据库 + +```powershell +# 停止服务 +docker-compose stop + +# 复制数据文件 +xcopy E:\docker_workspace\futures_datas\buffer.db E:\backup\buffer_$(Get-Date -Format 'yyyyMMdd').db + +# 启动服务 +docker-compose start +``` + +### 恢复数据库 + +```powershell +# 停止服务 +docker-compose stop + +# 复制备份文件到数据目录 +copy E:\backup\buffer_20260517.db E:\docker_workspace\futures_datas\buffer.db + +# 启动服务 +docker-compose start +``` + +## 故障排查 + +### 容器无法启动 + +```powershell +# 查看详细日志 +docker-compose logs + +# 检查容器状态 +docker ps -a +``` + +### 数据库权限问题 + +确保 `E:\docker_workspace\futures_datas` 目录存在且有写入权限: + +```powershell +# 创建数据目录 +mkdir E:\docker_workspace\futures_datas +``` + +### 端口冲突 + +```powershell +# 检查端口占用 +netstat -ano | findstr "9600" +``` + +### 进入容器 + +```powershell +docker exec -it buffer-platform /bin/bash +``` + +## 健康检查 + +服务内置健康检查端点: + +```powershell +# PowerShell +Invoke-WebRequest -Uri http://localhost:9600/api/v1/health + +# 或使用 curl +curl http://localhost:9600/api/v1/health +``` + +正常响应: + +```json +{ + "status": "ok", + "service": "market-data-buffer" +} +``` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b673678 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +FROM python:3.11-slim + +WORKDIR /app + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +ENV BUFFER_DB_PATH=/app/data/buffer.db +ENV BUFFER_HOST=0.0.0.0 +ENV BUFFER_PORT=8600 + +RUN rm -f /etc/apt/sources.list.d/debian.sources && \ + echo "deb http://mirrors.aliyun.com/debian trixie main" > /etc/apt/sources.list && \ + echo "deb http://mirrors.aliyun.com/debian trixie-updates main" >> /etc/apt/sources.list && \ + echo "deb http://mirrors.aliyun.com/debian-security trixie-security main" >> /etc/apt/sources.list && \ + apt-get update && \ + apt-get install -y --no-install-recommends gcc && \ + rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com + +COPY . . + +RUN mkdir -p /app/data + +EXPOSE 8600 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8600"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e902d22 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,23 @@ +version: '3.8' + +services: + buffer-platform: + build: . + container_name: buffer-platform + ports: + - "9600:8600" + volumes: + - E:\docker_workspace\futures_datas:/app/data + environment: + - BUFFER_DB_PATH=/app/data/buffer.db + - BUFFER_HOST=0.0.0.0 + - BUFFER_PORT=8600 + - CACHE_TTL=300 + - BUFFER_LOG_LEVEL=INFO + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8600/api/v1/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s diff --git a/requirements.txt b/requirements.txt index eb97b1a..9b1527d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,3 +9,4 @@ pandas>=2.0.0 tenacity>=8.2.0 requests>=2.31.0 httpx>=0.27.0 +python-multipart>=0.0.9 From 35764cb52ff5caaed86a15b4d4277f2c2fd80024 Mon Sep 17 00:00:00 2001 From: Lxy Date: Mon, 18 May 2026 22:24:28 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=E5=A2=9E=E5=8A=A0=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=AF=BC=E5=87=BA=E5=8A=9F=E8=83=BD=EF=BC=9B=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?docker=E4=B8=AD=E8=BF=90=E8=A1=8C=E5=A4=B1=E8=B4=A5=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/collector.py | 22 +- futures_data_collector.py | 427 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 432 insertions(+), 17 deletions(-) create mode 100644 futures_data_collector.py diff --git a/app/services/collector.py b/app/services/collector.py index 469f786..55bbacf 100644 --- a/app/services/collector.py +++ b/app/services/collector.py @@ -10,11 +10,11 @@ from typing import Dict, List, Optional logger = logging.getLogger(__name__) -# 获取原始采集脚本路径 (buffer_platform/app/services -> buffer_platform -> parent = market_data_colector_platform) -SCRIPT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..')) -if SCRIPT_DIR not in sys.path: - sys.path.insert(0, SCRIPT_DIR) - logger.info(f"已添加采集脚本路径到sys.path: {SCRIPT_DIR}") +# 获取项目根目录 (buffer_platform/app/services -> buffer_platform) +PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) +if PROJECT_ROOT not in sys.path: + sys.path.insert(0, PROJECT_ROOT) + logger.info(f"已添加项目根目录到sys.path: {PROJECT_ROOT}") def fetch_symbol_data( @@ -25,18 +25,6 @@ def fetch_symbol_data( ) -> Dict: """ 获取单个品种的多周期数据。 - - 返回格式: - { - "symbol": "SN2504", - "type": "futures", - "current_price": 12345.0, - "timestamp": "2025-01-15T10:30:00+08:00", - "timeframes": { - "5min": [{"datetime": ..., "open": ..., ...}, ...], - ... - } - } """ try: from futures_data_collector import collect_futures_data, collect_stock_data diff --git a/futures_data_collector.py b/futures_data_collector.py new file mode 100644 index 0000000..95f1ebe --- /dev/null +++ b/futures_data_collector.py @@ -0,0 +1,427 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +期货/股票多周期数据获取与技术指标计算脚本 +""" + +import akshare as ak +import pandas as pd +import json +import argparse +import os +from datetime import datetime, timedelta +from typing import Dict, List +import warnings +warnings.filterwarnings('ignore') +ak.cache = {} + +DATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'data') +os.makedirs(DATA_DIR, exist_ok=True) + +def calculate_ma(df: pd.DataFrame, periods: List[int] = [10, 20]) -> pd.DataFrame: + """计算移动平均线""" + for period in periods: + df[f'MA{period}'] = df['close'].rolling(window=period, min_periods=1).mean() + return df + + +def calculate_macd(df: pd.DataFrame, fast: int = 12, slow: int = 26, signal: int = 9) -> pd.DataFrame: + """计算MACD指标""" + ema_fast = df['close'].ewm(span=fast, adjust=False).mean() + ema_slow = df['close'].ewm(span=slow, adjust=False).mean() + df['macd_dif'] = ema_fast - ema_slow + df['macd_dea'] = df['macd_dif'].ewm(span=signal, adjust=False).mean() + df['macd_histogram'] = (df['macd_dif'] - df['macd_dea']) * 2 + + df['macd_signal'] = df.apply(lambda row: + 'bullish' if row['macd_dif'] > row['macd_dea'] and row['macd_histogram'] > 0 + else 'bearish' if row['macd_dif'] < row['macd_dea'] and row['macd_histogram'] < 0 + else 'neutral', axis=1) + + return df + + +def get_current_time() -> datetime: + """获取当前北京时间(去除微秒)""" + return datetime.now().replace(microsecond=0) + + +def filter_future_data(df: pd.DataFrame, current_time: datetime = None) -> pd.DataFrame: + """过滤掉未来数据""" + if current_time is None: + current_time = get_current_time() + + if 'datetime' not in df.columns: + return df + + df['datetime'] = pd.to_datetime(df['datetime']) + original_count = len(df) + df = df[df['datetime'] <= current_time].copy() + filtered_count = original_count - len(df) + + if filtered_count > 0: + print(f" 过滤了 {filtered_count} 条未来数据") + + return df + + +def extend_night_session_data(df: pd.DataFrame, symbol: str, period: str) -> pd.DataFrame: + """尝试获取完整的夜盘数据""" + if df.empty or 'datetime' not in df.columns: + return df + + df['datetime'] = pd.to_datetime(df['datetime']) + df = df.sort_values('datetime').reset_index(drop=True) + + last_time = df['datetime'].iloc[-1] + last_hour = last_time.hour + last_minute = last_time.minute + + is_night_session = ( + (last_hour >= 21) or + (last_hour < 2) or + (last_hour == 2 and last_minute <= 30) + ) + + if not is_night_session: + return df + + has_0230 = False + for dt in df['datetime']: + if dt.hour == 2 and dt.minute == 30: + has_0230 = True + break + + if has_0230: + return df + + print(f" 注意: 夜盘数据可能不完整(缺少02:30及之前的数据)") + + return df + + +def get_minute_data(symbol: str, period: str) -> pd.DataFrame: + """获取期货分钟K线数据""" + try: + current_time = get_current_time() + df = ak.futures_zh_minute_sina(symbol=symbol, period=period) + + df = df.rename(columns={ + 'day': 'datetime', + 'open': 'open', + 'high': 'high', + 'low': 'low', + 'close': 'close', + 'volume': 'volume' + }) + + for col in ['open', 'high', 'low', 'close', 'volume']: + df[col] = pd.to_numeric(df[col], errors='coerce') + + df['datetime'] = pd.to_datetime(df['datetime']) + df = filter_future_data(df, current_time) + df = extend_night_session_data(df, symbol, period) + + if len(df) < 50: + print(f" 警告: {period}分钟只获取到{len(df)}根K线,建议检查数据源") + + return df + + except Exception as e: + print(f" 获取{period}分钟数据失败: {e}") + return pd.DataFrame() + + +def get_daily_data(symbol: str, days: int = 60) -> pd.DataFrame: + """获取期货日K线数据""" + try: + current_time = get_current_time() + df = ak.futures_zh_daily_sina(symbol=symbol) + + df = df.rename(columns={ + 'date': 'datetime', + 'open': 'open', + 'high': 'high', + 'low': 'low', + 'close': 'close', + 'volume': 'volume' + }) + + for col in ['open', 'high', 'low', 'close', 'volume']: + df[col] = pd.to_numeric(df[col], errors='coerce') + + df['datetime'] = pd.to_datetime(df['datetime']) + df = df.sort_values('datetime').reset_index(drop=True) + df = filter_future_data(df, current_time) + df = df.tail(days).reset_index(drop=True) + + return df + + except Exception as e: + print(f" 获取日K数据失败: {e}") + return pd.DataFrame() + + +def get_stock_minute_data(symbol: str, period: str) -> pd.DataFrame: + """获取股票分钟K线数据""" + try: + current_time = get_current_time() + + if symbol.startswith('6'): + full_symbol = f"sh{symbol}" + else: + full_symbol = f"sz{symbol}" + + df = ak.stock_zh_a_minute(symbol=full_symbol, period=period) + + df = df.rename(columns={ + 'day': 'datetime', + 'open': 'open', + 'high': 'high', + 'low': 'low', + 'close': 'close', + 'volume': 'volume' + }) + + for col in ['open', 'high', 'low', 'close', 'volume']: + df[col] = pd.to_numeric(df[col], errors='coerce') + + df['datetime'] = pd.to_datetime(df['datetime']) + df = filter_future_data(df, current_time) + + if len(df) < 50: + print(f" 警告: {period}分钟只获取到{len(df)}根K线,建议检查数据源") + + return df + + except Exception as e: + print(f" 获取{period}分钟数据失败: {e}") + return pd.DataFrame() + + +def get_stock_daily_data(symbol: str, days: int = 60) -> pd.DataFrame: + """获取股票日K线数据""" + try: + current_time = get_current_time() + end_date = current_time.strftime('%Y%m%d') + start_date = (current_time - timedelta(days=days*2)).strftime('%Y%m%d') + + df = ak.stock_zh_a_hist(symbol=symbol, period="daily", start_date=start_date, end_date=end_date) + + df = df.rename(columns={ + '日期': 'datetime', + '开盘': 'open', + '最高': 'high', + '最低': 'low', + '收盘': 'close', + '成交量': 'volume' + }) + + for col in ['open', 'high', 'low', 'close', 'volume']: + df[col] = pd.to_numeric(df[col], errors='coerce') + + df['datetime'] = pd.to_datetime(df['datetime']) + df = df.sort_values('datetime').reset_index(drop=True) + df = filter_future_data(df, current_time) + df = df.tail(days).reset_index(drop=True) + + return df + + except Exception as e: + print(f" 获取日K数据失败: {e}") + return pd.DataFrame() + + +def process_data(df: pd.DataFrame, timeframe: str) -> List[Dict]: + """处理数据,计算指标并格式化输出""" + if df.empty or len(df) < 10: + return [] + + df = calculate_ma(df) + df = calculate_macd(df) + + candles = [] + df_tail = df.tail(50) if len(df) > 50 else df + + for _, row in df_tail.iterrows(): + candle = { + "time": str(row['datetime']), + "open": round(float(row['open']), 2), + "high": round(float(row['high']), 2), + "low": round(float(row['low']), 2), + "close": round(float(row['close']), 2), + "volume": int(row['volume']) if not pd.isna(row['volume']) else 0, + "ma10": round(float(row['MA10']), 2) if not pd.isna(row.get('MA10')) else None, + "ma20": round(float(row['MA20']), 2) if not pd.isna(row.get('MA20')) else None, + "macd_dif": round(float(row['macd_dif']), 4) if not pd.isna(row.get('macd_dif')) else 0, + "macd_dea": round(float(row['macd_dea']), 4) if not pd.isna(row.get('macd_dea')) else 0, + "macd_histogram": round(float(row['macd_histogram']), 4) if not pd.isna(row.get('macd_histogram')) else 0 + } + candles.append(candle) + + return candles + + +def collect_futures_data(symbol: str) -> Dict: + """收集期货多周期完整数据""" + print(f"\n正在获取期货 {symbol} 的多周期数据...") + print(f"当前时间: {get_current_time().strftime('%Y-%m-%d %H:%M:%S')}") + print("-" * 50) + + result = { + "symbol": symbol, + "type": "futures", + "current_price": None, + "timestamp": datetime.now().strftime("%Y-%m-%dT%H:%M:%S+08:00"), + "timeframes": {} + } + + periods = [ + ("60min", "60"), + ("30min", "30"), + ("15min", "15"), + ("5min", "5") + ] + + for tf_name, tf_period in periods: + print(f"获取 {tf_name} 数据...") + try: + df = get_minute_data(symbol, tf_period) + if not df.empty and len(df) >= 50: + candles = process_data(df, tf_name) + if candles: + result["timeframes"][tf_name] = candles + if result["current_price"] is None: + result["current_price"] = candles[-1]["close"] + print(f" [OK] 成功获取 {len(candles)} 根K线") + else: + print(f" [FAIL] 数据不足或获取失败 (获取到{len(df)}根)") + except Exception as e: + print(f" [ERROR] 错误: {e}") + + print("获取 daily 数据...") + try: + df_daily = get_daily_data(symbol, days=60) + if not df_daily.empty and len(df_daily) >= 50: + candles = process_data(df_daily, "daily") + if candles: + result["timeframes"]["daily"] = candles + print(f" [OK] 成功获取 {len(candles)} 根K线") + else: + print(f" [FAIL] 数据不足或获取失败 (获取到{len(df_daily)}根)") + except Exception as e: + print(f" [ERROR] 错误: {e}") + + print("-" * 50) + return result + + +def collect_stock_data(symbol: str) -> Dict: + """收集股票多周期完整数据""" + print(f"\n正在获取股票 {symbol} 的多周期数据...") + print(f"当前时间: {get_current_time().strftime('%Y-%m-%d %H:%M:%S')}") + print("-" * 50) + + result = { + "symbol": symbol, + "type": "stock", + "current_price": None, + "timestamp": datetime.now().strftime("%Y-%m-%dT%H:%M:%S+08:00"), + "timeframes": {} + } + + periods = [ + ("60min", "60"), + ("30min", "30"), + ("15min", "15"), + ("5min", "5") + ] + + for tf_name, tf_period in periods: + print(f"获取 {tf_name} 数据...") + try: + df = get_stock_minute_data(symbol, tf_period) + if not df.empty and len(df) >= 50: + candles = process_data(df, tf_name) + if candles: + result["timeframes"][tf_name] = candles + if result["current_price"] is None: + result["current_price"] = candles[-1]["close"] + print(f" [OK] 成功获取 {len(candles)} 根K线") + else: + print(f" [FAIL] 数据不足或获取失败 (获取到{len(df)}根)") + except Exception as e: + print(f" [ERROR] 错误: {e}") + + print("获取 daily 数据...") + try: + df_daily = get_stock_daily_data(symbol, days=60) + if not df_daily.empty and len(df_daily) >= 50: + candles = process_data(df_daily, "daily") + if candles: + result["timeframes"]["daily"] = candles + print(f" [OK] 成功获取 {len(candles)} 根K线") + else: + print(f" [FAIL] 数据不足或获取失败 (获取到{len(df_daily)}根)") + except Exception as e: + print(f" [ERROR] 错误: {e}") + + print("-" * 50) + return result + + +def main(): + parser = argparse.ArgumentParser(description='期货/股票多周期数据获取与技术指标计算') + parser.add_argument('--symbol', type=str, required=True, + help='代码,期货如 SN2504(沪锡), 股票如 000001(平安银行)') + parser.add_argument('--type', type=str, default='futures', choices=['futures', 'stock'], + help='数据类型:futures(期货)、stock(股票),默认为 futures') + parser.add_argument('--output', type=str, default=None, + help='输出JSON文件名,默认为 代码_时间戳.json') + + args = parser.parse_args() + + if args.type == 'stock': + data = collect_stock_data(args.symbol) + else: + data = collect_futures_data(args.symbol) + + if not data["timeframes"]: + print("\n错误: 未能获取到任何数据,请检查代码是否正确") + if args.type == 'stock': + print("常见股票代码示例:") + print(" 000001 - 平安银行") + print(" 600000 - 浦发银行") + print(" 000858 - 五粮液") + print(" 600519 - 贵州茅台") + else: + print("常见期货合约代码示例:") + print(" SN2504 - 沪锡2504") + print(" AG2506 - 沪银2506") + print(" LC2505 - 碳酸锂2505") + print(" NI2505 - 沪镍2505") + return + + print("\n" + "="*60) + print("JSON 输出:") + print("="*60) + json_output = json.dumps(data, ensure_ascii=False, indent=2) + print(json_output) + + if args.output: + filename = os.path.join(DATA_DIR, args.output) + else: + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = os.path.join(DATA_DIR, f"{data['symbol']}_{timestamp}.json") + + with open(filename, 'w', encoding='utf-8') as f: + f.write(json_output) + + print("\n" + "="*60) + print(f"[OK] 数据已保存到: {filename}") + print(f"[OK] 共获取 {len(data['timeframes'])} 个周期数据") + print("="*60) + + +if __name__ == "__main__": + main()