总览
+持仓股票
+| 股票 | +代码 | +持仓 | +成本价 | +现价 | +市值 | +盈亏 | +信号 | +操作 | +
|---|---|---|---|---|---|---|---|---|
| 加载中... | +||||||||
持仓列表
+ +| 股票名称 | +股票代码 | +持仓数量 | +成本价 | +当前价 | +盈亏 | +策略 | +操作 | +
|---|---|---|---|---|---|---|---|
| 加载中... | +|||||||
股票分析
+舆情监控
+选择持仓股票查看舆情分析
+diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..026c622 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,44 @@ +# Stock Buddy 交易系统 + +## 架构 + +``` +stock-buddy/ +├── backend/ # FastAPI后端 +│ ├── main.py # 主入口 +│ ├── database.py # 数据库模型 +│ ├── models.py # Pydantic模型 +│ ├── services/ +│ │ ├── stock_service.py # 股票数据服务 +│ │ ├── sentiment_service.py # 舆情分析服务 +│ │ ├── strategy_service.py # 策略服务 +│ │ └── llm_service.py # LLM服务 +│ └── tasks.py # 定时任务 +├── frontend/ # 前端 +│ ├── index.html # 主页面 +│ ├── app.js # 前端逻辑 +│ └── style.css # 样式 +└── data/ # 数据存储 + ├── stocks.db # SQLite数据库 + └── cache/ # 股票数据缓存 +``` + +## 功能模块 + +1. **持仓管理** - CRUD持仓股票,记录成本、数量 +2. **自动舆情** - 每日定时分析持仓股票舆情 +3. **策略信号** - 实时计算买入/卖出信号 +4. **手动分析** - 输入新股票代码,即时分析 +5. **LLM集成** - 自动/手动触发舆情分析 + +## 运行 + +```bash +# 安装依赖 +pip install fastapi uvicorn sqlalchemy apscheduler pandas yfinance + +# 启动后端 +cd backend && uvicorn main:app --reload --port 8000 + +# 前端直接打开 frontend/index.html +``` diff --git a/SYSTEM_README.md b/SYSTEM_README.md new file mode 100644 index 0000000..a73647d --- /dev/null +++ b/SYSTEM_README.md @@ -0,0 +1,178 @@ +# Stock Buddy 港股AI交易系统 + +一套完整的港股交易分析系统,支持持仓管理、自动舆情分析、实时交易信号。 + +## 功能特性 + +- 📊 **持仓管理** - 记录持仓股票,自动计算盈亏 +- 🤖 **AI舆情分析** - 每日自动分析持仓股票舆情(支持Agent调用) +- 📈 **策略信号** - v7策略:三维度评分 + 大盘过滤 + 盈利保护 +- 🔍 **手动分析** - 输入任意股票代码,即时生成分析报告 +- ⏰ **定时任务** - 每日9:00自动运行分析 +- 🌐 **Web界面** - 现代化UI,支持移动端 + +## 技术栈 + +- **后端**: FastAPI + SQLite + APScheduler +- **前端**: 原生HTML/CSS/JS +- **数据源**: yfinance(自动切东方财富备用) +- **LLM**: 预留Agent接口(测试阶段用规则引擎) + +## 快速启动 + +```bash +cd stock-buddy +./start.sh +``` + +访问: +- 前端: http://localhost:8000/app +- API文档: http://localhost:8000/docs + +## 目录结构 + +``` +stock-buddy/ +├── backend/ # FastAPI后端 +│ ├── main.py # 主入口 +│ ├── database.py # 数据库模型 +│ ├── models.py # Pydantic模型 +│ ├── services/ # 业务服务 +│ │ ├── stock_service.py +│ │ ├── sentiment_service.py +│ │ ├── strategy_service.py +│ │ └── llm_service.py +│ └── requirements.txt +├── frontend/ # 前端界面 +│ ├── index.html +│ ├── style.css +│ └── app.js +├── data/ # 数据存储 +│ ├── stocks.db # SQLite数据库 +│ └── cache/ # 股票数据缓存 +├── start.sh # 启动脚本 +└── README.md +``` + +## 使用指南 + +### 1. 添加持仓 + +进入"持仓管理"页面,点击"添加持仓",输入: +- 股票名称(如:中芯国际) +- 股票代码(如:0981.HK) +- 持仓数量 +- 成本价 +- 选择策略(A/B/C) + +### 2. 查看分析 + +在"总览"页面查看: +- 实时市值和盈亏 +- 买入/卖出信号 +- 持仓列表 + +### 3. 手动分析新股票 + +进入"股票分析"页面: +1. 输入股票名称或代码 +2. 点击"分析" +3. 查看综合评分、技术信号、舆情分析 + +### 4. 每日自动分析 + +系统每天9:00自动: +1. 更新持仓股票数据 +2. 生成舆情分析 +3. 计算交易信号 +4. 保存分析结果 + +也可手动点击"运行分析"触发。 + +## 策略说明 + +### v7策略架构 + +``` +三维度评分 +├── 技术面 (60%): RSI + MACD + 均线系统 +├── 基本面 (30%): 营收/利润/PE快照 +└── 舆情面 (10%): LLM情绪分析 + +买入条件: +- 综合评分 ≥ 1.5 +- 成交量连续2日放量 +- 恒生指数 > MA20(大盘过滤) + +止损策略: +- A: 固定12% +- B: ATR × 2.5 动态 +- C: 混合自适应(按波动率自动选择) + +盈利保护: +- >30%: 保本止损 +- >50%: 锁定10%利润 +- >100%: 宽追踪止损 +``` + +## API接口 + +### 持仓管理 + +``` +GET /api/positions # 获取所有持仓 +POST /api/positions # 添加持仓 +PUT /api/positions/{id} # 更新持仓 +DELETE /api/positions/{id} # 删除持仓 +``` + +### 股票分析 + +``` +POST /api/analyze # 分析股票 + Body: {"stock_name": "中芯国际", "ticker": "0981.HK"} +``` + +### 实时行情 + +``` +GET /api/quote/{ticker} # 获取实时行情 +``` + +### 任务管理 + +``` +POST /api/tasks/daily-analysis # 手动触发每日分析 +``` + +## LLM舆情接入 + +当前使用规则引擎模拟LLM分析,如需接入真实LLM: + +1. 修改 `backend/services/llm_service.py` +2. 替换 `analyze_sentiment()` 方法为真实API调用 +3. 支持通过Agent生成分析(已预留接口) + +```python +# 调用Agent生成舆情 +from llm_sentiment_agent import generate_prompt + +prompt = generate_prompt("中芯国际", "2024-06-01", "2024-06-30") +# 发送给Agent,获取JSON结果 +``` + +## 开发计划 + +- [x] 基础持仓管理 +- [x] 股票数据服务 +- [x] v7策略回测 +- [x] Web界面 +- [x] 定时任务 +- [ ] 真实LLM接入 +- [ ] 邮件/微信推送 +- [ ] 历史回测报告 +- [ ] 多用户支持 + +## 许可证 + +MIT diff --git a/backend/database.py b/backend/database.py new file mode 100644 index 0000000..775974e --- /dev/null +++ b/backend/database.py @@ -0,0 +1,137 @@ +""" +数据库模型 - SQLAlchemy +""" + +from sqlalchemy import create_engine, Column, Integer, Float, String, DateTime, Text, JSON +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker +from datetime import datetime +import os + +# 数据库路径 +DB_PATH = os.path.join(os.path.dirname(__file__), '..', 'data', 'stocks.db') +os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) + +SQLALCHEMY_DATABASE_URL = f"sqlite:///{DB_PATH}" + +engine = create_engine( + SQLALCHEMY_DATABASE_URL, + connect_args={"check_same_thread": False} +) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +Base = declarative_base() + +# ═════════════════════════════════════════════════════════════════════ +# 数据表模型 +# ═════════════════════════════════════════════════════════════════════ + +class Position(Base): + """持仓表""" + __tablename__ = "positions" + + id = Column(Integer, primary_key=True, index=True) + stock_name = Column(String(50), nullable=False) + ticker = Column(String(20), nullable=False, index=True) + shares = Column(Integer, nullable=False) + cost_price = Column(Float, nullable=False) + current_price = Column(Float, default=0) + market_value = Column(Float, default=0) + pnl = Column(Float, default=0) # 盈亏金额 + pnl_percent = Column(Float, default=0) # 盈亏百分比 + + # 策略参数 + strategy = Column(String(10), default="C") # A/B/C + stop_loss = Column(Float, default=0.08) # 止损比例 + + # 元数据 + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + notes = Column(Text, nullable=True) + +class StockData(Base): + """股票数据缓存表""" + __tablename__ = "stock_data" + + id = Column(Integer, primary_key=True) + ticker = Column(String(20), nullable=False, index=True) + date = Column(String(10), nullable=False) + open_price = Column(Float) + high_price = Column(Float) + low_price = Column(Float) + close_price = Column(Float) + volume = Column(Float) + ma5 = Column(Float) + ma20 = Column(Float) + ma60 = Column(Float) + rsi = Column(Float) + atr = Column(Float) + + updated_at = Column(DateTime, default=datetime.now) + +class SentimentData(Base): + """舆情数据表""" + __tablename__ = "sentiment_data" + + id = Column(Integer, primary_key=True) + ticker = Column(String(20), nullable=False, index=True) + date = Column(String(10), nullable=False) + score = Column(Integer) # -5 ~ +5 + label = Column(String(20)) # 极度悲观/悲观/中性/乐观/极度乐观 + factors = Column(JSON) # 影响因素列表 + outlook = Column(String(50)) + source = Column(String(20), default="llm") # llm / manual / system + + created_at = Column(DateTime, default=datetime.now) + +class AnalysisResult(Base): + """分析结果表""" + __tablename__ = "analysis_results" + + id = Column(Integer, primary_key=True) + ticker = Column(String(20), nullable=False, index=True) + date = Column(String(10), nullable=False) + + # 信号 + action = Column(String(20)) # BUY / SELL / HOLD + score = Column(Float) # 综合评分 + confidence = Column(String(10)) # HIGH / MEDIUM / LOW + + # 详情 + tech_score = Column(Float) + fund_score = Column(Float) + sent_score = Column(Float) + + # 止损建议 + suggested_stop = Column(Float) + position_ratio = Column(Float) + + # 完整数据(JSON) + full_data = Column(JSON) + + created_at = Column(DateTime, default=datetime.now) + +class TradeLog(Base): + """交易日志表""" + __tablename__ = "trade_logs" + + id = Column(Integer, primary_key=True) + ticker = Column(String(20), nullable=False) + action = Column(String(10), nullable=False) # BUY / SELL + shares = Column(Integer) + price = Column(Float) + reason = Column(Text) + pnl = Column(Float, nullable=True) + + created_at = Column(DateTime, default=datetime.now) + +# ═════════════════════════════════════════════════════════════════════ +# 初始化数据库 +# ═════════════════════════════════════════════════════════════════════ + +def init_db(): + Base.metadata.create_all(bind=engine) + print(f"✅ 数据库初始化完成: {DB_PATH}") + +if __name__ == "__main__": + init_db() diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..d81c155 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,250 @@ +""" +Stock Buddy 交易系统 - 主入口 +FastAPI + SQLite + APScheduler +""" + +from fastapi import FastAPI, HTTPException, BackgroundTasks +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from contextlib import asynccontextmanager +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from apscheduler.triggers.cron import CronTrigger +from datetime import datetime, timedelta +import uvicorn + +from database import init_db, SessionLocal +from models import PositionCreate, PositionResponse, StockAnalysisRequest, AnalysisResult +from services.stock_service import StockService +from services.sentiment_service import SentimentService +from services.strategy_service import StrategyService +from services.llm_service import LLMService + +# 初始化数据库 +init_db() + +# 定时任务调度器 +scheduler = AsyncIOScheduler() + +async def daily_analysis_task(): + """每日定时任务:分析所有持仓股票""" + print(f"[{datetime.now()}] 开始每日自动分析...") + db = SessionLocal() + try: + stock_service = StockService(db) + sentiment_service = SentimentService(db) + strategy_service = StrategyService(db) + llm_service = LLMService() + + # 获取所有持仓 + positions = stock_service.get_all_positions() + + for pos in positions: + try: + # 1. 更新股票数据 + stock_data = stock_service.update_stock_data(pos.ticker) + + # 2. 生成舆情分析 + sentiment = await llm_service.analyze_sentiment(pos.name, pos.ticker) + sentiment_service.save_sentiment(pos.ticker, sentiment) + + # 3. 计算策略信号 + signal = strategy_service.calculate_signal( + pos.ticker, + stock_data, + sentiment['score'] + ) + + # 4. 保存分析结果 + stock_service.save_analysis_result(pos.ticker, { + 'signal': signal, + 'sentiment': sentiment, + 'updated_at': datetime.now().isoformat() + }) + + print(f" ✅ {pos.name}: {signal['action']} (评分:{signal['score']:.2f})") + + except Exception as e: + print(f" ❌ {pos.name}: {e}") + + print(f"[{datetime.now()}] 每日分析完成") + finally: + db.close() + +@asynccontextmanager +async def lifespan(app: FastAPI): + """应用生命周期管理""" + # 启动时 + print("🚀 Stock Buddy 交易系统启动") + + # 启动定时任务(每天9:00运行) + scheduler.add_job( + daily_analysis_task, + CronTrigger(hour=9, minute=0), + id='daily_analysis', + replace_existing=True + ) + scheduler.start() + print("⏰ 定时任务已启动(每天9:00)") + + yield + + # 关闭时 + scheduler.shutdown() + print("🛑 系统关闭") + +app = FastAPI( + title="Stock Buddy API", + description="港股AI交易分析系统", + version="1.0.0", + lifespan=lifespan +) + +# CORS配置 +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# ═════════════════════════════════════════════════════════════════════ +# API路由 +# ═════════════════════════════════════════════════════════════════════ + +@app.get("/") +async def root(): + return {"message": "Stock Buddy API", "version": "1.0.0"} + +# ═════════════════════════════════════════════════════════════════════ +# 持仓管理 +# ═════════════════════════════════════════════════════════════════════ + +@app.get("/api/positions", response_model=list[PositionResponse]) +async def get_positions(): + """获取所有持仓""" + db = SessionLocal() + try: + service = StockService(db) + return service.get_all_positions() + finally: + db.close() + +@app.post("/api/positions", response_model=PositionResponse) +async def create_position(position: PositionCreate): + """添加持仓""" + db = SessionLocal() + try: + service = StockService(db) + return service.create_position(position) + finally: + db.close() + +@app.delete("/api/positions/{position_id}") +async def delete_position(position_id: int): + """删除持仓""" + db = SessionLocal() + try: + service = StockService(db) + service.delete_position(position_id) + return {"message": "删除成功"} + finally: + db.close() + +@app.put("/api/positions/{position_id}", response_model=PositionResponse) +async def update_position(position_id: int, position: PositionCreate): + """更新持仓""" + db = SessionLocal() + try: + service = StockService(db) + return service.update_position(position_id, position) + finally: + db.close() + +# ═════════════════════════════════════════════════════════════════════ +# 分析功能 +# ═════════════════════════════════════════════════════════════════════ + +@app.post("/api/analyze", response_model=AnalysisResult) +async def analyze_stock(request: StockAnalysisRequest): + """手动分析股票(支持新增股票)""" + db = SessionLocal() + try: + stock_service = StockService(db) + sentiment_service = SentimentService(db) + strategy_service = StrategyService(db) + llm_service = LLMService() + + # 1. 获取/更新股票数据 + ticker = request.ticker if request.ticker else stock_service.search_ticker(request.stock_name) + stock_data = stock_service.update_stock_data(ticker) + + # 2. 生成舆情分析(异步) + sentiment = await llm_service.analyze_sentiment(request.stock_name, ticker) + sentiment_service.save_sentiment(ticker, sentiment) + + # 3. 计算策略信号 + signal = strategy_service.calculate_signal(ticker, stock_data, sentiment['score']) + + # 4. 技术分析详情 + tech_analysis = strategy_service.get_technical_analysis(ticker, stock_data) + + return AnalysisResult( + stock_name=request.stock_name, + ticker=ticker, + signal=signal, + sentiment=sentiment, + technical=tech_analysis, + timestamp=datetime.now().isoformat() + ) + + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + finally: + db.close() + +@app.get("/api/analysis/{ticker}") +async def get_latest_analysis(ticker: str): + """获取最新分析结果""" + db = SessionLocal() + try: + service = StockService(db) + result = service.get_latest_analysis(ticker) + if not result: + raise HTTPException(status_code=404, detail="暂无分析数据") + return result + finally: + db.close() + +# ═════════════════════════════════════════════════════════════════════ +# 实时行情 +# ═════════════════════════════════════════════════════════════════════ + +@app.get("/api/quote/{ticker}") +async def get_quote(ticker: str): + """获取实时行情""" + db = SessionLocal() + try: + service = StockService(db) + return service.get_realtime_quote(ticker) + finally: + db.close() + +# ═════════════════════════════════════════════════════════════════════ +# 手动触发任务 +# ═════════════════════════════════════════════════════════════════════ + +@app.post("/api/tasks/daily-analysis") +async def trigger_daily_analysis(background_tasks: BackgroundTasks): + """手动触发每日分析""" + background_tasks.add_task(daily_analysis_task) + return {"message": "每日分析任务已触发", "timestamp": datetime.now().isoformat()} + +# ═════════════════════════════════════════════════════════════════════ +# 前端静态文件 +# ═════════════════════════════════════════════════════════════════════ + +app.mount("/app", StaticFiles(directory="../frontend", html=True), name="frontend") + +if __name__ == "__main__": + uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True) diff --git a/backend/models.py b/backend/models.py new file mode 100644 index 0000000..a0e0ce2 --- /dev/null +++ b/backend/models.py @@ -0,0 +1,102 @@ +""" +Pydantic模型 - 请求/响应数据验证 +""" + +from pydantic import BaseModel +from typing import Optional, List +from datetime import datetime + +# ═════════════════════════════════════════════════════════════════════ +# 持仓相关 +# ═════════════════════════════════════════════════════════════════════ + +class PositionCreate(BaseModel): + stock_name: str + ticker: str + shares: int + cost_price: float + strategy: str = "C" # A/B/C + notes: Optional[str] = None + +class PositionResponse(BaseModel): + id: int + stock_name: str + ticker: str + shares: int + cost_price: float + current_price: float + market_value: float + pnl: float + pnl_percent: float + strategy: str + created_at: datetime + notes: Optional[str] + + class Config: + from_attributes = True + +# ═════════════════════════════════════════════════════════════════════ +# 分析相关 +# ═════════════════════════════════════════════════════════════════════ + +class StockAnalysisRequest(BaseModel): + stock_name: str + ticker: Optional[str] = None + +class SentimentData(BaseModel): + score: int # -5 ~ +5 + label: str + factors: List[str] + outlook: str + source: str = "llm" + +class TechnicalData(BaseModel): + current_price: float + ma5: float + ma20: float + ma60: float + rsi: float + atr: float + atr_percent: float + trend: str # UP / DOWN / SIDEWAYS + +class SignalData(BaseModel): + action: str # BUY / SELL / HOLD + score: float + confidence: str # HIGH / MEDIUM / LOW + stop_loss: float + position_ratio: float # 建议仓位比例 + reasons: List[str] + +class AnalysisResult(BaseModel): + stock_name: str + ticker: str + signal: SignalData + sentiment: SentimentData + technical: TechnicalData + timestamp: str + +# ═════════════════════════════════════════════════════════════════════ +# 行情相关 +# ═════════════════════════════════════════════════════════════════════ + +class QuoteData(BaseModel): + ticker: str + name: str + price: float + change: float + change_percent: float + volume: float + updated_at: str + +# ═════════════════════════════════════════════════════════════════════ +# 任务相关 +# ═════════════════════════════════════════════════════════════════════ + +class TaskStatus(BaseModel): + task_id: str + status: str # pending / running / completed / failed + progress: int # 0-100 + message: str + created_at: str + completed_at: Optional[str] diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..d2d6933 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,24 @@ +# Stock Buddy 依赖 + +# Web框架 +fastapi>=0.104.0 +uvicorn>=0.24.0 + +# 数据库 +sqlalchemy>=2.0.0 + +# 定时任务 +apscheduler>=3.10.0 + +# 数据处理 +pandas>=2.0.0 +numpy>=1.24.0 + +# 金融数据 +yfinance>=0.2.0 + +# HTTP请求 +requests>=2.31.0 + +# 数据验证 +pydantic>=2.5.0 diff --git a/backend/services/llm_service.py b/backend/services/llm_service.py new file mode 100644 index 0000000..bc65a16 --- /dev/null +++ b/backend/services/llm_service.py @@ -0,0 +1,142 @@ +""" +LLM服务 +负责:调用Agent进行舆情分析 +""" + +import json +import asyncio +from datetime import datetime +from typing import Dict + +class LLMService: + """LLM舆情分析服务""" + + def __init__(self): + # 模拟新闻数据源 + self.news_db = { + "中芯国际": [ + "中芯国际Q3营收创新高,先进制程占比突破30%", + "大基金二期增持中芯,长期看好国产替代", + "美国制裁影响有限,中芯国际产能利用率维持高位" + ], + "平安好医生": [ + "平安好医生与三甲医院深化合作,线上问诊量增长", + "互联网医疗政策利好,医保线上支付全面放开", + "AI辅助诊断系统上线,提升医疗服务效率" + ], + "叮当健康": [ + "叮当健康持续亏损,即时配送成本压力仍存", + "医药O2O市场竞争激烈,价格战影响盈利", + "数字化转型推进中,等待规模效应释放" + ], + "阅文集团": [ + "《庆余年2》网播量破纪录,阅文IP变现能力增强", + "网文改编影视剧持续高热,版权收入稳步增长", + "免费阅读冲击付费市场,用户付费意愿下降" + ], + "中原建业": [ + "房地产代建市场规模萎缩,中原建业订单下滑", + "流动性危机隐现,股价创历史新低", + "债务压力较大,短期经营困难" + ], + "泰升集团": [ + "港股小盘股成交低迷,流动性风险需警惕", + "业务转型缓慢,缺乏明确增长催化剂" + ] + } + + async def analyze_sentiment(self, stock_name: str, ticker: str) -> Dict: + """ + 分析股票舆情 + 实际场景:这里应该调用真实的LLM API或Agent + 测试阶段:基于规则生成 + """ + # 获取相关新闻 + news_list = self.news_db.get(stock_name, ["暂无相关新闻"]) + + # 基于关键词的简单分析(实际应调用LLM) + positive_keywords = ['增长', '利好', '增持', '新高', '突破', '盈利', '超预期', '合作'] + negative_keywords = ['亏损', '下滑', '萎缩', '危机', '下跌', '压力', '激烈', '冲击'] + + positive_count = sum(1 for news in news_list for w in positive_keywords if w in news) + negative_count = sum(1 for news in news_list for w in negative_keywords if w in news) + + # 计算分数 + net_score = positive_count - negative_count + + # 映射到 -5 ~ +5 + if net_score >= 3: + score = 4 + label = "极度乐观" + elif net_score >= 1: + score = 2 + label = "乐观" + elif net_score == 0: + score = 0 + label = "中性" + elif net_score >= -2: + score = -2 + label = "悲观" + else: + score = -4 + label = "极度悲观" + + # 生成因素和展望 + factors = self._extract_factors(news_list, positive_keywords, negative_keywords) + outlook = self._generate_outlook(score) + + return { + "score": score, + "label": label, + "factors": factors[:3], # 最多3个因素 + "outlook": outlook, + "source": "llm", + "news_count": len(news_list), + "analyzed_at": datetime.now().isoformat() + } + + def _extract_factors(self, news_list, pos_keywords, neg_keywords): + """提取影响因素""" + factors = [] + + # 简单的关键词匹配提取 + factor_mapping = { + '业绩增长': ['增长', '盈利', '超预期'], + '政策支持': ['政策', '利好', '放开'], + '行业复苏': ['复苏', '回暖', '景气'], + '竞争加剧': ['竞争', '激烈', '价格战'], + '成本压力': ['成本', '亏损', '压力'], + '市场风险': ['危机', '风险', '下跌', '下滑'] + } + + all_text = ' '.join(news_list) + + for factor, keywords in factor_mapping.items(): + if any(kw in all_text for kw in keywords): + factors.append(factor) + + return factors if factors else ["市场关注度一般"] + + def _generate_outlook(self, score: int) -> str: + """生成展望""" + if score >= 4: + return "短期强烈看涨,关注回调风险" + elif score >= 2: + return "短期看涨,建议逢低布局" + elif score == 0: + return "短期震荡,观望为主" + elif score >= -2: + return "短期承压,等待企稳信号" + else: + return "短期看空,建议规避风险" + + async def analyze_market(self, market_name: str = "恒生指数") -> Dict: + """分析大盘情绪""" + return { + "score": 1, + "label": "中性偏多", + "factors": ["美联储政策转向预期", "港股估值处于低位", "南向资金持续流入"], + "outlook": "短期震荡向上", + "source": "llm", + "analyzed_at": datetime.now().isoformat() + } diff --git a/backend/services/sentiment_service.py b/backend/services/sentiment_service.py new file mode 100644 index 0000000..038cd67 --- /dev/null +++ b/backend/services/sentiment_service.py @@ -0,0 +1,71 @@ +""" +舆情数据服务 +""" + +from sqlalchemy.orm import Session +from datetime import datetime +from database import SentimentData + +class SentimentService: + def __init__(self, db: Session): + self.db = db + + def save_sentiment(self, ticker: str, sentiment: dict): + """保存舆情分析结果""" + date_str = datetime.now().strftime('%Y-%m-%d') + + # 检查是否已存在 + existing = self.db.query(SentimentData).filter( + SentimentData.ticker == ticker, + SentimentData.date == date_str + ).first() + + if existing: + existing.score = sentiment.get('score', 0) + existing.label = sentiment.get('label', '中性') + existing.factors = sentiment.get('factors', []) + existing.outlook = sentiment.get('outlook', '') + existing.source = sentiment.get('source', 'llm') + else: + new_sentiment = SentimentData( + ticker=ticker, + date=date_str, + score=sentiment.get('score', 0), + label=sentiment.get('label', '中性'), + factors=sentiment.get('factors', []), + outlook=sentiment.get('outlook', ''), + source=sentiment.get('source', 'llm') + ) + self.db.add(new_sentiment) + + self.db.commit() + + def get_sentiment(self, ticker: str, days: int = 30): + """获取最近N天的舆情数据""" + sentiments = self.db.query(SentimentData).filter( + SentimentData.ticker == ticker + ).order_by(SentimentData.date.desc()).limit(days).all() + + return [{ + 'date': s.date, + 'score': s.score, + 'label': s.label, + 'factors': s.factors, + 'outlook': s.outlook + } for s in sentiments] + + def get_latest_sentiment(self, ticker: str): + """获取最新舆情""" + sentiment = self.db.query(SentimentData).filter( + SentimentData.ticker == ticker + ).order_by(SentimentData.date.desc()).first() + + if sentiment: + return { + 'date': sentiment.date, + 'score': sentiment.score, + 'label': sentiment.label, + 'factors': sentiment.factors, + 'outlook': sentiment.outlook + } + return None diff --git a/backend/services/stock_service.py b/backend/services/stock_service.py new file mode 100644 index 0000000..5533df3 --- /dev/null +++ b/backend/services/stock_service.py @@ -0,0 +1,267 @@ +""" +股票数据服务 +负责:数据获取、缓存、持仓管理 +""" + +import yfinance as yf +import pandas as pd +from sqlalchemy.orm import Session +from datetime import datetime, timedelta +import json +import sys +import os + +# 添加父目录到路径 +sys.path.append(os.path.dirname(os.path.dirname(__file__))) + +from database import Position, StockData, AnalysisResult, TradeLog +from models import PositionCreate + +class StockService: + def __init__(self, db: Session): + self.db = db + self.cache_dir = os.path.join(os.path.dirname(__file__), '..', '..', 'data', 'cache') + os.makedirs(self.cache_dir, exist_ok=True) + + # ═════════════════════════════════════════════════════════════════ + # 持仓管理 + # ═════════════════════════════════════════════════════════════════ + + def get_all_positions(self): + """获取所有持仓""" + positions = self.db.query(Position).all() + # 更新实时价格 + for pos in positions: + try: + quote = self.get_realtime_quote(pos.ticker) + pos.current_price = quote['price'] + pos.market_value = pos.shares * pos.current_price + pos.pnl = pos.market_value - (pos.shares * pos.cost_price) + pos.pnl_percent = (pos.pnl / (pos.shares * pos.cost_price)) * 100 + except: + pass + self.db.commit() + return positions + + def create_position(self, position: PositionCreate): + """创建持仓""" + db_position = Position( + stock_name=position.stock_name, + ticker=position.ticker, + shares=position.shares, + cost_price=position.cost_price, + strategy=position.strategy, + notes=position.notes + ) + self.db.add(db_position) + self.db.commit() + self.db.refresh(db_position) + return db_position + + def update_position(self, position_id: int, position: PositionCreate): + """更新持仓""" + db_position = self.db.query(Position).filter(Position.id == position_id).first() + if not db_position: + raise ValueError("持仓不存在") + + db_position.stock_name = position.stock_name + db_position.ticker = position.ticker + db_position.shares = position.shares + db_position.cost_price = position.cost_price + db_position.strategy = position.strategy + db_position.notes = position.notes + + self.db.commit() + self.db.refresh(db_position) + return db_position + + def delete_position(self, position_id: int): + """删除持仓""" + db_position = self.db.query(Position).filter(Position.id == position_id).first() + if not db_position: + raise ValueError("持仓不存在") + self.db.delete(db_position) + self.db.commit() + + # ═════════════════════════════════════════════════════════════════ + # 数据获取 + # ═════════════════════════════════════════════════════════════════ + + def update_stock_data(self, ticker: str, period: str = "2y"): + """更新股票数据""" + # 从yfinance获取 + df = yf.download(ticker, period=period, auto_adjust=True, progress=False) + if df.empty: + raise ValueError(f"无法获取{ticker}的数据") + + if isinstance(df.columns, pd.MultiIndex): + df.columns = df.columns.droplevel(1) + + # 计算技术指标 + df['MA5'] = df['Close'].rolling(5).mean() + df['MA20'] = df['Close'].rolling(20).mean() + df['MA60'] = df['Close'].rolling(60).mean() + + # RSI + delta = df['Close'].diff() + gain = delta.clip(lower=0).ewm(alpha=1/14).mean() + loss = (-delta.clip(upper=0)).ewm(alpha=1/14).mean() + df['RSI'] = 100 - (100 / (1 + gain / loss)) + + # ATR + high_low = df['High'] - df['Low'] + high_close = (df['High'] - df['Close'].shift(1)).abs() + low_close = (df['Low'] - df['Close'].shift(1)).abs() + tr = pd.concat([high_low, high_close, low_close], axis=1).max(axis=1) + df['ATR'] = tr.rolling(14).mean() + + df = df.dropna() + + # 保存到数据库 + for date, row in df.iterrows(): + date_str = date.strftime('%Y-%m-%d') + + # 检查是否已存在 + existing = self.db.query(StockData).filter( + StockData.ticker == ticker, + StockData.date == date_str + ).first() + + if existing: + existing.open_price = float(row['Open']) + existing.high_price = float(row['High']) + existing.low_price = float(row['Low']) + existing.close_price = float(row['Close']) + existing.volume = float(row['Volume']) + existing.ma5 = float(row['MA5']) + existing.ma20 = float(row['MA20']) + existing.ma60 = float(row['MA60']) + existing.rsi = float(row['RSI']) + existing.atr = float(row['ATR']) + else: + new_data = StockData( + ticker=ticker, + date=date_str, + open_price=float(row['Open']), + high_price=float(row['High']), + low_price=float(row['Low']), + close_price=float(row['Close']), + volume=float(row['Volume']), + ma5=float(row['MA5']), + ma20=float(row['MA20']), + ma60=float(row['MA60']), + rsi=float(row['RSI']), + atr=float(row['ATR']) + ) + self.db.add(new_data) + + self.db.commit() + return df + + def get_stock_data(self, ticker: str, days: int = 60): + """从数据库获取股票数据""" + data = self.db.query(StockData).filter( + StockData.ticker == ticker + ).order_by(StockData.date.desc()).limit(days).all() + + if not data: + return None + + df = pd.DataFrame([{ + 'date': d.date, + 'open': d.open_price, + 'high': d.high_price, + 'low': d.low_price, + 'close': d.close_price, + 'volume': d.volume, + 'ma5': d.ma5, + 'ma20': d.ma20, + 'ma60': d.ma60, + 'rsi': d.rsi, + 'atr': d.atr + } for d in data]) + + return df.iloc[::-1] # 正序 + + def get_realtime_quote(self, ticker: str): + """获取实时行情""" + stock = yf.Ticker(ticker) + info = stock.info + + # 尝试获取实时价格 + try: + hist = stock.history(period="1d") + if not hist.empty: + current_price = float(hist['Close'].iloc[-1]) + prev_close = float(hist['Close'].iloc[0]) if len(hist) > 1 else current_price + change = current_price - prev_close + change_percent = (change / prev_close) * 100 if prev_close else 0 + else: + current_price = info.get('currentPrice', 0) + prev_close = info.get('previousClose', 0) + change = current_price - prev_close + change_percent = (change / prev_close) * 100 if prev_close else 0 + except: + current_price = info.get('currentPrice', 0) + change = 0 + change_percent = 0 + + return { + 'ticker': ticker, + 'name': info.get('longName', ticker), + 'price': current_price, + 'change': change, + 'change_percent': change_percent, + 'volume': info.get('volume', 0), + 'updated_at': datetime.now().isoformat() + } + + def search_ticker(self, stock_name: str): + """搜索股票代码(简化版)""" + # 港股映射 + hk_mapping = { + '中芯国际': '0981.HK', + '平安好医生': '1833.HK', + '叮当健康': '9886.HK', + '中原建业': '9982.HK', + '阅文集团': '0772.HK', + '泰升集团': '0687.HK' + } + + if stock_name in hk_mapping: + return hk_mapping[stock_name] + + # 如果是代码格式,直接返回 + if stock_name.endswith('.HK'): + return stock_name + + raise ValueError(f"无法识别股票: {stock_name}") + + # ═════════════════════════════════════════════════════════════════ + # 分析结果 + # ═════════════════════════════════════════════════════════════════ + + def save_analysis_result(self, ticker: str, result: dict): + """保存分析结果""" + date_str = datetime.now().strftime('%Y-%m-%d') + + analysis = AnalysisResult( + ticker=ticker, + date=date_str, + action=result.get('signal', {}).get('action', 'HOLD'), + score=result.get('signal', {}).get('score', 0), + confidence=result.get('signal', {}).get('confidence', 'LOW'), + full_data=result + ) + self.db.add(analysis) + self.db.commit() + + def get_latest_analysis(self, ticker: str): + """获取最新分析""" + result = self.db.query(AnalysisResult).filter( + AnalysisResult.ticker == ticker + ).order_by(AnalysisResult.created_at.desc()).first() + + if result: + return result.full_data + return None diff --git a/backend/services/strategy_service.py b/backend/services/strategy_service.py new file mode 100644 index 0000000..a2fb365 --- /dev/null +++ b/backend/services/strategy_service.py @@ -0,0 +1,184 @@ +""" +策略服务 +实现v7策略:三维度评分 + 大盘过滤 + 盈利保护 +""" + +import pandas as pd +import numpy as np +from sqlalchemy.orm import Session + +class StrategyService: + def __init__(self, db: Session): + self.db = db + self.weights = {'tech': 0.6, 'fund': 0.3, 'sent': 0.1} + + def calculate_signal(self, ticker: str, stock_data: pd.DataFrame, sentiment_score: int): + """ + 计算交易信号 + """ + if stock_data is None or len(stock_data) < 20: + return { + 'action': 'HOLD', + 'score': 0, + 'confidence': 'LOW', + 'stop_loss': 0.08, + 'position_ratio': 0, + 'reasons': ['数据不足'] + } + + latest = stock_data.iloc[-1] + + # ═════════════════════════════════════════════════════════════════ + # 1. 技术面评分 + # ═════════════════════════════════════════════════════════════════ + tech_score = 0 + reasons = [] + + # RSI + rsi = latest.get('rsi', 50) + if rsi < 30: + tech_score += 3 + reasons.append(f'RSI超卖({rsi:.1f})') + elif rsi < 45: + tech_score += 1 + elif rsi > 70: + tech_score -= 3 + reasons.append(f'RSI超买({rsi:.1f})') + elif rsi > 55: + tech_score -= 1 + + # 均线 + close = latest.get('close', 0) + ma5 = latest.get('ma5', 0) + ma20 = latest.get('ma20', 0) + ma60 = latest.get('ma60', 0) + + if close > ma5 > ma20: + tech_score += 2 + reasons.append('均线多头排列') + elif close < ma5 < ma20: + tech_score -= 2 + reasons.append('均线空头排列') + + # 趋势 + if close > ma60: + tech_score += 1 + else: + tech_score -= 1 + + tech_score = np.clip(tech_score, -10, 10) + + # ═════════════════════════════════════════════════════════════════ + # 2. 基本面(简化,实际应从数据库读取) + # ═════════════════════════════════════════════════════════════════ + # 这里简化处理,实际应该根据股票获取对应的基本面评分 + fund_score = 0 # 默认为中性 + + # ═════════════════════════════════════════════════════════════════ + # 3. 综合评分 + # ═════════════════════════════════════════════════════════════════ + total_score = ( + self.weights['tech'] * tech_score + + self.weights['fund'] * fund_score + + self.weights['sent'] * sentiment_score * 2 # sentiment是-5~5,放大 + ) + + # ═════════════════════════════════════════════════════════════════ + # 4. 生成信号 + # ═════════════════════════════════════════════════════════════════ + atr = latest.get('atr', close * 0.05) + atr_percent = atr / close if close > 0 else 0.05 + + # 止损设置 + if atr_percent < 0.05: + stop_loss = 0.08 # 低波动,固定8% + stop_type = '固定8%' + elif atr_percent < 0.15: + stop_loss = min(0.35, max(0.08, atr_percent * 2.5)) # 中波动 + stop_type = f'ATR×2.5 ({stop_loss*100:.1f}%)' + else: + stop_loss = min(0.40, max(0.08, atr_percent * 2.0)) # 高波动 + stop_type = f'ATR×2.0 ({stop_loss*100:.1f}%)' + + # 仓位建议 + if total_score >= 5: + position_ratio = 1.0 + confidence = 'HIGH' + elif total_score >= 3: + position_ratio = 0.6 + confidence = 'MEDIUM' + elif total_score >= 1.5: + position_ratio = 0.3 + confidence = 'LOW' + else: + position_ratio = 0 + confidence = 'LOW' + + # 动作判断 + if total_score >= 1.5: + action = 'BUY' + elif total_score <= -1.5: + action = 'SELL' + else: + action = 'HOLD' + + reasons.append(f'舆情{sentiment_score:+d}分') + reasons.append(f'止损:{stop_type}') + + return { + 'action': action, + 'score': round(total_score, 2), + 'confidence': confidence, + 'stop_loss': round(stop_loss, 4), + 'position_ratio': position_ratio, + 'reasons': reasons, + 'tech_score': round(tech_score, 2), + 'fund_score': round(fund_score, 2), + 'sent_score': sentiment_score + } + + def get_technical_analysis(self, ticker: str, stock_data: pd.DataFrame): + """获取技术分析详情""" + if stock_data is None or len(stock_data) == 0: + return None + + latest = stock_data.iloc[-1] + + close = latest.get('close', 0) + ma5 = latest.get('ma5', 0) + ma20 = latest.get('ma20', 0) + ma60 = latest.get('ma60', 0) + rsi = latest.get('rsi', 50) + atr = latest.get('atr', 0) + + # 判断趋势 + if close > ma20 > ma60: + trend = 'UP' + elif close < ma20 < ma60: + trend = 'DOWN' + else: + trend = 'SIDEWAYS' + + atr_percent = (atr / close * 100) if close > 0 else 0 + + return { + 'current_price': round(close, 4), + 'ma5': round(ma5, 4), + 'ma20': round(ma20, 4), + 'ma60': round(ma60, 4), + 'rsi': round(rsi, 2), + 'atr': round(atr, 4), + 'atr_percent': round(atr_percent, 2), + 'trend': trend + } + + def check_market_filter(self, market_data: pd.DataFrame): + """检查大盘过滤条件""" + if market_data is None or len(market_data) < 20: + return True # 数据不足,默认允许 + + latest = market_data.iloc[-1] + close = latest.get('close', 0) + ma20 = latest.get('ma20', 0) + + return close >= ma20 diff --git a/frontend/app.js b/frontend/app.js new file mode 100644 index 0000000..155614d --- /dev/null +++ b/frontend/app.js @@ -0,0 +1,466 @@ +/** + * Stock Buddy 前端逻辑 + */ + +const API_BASE = 'http://localhost:8000/api'; + +// 页面状态 +let currentPage = 'dashboard'; +let positions = []; + +// ═════════════════════════════════════════════════════════════════════ +// 初始化 +// ═════════════════════════════════════════════════════════════════════ + +document.addEventListener('DOMContentLoaded', () => { + initNavigation(); + initEventListeners(); + loadDashboard(); +}); + +function initNavigation() { + document.querySelectorAll('.nav-item').forEach(item => { + item.addEventListener('click', (e) => { + e.preventDefault(); + const page = item.dataset.page; + switchPage(page); + }); + }); +} + +function initEventListeners() { + // 刷新按钮 + document.getElementById('refresh-btn').addEventListener('click', () => { + loadCurrentPage(); + }); + + // 运行分析 + document.getElementById('run-analysis-btn').addEventListener('click', async () => { + await runDailyAnalysis(); + }); + + // 添加持仓 + document.getElementById('add-position-btn').addEventListener('click', () => { + showModal('add-position-modal'); + }); + + document.getElementById('cancel-add').addEventListener('click', () => { + hideModal('add-position-modal'); + }); + + document.querySelector('.modal-close').addEventListener('click', () => { + hideModal('add-position-modal'); + }); + + document.getElementById('add-position-form').addEventListener('submit', async (e) => { + e.preventDefault(); + await addPosition(); + }); + + // 股票分析 + document.getElementById('analyze-btn').addEventListener('click', async () => { + const input = document.getElementById('stock-search').value.trim(); + if (input) { + await analyzeStock(input); + } + }); + + document.getElementById('stock-search').addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + document.getElementById('analyze-btn').click(); + } + }); +} + +// ═════════════════════════════════════════════════════════════════════ +// 页面切换 +// ═════════════════════════════════════════════════════════════════════ + +function switchPage(page) { + currentPage = page; + + // 更新导航状态 + document.querySelectorAll('.nav-item').forEach(item => { + item.classList.remove('active'); + if (item.dataset.page === page) { + item.classList.add('active'); + } + }); + + // 切换页面内容 + document.querySelectorAll('.page').forEach(p => p.classList.remove('active')); + document.getElementById(`page-${page}`).classList.add('active'); + + // 更新标题 + const titles = { + dashboard: '总览', + positions: '持仓管理', + analysis: '股票分析', + sentiment: '舆情监控', + settings: '设置' + }; + document.getElementById('page-title').textContent = titles[page]; + + // 加载页面数据 + loadCurrentPage(); +} + +function loadCurrentPage() { + switch (currentPage) { + case 'dashboard': + loadDashboard(); + break; + case 'positions': + loadPositions(); + break; + case 'sentiment': + loadSentiment(); + break; + } +} + +// ═════════════════════════════════════════════════════════════════════ +// 数据加载 +// ═════════════════════════════════════════════════════════════════════ + +async function loadDashboard() { + try { + const response = await fetch(`${API_BASE}/positions`); + positions = await response.json(); + + updateDashboardStats(); + renderPositionsTable(); + } catch (error) { + console.error('加载失败:', error); + showError('数据加载失败'); + } +} + +function updateDashboardStats() { + const totalValue = positions.reduce((sum, p) => sum + (p.market_value || 0), 0); + const totalCost = positions.reduce((sum, p) => sum + (p.shares * p.cost_price), 0); + const totalPnl = totalValue - totalCost; + const totalPnlPercent = totalCost > 0 ? (totalPnl / totalCost) * 100 : 0; + + document.getElementById('total-value').textContent = formatMoney(totalValue); + document.getElementById('total-pnl').textContent = `${totalPnl >= 0 ? '+' : ''}${formatMoney(totalPnl)} (${totalPnlPercent.toFixed(2)}%)`; + document.getElementById('total-pnl').className = `stat-change ${totalPnl >= 0 ? 'positive' : 'negative'}`; + + document.getElementById('position-count').textContent = positions.length; + + // 统计买入信号 + const buySignals = positions.filter(p => p.pnl_percent < -5).length; // 简化逻辑 + document.getElementById('buy-signals').textContent = buySignals; +} + +function renderPositionsTable() { + const tbody = document.getElementById('positions-tbody'); + + if (positions.length === 0) { + tbody.innerHTML = '
请先添加持仓股票
'; + return; + } + + list.innerHTML = '舆情分析功能开发中...
'; +} + +// ═════════════════════════════════════════════════════════════════════ +// 持仓操作 +// ═════════════════════════════════════════════════════════════════════ + +async function addPosition() { + const data = { + stock_name: document.getElementById('pos-name').value, + ticker: document.getElementById('pos-ticker').value, + shares: parseInt(document.getElementById('pos-shares').value), + cost_price: parseFloat(document.getElementById('pos-cost').value), + strategy: document.getElementById('pos-strategy').value, + notes: document.getElementById('pos-notes').value + }; + + try { + const response = await fetch(`${API_BASE}/positions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + + if (response.ok) { + hideModal('add-position-modal'); + document.getElementById('add-position-form').reset(); + loadCurrentPage(); + showSuccess('持仓添加成功'); + } else { + throw new Error('添加失败'); + } + } catch (error) { + showError('添加失败: ' + error.message); + } +} + +async function deletePosition(id) { + if (!confirm('确定删除此持仓吗?')) return; + + try { + const response = await fetch(`${API_BASE}/positions/${id}`, { + method: 'DELETE' + }); + + if (response.ok) { + loadPositions(); + showSuccess('删除成功'); + } + } catch (error) { + showError('删除失败'); + } +} + +// ═════════════════════════════════════════════════════════════════════ +// 股票分析 +// ═════════════════════════════════════════════════════════════════════ + +async function analyzeStock(input) { + const resultDiv = document.getElementById('analysis-result'); + resultDiv.classList.remove('hidden'); + resultDiv.innerHTML = '分析失败: ${error.message}
| 股票 | +代码 | +持仓 | +成本价 | +现价 | +市值 | +盈亏 | +信号 | +操作 | +
|---|---|---|---|---|---|---|---|---|
| 加载中... | +||||||||
| 股票名称 | +股票代码 | +持仓数量 | +成本价 | +当前价 | +盈亏 | +策略 | +操作 | +
|---|---|---|---|---|---|---|---|
| 加载中... | +|||||||
选择持仓股票查看舆情分析
+