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

44
ARCHITECTURE.md Normal file
View File

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

178
SYSTEM_README.md Normal file
View File

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

137
backend/database.py Normal file
View 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
View 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
View 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
View 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

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

466
frontend/app.js Normal file
View File

@@ -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 = '<tr><td colspan="9" class="empty-state">暂无持仓</td></tr>';
return;
}
tbody.innerHTML = positions.map(p => {
const pnlClass = p.pnl >= 0 ? 'positive' : 'negative';
const signal = p.pnl_percent < -10 ? 'BUY' : p.pnl_percent > 20 ? 'SELL' : 'HOLD';
const signalClass = signal.toLowerCase();
return `
<tr>
<td><strong>${p.stock_name}</strong></td>
<td>${p.ticker}</td>
<td>${p.shares}</td>
<td>${p.cost_price.toFixed(3)}</td>
<td>${(p.current_price || 0).toFixed(3)}</td>
<td>${formatMoney(p.market_value || 0)}</td>
<td class="${pnlClass}">${p.pnl >= 0 ? '+' : ''}${formatMoney(p.pnl)} (${p.pnl_percent.toFixed(2)}%)</td>
<td><span class="signal-tag ${signalClass}">${signal}</span></td>
<td>
<button class="btn btn-sm" onclick="viewPosition(${p.id})">详情</button>
</td>
</tr>
`;
}).join('');
}
async function loadPositions() {
try {
const response = await fetch(`${API_BASE}/positions`);
positions = await response.json();
const tbody = document.getElementById('manage-positions-tbody');
if (positions.length === 0) {
tbody.innerHTML = '<tr><td colspan="8" class="empty-state">暂无持仓</td></tr>';
return;
}
tbody.innerHTML = positions.map(p => {
const pnlClass = p.pnl >= 0 ? 'positive' : 'negative';
return `
<tr>
<td>${p.stock_name}</td>
<td>${p.ticker}</td>
<td>${p.shares}</td>
<td>${p.cost_price.toFixed(3)}</td>
<td>${(p.current_price || 0).toFixed(3)}</td>
<td class="${pnlClass}">${p.pnl >= 0 ? '+' : ''}${formatMoney(p.pnl)}</td>
<td>${p.strategy}</td>
<td>
<button class="btn btn-sm btn-danger" onclick="deletePosition(${p.id})">删除</button>
</td>
</tr>
`;
}).join('');
} catch (error) {
console.error('加载失败:', error);
}
}
async function loadSentiment() {
const list = document.getElementById('sentiment-list');
if (positions.length === 0) {
list.innerHTML = '<p class="empty-state">请先添加持仓股票</p>';
return;
}
list.innerHTML = '<p class="empty-state">舆情分析功能开发中...</p>';
}
// ═════════════════════════════════════════════════════════════════════
// 持仓操作
// ═════════════════════════════════════════════════════════════════════
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 = '<div class="result-loading">分析中,请稍候...</div>';
try {
// 判断是名称还是代码
const isTicker = input.endsWith('.HK') || /^\d{4,5}$/.test(input);
const requestData = isTicker
? { stock_name: input, ticker: input }
: { stock_name: input };
const response = await fetch(`${API_BASE}/analyze`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestData)
});
const result = await response.json();
if (response.ok) {
renderAnalysisResult(result);
} else {
throw new Error(result.detail || '分析失败');
}
} catch (error) {
resultDiv.innerHTML = `<div class="result-card"><p class="negative">分析失败: ${error.message}</p></div>`;
}
}
function renderAnalysisResult(result) {
const resultDiv = document.getElementById('analysis-result');
const signalClass = result.signal.action.toLowerCase();
const sentimentClass = result.sentiment.score >= 0 ? 'positive' : 'negative';
resultDiv.innerHTML = `
<div class="result-card">
<div class="result-header">
<div>
<div class="result-title">${result.stock_name} (${result.ticker})</div>
<div style="color: var(--text-secondary); font-size: 13px; margin-top: 4px;">
分析时间: ${new Date(result.timestamp).toLocaleString()}
</div>
</div>
<span class="signal-tag ${signalClass}">${result.signal.action}</span>
</div>
<div class="result-body">
<div class="result-item">
<div class="result-label">综合评分</div>
<div class="result-value" style="color: ${result.signal.score >= 0 ? 'var(--success)' : 'var(--danger)'}">
${result.signal.score >= 0 ? '+' : ''}${result.signal.score.toFixed(2)}
</div>
</div>
<div class="result-item">
<div class="result-label">当前价格</div>
<div class="result-value">${result.technical.current_price.toFixed(3)}</div>
</div>
<div class="result-item">
<div class="result-label">建议仓位</div>
<div class="result-value">${(result.signal.position_ratio * 100).toFixed(0)}%</div>
</div>
</div>
</div>
<div class="result-card">
<h4 style="margin-bottom: 16px;">技术分析</h4>
<div class="result-body">
<div class="result-item">
<div class="result-label">RSI</div>
<div class="result-value">${result.technical.rsi.toFixed(1)}</div>
</div>
<div class="result-item">
<div class="result-label">趋势</div>
<div class="result-value" style="font-size: 16px;">${result.technical.trend === 'UP' ? '上涨📈' : result.technical.trend === 'DOWN' ? '下跌📉' : '震荡➡️'}</div>
</div>
<div class="result-item">
<div class="result-label">止损位</div>
<div class="result-value">${(result.signal.stop_loss * 100).toFixed(1)}%</div>
</div>
</div>
</div>
<div class="result-card">
<h4 style="margin-bottom: 16px;">舆情分析</h4>
<div style="display: flex; align-items: center; gap: 16px; margin-bottom: 12px;">
<span style="font-size: 32px; font-weight: 700; color: ${sentimentClass};">
${result.sentiment.score > 0 ? '+' : ''}${result.sentiment.score}
</span>
<span>${result.sentiment.label}</span>
</div>
<div style="color: var(--text-secondary); font-size: 14px;">
<strong>影响因素:</strong> ${result.sentiment.factors.join('、')}
</div>
<div style="color: var(--text-secondary); font-size: 14px; margin-top: 8px;">
<strong>展望:</strong> ${result.sentiment.outlook}
</div>
</div>
<div class="result-card">
<h4 style="margin-bottom: 16px;">分析理由</h4>
<ul style="color: var(--text-secondary); font-size: 14px; padding-left: 20px;">
${result.signal.reasons.map(r => `<li>${r}</li>`).join('')}
</ul>
</div>
`;
}
// ═════════════════════════════════════════════════════════════════════
// 任务操作
// ═════════════════════════════════════════════════════════════════════
async function runDailyAnalysis() {
const btn = document.getElementById('run-analysis-btn');
btn.disabled = true;
btn.innerHTML = '<span>⏳</span> 分析中...';
try {
const response = await fetch(`${API_BASE}/tasks/daily-analysis`, {
method: 'POST'
});
if (response.ok) {
showSuccess('每日分析任务已启动');
} else {
throw new Error('启动失败');
}
} catch (error) {
showError('启动失败: ' + error.message);
} finally {
btn.disabled = false;
btn.innerHTML = '<span>▶️</span> 运行分析';
}
}
// ═════════════════════════════════════════════════════════════════════
// 工具函数
// ═════════════════════════════════════════════════════════════════════
function formatMoney(amount) {
if (amount === undefined || amount === null) return '--';
if (amount >= 100000000) {
return (amount / 100000000).toFixed(2) + '亿';
} else if (amount >= 10000) {
return (amount / 10000).toFixed(2) + '万';
}
return amount.toFixed(2);
}
function showModal(id) {
document.getElementById(id).classList.remove('hidden');
}
function hideModal(id) {
document.getElementById(id).classList.add('hidden');
}
function showSuccess(message) {
// 简化实现,实际可用 toast
console.log('✅', message);
}
function showError(message) {
console.error('❌', message);
alert(message);
}
// ═════════════════════════════════════════════════════════════════════
// 其他功能
// ═════════════════════════════════════════════════════════════════════
function viewPosition(id) {
const pos = positions.find(p => p.id === id);
if (pos) {
alert(`股票: ${pos.stock_name}\n代码: ${pos.ticker}\n持仓: ${pos.shares}\n成本: ${pos.cost_price}\n策略: ${pos.strategy}\n\n${pos.notes || ''}`);
}
}

