Files
daily_stock_analysis/storage.py
2026-01-10 15:38:56 +08:00

509 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- coding: utf-8 -*-
"""
===================================
A股自选股智能分析系统 - 存储层
===================================
职责:
1. 管理 SQLite 数据库连接(单例模式)
2. 定义 ORM 数据模型
3. 提供数据存取接口
4. 实现智能更新逻辑(断点续传)
"""
import logging
from datetime import datetime, date, timedelta
from typing import Optional, List, Dict, Any
from pathlib import Path
import pandas as pd
from sqlalchemy import (
create_engine,
Column,
String,
Float,
Date,
DateTime,
Integer,
Index,
UniqueConstraint,
select,
and_,
desc,
)
from sqlalchemy.orm import (
declarative_base,
sessionmaker,
Session,
)
from sqlalchemy.exc import IntegrityError
from config import get_config
logger = logging.getLogger(__name__)
# SQLAlchemy ORM 基类
Base = declarative_base()
# === 数据模型定义 ===
class StockDaily(Base):
"""
股票日线数据模型
存储每日行情数据和计算的技术指标
支持多股票、多日期的唯一约束
"""
__tablename__ = 'stock_daily'
# 主键
id = Column(Integer, primary_key=True, autoincrement=True)
# 股票代码(如 600519, 000001
code = Column(String(10), nullable=False, index=True)
# 交易日期
date = Column(Date, nullable=False, index=True)
# OHLC 数据
open = Column(Float)
high = Column(Float)
low = Column(Float)
close = Column(Float)
# 成交数据
volume = Column(Float) # 成交量(股)
amount = Column(Float) # 成交额(元)
pct_chg = Column(Float) # 涨跌幅(%
# 技术指标
ma5 = Column(Float)
ma10 = Column(Float)
ma20 = Column(Float)
volume_ratio = Column(Float) # 量比
# 数据来源
data_source = Column(String(50)) # 记录数据来源(如 AkshareFetcher
# 更新时间
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
# 唯一约束:同一股票同一日期只能有一条数据
__table_args__ = (
UniqueConstraint('code', 'date', name='uix_code_date'),
Index('ix_code_date', 'code', 'date'),
)
def __repr__(self):
return f"<StockDaily(code={self.code}, date={self.date}, close={self.close})>"
def to_dict(self) -> Dict[str, Any]:
"""转换为字典"""
return {
'code': self.code,
'date': self.date,
'open': self.open,
'high': self.high,
'low': self.low,
'close': self.close,
'volume': self.volume,
'amount': self.amount,
'pct_chg': self.pct_chg,
'ma5': self.ma5,
'ma10': self.ma10,
'ma20': self.ma20,
'volume_ratio': self.volume_ratio,
'data_source': self.data_source,
}
class DatabaseManager:
"""
数据库管理器 - 单例模式
职责:
1. 管理数据库连接池
2. 提供 Session 上下文管理
3. 封装数据存取操作
"""
_instance: Optional['DatabaseManager'] = None
def __new__(cls, *args, **kwargs):
"""单例模式实现"""
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._initialized = False
return cls._instance
def __init__(self, db_url: Optional[str] = None):
"""
初始化数据库管理器
Args:
db_url: 数据库连接 URL可选默认从配置读取
"""
if self._initialized:
return
if db_url is None:
config = get_config()
db_url = config.get_db_url()
# 创建数据库引擎
self._engine = create_engine(
db_url,
echo=False, # 设为 True 可查看 SQL 语句
pool_pre_ping=True, # 连接健康检查
)
# 创建 Session 工厂
self._SessionLocal = sessionmaker(
bind=self._engine,
autocommit=False,
autoflush=False,
)
# 创建所有表
Base.metadata.create_all(self._engine)
self._initialized = True
logger.info(f"数据库初始化完成: {db_url}")
@classmethod
def get_instance(cls) -> 'DatabaseManager':
"""获取单例实例"""
if cls._instance is None:
cls._instance = cls()
return cls._instance
@classmethod
def reset_instance(cls) -> None:
"""重置单例(用于测试)"""
if cls._instance is not None:
cls._instance._engine.dispose()
cls._instance = None
def get_session(self) -> Session:
"""
获取数据库 Session
使用示例:
with db.get_session() as session:
# 执行查询
session.commit() # 如果需要
"""
session = self._SessionLocal()
try:
return session
except Exception:
session.close()
raise
def has_today_data(self, code: str, target_date: Optional[date] = None) -> bool:
"""
检查是否已有指定日期的数据
用于断点续传逻辑:如果已有数据则跳过网络请求
Args:
code: 股票代码
target_date: 目标日期(默认今天)
Returns:
是否存在数据
"""
if target_date is None:
target_date = date.today()
with self.get_session() as session:
result = session.execute(
select(StockDaily).where(
and_(
StockDaily.code == code,
StockDaily.date == target_date
)
)
).scalar_one_or_none()
return result is not None
def get_latest_data(
self,
code: str,
days: int = 2
) -> List[StockDaily]:
"""
获取最近 N 天的数据
用于计算"相比昨日"的变化
Args:
code: 股票代码
days: 获取天数
Returns:
StockDaily 对象列表(按日期降序)
"""
with self.get_session() as session:
results = session.execute(
select(StockDaily)
.where(StockDaily.code == code)
.order_by(desc(StockDaily.date))
.limit(days)
).scalars().all()
return list(results)
def get_data_range(
self,
code: str,
start_date: date,
end_date: date
) -> List[StockDaily]:
"""
获取指定日期范围的数据
Args:
code: 股票代码
start_date: 开始日期
end_date: 结束日期
Returns:
StockDaily 对象列表
"""
with self.get_session() as session:
results = session.execute(
select(StockDaily)
.where(
and_(
StockDaily.code == code,
StockDaily.date >= start_date,
StockDaily.date <= end_date
)
)
.order_by(StockDaily.date)
).scalars().all()
return list(results)
def save_daily_data(
self,
df: pd.DataFrame,
code: str,
data_source: str = "Unknown"
) -> int:
"""
保存日线数据到数据库
策略:
- 使用 UPSERT 逻辑(存在则更新,不存在则插入)
- 跳过已存在的数据,避免重复
Args:
df: 包含日线数据的 DataFrame
code: 股票代码
data_source: 数据来源名称
Returns:
新增/更新的记录数
"""
if df is None or df.empty:
logger.warning(f"保存数据为空,跳过 {code}")
return 0
saved_count = 0
with self.get_session() as session:
try:
for _, row in df.iterrows():
# 解析日期
row_date = row.get('date')
if isinstance(row_date, str):
row_date = datetime.strptime(row_date, '%Y-%m-%d').date()
elif isinstance(row_date, datetime):
row_date = row_date.date()
elif isinstance(row_date, pd.Timestamp):
row_date = row_date.date()
# 检查是否已存在
existing = session.execute(
select(StockDaily).where(
and_(
StockDaily.code == code,
StockDaily.date == row_date
)
)
).scalar_one_or_none()
if existing:
# 更新现有记录
existing.open = row.get('open')
existing.high = row.get('high')
existing.low = row.get('low')
existing.close = row.get('close')
existing.volume = row.get('volume')
existing.amount = row.get('amount')
existing.pct_chg = row.get('pct_chg')
existing.ma5 = row.get('ma5')
existing.ma10 = row.get('ma10')
existing.ma20 = row.get('ma20')
existing.volume_ratio = row.get('volume_ratio')
existing.data_source = data_source
existing.updated_at = datetime.now()
else:
# 创建新记录
record = StockDaily(
code=code,
date=row_date,
open=row.get('open'),
high=row.get('high'),
low=row.get('low'),
close=row.get('close'),
volume=row.get('volume'),
amount=row.get('amount'),
pct_chg=row.get('pct_chg'),
ma5=row.get('ma5'),
ma10=row.get('ma10'),
ma20=row.get('ma20'),
volume_ratio=row.get('volume_ratio'),
data_source=data_source,
)
session.add(record)
saved_count += 1
session.commit()
logger.info(f"保存 {code} 数据成功,新增 {saved_count}")
except Exception as e:
session.rollback()
logger.error(f"保存 {code} 数据失败: {e}")
raise
return saved_count
def get_analysis_context(
self,
code: str,
target_date: Optional[date] = None
) -> Optional[Dict[str, Any]]:
"""
获取分析所需的上下文数据
返回今日数据 + 昨日数据的对比信息
Args:
code: 股票代码
target_date: 目标日期(默认今天)
Returns:
包含今日数据、昨日对比等信息的字典
"""
if target_date is None:
target_date = date.today()
# 获取最近2天数据
recent_data = self.get_latest_data(code, days=2)
if not recent_data:
logger.warning(f"未找到 {code} 的数据")
return None
today_data = recent_data[0]
yesterday_data = recent_data[1] if len(recent_data) > 1 else None
context = {
'code': code,
'date': today_data.date.isoformat(),
'today': today_data.to_dict(),
}
if yesterday_data:
context['yesterday'] = yesterday_data.to_dict()
# 计算相比昨日的变化
if yesterday_data.volume and yesterday_data.volume > 0:
context['volume_change_ratio'] = round(
today_data.volume / yesterday_data.volume, 2
)
if yesterday_data.close and yesterday_data.close > 0:
context['price_change_ratio'] = round(
(today_data.close - yesterday_data.close) / yesterday_data.close * 100, 2
)
# 均线形态判断
context['ma_status'] = self._analyze_ma_status(today_data)
return context
def _analyze_ma_status(self, data: StockDaily) -> str:
"""
分析均线形态
判断条件:
- 多头排列close > ma5 > ma10 > ma20
- 空头排列close < ma5 < ma10 < ma20
- 震荡整理:其他情况
"""
close = data.close or 0
ma5 = data.ma5 or 0
ma10 = data.ma10 or 0
ma20 = data.ma20 or 0
if close > ma5 > ma10 > ma20 > 0:
return "多头排列 📈"
elif close < ma5 < ma10 < ma20 and ma20 > 0:
return "空头排列 📉"
elif close > ma5 and ma5 > ma10:
return "短期向好 🔼"
elif close < ma5 and ma5 < ma10:
return "短期走弱 🔽"
else:
return "震荡整理 ↔️"
# 便捷函数
def get_db() -> DatabaseManager:
"""获取数据库管理器实例的快捷方式"""
return DatabaseManager.get_instance()
if __name__ == "__main__":
# 测试代码
logging.basicConfig(level=logging.DEBUG)
db = get_db()
print("=== 数据库测试 ===")
print(f"数据库初始化成功")
# 测试检查今日数据
has_data = db.has_today_data('600519')
print(f"茅台今日是否有数据: {has_data}")
# 测试保存数据
test_df = pd.DataFrame({
'date': [date.today()],
'open': [1800.0],
'high': [1850.0],
'low': [1780.0],
'close': [1820.0],
'volume': [10000000],
'amount': [18200000000],
'pct_chg': [1.5],
'ma5': [1810.0],
'ma10': [1800.0],
'ma20': [1790.0],
'volume_ratio': [1.2],
})
saved = db.save_daily_data(test_df, '600519', 'TestSource')
print(f"保存测试数据: {saved}")
# 测试获取上下文
context = db.get_analysis_context('600519')
print(f"分析上下文: {context}")