first commit

This commit is contained in:
2026-08-07 16:08:34 +08:00
commit e0b5228008
51 changed files with 5175 additions and 0 deletions

View File

View 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()
]

View 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

View 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}

View 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())

View 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"

View 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()

View 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