feat: complete trading system with FastAPI backend, web frontend, and auto-analysis

This commit is contained in:
Stock Buddy Bot
2026-03-22 21:59:14 +08:00
parent 17c124009f
commit 3d8e59cc0e
16 changed files with 2923 additions and 0 deletions

View 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()
}

View 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

View 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

View 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