feat: complete trading system with FastAPI backend, web frontend, and auto-analysis
This commit is contained in:
137
backend/database.py
Normal file
137
backend/database.py
Normal file
@@ -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()
|
||||
250
backend/main.py
Normal file
250
backend/main.py
Normal file
@@ -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)
|
||||
102
backend/models.py
Normal file
102
backend/models.py
Normal file
@@ -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]
|
||||
24
backend/requirements.txt
Normal file
24
backend/requirements.txt
Normal file
@@ -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
|
||||
142
backend/services/llm_service.py
Normal file
142
backend/services/llm_service.py
Normal file
@@ -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()
|
||||
}
|
||||
71
backend/services/sentiment_service.py
Normal file
71
backend/services/sentiment_service.py
Normal file
@@ -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
|
||||
267
backend/services/stock_service.py
Normal file
267
backend/services/stock_service.py
Normal file
@@ -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
|
||||
184
backend/services/strategy_service.py
Normal file
184
backend/services/strategy_service.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user