263
frontend/index.html Normal file
View File

@@ -0,0 +1,263 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Stock Buddy - 港股AI交易系统</title>
<link rel="stylesheet" href="style.css">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<div id="app">
<!-- 侧边栏 -->
<aside class="sidebar">
<div class="logo">
<span class="logo-icon">📈</span>
<span class="logo-text">Stock Buddy</span>
</div>
<nav class="nav-menu">
<a href="#" class="nav-item active" data-page="dashboard">
<span class="nav-icon">🏠</span>
<span>总览</span>
</a>
<a href="#" class="nav-item" data-page="positions">
<span class="nav-icon">💼</span>
<span>持仓管理</span>
</a>
<a href="#" class="nav-item" data-page="analysis">
<span class="nav-icon">🔍</span>
<span>股票分析</span>
</a>
<a href="#" class="nav-item" data-page="sentiment">
<span class="nav-icon">📰</span>
<span>舆情监控</span>
</a>
<a href="#" class="nav-item" data-page="settings">
<span class="nav-icon">⚙️</span>
<span>设置</span>
</a>
</nav>
<div class="sidebar-footer">
<div class="market-status">
<span class="status-dot green"></span>
<span>市场状态: 交易中</span>
</div>
</div>
</aside>
<!-- 主内容区 -->
<main class="main-content">
<!-- 顶部栏 -->
<header class="header">
<h1 id="page-title">总览</h1>
<div class="header-actions">
<button class="btn btn-primary" id="refresh-btn">
<span>🔄</span> 刷新数据
</button>
<button class="btn btn-secondary" id="run-analysis-btn">
<span>▶️</span> 运行分析
</button>
</div>
</header>
<!-- 页面内容 -->
<div class="content" id="content-area">
<!-- 总览页 -->
<div id="page-dashboard" class="page active">
<!-- 统计卡片 -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">总持仓市值</div>
<div class="stat-value" id="total-value">--</div>
<div class="stat-change" id="total-pnl">--</div>
</div>
<div class="stat-card">
<div class="stat-label">今日盈亏</div>
<div class="stat-value" id="daily-pnl">--</div>
<div class="stat-change" id="daily-pnl-percent">--</div>
</div>
<div class="stat-card">
<div class="stat-label">持仓数量</div>
<div class="stat-value" id="position-count">--</div>
<div class="stat-label-small">只股票</div>
</div>
<div class="stat-card">
<div class="stat-label">买入信号</div>
<div class="stat-value buy" id="buy-signals">--</div>
<div class="stat-label-small">个机会</div>
</div>
</div>
<!-- 持仓列表 -->
<div class="section">
<h2 class="section-title">持仓股票</h2>
<div class="table-container">
<table class="data-table" id="positions-table">
<thead>
<tr>
<th>股票</th>
<th>代码</th>
<th>持仓</th>
<th>成本价</th>
<th>现价</th>
<th>市值</th>
<th>盈亏</th>
<th>信号</th>
<th>操作</th>
</tr>
</thead>
<tbody id="positions-tbody">
<tr>
<td colspan="9" class="loading">加载中...</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- 持仓管理页 -->
<div id="page-positions" class="page">
<div class="section">
<div class="section-header">
<h2 class="section-title">持仓列表</h2>
<button class="btn btn-primary" id="add-position-btn">
<span>+</span> 添加持仓
</button>
</div>
<div class="table-container">
<table class="data-table" id="manage-positions-table">
<thead>
<tr>
<th>股票名称</th>
<th>股票代码</th>
<th>持仓数量</th>
<th>成本价</th>
<th>当前价</th>
<th>盈亏</th>
<th>策略</th>
<th>操作</th>
</tr>
</thead>
<tbody id="manage-positions-tbody">
<tr>
<td colspan="8" class="loading">加载中...</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- 股票分析页 -->
<div id="page-analysis" class="page">
<div class="section">
<div class="section-header">
<h2 class="section-title">股票分析</h2>
</div>
<!-- 搜索框 -->
<div class="search-box">
<input type="text" id="stock-search" placeholder="输入股票名称或代码(如:中芯国际 或 0981.HK">
<button class="btn btn-primary" id="analyze-btn">分析</button>
</div>
<!-- 分析结果 -->
<div id="analysis-result" class="analysis-result hidden">
<div class="result-loading">分析中...</div>
</div>
</div>
</div>
<!-- 舆情监控页 -->
<div id="page-sentiment" class="page">
<div class="section">
<h2 class="section-title">舆情监控</h2>
<div id="sentiment-list">
<p class="empty-state">选择持仓股票查看舆情分析</p>
</div>
</div>
</div>
<!-- 设置页 -->
<div id="page-settings" class="page">
<div class="section">
<h2 class="section-title">系统设置</h2>
<div class="settings-form">
<div class="form-group">
<label>默认策略</label>
<select id="default-strategy">
<option value="A">A - 固定止损12%</option>
<option value="B">B - ATR动态止损</option>
<option value="C" selected>C - 混合自适应</option>
</select>
</div>
<div class="form-group">
<label>大盘过滤</label>
<label class="toggle">
<input type="checkbox" id="market-filter" checked>
<span class="toggle-slider"></span>
<span class="toggle-label">启用恒生指数MA20过滤</span>
</label>
</div>
<div class="form-group">
<label>自动分析时间</label>
<input type="time" id="auto-analysis-time" value="09:00">
</div>
<button class="btn btn-primary" id="save-settings">保存设置</button>
</div>
</div>
</div>
</div>
</main>
</div>
<!-- 添加持仓弹窗 -->
<div class="modal hidden" id="add-position-modal">
<div class="modal-content">
<div class="modal-header">
<h3>添加持仓</h3>
<button class="modal-close">&times;</button>
</div>
<form id="add-position-form">
<div class="form-group">
<label>股票名称</label>
<input type="text" id="pos-name" required placeholder="如:中芯国际">
</div>
<div class="form-group">
<label>股票代码</label>
<input type="text" id="pos-ticker" required placeholder="如0981.HK">
</div>
<div class="form-group">
<label>持仓数量</label>
<input type="number" id="pos-shares" required min="1" placeholder="1000">
</div>
<div class="form-group">
<label>成本价</label>
<input type="number" id="pos-cost" required step="0.001" placeholder="50.00">
</div>
<div class="form-group">
<label>策略</label>
<select id="pos-strategy">
<option value="C" selected>C - 混合自适应</option>
<option value="A">A - 固定止损12%</option>
<option value="B">B - ATR动态止损</option>
</select>
</div>
<div class="form-group">
<label>备注</label>
<textarea id="pos-notes" rows="2"></textarea>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" id="cancel-add">取消</button>
<button type="submit" class="btn btn-primary">添加</button>
</div>
</form>
</div>
</div>
<script src="app.js"></script>
</body>
</html>

