feat: complete trading system with FastAPI backend, web frontend, and auto-analysis
This commit is contained in:
44
ARCHITECTURE.md
Normal file
44
ARCHITECTURE.md
Normal 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
178
SYSTEM_README.md
Normal 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
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
|
||||||
466
frontend/app.js
Normal file
466
frontend/app.js
Normal 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
263
frontend/index.html
Normal 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">×</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
608
frontend/style.css
Normal 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
118
llm_sentiment_agent.py
Normal 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
30
start.sh
Executable 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
|
||||||
@@ -23,6 +23,9 @@ STOCKS = {
|
|||||||
"平安好医生": "1833.HK",
|
"平安好医生": "1833.HK",
|
||||||
"叮当健康": "9886.HK",
|
"叮当健康": "9886.HK",
|
||||||
"中原建业": "9982.HK",
|
"中原建业": "9982.HK",
|
||||||
|
"泰升集团": "0687.HK",
|
||||||
|
"阅文集团": "0772.HK",
|
||||||
|
"中芯国际": "0981.HK",
|
||||||
}
|
}
|
||||||
PERIOD = "2y"
|
PERIOD = "2y"
|
||||||
INITIAL_CAPITAL = 10000.0
|
INITIAL_CAPITAL = 10000.0
|
||||||
@@ -67,6 +70,24 @@ FUNDAMENTAL = {
|
|||||||
{"from": "2025-01-01", "score": -4.0},
|
{"from": "2025-01-01", "score": -4.0},
|
||||||
{"from": "2025-10-01", "score": -5.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 = {
|
SENTIMENT = {
|
||||||
"平安好医生": [
|
"平安好医生": [
|
||||||
@@ -87,6 +108,24 @@ SENTIMENT = {
|
|||||||
{"from": "2025-01-01", "score": -3.0},
|
{"from": "2025-01-01", "score": -3.0},
|
||||||
{"from": "2025-10-01", "score": -4.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},
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── 工具函数 ──────────────────────────────────────────────────────────
|
# ── 工具函数 ──────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user