|
|
|
|
|
"""
|
|
|
|
|
|
K 线数据服务 v2 - 缓存优先策略
|
|
|
|
|
|
"""
|
|
|
|
|
|
import logging
|
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
|
from typing import List, Optional
|
|
|
|
|
|
|
|
|
|
|
|
from sqlalchemy import text
|
|
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
|
|
|
|
|
|
|
|
from app.db.init_db import TimescaleSessionLocal
|
|
|
|
|
|
from app.services.cache_service import cache_service
|
|
|
|
|
|
from app.services.amazing_data_service import amazing_data_service
|
|
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class KlineService:
|
|
|
|
|
|
"""K 线数据服务"""
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def get_kline_data(
|
|
|
|
|
|
symbol: str,
|
|
|
|
|
|
period: str,
|
|
|
|
|
|
start: datetime,
|
|
|
|
|
|
end: datetime,
|
|
|
|
|
|
page: int = 1,
|
|
|
|
|
|
page_size: int = 1000
|
|
|
|
|
|
) -> List[dict]:
|
|
|
|
|
|
"""
|
|
|
|
|
|
获取 K 线数据(支持分页)
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
symbol: 品种代码
|
|
|
|
|
|
period: 周期 (1m, 5m, 1h, 1d 等)
|
|
|
|
|
|
start: 开始时间
|
|
|
|
|
|
end: 结束时间
|
|
|
|
|
|
page: 页码 (默认 1)
|
|
|
|
|
|
page_size: 每页数量 (默认 1000)
|
|
|
|
|
|
|
|
|
|
|
|
Raises:
|
|
|
|
|
|
ValueError: 当 start > end 时抛出异常
|
|
|
|
|
|
"""
|
|
|
|
|
|
# 边界条件验证:开始时间不能大于结束时间
|
|
|
|
|
|
if start > end:
|
|
|
|
|
|
raise ValueError("开始时间不能大于结束时间 (startTime > endTime)")
|
|
|
|
|
|
|
|
|
|
|
|
with TimescaleSessionLocal() as db:
|
|
|
|
|
|
offset = (page - 1) * page_size
|
|
|
|
|
|
query = text("""
|
|
|
|
|
|
SELECT time, open, high, low, close, volume, amount, open_interest
|
|
|
|
|
|
FROM kline_data
|
|
|
|
|
|
WHERE symbol = :symbol
|
|
|
|
|
|
AND period = :period
|
|
|
|
|
|
AND time >= :start
|
|
|
|
|
|
AND time <= :end
|
|
|
|
|
|
ORDER BY time ASC
|
|
|
|
|
|
LIMIT :limit OFFSET :offset
|
|
|
|
|
|
""")
|
|
|
|
|
|
|
|
|
|
|
|
result = db.execute(
|
|
|
|
|
|
query,
|
|
|
|
|
|
{
|
|
|
|
|
|
"symbol": symbol,
|
|
|
|
|
|
"period": period,
|
|
|
|
|
|
"start": start,
|
|
|
|
|
|
"end": end,
|
|
|
|
|
|
"limit": page_size,
|
|
|
|
|
|
"offset": offset
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
rows = result.fetchall()
|
|
|
|
|
|
return [
|
|
|
|
|
|
{
|
|
|
|
|
|
"time": row[0].isoformat(),
|
|
|
|
|
|
"open": float(row[1]),
|
|
|
|
|
|
"high": float(row[2]),
|
|
|
|
|
|
"low": float(row[3]),
|
|
|
|
|
|
"close": float(row[4]),
|
|
|
|
|
|
"volume": int(row[5]),
|
|
|
|
|
|
"amount": float(row[6]) if row[6] else None,
|
|
|
|
|
|
"open_interest": int(row[7]) if row[7] else None
|
|
|
|
|
|
}
|
|
|
|
|
|
for row in rows
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def get_latest_kline(symbol: str, period: str) -> Optional[dict]:
|
|
|
|
|
|
"""获取最新一条 K 线数据"""
|
|
|
|
|
|
with TimescaleSessionLocal() as db:
|
|
|
|
|
|
query = text("""
|
|
|
|
|
|
SELECT time, open, high, low, close, volume, amount, open_interest
|
|
|
|
|
|
FROM kline_data
|
|
|
|
|
|
WHERE symbol = :symbol AND period = :period
|
|
|
|
|
|
ORDER BY time DESC
|
|
|
|
|
|
LIMIT 1
|
|
|
|
|
|
""")
|
|
|
|
|
|
|
|
|
|
|
|
result = db.execute(query, {"symbol": symbol, "period": period})
|
|
|
|
|
|
row = result.fetchone()
|
|
|
|
|
|
|
|
|
|
|
|
if row:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"time": row[0].isoformat(),
|
|
|
|
|
|
"open": float(row[1]),
|
|
|
|
|
|
"high": float(row[2]),
|
|
|
|
|
|
"low": float(row[3]),
|
|
|
|
|
|
"close": float(row[4]),
|
|
|
|
|
|
"volume": int(row[5]),
|
|
|
|
|
|
"amount": float(row[6]) if row[6] else None,
|
|
|
|
|
|
"open_interest": int(row[7]) if row[7] else None
|
|
|
|
|
|
}
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def insert_kline_data(
|
|
|
|
|
|
symbol: str,
|
|
|
|
|
|
period: str,
|
|
|
|
|
|
kline_data: List[dict]
|
|
|
|
|
|
) -> int:
|
|
|
|
|
|
"""
|
|
|
|
|
|
批量插入 K 线数据
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
插入的记录数
|
|
|
|
|
|
"""
|
|
|
|
|
|
with TimescaleSessionLocal() as db:
|
|
|
|
|
|
query = text("""
|
|
|
|
|
|
INSERT INTO kline_data
|
|
|
|
|
|
(time, symbol, period, open, high, low, close, volume, amount, open_interest)
|
|
|
|
|
|
VALUES
|
|
|
|
|
|
(:time, :symbol, :period, :open, :high, :low, :close, :volume, :amount, :open_interest)
|
|
|
|
|
|
""")
|
|
|
|
|
|
|
|
|
|
|
|
count = 0
|
|
|
|
|
|
for kline in kline_data:
|
|
|
|
|
|
db.execute(
|
|
|
|
|
|
query,
|
|
|
|
|
|
{
|
|
|
|
|
|
"time": kline["time"],
|
|
|
|
|
|
"symbol": symbol,
|
|
|
|
|
|
"period": period,
|
|
|
|
|
|
"open": kline["open"],
|
|
|
|
|
|
"high": kline["high"],
|
|
|
|
|
|
"low": kline["low"],
|
|
|
|
|
|
"close": kline["close"],
|
|
|
|
|
|
"volume": kline["volume"],
|
|
|
|
|
|
"amount": kline.get("amount", 0),
|
|
|
|
|
|
"open_interest": kline.get("open_interest", 0)
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
count += 1
|
|
|
|
|
|
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
return count
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def get_symbols() -> List[str]:
|
|
|
|
|
|
"""获取所有品种代码"""
|
|
|
|
|
|
with TimescaleSessionLocal() as db:
|
|
|
|
|
|
query = text("""
|
|
|
|
|
|
SELECT DISTINCT symbol FROM kline_data ORDER BY symbol
|
|
|
|
|
|
""")
|
|
|
|
|
|
result = db.execute(query)
|
|
|
|
|
|
return [row[0] for row in result.fetchall()]
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def get_periods() -> List[str]:
|
|
|
|
|
|
"""获取所有周期"""
|
|
|
|
|
|
with TimescaleSessionLocal() as db:
|
|
|
|
|
|
query = text("""
|
|
|
|
|
|
SELECT DISTINCT period FROM kline_data ORDER BY period
|
|
|
|
|
|
""")
|
|
|
|
|
|
result = db.execute(query)
|
|
|
|
|
|
return [row[0] for row in result.fetchall()]
|
|
|
|
|
|
|
|
|
|
|
|
# ==================== V2 缓存优先策略 ====================
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
async def get_kline_data_v2(
|
|
|
|
|
|
symbol: str,
|
|
|
|
|
|
period: str,
|
|
|
|
|
|
start_date: datetime,
|
|
|
|
|
|
end_date: datetime,
|
|
|
|
|
|
page: int = 1,
|
|
|
|
|
|
page_size: int = 1000,
|
|
|
|
|
|
use_cache: bool = True
|
|
|
|
|
|
) -> List[dict]:
|
|
|
|
|
|
"""
|
|
|
|
|
|
获取 K 线数据 v2 - 缓存优先策略
|
|
|
|
|
|
|
|
|
|
|
|
核心逻辑:
|
|
|
|
|
|
1. 先查询 Redis 缓存
|
|
|
|
|
|
2. 缓存命中直接返回
|
|
|
|
|
|
3. 缓存未命中则调用 amazingData 获取数据
|
|
|
|
|
|
4. 写入缓存并返回
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
symbol: 品种代码
|
|
|
|
|
|
period: 周期 (1m, 5m, 1h, 1d 等)
|
|
|
|
|
|
start_date: 开始时间
|
|
|
|
|
|
end_date: 结束时间
|
|
|
|
|
|
page: 页码
|
|
|
|
|
|
page_size: 每页数量
|
|
|
|
|
|
use_cache: 是否使用缓存
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
K 线数据列表
|
|
|
|
|
|
"""
|
|
|
|
|
|
# 边界条件验证
|
|
|
|
|
|
if start_date > end_date:
|
|
|
|
|
|
raise ValueError("开始时间不能大于结束时间 (startTime > endTime)")
|
|
|
|
|
|
|
|
|
|
|
|
# 1. 尝试从缓存获取
|
|
|
|
|
|
if use_cache:
|
|
|
|
|
|
cached_data = await cache_service.get_kline(
|
|
|
|
|
|
symbol=symbol,
|
|
|
|
|
|
period=period,
|
|
|
|
|
|
start_date=start_date,
|
|
|
|
|
|
end_date=end_date
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if cached_data:
|
|
|
|
|
|
logger.info(f"Cache hit for {symbol} {period} {start_date} to {end_date}")
|
|
|
|
|
|
# 分页处理
|
|
|
|
|
|
start_idx = (page - 1) * page_size
|
|
|
|
|
|
end_idx = start_idx + page_size
|
|
|
|
|
|
return cached_data[start_idx:end_idx]
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(f"Cache miss for {symbol} {period}, fetching from amazingData")
|
|
|
|
|
|
|
|
|
|
|
|
# 2. 缓存未命中,调用 amazingData 获取数据
|
|
|
|
|
|
try:
|
|
|
|
|
|
# 确保连接
|
|
|
|
|
|
if not amazing_data_service.ensure_connected():
|
|
|
|
|
|
logger.warning("amazingData not connected, trying database fallback")
|
|
|
|
|
|
# 回退到数据库查询
|
|
|
|
|
|
return KlineService.get_kline_data(symbol, period, start_date, end_date, page, page_size)
|
|
|
|
|
|
|
|
|
|
|
|
# 从 amazingData 获取数据
|
|
|
|
|
|
kline_data = amazing_data_service.get_kline_data(
|
|
|
|
|
|
symbol=symbol,
|
|
|
|
|
|
period=period,
|
|
|
|
|
|
start_date=start_date.strftime("%Y-%m-%d"),
|
|
|
|
|
|
end_date=end_date.strftime("%Y-%m-%d")
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if not kline_data:
|
|
|
|
|
|
logger.warning(f"No data from amazingData for {symbol} {period}")
|
|
|
|
|
|
# 回退到数据库查询
|
|
|
|
|
|
return KlineService.get_kline_data(symbol, period, start_date, end_date, page, page_size)
|
|
|
|
|
|
|
|
|
|
|
|
# 3. 写入缓存
|
|
|
|
|
|
if use_cache:
|
|
|
|
|
|
await cache_service.set_kline(
|
|
|
|
|
|
symbol=symbol,
|
|
|
|
|
|
period=period,
|
|
|
|
|
|
start_date=start_date,
|
|
|
|
|
|
end_date=end_date,
|
|
|
|
|
|
data=kline_data
|
|
|
|
|
|
)
|
|
|
|
|
|
logger.info(f"Cached {len(kline_data)} records for {symbol} {period}")
|
|
|
|
|
|
|
|
|
|
|
|
# 4. 分页返回
|
|
|
|
|
|
start_idx = (page - 1) * page_size
|
|
|
|
|
|
end_idx = start_idx + page_size
|
|
|
|
|
|
return kline_data[start_idx:end_idx]
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"Failed to fetch from amazingData: {e}")
|
|
|
|
|
|
# 回退到数据库查询
|
|
|
|
|
|
return KlineService.get_kline_data(symbol, period, start_date, end_date, page, page_size)
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
async def get_latest_kline_v2(
|
|
|
|
|
|
symbol: str,
|
|
|
|
|
|
period: str,
|
|
|
|
|
|
use_cache: bool = True
|
|
|
|
|
|
) -> Optional[dict]:
|
|
|
|
|
|
"""
|
|
|
|
|
|
获取最新一条 K 线数据(缓存优先)
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
symbol: 品种代码
|
|
|
|
|
|
period: 周期
|
|
|
|
|
|
use_cache: 是否使用缓存
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
最新一条 K 线数据,如果没有则返回 None
|
|
|
|
|
|
"""
|
|
|
|
|
|
from datetime import timedelta
|
|
|
|
|
|
|
|
|
|
|
|
# 查询最近 7 天的数据
|
|
|
|
|
|
end_date = datetime.utcnow()
|
|
|
|
|
|
start_date = end_date - timedelta(days=7)
|
|
|
|
|
|
|
|
|
|
|
|
# 获取数据
|
|
|
|
|
|
data = await KlineService.get_kline_data_v2(
|
|
|
|
|
|
symbol=symbol,
|
|
|
|
|
|
period=period,
|
|
|
|
|
|
start_date=start_date,
|
|
|
|
|
|
end_date=end_date,
|
|
|
|
|
|
page=1,
|
|
|
|
|
|
page_size=1,
|
|
|
|
|
|
use_cache=use_cache
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return data[0] if data else None
|