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/.gitignore b/.gitignore new file mode 100644 index 0000000..439ab38 --- /dev/null +++ b/.gitignore @@ -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 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/app/__pycache__/__init__.cpython-311.pyc b/app/__pycache__/__init__.cpython-311.pyc index 92086fc..a2eb7f4 100644 Binary files a/app/__pycache__/__init__.cpython-311.pyc and b/app/__pycache__/__init__.cpython-311.pyc differ diff --git a/app/__pycache__/config.cpython-311.pyc b/app/__pycache__/config.cpython-311.pyc index 104f2d4..bb4ef7d 100644 Binary files a/app/__pycache__/config.cpython-311.pyc and b/app/__pycache__/config.cpython-311.pyc differ diff --git a/app/__pycache__/database.cpython-311.pyc b/app/__pycache__/database.cpython-311.pyc index 2e3b6db..caaca94 100644 Binary files a/app/__pycache__/database.cpython-311.pyc and b/app/__pycache__/database.cpython-311.pyc differ diff --git a/app/__pycache__/main.cpython-311.pyc b/app/__pycache__/main.cpython-311.pyc index c52b950..53add62 100644 Binary files a/app/__pycache__/main.cpython-311.pyc and b/app/__pycache__/main.cpython-311.pyc differ diff --git a/app/__pycache__/models.cpython-311.pyc b/app/__pycache__/models.cpython-311.pyc index fc97c34..4efde23 100644 Binary files a/app/__pycache__/models.cpython-311.pyc and b/app/__pycache__/models.cpython-311.pyc differ diff --git a/app/analysis_db.py b/app/analysis_db.py new file mode 100644 index 0000000..930f25f --- /dev/null +++ b/app/analysis_db.py @@ -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) diff --git a/app/analysis_models.py b/app/analysis_models.py new file mode 100644 index 0000000..8a84288 --- /dev/null +++ b/app/analysis_models.py @@ -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"" + + +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"" + + +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"" + + +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"" diff --git a/app/api/__pycache__/__init__.cpython-311.pyc b/app/api/__pycache__/__init__.cpython-311.pyc index 948dd22..281e2fe 100644 Binary files a/app/api/__pycache__/__init__.cpython-311.pyc and b/app/api/__pycache__/__init__.cpython-311.pyc differ diff --git a/app/api/__pycache__/ai_config.cpython-311.pyc b/app/api/__pycache__/ai_config.cpython-311.pyc index 9982b26..755c2e9 100644 Binary files a/app/api/__pycache__/ai_config.cpython-311.pyc and b/app/api/__pycache__/ai_config.cpython-311.pyc differ diff --git a/app/api/__pycache__/config.cpython-311.pyc b/app/api/__pycache__/config.cpython-311.pyc index 07635b5..4b1535f 100644 Binary files a/app/api/__pycache__/config.cpython-311.pyc and b/app/api/__pycache__/config.cpython-311.pyc differ diff --git a/app/api/__pycache__/data.cpython-311.pyc b/app/api/__pycache__/data.cpython-311.pyc index c75b420..f68cc5f 100644 Binary files a/app/api/__pycache__/data.cpython-311.pyc and b/app/api/__pycache__/data.cpython-311.pyc differ diff --git a/app/api/__pycache__/futures_analysis.cpython-311.pyc b/app/api/__pycache__/futures_analysis.cpython-311.pyc index dbb1093..8c155c0 100644 Binary files a/app/api/__pycache__/futures_analysis.cpython-311.pyc and b/app/api/__pycache__/futures_analysis.cpython-311.pyc differ diff --git a/app/api/__pycache__/tasks.cpython-311.pyc b/app/api/__pycache__/tasks.cpython-311.pyc index c97bc57..99a8554 100644 Binary files a/app/api/__pycache__/tasks.cpython-311.pyc and b/app/api/__pycache__/tasks.cpython-311.pyc differ diff --git a/app/api/ai_config.py b/app/api/ai_config.py index a506da7..4235e32 100644 --- a/app/api/ai_config.py +++ b/app/api/ai_config.py @@ -161,6 +161,18 @@ def get_ai_providers(): "api_base": "https://dashscope.aliyuncs.com/compatible-mode/v1", "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", "name": "百度文心一言", diff --git a/app/api/futures_analysis.py b/app/api/futures_analysis.py index 4f4b0a8..6e9653a 100644 --- a/app/api/futures_analysis.py +++ b/app/api/futures_analysis.py @@ -10,6 +10,8 @@ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session 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 logger = logging.getLogger(__name__) @@ -453,3 +455,234 @@ def _calc_kdj(candles: list) -> dict: signal = "中性" 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)} diff --git a/app/main.py b/app/main.py index 6e7cfa9..6b3b483 100644 --- a/app/main.py +++ b/app/main.py @@ -29,6 +29,9 @@ async def lifespan(app: FastAPI): # 启动时:建表 + 启动调度器 logger.info("创建数据库表...") Base.metadata.create_all(bind=engine) + from app.analysis_db import init_analysis_db + init_analysis_db() + logger.info("期货智析数据库初始化完成") logger.info("启动定时调度器...") start_scheduler() diff --git a/app/services/__pycache__/__init__.cpython-311.pyc b/app/services/__pycache__/__init__.cpython-311.pyc index 0fe3f4c..030fe03 100644 Binary files a/app/services/__pycache__/__init__.cpython-311.pyc and b/app/services/__pycache__/__init__.cpython-311.pyc differ diff --git a/app/services/__pycache__/cache.cpython-311.pyc b/app/services/__pycache__/cache.cpython-311.pyc index 8c19002..9e6ef0a 100644 Binary files a/app/services/__pycache__/cache.cpython-311.pyc and b/app/services/__pycache__/cache.cpython-311.pyc differ diff --git a/app/services/__pycache__/collector.cpython-311.pyc b/app/services/__pycache__/collector.cpython-311.pyc index a386f8e..23034c8 100644 Binary files a/app/services/__pycache__/collector.cpython-311.pyc and b/app/services/__pycache__/collector.cpython-311.pyc differ diff --git a/app/services/__pycache__/scheduler.cpython-311.pyc b/app/services/__pycache__/scheduler.cpython-311.pyc index 5e1f5a2..4d6baf5 100644 Binary files a/app/services/__pycache__/scheduler.cpython-311.pyc and b/app/services/__pycache__/scheduler.cpython-311.pyc differ 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/app/static/ai_config.html b/app/static/ai_config.html index 22e50cb..7608529 100644 --- a/app/static/ai_config.html +++ b/app/static/ai_config.html @@ -51,6 +51,8 @@ + + @@ -77,6 +79,10 @@ +
+ + +
+
+ +
@@ -1814,12 +1820,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线数据

'; @@ -1831,7 +1839,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) { diff --git a/data/buffer.db b/data/buffer.db index dc6a94f..b562d48 100644 Binary files a/data/buffer.db and b/data/buffer.db differ diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..24b2411 --- /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", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8600/api/v1/health')"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s 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() 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