看股功能更新
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
MVP 在应用层用 pandas resample 即可,逻辑等价、便于切换。
|
||||
|
||||
OHLCV 聚合规则:开=周期内首根开、高=最高、低=最低、收=末根收、量=求和。
|
||||
成交额/换手率为名义量:求和(全缺则保持 None,不伪造 0)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -22,6 +23,13 @@ def bars_per_year(timeframe: str) -> int:
|
||||
return _BARS_PER_YEAR.get(timeframe, 252)
|
||||
|
||||
|
||||
def _sum_or_none(s: pd.Series):
|
||||
"""求和;全为 NaN 返回 None(部分缺失则忽略缺失项求和)。"""
|
||||
if s.isna().all():
|
||||
return None
|
||||
return float(s.sum())
|
||||
|
||||
|
||||
def resample_bars(bars: list[Bar], timeframe: str) -> list[Bar]:
|
||||
"""把日线 bars 聚合为目标周期;日线或未知周期原样返回。"""
|
||||
if not bars or timeframe in ("1d", "d", "day", "", None):
|
||||
@@ -31,14 +39,18 @@ def resample_bars(bars: list[Bar], timeframe: str) -> list[Bar]:
|
||||
return bars
|
||||
|
||||
df = pd.DataFrame(
|
||||
[{"ts": b.ts, "open": b.open, "high": b.high, "low": b.low, "close": b.close, "volume": b.volume}
|
||||
[{"ts": b.ts, "open": b.open, "high": b.high, "low": b.low, "close": b.close,
|
||||
"volume": b.volume,
|
||||
"amount": b.amount if b.amount is not None else float("nan"),
|
||||
"turnover": b.turnover if b.turnover is not None else float("nan")}
|
||||
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()
|
||||
.agg({"open": "first", "high": "max", "low": "min", "close": "last",
|
||||
"volume": "sum", "amount": _sum_or_none, "turnover": _sum_or_none})
|
||||
.dropna(subset=["open"])
|
||||
)
|
||||
|
||||
return [
|
||||
@@ -49,6 +61,8 @@ def resample_bars(bars: list[Bar], timeframe: str) -> list[Bar]:
|
||||
low=float(row["low"]),
|
||||
close=float(row["close"]),
|
||||
volume=float(row["volume"]),
|
||||
amount=row["amount"] if row["amount"] == row["amount"] else None, # NaN -> None
|
||||
turnover=row["turnover"] if row["turnover"] == row["turnover"] else None,
|
||||
)
|
||||
for ts, row in agg.iterrows()
|
||||
]
|
||||
|
||||
@@ -27,12 +27,14 @@ def fetch_daily(code: str, start: str = "20200101", end: str | None = None,
|
||||
|
||||
bars: list[Bar] = []
|
||||
for _, r in df.iterrows():
|
||||
amt = r.get("成交额")
|
||||
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 成交量单位为手 -> 股
|
||||
amount=float(amt) if amt is not None and amt == amt else None, # AKShare 成交额单位为元
|
||||
)
|
||||
)
|
||||
return bars
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
"""数据编排:拉取(Tushare 主 -> AKShare 兜底)+ 本地缓存。
|
||||
|
||||
真实行情落库到 candles 表(timeframe='1d'),回测统一从库读。
|
||||
真实行情落库到 candles 表(timeframe='1d',**不复权底座**),回测统一从库读。
|
||||
复权(qfq/hfq)在读取时按 adj_factor 表本地换算,见 api._adjust_bars。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..config import settings
|
||||
@@ -40,6 +43,13 @@ async def is_cached(session: AsyncSession, symbol: str) -> bool:
|
||||
return await count_cached(session, symbol) > 0
|
||||
|
||||
|
||||
async def _last_cached_ts(session: AsyncSession, symbol: str):
|
||||
res = await session.execute(
|
||||
select(func.max(Candle.ts)).where(Candle.symbol == symbol, Candle.timeframe == "1d")
|
||||
)
|
||||
return res.scalar()
|
||||
|
||||
|
||||
async def sync_symbol(
|
||||
session: AsyncSession,
|
||||
code: str,
|
||||
@@ -48,34 +58,57 @@ async def sync_symbol(
|
||||
source: str = "auto",
|
||||
force: bool = False,
|
||||
) -> dict:
|
||||
"""拉取并缓存某标的日线。已缓存且非 force 时直接返回缓存计数。"""
|
||||
if not force and await is_cached(session, code):
|
||||
"""增量拉取并 upsert 某标的日线(**不复权**底座)。
|
||||
|
||||
- 永不删除已有行:按 (symbol, timeframe, ts) 主键 upsert,
|
||||
不会把 TDX 导入的 30 年历史冲掉;
|
||||
- 已缓存时从最后一根的次日开始增量拉取(force 仅跳过「有缓存就返回」
|
||||
的短路,用于缓存落后于最新交易日时的刷新);
|
||||
- 拉不到新行时保持原缓存不动。
|
||||
"""
|
||||
last_ts = await _last_cached_ts(session, code)
|
||||
if last_ts is not None and not force and not start:
|
||||
return {"symbol": code, "bars": await count_cached(session, code), "source": "cache"}
|
||||
|
||||
if last_ts is not None and not start:
|
||||
# 增量:从缓存最后一根当天开始(重叠一天重新拉取,容忍数据源漏行/盘后修订)
|
||||
start = last_ts.strftime("%Y%m%d")
|
||||
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)
|
||||
# tushare/akshare 是同步网络 IO,丢到线程池避免阻塞事件循环;
|
||||
# adjust=None -> 不复权(复权在读取时按 adj_factor 换算)
|
||||
bars = await asyncio.to_thread(fn, code, start, end, None)
|
||||
used = name
|
||||
break
|
||||
except Exception as e: # noqa: BLE001
|
||||
errors.append(f"{name}: {e}")
|
||||
|
||||
if not bars:
|
||||
if last_ts is not None:
|
||||
# 增量失败(如停牌/新股无新行):保留缓存,不算错误
|
||||
return {"symbol": code, "bars": await count_cached(session, code), "source": "cache"}
|
||||
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)
|
||||
)
|
||||
# upsert:不 delete,避免破坏既有底座(TDX 全量历史)
|
||||
stmt = pg_insert(Candle).values([
|
||||
{"symbol": code, "timeframe": "1d", "ts": b.ts, "open": b.open, "high": b.high,
|
||||
"low": b.low, "close": b.close, "volume": b.volume,
|
||||
"amount": b.amount, "turnover": b.turnover}
|
||||
for b in bars
|
||||
])
|
||||
stmt = stmt.on_conflict_do_update(
|
||||
index_elements=["symbol", "timeframe", "ts"],
|
||||
set_={"open": stmt.excluded.open, "high": stmt.excluded.high, "low": stmt.excluded.low,
|
||||
"close": stmt.excluded.close, "volume": stmt.excluded.volume,
|
||||
# 增量源缺失额/换手时保留库里的旧值(如 TDX 已回补的 30 年成交额)
|
||||
"amount": func.coalesce(stmt.excluded.amount, Candle.amount),
|
||||
"turnover": func.coalesce(stmt.excluded.turnover, Candle.turnover)},
|
||||
)
|
||||
await session.execute(stmt)
|
||||
await session.commit()
|
||||
return {"symbol": code, "bars": len(bars), "source": used}
|
||||
|
||||
@@ -32,3 +32,38 @@ async def get_candles(
|
||||
stmt = stmt.order_by(Candle.ts.asc()).limit(limit)
|
||||
result = await session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_recent_candles(
|
||||
session: AsyncSession,
|
||||
symbol: str,
|
||||
timeframe: str = "1d",
|
||||
limit: int = 5000,
|
||||
) -> list[Candle]:
|
||||
"""取最近 limit 根 K 线(含最新交易日),按时间升序返回。"""
|
||||
stmt = (
|
||||
select(Candle)
|
||||
.where(Candle.symbol == symbol, Candle.timeframe == timeframe)
|
||||
.order_by(Candle.ts.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return list(reversed(result.scalars().all()))
|
||||
|
||||
|
||||
async def get_candles_before(
|
||||
session: AsyncSession,
|
||||
symbol: str,
|
||||
timeframe: str,
|
||||
before: datetime,
|
||||
limit: int = 5000,
|
||||
) -> list[Candle]:
|
||||
"""取 before 之前(不含)的最近 limit 根 K 线,按时间升序返回(历史向前翻页用)。"""
|
||||
stmt = (
|
||||
select(Candle)
|
||||
.where(Candle.symbol == symbol, Candle.timeframe == timeframe, Candle.ts < before)
|
||||
.order_by(Candle.ts.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return list(reversed(result.scalars().all()))
|
||||
|
||||
@@ -40,12 +40,14 @@ def fetch_daily(code: str, start: str = "20200101", end: str | None = None,
|
||||
df = df.sort_values("trade_date")
|
||||
bars: list[Bar] = []
|
||||
for _, r in df.iterrows():
|
||||
amt = r.get("amount")
|
||||
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 单位为手 -> 股
|
||||
amount=float(amt) * 1000.0 if amt is not None and amt == amt else None, # 千元 -> 元
|
||||
)
|
||||
)
|
||||
return bars
|
||||
|
||||
Reference in New Issue
Block a user