608
frontend/style.css Normal file
View File

@@ -0,0 +1,608 @@
/* Stock Buddy 样式 */
:root {
--primary: #3b82f6;
--primary-dark: #2563eb;
--secondary: #64748b;
--success: #22c55e;
--danger: #ef4444;
--warning: #f59e0b;
--bg-primary: #0f172a;
--bg-secondary: #1e293b;
--bg-tertiary: #334155;
--text-primary: #f8fafc;
--text-secondary: #94a3b8;
--border: #334155;
--radius: 8px;
--shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
}
#app {
display: flex;
min-height: 100vh;
}
/* 侧边栏 */
.sidebar {
width: 240px;
background: var(--bg-secondary);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
position: fixed;
height: 100vh;
}
.logo {
padding: 24px 20px;
display: flex;
align-items: center;
gap: 12px;
border-bottom: 1px solid var(--border);
}
.logo-icon {
font-size: 24px;
}
.logo-text {
font-size: 20px;
font-weight: 700;
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.nav-menu {
flex: 1;
padding: 16px 12px;
}
.nav-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
border-radius: var(--radius);
color: var(--text-secondary);
text-decoration: none;
margin-bottom: 4px;
transition: all 0.2s;
}
.nav-item:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.nav-item.active {
background: rgba(59, 130, 246, 0.15);
color: var(--primary);
}
.nav-icon {
font-size: 18px;
}
.sidebar-footer {
padding: 16px;
border-top: 1px solid var(--border);
}
.market-status {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--text-secondary);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.status-dot.green {
background: var(--success);
box-shadow: 0 0 8px var(--success);
}
/* 主内容区 */
.main-content {
flex: 1;
margin-left: 240px;
display: flex;
flex-direction: column;
}
.header {
height: 64px;
padding: 0 32px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--border);
background: var(--bg-secondary);
}
.header h1 {
font-size: 24px;
font-weight: 600;
}
.header-actions {
display: flex;
gap: 12px;
}
.content {
flex: 1;
padding: 32px;
overflow-y: auto;
}
/* 页面切换 */
.page {
display: none;
}
.page.active {
display: block;
}
/* 按钮 */
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
border-radius: var(--radius);
border: none;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary {
background: var(--primary);
color: white;
}
.btn-primary:hover {
background: var(--primary-dark);
}
.btn-secondary {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.btn-secondary:hover {
background: var(--border);
}
.btn-danger {
background: var(--danger);
color: white;
}
/* 统计卡片 */
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 24px;
margin-bottom: 32px;
}
.stat-card {
background: var(--bg-secondary);
padding: 24px;
border-radius: var(--radius);
border: 1px solid var(--border);
}
.stat-label {
font-size: 14px;
color: var(--text-secondary);
margin-bottom: 8px;
}
.stat-value {
font-size: 32px;
font-weight: 700;
margin-bottom: 8px;
}
.stat-value.buy {
color: var(--success);
}
.stat-value.sell {
color: var(--danger);
}
.stat-change {
font-size: 14px;
font-weight: 500;
}
.stat-change.positive {
color: var(--success);
}
.stat-change.negative {
color: var(--danger);
}
.stat-label-small {
font-size: 13px;
color: var(--text-secondary);
}
/* 表格 */
.section {
background: var(--bg-secondary);
border-radius: var(--radius);
border: 1px solid var(--border);
margin-bottom: 24px;
}
.section-title {
padding: 20px 24px;
font-size: 18px;
font-weight: 600;
border-bottom: 1px solid var(--border);
}
.section-header {
padding: 20px 24px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--border);
}
.table-container {
overflow-x: auto;
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th,
.data-table td {
padding: 16px 24px;
text-align: left;
border-bottom: 1px solid var(--border);
}
.data-table th {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.data-table tbody tr:hover {
background: rgba(255, 255, 255, 0.03);
}
.data-table .positive {
color: var(--success);
}
.data-table .negative {
color: var(--danger);
}
.data-table .loading {
text-align: center;
color: var(--text-secondary);
padding: 40px;
}
/* 信号标签 */
.signal-tag {
display: inline-flex;
align-items: center;
padding: 4px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
}
.signal-tag.buy {
background: rgba(34, 197, 94, 0.15);
color: var(--success);
}
.signal-tag.sell {
background: rgba(239, 68, 68, 0.15);
color: var(--danger);
}
.signal-tag.hold {
background: rgba(148, 163, 184, 0.15);
color: var(--text-secondary);
}
/* 搜索框 */
.search-box {
display: flex;
gap: 12px;
padding: 24px;
border-bottom: 1px solid var(--border);
}
.search-box input {
flex: 1;
padding: 12px 16px;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text-primary);
font-size: 14px;
}
.search-box input:focus {
outline: none;
border-color: var(--primary);
}
/* 分析结果 */
.analysis-result {
padding: 24px;
}
.analysis-result.hidden {
display: none;
}
.result-loading {
text-align: center;
padding: 60px;
color: var(--text-secondary);
}
.result-card {
background: var(--bg-primary);
border-radius: var(--radius);
padding: 24px;
margin-bottom: 16px;
}
.result-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.result-title {
font-size: 20px;
font-weight: 600;
}
.result-body {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
}
.result-item {
padding: 16px;
background: var(--bg-secondary);
border-radius: var(--radius);
}
.result-label {
font-size: 12px;
color: var(--text-secondary);
margin-bottom: 8px;
}
.result-value {
font-size: 24px;
font-weight: 700;
}
/* 弹窗 */
.modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal.hidden {
display: none;
}
.modal-content {
background: var(--bg-secondary);
border-radius: var(--radius);
width: 100%;
max-width: 480px;
max-height: 90vh;
overflow-y: auto;
}
.modal-header {
padding: 20px 24px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--border);
}
.modal-header h3 {
font-size: 18px;
font-weight: 600;
}
.modal-close {
background: none;
border: none;
color: var(--text-secondary);
font-size: 24px;
cursor: pointer;
}
.modal-close:hover {
color: var(--text-primary);
}
/* 表单 */
form {
padding: 24px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
font-size: 14px;
font-weight: 500;
margin-bottom: 8px;
color: var(--text-secondary);
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: 12px 16px;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text-primary);
font-size: 14px;
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--primary);
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 24px;
}
/* 设置页 */
.settings-form {
padding: 24px;
max-width: 480px;
}
.toggle {
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
}
.toggle input {
display: none;
}
.toggle-slider {
width: 48px;
height: 24px;
background: var(--bg-tertiary);
border-radius: 12px;
position: relative;
transition: all 0.2s;
}
.toggle-slider::after {
content: '';
position: absolute;
width: 20px;
height: 20px;
background: white;
border-radius: 50%;
top: 2px;
left: 2px;
transition: all 0.2s;
}
.toggle input:checked + .toggle-slider {
background: var(--primary);
}
.toggle input:checked + .toggle-slider::after {
left: 26px;
}
.toggle-label {
font-size: 14px;
color: var(--text-primary);
}
/* 空状态 */
.empty-state {
text-align: center;
padding: 60px;
color: var(--text-secondary);
}
/* 响应式 */
@media (max-width: 1200px) {
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.sidebar {
width: 60px;
}
.logo-text,
.nav-item span:not(.nav-icon) {
display: none;
}
.main-content {
margin-left: 60px;
}
.stats-grid {
grid-template-columns: 1fr;
}
.result-body {
grid-template-columns: 1fr;
}
}

