fix: 合并master代码;增加百炼coding plan配置及测试

master^2
Lxy 2 weeks ago
commit 394901c4c7

@ -0,0 +1,17 @@
__pycache__/
*.pyc
*.pyo
*.db
.git/
.gitignore
*.md
.trae/
.vscode/
*.egg-info/
dist/
build/
.eggs/
*.egg
docker-compose.yml
Dockerfile
.dockerignore

35
.gitignore vendored

@ -0,0 +1,35 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
*.egg-info/
dist/
build/
# Virtual environments
venv/
.venv/
# Database files
data/*.db
data/*.db-journal
# Config files with secrets
config/ai_config.json
# Logs
*.log
data/collector.log
# IDE
.vscode/
.idea/
# OS
.DS_Store
Thumbs.db
# Cache
cache/*.pkl

@ -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"
}
```

@ -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"]

@ -0,0 +1,36 @@
"""
期货智析数据库 - 独立存储
"""
from pathlib import Path
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, declarative_base
from datetime import datetime
# 数据目录
DATA_DIR = Path(__file__).resolve().parent.parent / "data"
DATA_DIR.mkdir(parents=True, exist_ok=True)
ANALYSIS_DB_PATH = DATA_DIR / "futures_analysis.db"
analysis_engine = create_engine(
f"sqlite:///{ANALYSIS_DB_PATH}",
connect_args={"check_same_thread": False},
pool_pre_ping=True,
)
AnalysisSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=analysis_engine)
AnalysisBase = declarative_base()
def get_analysis_db():
"""获取期货智析数据库会话"""
db = AnalysisSessionLocal()
try:
yield db
finally:
db.close()
def init_analysis_db():
"""初始化期货智析数据库表"""
AnalysisBase.metadata.create_all(bind=analysis_engine)

@ -0,0 +1,88 @@
"""
期货智析数据模型
"""
from datetime import datetime
from sqlalchemy import Column, String, Integer, Float, Text, DateTime, Boolean, Index, UniqueConstraint, JSON
from app.analysis_db import AnalysisBase
class FuturesAnalysis(AnalysisBase):
"""期货分析报告表"""
__tablename__ = "futures_analysis"
id = Column(Integer, primary_key=True, autoincrement=True)
symbol = Column(String(32), nullable=False, index=True, comment="品种合约代码")
analysis_time = Column(DateTime, nullable=False, default=datetime.now, index=True, comment="分析时间")
period = Column(String(16), nullable=False, default="15min", comment="分析周期")
# 分析结果
suggestion = Column(String(32), nullable=True, comment="交易建议: 逢低做多/逢高做空/观望等待")
suggestion_type = Column(String(16), nullable=True, comment="建议类型: up/down/neutral")
entry_price = Column(Float, nullable=True, comment="建议入场价")
target_price = Column(Float, nullable=True, comment="目标价位")
stop_loss = Column(Float, nullable=True, comment="止损价位")
risk_level = Column(String(16), nullable=True, comment="风险等级: 低/中/高")
# 技术指标
macd_signal = Column(String(16), nullable=True, comment="MACD信号")
rsi_value = Column(Float, nullable=True, comment="RSI值")
boll_signal = Column(String(16), nullable=True, comment="布林带信号")
kdj_signal = Column(String(16), nullable=True, comment="KDJ信号")
# 趋势评分
trend_score = Column(Integer, nullable=True, comment="趋势评分 0-100")
success_rate = Column(Float, nullable=True, comment="交易成功率")
# 关键点位
resistance_levels = Column(JSON, nullable=True, comment="压力位列表")
support_levels = Column(JSON, nullable=True, comment="支撑位列表")
# 多周期趋势
period_trends = Column(JSON, nullable=True, comment="各周期趋势")
def __repr__(self):
return f"<FuturesAnalysis {self.symbol} {self.analysis_time}>"
class WatchedSymbol(AnalysisBase):
"""用户关注品种表"""
__tablename__ = "watched_symbols"
id = Column(Integer, primary_key=True, autoincrement=True)
symbol = Column(String(32), nullable=False, unique=True, comment="品种合约代码")
name = Column(String(64), nullable=True, comment="品种名称")
note = Column(Text, nullable=True, comment="备注")
created_at = Column(DateTime, nullable=False, default=datetime.now)
updated_at = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now)
def __repr__(self):
return f"<WatchedSymbol {self.symbol}>"
class AIModelConfig(AnalysisBase):
"""AI模型配置表"""
__tablename__ = "ai_model_configs"
id = Column(Integer, primary_key=True, autoincrement=True)
provider = Column(String(32), nullable=False, comment="AI提供商: openai/anthropic/google等")
model_name = Column(String(64), nullable=False, comment="模型名称")
api_key = Column(String(256), nullable=False, comment="API密钥")
api_base = Column(String(256), nullable=True, comment="API基础URL")
model_id = Column(String(64), nullable=True, comment="模型ID")
temperature = Column(Float, nullable=True, default=0.7, comment="温度参数")
max_tokens = Column(Integer, nullable=True, default=2000, comment="最大输出token")
enabled = Column(Boolean, nullable=False, default=True, comment="是否启用")
is_active = Column(Boolean, nullable=False, default=False, comment="是否为当前活跃模型")
created_at = Column(DateTime, nullable=False, default=datetime.now)
updated_at = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now)
def __repr__(self):
return f"<AIModelConfig {self.provider} {self.model_name}>"
class AnalysisSettings(AnalysisBase):
"""分析设置表(单例配置)"""
__tablename__ = "analysis_settings"
id = Column(Integer, primary_key=True, autoincrement=True)
key = Column(String(64), nullable=False, unique=True, comment="配置键")
value = Column(JSON, nullable=False, comment="配置值")
updated_at = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now)
def __repr__(self):
return f"<AnalysisSettings {self.key}>"

@ -161,6 +161,18 @@ def get_ai_providers():
"api_base": "https://dashscope.aliyuncs.com/compatible-mode/v1", "api_base": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"models": ["qwen-max", "qwen-plus", "qwen-turbo"] "models": ["qwen-max", "qwen-plus", "qwen-turbo"]
}, },
{
"id": "aliyun_coding",
"name": "阿里云通义灵码",
"api_base": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"models": ["qwen-coder-plus", "qwen-coder-turbo"]
},
{
"id": "bailian",
"name": "阿里百炼",
"api_base": "https://coding.dashscope.aliyuncs.com/v1",
"models": ["qwen3.6-plus", "qwen3.5-plus", "qwen3-max", "qwen3-coder-plus", "MiniMax-M2.5", "glm-4.7", "kimi-k2.5"]
},
{ {
"id": "baidu", "id": "baidu",
"name": "百度文心一言", "name": "百度文心一言",

@ -10,6 +10,8 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.database import get_db from app.database import get_db
from app.analysis_db import get_analysis_db
from app.analysis_models import FuturesAnalysis, WatchedSymbol, AIModelConfig, AnalysisSettings
from app.services.cache import get_cached_data, get_latest_cached from app.services.cache import get_cached_data, get_latest_cached
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -453,3 +455,234 @@ def _calc_kdj(candles: list) -> dict:
signal = "中性" signal = "中性"
return {"signal": signal, "detail": f"K: {k} D: {d}"} return {"signal": signal, "detail": f"K: {k} D: {d}"}
# ==================== 期货智析数据管理接口 ====================
@router.get("/analysis/history/{symbol}")
def get_analysis_history(symbol: str, limit: int = 10, adb: Session = Depends(get_analysis_db)):
"""获取品种历史分析记录"""
records = adb.query(FuturesAnalysis).filter(
FuturesAnalysis.symbol == symbol
).order_by(
FuturesAnalysis.analysis_time.desc()
).limit(limit).all()
return {
"success": True,
"data": [{
"id": r.id,
"symbol": r.symbol,
"analysis_time": r.analysis_time.isoformat(),
"suggestion": r.suggestion,
"suggestion_type": r.suggestion_type,
"trend_score": r.trend_score,
"entry_price": r.entry_price,
"target_price": r.target_price,
"stop_loss": r.stop_loss,
"risk_level": r.risk_level
} for r in records]
}
@router.post("/analysis/save")
def save_analysis_record(data: dict, adb: Session = Depends(get_analysis_db)):
"""保存分析记录到数据库"""
try:
record = FuturesAnalysis(
symbol=data.get("symbol"),
suggestion=data.get("suggestion"),
suggestion_type=data.get("suggestion_type"),
entry_price=data.get("entry_price"),
target_price=data.get("target_price"),
stop_loss=data.get("stop_loss"),
risk_level=data.get("risk_level"),
macd_signal=data.get("macd", {}).get("signal") if data.get("macd") else None,
rsi_value=data.get("rsi", {}).get("value") if data.get("rsi") else None,
boll_signal=data.get("boll", {}).get("signal") if data.get("boll") else None,
kdj_signal=data.get("kdj", {}).get("signal") if data.get("kdj") else None,
trend_score=data.get("trend_score"),
success_rate=data.get("success_rate"),
resistance_levels=data.get("resistances"),
support_levels=data.get("supports"),
period_trends=data.get("periodConsistency")
)
adb.add(record)
adb.commit()
return {"success": True, "message": "分析记录已保存", "id": record.id}
except Exception as e:
adb.rollback()
logger.error(f"保存分析记录失败: {e}")
return {"success": False, "message": str(e)}
# ==================== 关注品种管理 ====================
@router.get("/watched")
def get_watched_symbols(adb: Session = Depends(get_analysis_db)):
"""获取关注的品种列表"""
symbols = adb.query(WatchedSymbol).order_by(WatchedSymbol.created_at.desc()).all()
return {
"success": True,
"data": [{
"id": s.id,
"symbol": s.symbol,
"name": s.name,
"note": s.note,
"created_at": s.created_at.isoformat()
} for s in symbols]
}
@router.post("/watched")
def add_watched_symbol(data: dict, adb: Session = Depends(get_analysis_db)):
"""添加关注品种"""
try:
symbol = data.get("symbol")
existing = adb.query(WatchedSymbol).filter(WatchedSymbol.symbol == symbol).first()
if existing:
return {"success": False, "message": "该品种已关注"}
new_symbol = WatchedSymbol(
symbol=symbol,
name=data.get("name"),
note=data.get("note")
)
adb.add(new_symbol)
adb.commit()
return {"success": True, "message": "已添加关注", "id": new_symbol.id}
except Exception as e:
adb.rollback()
return {"success": False, "message": str(e)}
@router.delete("/watched/{symbol}")
def remove_watched_symbol(symbol: str, adb: Session = Depends(get_analysis_db)):
"""取消关注品种"""
try:
record = adb.query(WatchedSymbol).filter(WatchedSymbol.symbol == symbol).first()
if not record:
return {"success": False, "message": "未找到该品种"}
adb.delete(record)
adb.commit()
return {"success": True, "message": "已取消关注"}
except Exception as e:
adb.rollback()
return {"success": False, "message": str(e)}
# ==================== AI模型配置管理 ====================
@router.get("/ai-models")
def get_ai_models(adb: Session = Depends(get_analysis_db)):
"""获取AI模型配置列表"""
models = adb.query(AIModelConfig).order_by(AIModelConfig.created_at.desc()).all()
settings = adb.query(AnalysisSettings).filter(
AnalysisSettings.key == "analysis_settings"
).first()
return {
"success": True,
"data": {
"models": [{
"id": m.id,
"provider": m.provider,
"model_name": m.model_name,
"api_base": m.api_base,
"model_id": m.model_id,
"temperature": m.temperature,
"max_tokens": m.max_tokens,
"enabled": m.enabled,
"is_active": m.is_active,
"created_at": m.created_at.isoformat()
} for m in models],
"analysis_settings": settings.value if settings else {
"enable_technical_analysis": True,
"enable_fundamental_analysis": False,
"enable_sentiment_analysis": False,
"risk_tolerance": "medium",
"max_position_pct": 10
}
}
}
@router.post("/ai-models")
def save_ai_model(data: dict, adb: Session = Depends(get_analysis_db)):
"""保存AI模型配置"""
try:
if data.get("action") == "save_settings":
settings = adb.query(AnalysisSettings).filter(
AnalysisSettings.key == "analysis_settings"
).first()
if settings:
settings.value = data.get("settings", {})
else:
settings = AnalysisSettings(
key="analysis_settings",
value=data.get("settings", {})
)
adb.add(settings)
adb.commit()
return {"success": True, "message": "分析设置已保存"}
model_data = data.get("model", {})
model = AIModelConfig(
provider=model_data.get("provider", "custom"),
model_name=model_data.get("model_name", ""),
api_key=model_data.get("api_key", ""),
api_base=model_data.get("api_base"),
model_id=model_data.get("model_id"),
temperature=model_data.get("temperature", 0.7),
max_tokens=model_data.get("max_tokens", 2000),
enabled=model_data.get("enabled", True),
is_active=model_data.get("is_active", False)
)
if model.is_active:
adb.query(AIModelConfig).update({"is_active": False})
adb.add(model)
adb.commit()
return {"success": True, "message": "AI模型已保存", "id": model.id}
except Exception as e:
adb.rollback()
return {"success": False, "message": str(e)}
@router.put("/ai-models/{model_id}")
def update_ai_model(model_id: int, data: dict, adb: Session = Depends(get_analysis_db)):
"""更新AI模型配置"""
try:
model = adb.query(AIModelConfig).filter(AIModelConfig.id == model_id).first()
if not model:
return {"success": False, "message": "模型不存在"}
if "is_active" in data and data["is_active"]:
adb.query(AIModelConfig).update({"is_active": False})
model.is_active = True
else:
for key, value in data.items():
if hasattr(model, key):
setattr(model, key, value)
adb.commit()
return {"success": True, "message": "模型已更新"}
except Exception as e:
adb.rollback()
return {"success": False, "message": str(e)}
@router.delete("/ai-models/{model_id}")
def delete_ai_model(model_id: int, adb: Session = Depends(get_analysis_db)):
"""删除AI模型配置"""
try:
model = adb.query(AIModelConfig).filter(AIModelConfig.id == model_id).first()
if not model:
return {"success": False, "message": "模型不存在"}
adb.delete(model)
adb.commit()
return {"success": True, "message": "模型已删除"}
except Exception as e:
adb.rollback()
return {"success": False, "message": str(e)}

@ -29,6 +29,9 @@ async def lifespan(app: FastAPI):
# 启动时:建表 + 启动调度器 # 启动时:建表 + 启动调度器
logger.info("创建数据库表...") logger.info("创建数据库表...")
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
from app.analysis_db import init_analysis_db
init_analysis_db()
logger.info("期货智析数据库初始化完成")
logger.info("启动定时调度器...") logger.info("启动定时调度器...")
start_scheduler() start_scheduler()

@ -10,11 +10,11 @@ from typing import Dict, List, Optional
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# 获取原始采集脚本路径 (buffer_platform/app/services -> buffer_platform -> parent = market_data_colector_platform) # 获取项目根目录 (buffer_platform/app/services -> buffer_platform)
SCRIPT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..')) PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
if SCRIPT_DIR not in sys.path: if PROJECT_ROOT not in sys.path:
sys.path.insert(0, SCRIPT_DIR) sys.path.insert(0, PROJECT_ROOT)
logger.info(f"已添加采集脚本路径到sys.path: {SCRIPT_DIR}") logger.info(f"已添加项目根目录到sys.path: {PROJECT_ROOT}")
def fetch_symbol_data( def fetch_symbol_data(
@ -25,18 +25,6 @@ def fetch_symbol_data(
) -> Dict: ) -> Dict:
""" """
获取单个品种的多周期数据 获取单个品种的多周期数据
返回格式:
{
"symbol": "SN2504",
"type": "futures",
"current_price": 12345.0,
"timestamp": "2025-01-15T10:30:00+08:00",
"timeframes": {
"5min": [{"datetime": ..., "open": ..., ...}, ...],
...
}
}
""" """
try: try:
from futures_data_collector import collect_futures_data, collect_stock_data from futures_data_collector import collect_futures_data, collect_stock_data

@ -51,6 +51,8 @@
<option value="anthropic">Anthropic Claude</option> <option value="anthropic">Anthropic Claude</option>
<option value="google">Google Gemini</option> <option value="google">Google Gemini</option>
<option value="aliyun">阿里云通义千问</option> <option value="aliyun">阿里云通义千问</option>
<option value="aliyun_coding">阿里云通义灵码</option>
<option value="bailian">阿里百炼</option>
<option value="baidu">百度文心一言</option> <option value="baidu">百度文心一言</option>
<option value="zhipu">智谱清言</option> <option value="zhipu">智谱清言</option>
<option value="custom">自定义</option> <option value="custom">自定义</option>
@ -77,6 +79,10 @@
<option value="gpt-3.5-turbo">gpt-3.5-turbo</option> <option value="gpt-3.5-turbo">gpt-3.5-turbo</option>
</select> </select>
</div> </div>
<div class="form-group">
<label>自定义模型</label>
<input type="text" id="custom-model" class="form-control" placeholder="输入自定义模型名称(留空使用上方选择)">
</div>
</div> </div>
<div class="form-actions"> <div class="form-actions">
<button class="btn btn-secondary" onclick="testConnection()"> <button class="btn btn-secondary" onclick="testConnection()">

@ -1,4 +1,4 @@
const API_BASE = '/api/ai-config'; const API_BASE = '/api/v1/ai-config';
let currentConfig = null; let currentConfig = null;
let selectedProvider = 'openai'; let selectedProvider = 'openai';
@ -39,6 +39,8 @@ function getDefaultProviders() {
{ id: 'anthropic', name: 'Claude', icon: 'fas fa-robot' }, { id: 'anthropic', name: 'Claude', icon: 'fas fa-robot' },
{ id: 'google', name: 'Gemini', icon: 'fas fa-gem' }, { id: 'google', name: 'Gemini', icon: 'fas fa-gem' },
{ id: 'aliyun', name: '通义千问', icon: 'fas fa-cloud' }, { id: 'aliyun', name: '通义千问', icon: 'fas fa-cloud' },
{ id: 'aliyun_coding', name: '通义灵码', icon: 'fas fa-code' },
{ id: 'bailian', name: '阿里百炼', icon: 'fas fa-flask' },
{ id: 'baidu', name: '文心一言', icon: 'fas fa-comments' }, { id: 'baidu', name: '文心一言', icon: 'fas fa-comments' },
{ id: 'zhipu', name: '智谱清言', icon: 'fas fa-lightbulb' } { id: 'zhipu', name: '智谱清言', icon: 'fas fa-lightbulb' }
]; ];
@ -51,6 +53,8 @@ function renderProviders(providers) {
'anthropic': 'fas fa-robot', 'anthropic': 'fas fa-robot',
'google': 'fas fa-gem', 'google': 'fas fa-gem',
'aliyun': 'fas fa-cloud', 'aliyun': 'fas fa-cloud',
'aliyun_coding': 'fas fa-code',
'bailian': 'fas fa-flask',
'baidu': 'fas fa-comments', 'baidu': 'fas fa-comments',
'zhipu': 'fas fa-lightbulb', 'zhipu': 'fas fa-lightbulb',
'custom': 'fas fa-cog' 'custom': 'fas fa-cog'
@ -81,6 +85,8 @@ function updateProviderModels() {
'anthropic': ['claude-3-opus', 'claude-3-sonnet', 'claude-3-haiku'], 'anthropic': ['claude-3-opus', 'claude-3-sonnet', 'claude-3-haiku'],
'google': ['gemini-pro', 'gemini-pro-vision'], 'google': ['gemini-pro', 'gemini-pro-vision'],
'aliyun': ['qwen-max', 'qwen-plus', 'qwen-turbo'], 'aliyun': ['qwen-max', 'qwen-plus', 'qwen-turbo'],
'aliyun_coding': ['qwen-coder-plus', 'qwen-coder-turbo'],
'bailian': ['qwen3.6-plus', 'qwen3.5-plus', 'qwen3-max', 'qwen3-coder-plus', 'MiniMax-M2.5', 'glm-4.7', 'kimi-k2.5', 'custom'],
'baidu': ['ernie-4.0', 'ernie-3.5', 'ernie-speed'], 'baidu': ['ernie-4.0', 'ernie-3.5', 'ernie-speed'],
'zhipu': ['glm-4', 'glm-3-turbo'], 'zhipu': ['glm-4', 'glm-3-turbo'],
'custom': ['custom-model'] 'custom': ['custom-model']
@ -91,6 +97,8 @@ function updateProviderModels() {
'anthropic': 'https://api.anthropic.com/v1', 'anthropic': 'https://api.anthropic.com/v1',
'google': 'https://generativelanguage.googleapis.com/v1beta', 'google': 'https://generativelanguage.googleapis.com/v1beta',
'aliyun': 'https://dashscope.aliyuncs.com/compatible-mode/v1', 'aliyun': 'https://dashscope.aliyuncs.com/compatible-mode/v1',
'aliyun_coding': 'https://dashscope.aliyuncs.com/compatible-mode/v1',
'bailian': 'https://coding.dashscope.aliyuncs.com/v1',
'baidu': 'https://aip.baidubce.com/rpc/2.0/ai_custom/v1/wenxinworkshop', 'baidu': 'https://aip.baidubce.com/rpc/2.0/ai_custom/v1/wenxinworkshop',
'zhipu': 'https://open.bigmodel.cn/api/paas/v4', 'zhipu': 'https://open.bigmodel.cn/api/paas/v4',
'custom': '' 'custom': ''
@ -122,6 +130,7 @@ function populateForm(config) {
document.getElementById('api-key').value = activeModel.api_key || ''; document.getElementById('api-key').value = activeModel.api_key || '';
document.getElementById('api-base').value = activeModel.api_base || ''; document.getElementById('api-base').value = activeModel.api_base || '';
document.getElementById('model-id').value = activeModel.model_id || 'gpt-4o'; document.getElementById('model-id').value = activeModel.model_id || 'gpt-4o';
document.getElementById('custom-model').value = '';
document.getElementById('temperature').value = activeModel.temperature || 0.7; document.getElementById('temperature').value = activeModel.temperature || 0.7;
document.getElementById('temp-value').textContent = activeModel.temperature || 0.7; document.getElementById('temp-value').textContent = activeModel.temperature || 0.7;
document.getElementById('max-tokens').value = activeModel.max_tokens || 2000; document.getElementById('max-tokens').value = activeModel.max_tokens || 2000;
@ -166,6 +175,8 @@ function getProviderName(apiBase) {
'anthropic': 'Anthropic', 'anthropic': 'Anthropic',
'google': 'Google', 'google': 'Google',
'aliyun': '阿里云', 'aliyun': '阿里云',
'aliyun_coding': '阿里云通义灵码',
'bailian': '阿里百炼',
'baidu': '百度', 'baidu': '百度',
'zhipu': '智谱' 'zhipu': '智谱'
}; };
@ -189,11 +200,21 @@ async function testConnection() {
resultEl.textContent = '测试中...'; resultEl.textContent = '测试中...';
resultEl.className = 'test-result'; resultEl.className = 'test-result';
const customModel = document.getElementById('custom-model').value.trim();
const selectedModel = document.getElementById('model-id').value;
const modelId = customModel || (selectedModel === 'custom' ? '' : selectedModel);
if (!modelId) {
resultEl.textContent = '✗ 请输入模型ID';
resultEl.className = 'test-result error';
return;
}
const config = { const config = {
model_name: document.getElementById('model-id').value, model_name: modelId,
api_key: document.getElementById('api-key').value, api_key: document.getElementById('api-key').value,
api_base: document.getElementById('api-base').value, api_base: document.getElementById('api-base').value,
model_id: document.getElementById('model-id').value, model_id: modelId,
temperature: parseFloat(document.getElementById('temperature').value), temperature: parseFloat(document.getElementById('temperature').value),
max_tokens: parseInt(document.getElementById('max-tokens').value), max_tokens: parseInt(document.getElementById('max-tokens').value),
enabled: true enabled: true
@ -205,17 +226,24 @@ async function testConnection() {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config) body: JSON.stringify(config)
}); });
if (!response.ok) {
resultEl.textContent = `✗ 请求失败: ${response.status}`;
resultEl.className = 'test-result error';
return;
}
const data = await response.json(); const data = await response.json();
if (data.success) { if (data.success) {
resultEl.textContent = '✓ 连接成功'; resultEl.textContent = '✓ 连接成功';
resultEl.className = 'test-result success'; resultEl.className = 'test-result success';
} else { } else {
resultEl.textContent = '✗ ' + data.message; resultEl.textContent = '✗ ' + (data.message || '未知错误');
resultEl.className = 'test-result error'; resultEl.className = 'test-result error';
} }
} catch (error) { } catch (error) {
resultEl.textContent = '✗ 连接失败: ' + error.message; resultEl.textContent = '✗ 连接失败: ' + (error.message || '未知错误');
resultEl.className = 'test-result error'; resultEl.className = 'test-result error';
} }
} }
@ -224,12 +252,16 @@ async function saveConfig() {
const models = currentConfig?.models || []; const models = currentConfig?.models || [];
const existingIndex = models.findIndex(m => m.provider === selectedProvider); const existingIndex = models.findIndex(m => m.provider === selectedProvider);
const customModel = document.getElementById('custom-model').value.trim();
const selectedModel = document.getElementById('model-id').value;
const modelId = customModel || (selectedModel === 'custom' ? '' : selectedModel);
const newModel = { const newModel = {
model_name: document.getElementById('model-id').value, model_name: modelId,
provider: selectedProvider, provider: selectedProvider,
api_key: document.getElementById('api-key').value, api_key: document.getElementById('api-key').value,
api_base: document.getElementById('api-base').value, api_base: document.getElementById('api-base').value,
model_id: document.getElementById('model-id').value, model_id: modelId,
temperature: parseFloat(document.getElementById('temperature').value), temperature: parseFloat(document.getElementById('temperature').value),
max_tokens: parseInt(document.getElementById('max-tokens').value), max_tokens: parseInt(document.getElementById('max-tokens').value),
enabled: true enabled: true

@ -923,6 +923,12 @@
查询缓存 查询缓存
</button> </button>
</div> </div>
<div class="form-group">
<button class="btn btn-success" onclick="exportData()" style="height: 42px;" id="btnExportData" disabled>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
导出数据
</button>
</div>
</div> </div>
<div id="queryResult" class="hidden" style="margin-top: 20px;"></div> <div id="queryResult" class="hidden" style="margin-top: 20px;"></div>
</div> </div>
@ -1814,12 +1820,14 @@
if (!res.ok) { if (!res.ok) {
showToast(data.detail || '未找到缓存数据', 'error'); showToast(data.detail || '未找到缓存数据', 'error');
document.getElementById('btnExportData').disabled = true;
return; return;
} }
addLog(`查询成功: ${symbol}, 缓存 ${data.timeframes ? data.timeframes.length : 0} 个周期`, 'success'); addLog(`查询成功: ${symbol}, 缓存 ${data.timeframes ? data.timeframes.length : 0} 个周期`, 'success');
currentQueryData = data; currentQueryData = data;
document.getElementById('btnExportData').disabled = false;
if (!data.timeframes || data.timeframes.length === 0) { if (!data.timeframes || data.timeframes.length === 0) {
document.getElementById('queryResult').innerHTML = '<div class="empty-state"><p>暂无K线数据</p></div>'; document.getElementById('queryResult').innerHTML = '<div class="empty-state"><p>暂无K线数据</p></div>';
@ -1831,7 +1839,49 @@
} catch (e) { } catch (e) {
showToast(`查询失败: ${e.message}`, 'error'); 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) { function renderKlineChart(timeframe, symbol) {

Binary file not shown.

@ -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", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8600/api/v1/health')"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s

@ -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()

@ -9,3 +9,4 @@ pandas>=2.0.0
tenacity>=8.2.0 tenacity>=8.2.0
requests>=2.31.0 requests>=2.31.0
httpx>=0.27.0 httpx>=0.27.0
python-multipart>=0.0.9

Loading…
Cancel
Save