first commit
This commit is contained in:
0
backend/app/data/__init__.py
Normal file
0
backend/app/data/__init__.py
Normal file
54
backend/app/data/aggregation.py
Normal file
54
backend/app/data/aggregation.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""K 线周期聚合:日线 -> 周/月/年。
|
||||
|
||||
生产环境用 TimescaleDB Continuous Aggregates 在库里预物化(性能);
|
||||
MVP 在应用层用 pandas resample 即可,逻辑等价、便于切换。
|
||||
|
||||
OHLCV 聚合规则:开=周期内首根开、高=最高、低=最低、收=末根收、量=求和。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from ..domain import Bar
|
||||
|
||||
# pandas resample 规则(周一为周首;月/年以首日对齐)
|
||||
_RULES = {"1w": "W-MON", "1M": "MS", "1y": "YS"}
|
||||
|
||||
# 各周期的"年交易日数"(用于夏普等指标的年化)
|
||||
_BARS_PER_YEAR = {"1d": 252, "1w": 52, "1M": 12, "1y": 1}
|
||||
|
||||
|
||||
def bars_per_year(timeframe: str) -> int:
|
||||
return _BARS_PER_YEAR.get(timeframe, 252)
|
||||
|
||||
|
||||
def resample_bars(bars: list[Bar], timeframe: str) -> list[Bar]:
|
||||
"""把日线 bars 聚合为目标周期;日线或未知周期原样返回。"""
|
||||
if not bars or timeframe in ("1d", "d", "day", "", None):
|
||||
return bars
|
||||
rule = _RULES.get(timeframe)
|
||||
if rule is None:
|
||||
return bars
|
||||
|
||||
df = pd.DataFrame(
|
||||
[{"ts": b.ts, "open": b.open, "high": b.high, "low": b.low, "close": b.close, "volume": b.volume}
|
||||
for b in bars]
|
||||
).set_index("ts").sort_index()
|
||||
|
||||
agg = (
|
||||
df.resample(rule)
|
||||
.agg({"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"})
|
||||
.dropna()
|
||||
)
|
||||
|
||||
return [
|
||||
Bar(
|
||||
ts=ts.to_pydatetime(),
|
||||
open=float(row["open"]),
|
||||
high=float(row["high"]),
|
||||
low=float(row["low"]),
|
||||
close=float(row["close"]),
|
||||
volume=float(row["volume"]),
|
||||
)
|
||||
for ts, row in agg.iterrows()
|
||||
]
|
||||
38
backend/app/data/akshare_provider.py
Normal file
38
backend/app/data/akshare_provider.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""AKShare 数据源(兜底/校验)。免费、无需 token。
|
||||
|
||||
默认不安装(依赖较重);如需启用:`uv add akshare`。
|
||||
fetcher 在 Tushare 失败时会尝试本模块;未安装则该路径自动跳过。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from ..domain import Bar
|
||||
from .symbols import plain_code
|
||||
|
||||
|
||||
def fetch_daily(code: str, start: str = "20200101", end: str | None = None,
|
||||
adjust: str = "qfq") -> list[Bar]:
|
||||
import akshare as ak # 延迟导入
|
||||
|
||||
end = end or datetime.now().strftime("%Y%m%d")
|
||||
symbol = plain_code(code)
|
||||
adj_map = {"qfq": "qfq", "hfq": "hfq", "": "", None: ""}
|
||||
df = ak.stock_zh_a_hist(
|
||||
symbol=symbol, period="daily",
|
||||
start_date=start, end_date=end, adjust=adj_map.get(adjust, ""),
|
||||
)
|
||||
if df is None or df.empty:
|
||||
raise RuntimeError(f"AKShare 无数据: {symbol}")
|
||||
|
||||
bars: list[Bar] = []
|
||||
for _, r in df.iterrows():
|
||||
bars.append(
|
||||
Bar(
|
||||
ts=datetime.strptime(str(r["日期"]), "%Y-%m-%d"),
|
||||
open=float(r["开盘"]), high=float(r["最高"]),
|
||||
low=float(r["最低"]), close=float(r["收盘"]),
|
||||
volume=float(r["成交量"]) * 100.0, # AKShare 成交量单位为手 -> 股
|
||||
)
|
||||
)
|
||||
return bars
|
||||
81
backend/app/data/fetcher.py
Normal file
81
backend/app/data/fetcher.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""数据编排:拉取(Tushare 主 -> AKShare 兜底)+ 本地缓存。
|
||||
|
||||
真实行情落库到 candles 表(timeframe='1d'),回测统一从库读,与 DEMO 同路径。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..config import settings
|
||||
from ..domain import Bar
|
||||
from ..models import Candle
|
||||
from . import akshare_provider, tushare_provider
|
||||
|
||||
DEFAULT_START = settings.data_default_start or "20200101"
|
||||
|
||||
|
||||
def _providers(source: str):
|
||||
"""按优先级返回 (名称, 同步拉取函数) 列表。"""
|
||||
seq = []
|
||||
if source in ("auto", "tushare") and settings.tushare_token:
|
||||
seq.append(("tushare", tushare_provider.fetch_daily))
|
||||
if source in ("auto", "akshare"):
|
||||
seq.append(("akshare", akshare_provider.fetch_daily))
|
||||
return seq
|
||||
|
||||
|
||||
async def count_cached(session: AsyncSession, symbol: str) -> int:
|
||||
res = await session.execute(
|
||||
select(func.count()).select_from(Candle).where(
|
||||
Candle.symbol == symbol, Candle.timeframe == "1d"
|
||||
)
|
||||
)
|
||||
return int(res.scalar() or 0)
|
||||
|
||||
|
||||
async def is_cached(session: AsyncSession, symbol: str) -> bool:
|
||||
return await count_cached(session, symbol) > 0
|
||||
|
||||
|
||||
async def sync_symbol(
|
||||
session: AsyncSession,
|
||||
code: str,
|
||||
start: str | None = None,
|
||||
end: str | None = None,
|
||||
source: str = "auto",
|
||||
force: bool = False,
|
||||
) -> dict:
|
||||
"""拉取并缓存某标的日线。已缓存且非 force 时直接返回缓存计数。"""
|
||||
if not force and await is_cached(session, code):
|
||||
return {"symbol": code, "bars": await count_cached(session, code), "source": "cache"}
|
||||
|
||||
start = start or DEFAULT_START
|
||||
adjust = settings.data_adjust
|
||||
errors: list[str] = []
|
||||
bars: list[Bar] = []
|
||||
used = None
|
||||
|
||||
for name, fn in _providers(source):
|
||||
try:
|
||||
# tushare/akshare 是同步网络 IO,丢到线程池避免阻塞事件循环
|
||||
bars = await asyncio.to_thread(fn, code, start, end, adjust)
|
||||
used = name
|
||||
break
|
||||
except Exception as e: # noqa: BLE001
|
||||
errors.append(f"{name}: {e}")
|
||||
|
||||
if not bars:
|
||||
raise RuntimeError("所有数据源均失败 -> " + " | ".join(errors) if errors else "无可用数据源")
|
||||
|
||||
# 全量替换该标的日线(避免重复主键)
|
||||
await session.execute(delete(Candle).where(Candle.symbol == code, Candle.timeframe == "1d"))
|
||||
for b in bars:
|
||||
session.add(
|
||||
Candle(symbol=code, timeframe="1d", ts=b.ts, open=b.open, high=b.high,
|
||||
low=b.low, close=b.close, volume=b.volume)
|
||||
)
|
||||
await session.commit()
|
||||
return {"symbol": code, "bars": len(bars), "source": used}
|
||||
34
backend/app/data/repository.py
Normal file
34
backend/app/data/repository.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""K 线数据访问(从库读)。
|
||||
|
||||
写入由 DataProvider 适配器负责(阶段1 接 Tushare/AKShare)。
|
||||
MVP 的数据由 synthetic.seed_if_empty 灌入。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..models import Candle
|
||||
|
||||
|
||||
async def get_candles(
|
||||
session: AsyncSession,
|
||||
symbol: str,
|
||||
timeframe: str = "1d",
|
||||
start: datetime | None = None,
|
||||
end: datetime | None = None,
|
||||
limit: int = 5000,
|
||||
) -> list[Candle]:
|
||||
stmt = select(Candle).where(
|
||||
Candle.symbol == symbol,
|
||||
Candle.timeframe == timeframe,
|
||||
)
|
||||
if start is not None:
|
||||
stmt = stmt.where(Candle.ts >= start)
|
||||
if end is not None:
|
||||
stmt = stmt.where(Candle.ts <= end)
|
||||
stmt = stmt.order_by(Candle.ts.asc()).limit(limit)
|
||||
result = await session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
25
backend/app/data/symbols.py
Normal file
25
backend/app/data/symbols.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""A 股代码归一化。支持 6 位纯数字或带交易所后缀(000001 / 000001.SZ)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def plain_code(code: str) -> str:
|
||||
"""000001.SZ -> 000001"""
|
||||
return code.strip().upper().split(".")[0]
|
||||
|
||||
|
||||
def to_ts_code(code: str) -> str:
|
||||
"""转 Tushare ts_code(带交易所后缀)。"""
|
||||
c = code.strip().upper()
|
||||
if "." in c:
|
||||
return c
|
||||
c = plain_code(c)
|
||||
# 沪市:60xxxx 主板、68xxxx 科创、9xxxxx B 股
|
||||
if c.startswith(("60", "68", "9")):
|
||||
return c + ".SH"
|
||||
# 深市:00xxxx 主板/中小、30xxxx 创业、20xxxx B 股
|
||||
if c.startswith(("00", "30", "20")):
|
||||
return c + ".SZ"
|
||||
# 北交所:8xxxxx / 4xxxxx
|
||||
if c.startswith(("8", "4")):
|
||||
return c + ".BJ"
|
||||
return c + ".SZ"
|
||||
78
backend/app/data/synthetic.py
Normal file
78
backend/app/data/synthetic.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""合成数据(MVP 零依赖可跑)。
|
||||
|
||||
生成随机游走 OHLCV,灌入 DB。仅用于让回测链路在没有真实数据源时也能跑通演示。
|
||||
阶段1 接 Tushare/AKShare 后,这里仅保留为"离线测试夹具"。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import numpy as np
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..domain import Bar
|
||||
from ..models import Candle
|
||||
|
||||
|
||||
def _trading_days(n: int) -> list[datetime]:
|
||||
"""粗略生成 n 个工作日(跳过周末;节假日由阶段1 的交易日历服务处理)。"""
|
||||
start = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=int(n * 1.6))
|
||||
days: list[datetime] = []
|
||||
d = start
|
||||
while len(days) < n:
|
||||
if d.weekday() < 5:
|
||||
days.append(d.replace(hour=15, minute=0, second=0, microsecond=0))
|
||||
d += timedelta(days=1)
|
||||
return days
|
||||
|
||||
|
||||
def generate_ohlcv(n: int = 500, seed: int = 42) -> list[Bar]:
|
||||
"""随机游走 + A 股风格的价格区间(5~30 元)。"""
|
||||
rng = np.random.default_rng(seed)
|
||||
rets = rng.normal(loc=0.0003, scale=0.018, size=n)
|
||||
price = 10.0 * np.cumprod(1 + rets)
|
||||
days = _trading_days(n)
|
||||
bars: list[Bar] = []
|
||||
for i in range(n):
|
||||
close = float(price[i])
|
||||
op = close * (1 + rng.normal(0, 0.005))
|
||||
hi = max(op, close) * (1 + abs(rng.normal(0, 0.006)))
|
||||
lo = min(op, close) * (1 - abs(rng.normal(0, 0.006)))
|
||||
vol = float(rng.integers(1_000_000, 10_000_000))
|
||||
bars.append(
|
||||
Bar(
|
||||
ts=days[i],
|
||||
open=round(op, 2),
|
||||
high=round(hi, 2),
|
||||
low=round(lo, 2),
|
||||
close=round(close, 2),
|
||||
volume=vol,
|
||||
)
|
||||
)
|
||||
return bars
|
||||
|
||||
|
||||
async def seed_if_empty(session: AsyncSession, symbol: str = "DEMO", n: int = 500) -> None:
|
||||
"""若库中无该 symbol 数据,则灌入合成数据。"""
|
||||
existing = await session.execute(
|
||||
select(Candle.id).where(Candle.symbol == symbol).limit(1)
|
||||
)
|
||||
if existing.scalars().first() is not None:
|
||||
return
|
||||
|
||||
bars = generate_ohlcv(n=n)
|
||||
for b in bars:
|
||||
session.add(
|
||||
Candle(
|
||||
symbol=symbol,
|
||||
timeframe="1d",
|
||||
ts=b.ts,
|
||||
open=b.open,
|
||||
high=b.high,
|
||||
low=b.low,
|
||||
close=b.close,
|
||||
volume=b.volume,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
51
backend/app/data/tushare_provider.py
Normal file
51
backend/app/data/tushare_provider.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""Tushare 数据源(主)。日线 + 前复权。
|
||||
|
||||
token 从 settings.tushare_token 读取(.env)。免费版 pro.daily 与 ts.pro_bar 实测可用。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from ..config import settings
|
||||
from ..domain import Bar
|
||||
from .symbols import to_ts_code
|
||||
|
||||
|
||||
def _parse(date_str: str) -> datetime:
|
||||
return datetime.strptime(str(date_str), "%Y%m%d")
|
||||
|
||||
|
||||
def fetch_daily(code: str, start: str = "20200101", end: str | None = None,
|
||||
adjust: str = "qfq") -> list[Bar]:
|
||||
import tushare as ts # 延迟导入:未装/无 token 时 DEMO 仍可用
|
||||
|
||||
if not settings.tushare_token:
|
||||
raise RuntimeError("未配置 TUSHARE_TOKEN")
|
||||
ts.set_token(settings.tushare_token)
|
||||
pro = ts.pro_api()
|
||||
ts_code = to_ts_code(code)
|
||||
end = end or datetime.now().strftime("%Y%m%d")
|
||||
|
||||
# 优先 pro_bar(含复权);积分不足则退化为 pro.daily(不复权)
|
||||
df = None
|
||||
try:
|
||||
df = ts.pro_bar(ts_code=ts_code, adj=adjust, start_date=start, end_date=end, freq="D")
|
||||
except Exception:
|
||||
df = None
|
||||
if df is None or df.empty:
|
||||
df = pro.daily(ts_code=ts_code, start_date=start, end_date=end)
|
||||
if df is None or df.empty:
|
||||
raise RuntimeError(f"Tushare 无数据: {ts_code}")
|
||||
|
||||
df = df.sort_values("trade_date")
|
||||
bars: list[Bar] = []
|
||||
for _, r in df.iterrows():
|
||||
bars.append(
|
||||
Bar(
|
||||
ts=_parse(r["trade_date"]),
|
||||
open=float(r["open"]), high=float(r["high"]),
|
||||
low=float(r["low"]), close=float(r["close"]),
|
||||
volume=float(r["vol"]) * 100.0, # Tushare vol 单位为手 -> 股
|
||||
)
|
||||
)
|
||||
return bars
|
||||
Reference in New Issue
Block a user