118
llm_sentiment_agent.py Normal file
View File

@@ -0,0 +1,118 @@
"""
llm_sentiment_agent.py — 通过Agent生成舆情数据
用法:
python3 llm_sentiment_agent.py --generate 中芯国际 2024-06-01 2024-06-30
这会产生一个请求你可以复制给Agent来获取情绪分析
"""
import argparse
import json
from datetime import datetime, timedelta
def generate_prompt(stock_name, date_range):
"""生成给Agent的舆情分析请求"""
# 模拟新闻标题实际应从新闻API获取
mock_news = {
"中芯国际": [
"中芯国际Q2营收超预期先进制程占比提升",
"半导体行业复苏迹象明显,中芯产能利用率回升",
"大基金增持中芯国际,看好长期发展",
"美国制裁影响有限,中芯国产替代加速"
],
"平安好医生": [
"平安好医生亏损收窄,互联网医疗政策利好",
"医保支付接入线上问诊,行业迎来拐点",
"阿里健康京东健康竞争加剧,市场份额受挤压"
],
"叮当健康": [
"叮当健康持续亏损,即时配送成本居高不下",
"医药电商价格战激烈,盈利前景不明"
],
"中原建业": [
"房地产销售持续下滑,代建业务需求萎缩",
"中原建业股价创历史新低,流动性危机隐现"
],
"阅文集团": [
"《庆余年2》热播带动阅文IP变现增长",
"网文改编影视剧大获成功,版权收入提升",
"短视频冲击长文字阅读,用户增长放缓"
],
"泰升集团": [
"港股小盘股流动性枯竭,泰升集团成交低迷",
"地产业务不温不火,缺乏催化剂"
]
}
news = mock_news.get(stock_name, ["暂无相关新闻"])
prompt = f"""请分析【{stock_name}】在 {date_range} 期间的市场情绪。
新闻标题:
{chr(10).join(['- ' + n for n in news])}
请给出:
1. 整体情绪倾向(极度悲观/悲观/中性/乐观/极度乐观)
2. 情绪分数(-5到+5的整数0为中性
3. 主要影响因素(政策/业绩/行业/竞争等)
4. 未来1个月预期
返回JSON格式
{{
"sentiment_score": 0,
"sentiment_label": "中性",
"factors": ["因素1", "因素2"],
"outlook": "短期震荡"
}}
"""
return prompt
def main():
parser = argparse.ArgumentParser(description='通过Agent生成舆情数据')
parser.add_argument('--generate', nargs=3, metavar=('STOCK', 'START', 'END'),
help='生成舆情分析请求,如:--generate 中芯国际 2024-06-01 2024-06-30')
parser.add_argument('--example', action='store_true', help='显示示例输出')
args = parser.parse_args()
if args.generate:
stock, start, end = args.generate
prompt = generate_prompt(stock, f"{start} ~ {end}")
print("=" * 60)
print("📋 请将以下内容发送给Agent")
print("=" * 60)
print()
print(prompt)
print()
print("=" * 60)
print("📥 收到回复后将JSON结果保存到 data/llm_sentiment.json")
elif args.example:
example = {
"中芯国际": {
"2024-06-15": {
"sentiment_score": 3,
"sentiment_label": "乐观",
"factors": ["业绩超预期", "行业复苏"],
"outlook": "短期看涨",
"source": "agent_analysis"
}
}
}
print("示例输出格式(保存到 data/llm_sentiment.json")
print(json.dumps(example, indent=2, ensure_ascii=False))
else:
print("LLM舆情Agent接口")
print("\n用法:")
print(" python3 llm_sentiment_agent.py --generate 中芯国际 2024-06-01 2024-06-30")
print(" python3 llm_sentiment_agent.py --example")
print("\n提示:")
print(" 1. 先用 --generate 产生请求内容")
print(" 2. 将内容发给Agent获取分析")
print(" 3. 把返回的JSON保存到 data/llm_sentiment.json")
print(" 4. 运行 stock_backtest_v7.py 时会自动读取")
if __name__ == "__main__":
main()

30
start.sh Executable file
View File

@@ -0,0 +1,30 @@
#!/bin/bash
# Stock Buddy 启动脚本
echo "🚀 Stock Buddy 交易系统"
echo "======================="
# 检查Python
if ! command -v python3 &> /dev/null; then
echo "❌ 未找到 Python3"
exit 1
fi
# 安装依赖
echo "📦 检查依赖..."
pip install -q -r backend/requirements.txt
# 初始化数据库
echo "🗄️ 初始化数据库..."
cd backend
python3 -c "from database import init_db; init_db()"
# 启动后端
echo "🔥 启动后端服务 (http://localhost:8000)"
echo "📱 前端地址: http://localhost:8000/app"
echo ""
echo "按 Ctrl+C 停止服务"
echo ""
uvicorn main:app --host 0.0.0.0 --port 8000 --reload

View File

@@ -23,6 +23,9 @@ STOCKS = {
"平安好医生": "1833.HK",
"叮当健康": "9886.HK",
"中原建业": "9982.HK",
"泰升集团": "0687.HK",
"阅文集团": "0772.HK",
"中芯国际": "0981.HK",
}
PERIOD = "2y"
INITIAL_CAPITAL = 10000.0
@@ -67,6 +70,24 @@ FUNDAMENTAL = {
{"from": "2025-01-01", "score": -4.0},
{"from": "2025-10-01", "score": -5.0},
],
"泰升集团": [
{"from": "2024-01-01", "score": -1.0},
{"from": "2024-06-01", "score": -1.0},
{"from": "2025-01-01", "score": -2.0},
{"from": "2025-10-01", "score": -2.0},
],
"阅文集团": [
{"from": "2024-01-01", "score": 1.0},
{"from": "2024-06-01", "score": 2.0},
{"from": "2025-01-01", "score": 2.0},
{"from": "2025-10-01", "score": 3.0},
],
"中芯国际": [
{"from": "2024-01-01", "score": 2.0},
{"from": "2024-06-01", "score": 3.0},
{"from": "2025-01-01", "score": 3.0},
{"from": "2025-10-01", "score": 4.0},
],
}
SENTIMENT = {
"平安好医生": [
@@ -87,6 +108,24 @@ SENTIMENT = {
{"from": "2025-01-01", "score": -3.0},
{"from": "2025-10-01", "score": -4.0},
],
"泰升集团": [
{"from": "2024-01-01", "score": -1.0},
{"from": "2024-06-01", "score": -1.0},
{"from": "2025-01-01", "score": -1.0},
{"from": "2025-10-01", "score": -1.0},
],
"阅文集团": [
{"from": "2024-01-01", "score": 1.0},
{"from": "2024-06-01", "score": 2.0},
{"from": "2025-01-01", "score": 2.0},
{"from": "2025-10-01", "score": 2.0},
],
"中芯国际": [
{"from": "2024-01-01", "score": 2.0},
{"from": "2024-06-01", "score": 2.0},
{"from": "2025-01-01", "score": 3.0},
{"from": "2025-10-01", "score": 3.0},
],
}
# ── 工具函数 ──────────────────────────────────────────────────────────