mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 10:26:13 +00:00
feat: add watchlist groups and multi-day intraday view
This commit is contained in:
+127
-2
@@ -268,6 +268,47 @@ def _get_price_limit_info(
|
||||
return info
|
||||
|
||||
|
||||
def _get_previous_closes(
|
||||
repo,
|
||||
symbol: str,
|
||||
trade_dates: list[date],
|
||||
asset_type: str,
|
||||
) -> dict[date, float | None]:
|
||||
"""Return the previous trading day's adjusted close for each session."""
|
||||
if not trade_dates:
|
||||
return {}
|
||||
start = min(trade_dates) - timedelta(days=45)
|
||||
end = max(trade_dates)
|
||||
try:
|
||||
daily = repo.get_daily_asset(
|
||||
asset_type,
|
||||
symbol,
|
||||
start,
|
||||
end,
|
||||
columns=["date", "close"],
|
||||
).sort("date")
|
||||
except Exception:
|
||||
daily = None
|
||||
if daily is None or daily.is_empty():
|
||||
return {trade_date: None for trade_date in trade_dates}
|
||||
|
||||
closes: list[tuple[date, float]] = []
|
||||
for daily_date, close in daily.select(["date", "close"]).iter_rows():
|
||||
if close is None:
|
||||
continue
|
||||
numeric = float(close)
|
||||
if math.isfinite(numeric) and numeric > 0:
|
||||
closes.append((daily_date, numeric))
|
||||
|
||||
result: dict[date, float | None] = {}
|
||||
for trade_date in trade_dates:
|
||||
result[trade_date] = next(
|
||||
(close for daily_date, close in reversed(closes) if daily_date < trade_date),
|
||||
None,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/daily")
|
||||
def get_daily(
|
||||
request: Request,
|
||||
@@ -679,6 +720,73 @@ def get_minute_batch(request: Request, body: dict):
|
||||
return {"data": result}
|
||||
|
||||
|
||||
@router.get("/minute-range")
|
||||
def get_minute_range(
|
||||
request: Request,
|
||||
symbol: str = Query(..., description="标的代码"),
|
||||
days: int = Query(10, ge=1, le=20, description="最近交易日数量"),
|
||||
):
|
||||
"""读取单只标的最近 N 个已落库交易日的分钟 K。"""
|
||||
import polars as pl
|
||||
|
||||
repo = request.app.state.repo
|
||||
asset_type = repo.resolve_asset_type(symbol)
|
||||
stock_info = (
|
||||
_get_stock_info(repo, symbol)
|
||||
if asset_type == "stock"
|
||||
else _get_asset_info(repo, symbol, asset_type)
|
||||
)
|
||||
base_response = {
|
||||
"symbol": symbol,
|
||||
"name": stock_info.get("name"),
|
||||
"asset_type": asset_type,
|
||||
"requested_days": days,
|
||||
}
|
||||
|
||||
# 指数分钟 K 不落本地仓库, 最新分时仍由 /api/index/minute 实时读取。
|
||||
if asset_type == "index":
|
||||
return {**base_response, "sessions": [], "source": "none"}
|
||||
|
||||
end = cn_today()
|
||||
start = end - timedelta(days=days * 3 + 20)
|
||||
minute = repo.get_minute_range([symbol], start, end, asset_type=asset_type)
|
||||
if minute.is_empty() or "datetime" not in minute.columns:
|
||||
return {**base_response, "sessions": [], "source": "none"}
|
||||
|
||||
minute = minute.with_columns(
|
||||
pl.col("datetime").dt.date().alias("_trade_date"),
|
||||
)
|
||||
trade_dates = sorted(minute["_trade_date"].unique().to_list())[-days:]
|
||||
previous_closes = _get_previous_closes(repo, symbol, trade_dates, asset_type)
|
||||
row_columns = [
|
||||
column
|
||||
for column in (
|
||||
"datetime", "open", "high", "low", "close", "volume", "amount"
|
||||
)
|
||||
if column in minute.columns
|
||||
]
|
||||
sessions = []
|
||||
for trade_date in trade_dates:
|
||||
rows = (
|
||||
minute.filter(pl.col("_trade_date") == trade_date)
|
||||
.sort("datetime")
|
||||
.select(row_columns)
|
||||
.to_dicts()
|
||||
)
|
||||
if rows:
|
||||
sessions.append({
|
||||
"date": trade_date.isoformat(),
|
||||
"prev_close": previous_closes.get(trade_date),
|
||||
"rows": rows,
|
||||
})
|
||||
|
||||
return {
|
||||
**base_response,
|
||||
"sessions": sessions,
|
||||
"source": "local" if sessions else "none",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/minute")
|
||||
def get_minute(
|
||||
request: Request,
|
||||
@@ -721,13 +829,20 @@ def get_minute(
|
||||
price_limit = _get_price_limit_info(
|
||||
repo, symbol, trade_date, asset_type, stock_name,
|
||||
)
|
||||
prev_close = _get_previous_closes(
|
||||
repo, symbol, [trade_date], asset_type,
|
||||
).get(trade_date)
|
||||
return {
|
||||
"symbol": symbol, "name": stock_name, "stock_info": stock_info,
|
||||
"date": str(trade_date), "rows": df.to_dicts(), "source": "live",
|
||||
"asset_type": asset_type,
|
||||
"price_limit": price_limit,
|
||||
"prev_close": prev_close,
|
||||
}
|
||||
|
||||
prev_close = _get_previous_closes(
|
||||
repo, symbol, [trade_date], asset_type,
|
||||
).get(trade_date)
|
||||
price_limit = _get_price_limit_info(
|
||||
repo, symbol, trade_date, asset_type, stock_name,
|
||||
)
|
||||
@@ -758,6 +873,7 @@ def get_minute(
|
||||
"date": str(trade_date), "rows": df.to_dicts(), "source": "local",
|
||||
"asset_type": asset_type,
|
||||
"price_limit": price_limit,
|
||||
"prev_close": prev_close,
|
||||
}
|
||||
|
||||
# 本地不完整或无数据 → 从 TickFlow 实时拉取
|
||||
@@ -768,6 +884,7 @@ def get_minute(
|
||||
"source": "live" if not live_df.is_empty() else "none",
|
||||
"asset_type": asset_type,
|
||||
"price_limit": price_limit,
|
||||
"prev_close": prev_close,
|
||||
}
|
||||
|
||||
|
||||
@@ -909,13 +1026,21 @@ async def sync_minute_single(request: Request, body: dict):
|
||||
body: { "symbol": "000001.SZ" }
|
||||
用于个股分时图"获取数据"按钮: 本地无数据时单独拉取并持久化。
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
from app.services.preferences import get_minute_sync_days
|
||||
from app.tickflow.capabilities import Cap
|
||||
|
||||
symbol = body.get("symbol", "").strip()
|
||||
if not symbol:
|
||||
raise HTTPException(status_code=400, detail="symbol 不能为空")
|
||||
|
||||
requested_days = body.get("days")
|
||||
if requested_days is not None:
|
||||
if isinstance(requested_days, bool) or not isinstance(requested_days, int):
|
||||
raise HTTPException(status_code=400, detail="days 必须是整数")
|
||||
if requested_days < 1 or requested_days > 30:
|
||||
raise HTTPException(status_code=400, detail="days 必须在 1 到 30 之间")
|
||||
|
||||
repo = request.app.state.repo
|
||||
capset = request.app.state.capabilities
|
||||
|
||||
@@ -927,7 +1052,7 @@ async def sync_minute_single(request: Request, body: dict):
|
||||
if not _minute_allowed(capset):
|
||||
raise HTTPException(status_code=403, detail="需要 Pro+ 权限")
|
||||
|
||||
days = get_minute_sync_days()
|
||||
days = requested_days if requested_days is not None else get_minute_sync_days()
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
def _run():
|
||||
|
||||
@@ -36,11 +36,22 @@ _OCR_LIMITER = anyio.CapacityLimiter(2)
|
||||
class AddRequest(BaseModel):
|
||||
symbol: str
|
||||
note: str = ""
|
||||
group_id: str | None = None
|
||||
|
||||
|
||||
class BatchAddRequest(BaseModel):
|
||||
symbols: list[str]
|
||||
note: str = ""
|
||||
group_id: str | None = None
|
||||
|
||||
|
||||
class GroupNameRequest(BaseModel):
|
||||
name: str
|
||||
color: str | None = None
|
||||
|
||||
|
||||
class GroupAssignRequest(BaseModel):
|
||||
group_id: str | None = None
|
||||
|
||||
|
||||
def _with_names(rows: list[dict], request: Request) -> list[dict]:
|
||||
@@ -64,20 +75,54 @@ def list_all(request: Request):
|
||||
|
||||
@router.post("")
|
||||
def add_one(req: AddRequest, request: Request):
|
||||
rows = watchlist.add(req.symbol, req.note)
|
||||
try:
|
||||
rows = watchlist.add(req.symbol, req.note, req.group_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
return {"symbols": _with_names(rows, request)}
|
||||
|
||||
|
||||
@router.post("/batch")
|
||||
def add_batch(req: BatchAddRequest, request: Request):
|
||||
existing = {r["symbol"] for r in watchlist.list_symbols()}
|
||||
added = 0
|
||||
for sym in req.symbols:
|
||||
if sym not in existing:
|
||||
added += 1
|
||||
existing.add(sym)
|
||||
watchlist.add(sym, req.note)
|
||||
return {"symbols": _with_names(watchlist.list_symbols(), request), "added": added}
|
||||
try:
|
||||
rows, added = watchlist.add_batch(req.symbols, req.note, req.group_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
return {"symbols": _with_names(rows, request), "added": added}
|
||||
|
||||
|
||||
@router.get("/groups")
|
||||
def list_groups():
|
||||
return {"groups": watchlist.list_groups()}
|
||||
|
||||
|
||||
@router.post("/groups")
|
||||
def create_group(req: GroupNameRequest):
|
||||
try:
|
||||
groups, group = watchlist.create_group(req.name, req.color)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
return {"groups": groups, "group": group}
|
||||
|
||||
|
||||
@router.put("/groups/{group_id}")
|
||||
def rename_group(group_id: str, req: GroupNameRequest):
|
||||
try:
|
||||
groups = watchlist.rename_group(group_id, req.name, req.color)
|
||||
except KeyError as e:
|
||||
raise HTTPException(404, "自选分组不存在") from e
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
return {"groups": groups}
|
||||
|
||||
|
||||
@router.delete("/groups/{group_id}")
|
||||
def delete_group(group_id: str, request: Request):
|
||||
try:
|
||||
groups, rows = watchlist.delete_group(group_id)
|
||||
except KeyError as e:
|
||||
raise HTTPException(404, "自选分组不存在") from e
|
||||
return {"groups": groups, "symbols": _with_names(rows, request)}
|
||||
|
||||
|
||||
@router.get("/ocr-status")
|
||||
@@ -131,6 +176,17 @@ def move_one_to_top(symbol: str, request: Request):
|
||||
return {"symbols": _with_names(rows, request)}
|
||||
|
||||
|
||||
@router.put("/{symbol}/group")
|
||||
def assign_group(symbol: str, req: GroupAssignRequest, request: Request):
|
||||
try:
|
||||
rows = watchlist.set_group(symbol, req.group_id)
|
||||
except KeyError as e:
|
||||
raise HTTPException(404, "自选标的不存在") from e
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
return {"symbols": _with_names(rows, request)}
|
||||
|
||||
|
||||
@router.delete("/{symbol}")
|
||||
def remove_one(symbol: str, request: Request):
|
||||
rows = watchlist.remove(symbol)
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
"""自选股服务(§6.1)。
|
||||
"""自选股与分组服务。
|
||||
|
||||
存储:`data/user_data/watchlist.parquet`,字段 symbol + added_at + note。
|
||||
自选存储于 ``data/user_data/watchlist.parquet``,分组定义存储于同目录的
|
||||
``watchlist_groups.json``。历史 Parquet 缺少 ``group_id`` 时按未分组读取。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import uuid
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from concurrent.futures import TimeoutError as FuturesTimeout
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
@@ -17,6 +24,30 @@ from app.tickflow.rate_limits import chunked, resolve_limit
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_LOCK = threading.RLock()
|
||||
_MAX_GROUP_NAME_LENGTH = 24
|
||||
DEFAULT_GROUP_COLOR = "sky"
|
||||
GROUP_COLORS = frozenset({
|
||||
"sky",
|
||||
"blue",
|
||||
"indigo",
|
||||
"violet",
|
||||
"fuchsia",
|
||||
"rose",
|
||||
"orange",
|
||||
"amber",
|
||||
"lime",
|
||||
"emerald",
|
||||
"teal",
|
||||
"cyan",
|
||||
})
|
||||
_ENTRY_SCHEMA = {
|
||||
"symbol": pl.Utf8,
|
||||
"added_at": pl.Utf8,
|
||||
"note": pl.Utf8,
|
||||
"group_id": pl.Utf8,
|
||||
}
|
||||
|
||||
|
||||
def _path() -> Path:
|
||||
p = settings.data_dir / "user_data" / "watchlist.parquet"
|
||||
@@ -24,70 +55,230 @@ def _path() -> Path:
|
||||
return p
|
||||
|
||||
|
||||
def list_symbols() -> list[dict]:
|
||||
def _groups_path() -> Path:
|
||||
p = settings.data_dir / "user_data" / "watchlist_groups.json"
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
return p
|
||||
|
||||
|
||||
def _empty_entries() -> pl.DataFrame:
|
||||
return pl.DataFrame(schema=_ENTRY_SCHEMA)
|
||||
|
||||
|
||||
def _read_entries() -> pl.DataFrame:
|
||||
p = _path()
|
||||
if not p.exists():
|
||||
return []
|
||||
return _empty_entries()
|
||||
df = pl.read_parquet(p)
|
||||
if df.is_empty():
|
||||
return []
|
||||
return df.to_dicts()
|
||||
defaults = {"symbol": "", "added_at": "", "note": "", "group_id": None}
|
||||
for column, dtype in _ENTRY_SCHEMA.items():
|
||||
if column not in df.columns:
|
||||
df = df.with_columns(pl.lit(defaults[column], dtype=dtype).alias(column))
|
||||
return df.select(list(_ENTRY_SCHEMA))
|
||||
|
||||
|
||||
def add(symbol: str, note: str = "") -> list[dict]:
|
||||
def _write_entries(df: pl.DataFrame) -> None:
|
||||
p = _path()
|
||||
if p.exists():
|
||||
df = pl.read_parquet(p)
|
||||
# 已存在则先移除,后面重新插入到最前面
|
||||
if symbol in df["symbol"].to_list():
|
||||
df = df.filter(pl.col("symbol") != symbol)
|
||||
else:
|
||||
df = pl.DataFrame(schema={"symbol": pl.Utf8, "added_at": pl.Utf8, "note": pl.Utf8})
|
||||
tmp = p.with_suffix(p.suffix + ".tmp")
|
||||
df.select(list(_ENTRY_SCHEMA)).write_parquet(tmp)
|
||||
os.replace(tmp, p)
|
||||
|
||||
new_row = pl.DataFrame({
|
||||
"symbol": [symbol],
|
||||
"added_at": [datetime.utcnow().isoformat(timespec="seconds")],
|
||||
"note": [note],
|
||||
})
|
||||
out = pl.concat([new_row, df], how="diagonal_relaxed")
|
||||
out.write_parquet(p)
|
||||
return out.to_dicts()
|
||||
|
||||
def _read_groups() -> list[dict]:
|
||||
p = _groups_path()
|
||||
if not p.exists():
|
||||
return []
|
||||
try:
|
||||
raw = json.loads(p.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise ValueError("自选分组配置损坏,请检查 watchlist_groups.json") from exc
|
||||
if not isinstance(raw, list):
|
||||
raise ValueError("自选分组配置格式不正确")
|
||||
groups = []
|
||||
for item in raw:
|
||||
if not isinstance(item, dict) or not item.get("id") or not item.get("name"):
|
||||
continue
|
||||
color = str(item.get("color", DEFAULT_GROUP_COLOR))
|
||||
groups.append({
|
||||
"id": str(item["id"]),
|
||||
"name": str(item["name"]),
|
||||
"color": color if color in GROUP_COLORS else DEFAULT_GROUP_COLOR,
|
||||
})
|
||||
return groups
|
||||
|
||||
|
||||
def _write_groups(groups: list[dict]) -> None:
|
||||
p = _groups_path()
|
||||
tmp = p.with_suffix(p.suffix + ".tmp")
|
||||
tmp.write_text(json.dumps(groups, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
os.replace(tmp, p)
|
||||
|
||||
|
||||
def _normalize_group_name(name: str) -> str:
|
||||
normalized = name.strip()
|
||||
if not normalized:
|
||||
raise ValueError("分组名称不能为空")
|
||||
if len(normalized) > _MAX_GROUP_NAME_LENGTH:
|
||||
raise ValueError(f"分组名称不能超过 {_MAX_GROUP_NAME_LENGTH} 个字符")
|
||||
return normalized
|
||||
|
||||
|
||||
def _normalize_group_color(color: str | None) -> str:
|
||||
normalized = (color or DEFAULT_GROUP_COLOR).strip().lower()
|
||||
if normalized not in GROUP_COLORS:
|
||||
raise ValueError("不支持的分组颜色")
|
||||
return normalized
|
||||
|
||||
|
||||
def _validate_group_id(group_id: str | None, groups: list[dict]) -> None:
|
||||
if group_id is not None and not any(group["id"] == group_id for group in groups):
|
||||
raise ValueError("自选分组不存在")
|
||||
|
||||
|
||||
def list_symbols() -> list[dict]:
|
||||
with _LOCK:
|
||||
df = _read_entries()
|
||||
return [] if df.is_empty() else df.to_dicts()
|
||||
|
||||
|
||||
def add(symbol: str, note: str = "", group_id: str | None = None) -> list[dict]:
|
||||
rows, _ = add_batch([symbol], note=note, group_id=group_id)
|
||||
return rows
|
||||
|
||||
|
||||
def add_batch(
|
||||
symbols: list[str],
|
||||
note: str = "",
|
||||
group_id: str | None = None,
|
||||
) -> tuple[list[dict], int]:
|
||||
"""批量添加并保持既有语义:每个新处理的标的移动到列表最前面。"""
|
||||
with _LOCK:
|
||||
groups = _read_groups()
|
||||
_validate_group_id(group_id, groups)
|
||||
rows = _read_entries().to_dicts()
|
||||
added = 0
|
||||
for symbol in symbols:
|
||||
existing = next((row for row in rows if row["symbol"] == symbol), None)
|
||||
if existing is None:
|
||||
added += 1
|
||||
rows = [row for row in rows if row["symbol"] != symbol]
|
||||
resolved_group_id = (
|
||||
group_id if group_id is not None else (existing or {}).get("group_id")
|
||||
)
|
||||
rows.insert(0, {
|
||||
"symbol": symbol,
|
||||
"added_at": datetime.utcnow().isoformat(timespec="seconds"),
|
||||
"note": note,
|
||||
"group_id": resolved_group_id,
|
||||
})
|
||||
out = pl.DataFrame(rows, schema=_ENTRY_SCHEMA) if rows else _empty_entries()
|
||||
_write_entries(out)
|
||||
return out.to_dicts(), added
|
||||
|
||||
|
||||
def remove(symbol: str) -> list[dict]:
|
||||
p = _path()
|
||||
if not p.exists():
|
||||
return []
|
||||
df = pl.read_parquet(p)
|
||||
df = df.filter(pl.col("symbol") != symbol)
|
||||
df.write_parquet(p)
|
||||
return df.to_dicts()
|
||||
with _LOCK:
|
||||
df = _read_entries().filter(pl.col("symbol") != symbol)
|
||||
_write_entries(df)
|
||||
return df.to_dicts()
|
||||
|
||||
|
||||
def move_to_top(symbol: str) -> list[dict]:
|
||||
p = _path()
|
||||
if not p.exists():
|
||||
return []
|
||||
df = pl.read_parquet(p)
|
||||
if df.is_empty() or symbol not in df["symbol"].to_list():
|
||||
return df.to_dicts()
|
||||
target = df.filter(pl.col("symbol") == symbol)
|
||||
rest = df.filter(pl.col("symbol") != symbol)
|
||||
out = pl.concat([target, rest], how="diagonal_relaxed")
|
||||
out.write_parquet(p)
|
||||
return out.to_dicts()
|
||||
with _LOCK:
|
||||
df = _read_entries()
|
||||
if df.is_empty() or symbol not in df["symbol"].to_list():
|
||||
return df.to_dicts()
|
||||
target = df.filter(pl.col("symbol") == symbol)
|
||||
rest = df.filter(pl.col("symbol") != symbol)
|
||||
out = pl.concat([target, rest], how="diagonal_relaxed")
|
||||
_write_entries(out)
|
||||
return out.to_dicts()
|
||||
|
||||
|
||||
def clear() -> int:
|
||||
"""清空自选列表。返回移除的数量。"""
|
||||
p = _path()
|
||||
if not p.exists():
|
||||
return 0
|
||||
df = pl.read_parquet(p)
|
||||
count = df.height
|
||||
if count > 0:
|
||||
pl.DataFrame(schema={"symbol": pl.Utf8, "added_at": pl.Utf8, "note": pl.Utf8}).write_parquet(p)
|
||||
return count
|
||||
with _LOCK:
|
||||
df = _read_entries()
|
||||
count = df.height
|
||||
if count > 0:
|
||||
_write_entries(_empty_entries())
|
||||
return count
|
||||
|
||||
|
||||
def list_groups() -> list[dict]:
|
||||
with _LOCK:
|
||||
return _read_groups()
|
||||
|
||||
|
||||
def create_group(name: str, color: str | None = None) -> tuple[list[dict], dict]:
|
||||
with _LOCK:
|
||||
normalized = _normalize_group_name(name)
|
||||
normalized_color = _normalize_group_color(color)
|
||||
groups = _read_groups()
|
||||
if any(group["name"].casefold() == normalized.casefold() for group in groups):
|
||||
raise ValueError("分组名称已存在")
|
||||
group = {
|
||||
"id": uuid.uuid4().hex,
|
||||
"name": normalized,
|
||||
"color": normalized_color,
|
||||
}
|
||||
groups.append(group)
|
||||
_write_groups(groups)
|
||||
return groups, group
|
||||
|
||||
|
||||
def rename_group(group_id: str, name: str, color: str | None = None) -> list[dict]:
|
||||
with _LOCK:
|
||||
normalized = _normalize_group_name(name)
|
||||
groups = _read_groups()
|
||||
target = next((group for group in groups if group["id"] == group_id), None)
|
||||
if target is None:
|
||||
raise KeyError(group_id)
|
||||
if any(
|
||||
group["id"] != group_id and group["name"].casefold() == normalized.casefold()
|
||||
for group in groups
|
||||
):
|
||||
raise ValueError("分组名称已存在")
|
||||
target["name"] = normalized
|
||||
if color is not None:
|
||||
target["color"] = _normalize_group_color(color)
|
||||
_write_groups(groups)
|
||||
return groups
|
||||
|
||||
|
||||
def delete_group(group_id: str) -> tuple[list[dict], list[dict]]:
|
||||
"""删除分组定义,原分组内的自选保留并转为未分组。"""
|
||||
with _LOCK:
|
||||
groups = _read_groups()
|
||||
if not any(group["id"] == group_id for group in groups):
|
||||
raise KeyError(group_id)
|
||||
df = _read_entries().with_columns(
|
||||
pl.when(pl.col("group_id") == group_id)
|
||||
.then(None)
|
||||
.otherwise(pl.col("group_id"))
|
||||
.alias("group_id")
|
||||
)
|
||||
remaining = [group for group in groups if group["id"] != group_id]
|
||||
_write_entries(df)
|
||||
_write_groups(remaining)
|
||||
return remaining, df.to_dicts()
|
||||
|
||||
|
||||
def set_group(symbol: str, group_id: str | None) -> list[dict]:
|
||||
with _LOCK:
|
||||
groups = _read_groups()
|
||||
_validate_group_id(group_id, groups)
|
||||
df = _read_entries()
|
||||
if symbol not in df["symbol"].to_list():
|
||||
raise KeyError(symbol)
|
||||
df = df.with_columns(
|
||||
pl.when(pl.col("symbol") == symbol)
|
||||
.then(pl.lit(group_id, dtype=pl.Utf8))
|
||||
.otherwise(pl.col("group_id"))
|
||||
.alias("group_id")
|
||||
)
|
||||
_write_entries(df)
|
||||
return df.to_dicts()
|
||||
|
||||
|
||||
def fetch_quotes(symbols: list[str], capset: CapabilitySet, timeout_s: float = 8.0) -> list[dict]:
|
||||
@@ -96,8 +287,6 @@ def fetch_quotes(symbols: list[str], capset: CapabilitySet, timeout_s: float = 8
|
||||
优先用 quote.batch;否则降级为 quote.by_symbol 单股请求。
|
||||
timeout_s: 单批次请求超时(秒),防止 API 卡死阻塞整个请求。
|
||||
"""
|
||||
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout
|
||||
|
||||
if not symbols:
|
||||
return []
|
||||
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
"""多日分时 API 契约。"""
|
||||
|
||||
import asyncio
|
||||
from datetime import date, datetime
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import polars as pl
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.api import kline as kline_api
|
||||
|
||||
|
||||
def _request(repo=None, capset=None):
|
||||
return SimpleNamespace(
|
||||
app=SimpleNamespace(
|
||||
state=SimpleNamespace(
|
||||
repo=repo or MagicMock(),
|
||||
capabilities=capset or MagicMock(),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_minute_range_returns_latest_sessions_with_previous_closes():
|
||||
repo = MagicMock()
|
||||
repo.resolve_asset_type.return_value = "stock"
|
||||
repo.execute_one.return_value = ("浦发银行", 1.0, 1.0)
|
||||
repo.get_minute_range.return_value = pl.DataFrame({
|
||||
"symbol": ["600000.SH"] * 3,
|
||||
"datetime": [
|
||||
datetime(2026, 8, 5, 1, 30),
|
||||
datetime(2026, 8, 6, 1, 30),
|
||||
datetime(2026, 8, 7, 1, 30),
|
||||
],
|
||||
"open": [10.0, 11.0, 12.0],
|
||||
"high": [10.2, 11.2, 12.2],
|
||||
"low": [9.8, 10.8, 11.8],
|
||||
"close": [10.1, 11.1, 12.1],
|
||||
"volume": [100.0, 110.0, 120.0],
|
||||
"amount": [101_000.0, 122_100.0, 145_200.0],
|
||||
})
|
||||
repo.get_daily_asset.return_value = pl.DataFrame({
|
||||
"date": [
|
||||
date(2026, 8, 4),
|
||||
date(2026, 8, 5),
|
||||
date(2026, 8, 6),
|
||||
date(2026, 8, 7),
|
||||
],
|
||||
"close": [9.9, 10.1, 11.1, 12.1],
|
||||
})
|
||||
|
||||
result = kline_api.get_minute_range(_request(repo), "600000.SH", 2)
|
||||
|
||||
assert result["name"] == "浦发银行"
|
||||
assert result["requested_days"] == 2
|
||||
assert result["source"] == "local"
|
||||
assert [session["date"] for session in result["sessions"]] == [
|
||||
"2026-08-06",
|
||||
"2026-08-07",
|
||||
]
|
||||
assert [session["prev_close"] for session in result["sessions"]] == [
|
||||
10.1,
|
||||
11.1,
|
||||
]
|
||||
assert result["sessions"][0]["rows"][0]["close"] == 11.1
|
||||
|
||||
|
||||
def test_minute_range_does_not_read_stock_store_for_index():
|
||||
repo = MagicMock()
|
||||
repo.resolve_asset_type.return_value = "index"
|
||||
repo.get_instruments_asset.return_value = pl.DataFrame()
|
||||
|
||||
result = kline_api.get_minute_range(_request(repo), "000001.SH", 10)
|
||||
|
||||
assert result["asset_type"] == "index"
|
||||
assert result["sessions"] == []
|
||||
repo.get_minute_range.assert_not_called()
|
||||
|
||||
|
||||
def test_sync_minute_single_uses_requested_days(monkeypatch):
|
||||
repo = MagicMock()
|
||||
repo.resolve_asset_type.return_value = "stock"
|
||||
capset = MagicMock()
|
||||
sync = MagicMock(return_value=2400)
|
||||
refresh = MagicMock()
|
||||
monkeypatch.setattr(kline_api, "_minute_allowed", lambda _: True)
|
||||
monkeypatch.setattr(kline_api.kline_sync, "sync_and_persist_minute", sync)
|
||||
monkeypatch.setattr("app.jobs.daily_pipeline._refresh_single_view", refresh)
|
||||
|
||||
result = asyncio.run(kline_api.sync_minute_single(
|
||||
_request(repo, capset),
|
||||
{"symbol": "600000.SH", "days": 10},
|
||||
))
|
||||
|
||||
assert result["rows"] == 2400
|
||||
sync.assert_called_once_with(["600000.SH"], repo, capset, days=10)
|
||||
refresh.assert_called_once_with(repo, "kline_minute")
|
||||
|
||||
|
||||
def test_sync_minute_single_rejects_invalid_days():
|
||||
with pytest.raises(HTTPException, match="days 必须在 1 到 30 之间"):
|
||||
asyncio.run(kline_api.sync_minute_single(
|
||||
_request(),
|
||||
{"symbol": "600000.SH", "days": 0},
|
||||
))
|
||||
@@ -0,0 +1,114 @@
|
||||
"""自选分组持久化与 API 契约。"""
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import polars as pl
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.api import watchlist as watchlist_api
|
||||
from app.config import settings
|
||||
from app.services import watchlist
|
||||
|
||||
|
||||
def _request():
|
||||
repo = MagicMock()
|
||||
repo.get_name_map.return_value = {}
|
||||
return SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(repo=repo)))
|
||||
|
||||
|
||||
def test_historical_watchlist_is_read_as_ungrouped(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(settings, "data_dir", tmp_path)
|
||||
path = tmp_path / "user_data" / "watchlist.parquet"
|
||||
path.parent.mkdir(parents=True)
|
||||
pl.DataFrame({
|
||||
"symbol": ["600000.SH"],
|
||||
"added_at": ["2026-08-08T10:00:00"],
|
||||
"note": [""],
|
||||
}).write_parquet(path)
|
||||
|
||||
assert watchlist.list_symbols()[0]["group_id"] is None
|
||||
|
||||
|
||||
def test_group_lifecycle_preserves_watchlist_entries(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(settings, "data_dir", tmp_path)
|
||||
groups, created = watchlist.create_group(" 短线 ", "orange")
|
||||
assert groups == [{"id": created["id"], "name": "短线", "color": "orange"}]
|
||||
|
||||
watchlist.add("600000.SH", group_id=created["id"])
|
||||
watchlist.add("000001.SZ")
|
||||
assert watchlist.list_symbols()[1]["group_id"] == created["id"]
|
||||
|
||||
renamed = watchlist.rename_group(created["id"], "观察", "fuchsia")
|
||||
assert renamed[0]["name"] == "观察"
|
||||
assert renamed[0]["color"] == "fuchsia"
|
||||
|
||||
remaining, rows = watchlist.delete_group(created["id"])
|
||||
assert remaining == []
|
||||
assert {row["symbol"] for row in rows} == {"600000.SH", "000001.SZ"}
|
||||
assert all(row["group_id"] is None for row in rows)
|
||||
|
||||
|
||||
def test_group_validation_and_assignment_errors(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(settings, "data_dir", tmp_path)
|
||||
_, created = watchlist.create_group("核心")
|
||||
watchlist.add("600000.SH")
|
||||
|
||||
with pytest.raises(ValueError, match="已存在"):
|
||||
watchlist.create_group("核心")
|
||||
with pytest.raises(ValueError, match="颜色"):
|
||||
watchlist.create_group("无效颜色", "black")
|
||||
with pytest.raises(ValueError, match="颜色"):
|
||||
watchlist.rename_group(created["id"], "核心", "black")
|
||||
with pytest.raises(ValueError, match="不存在"):
|
||||
watchlist.set_group("600000.SH", "missing")
|
||||
with pytest.raises(KeyError):
|
||||
watchlist.set_group("000001.SZ", created["id"])
|
||||
|
||||
rows = watchlist.set_group("600000.SH", created["id"])
|
||||
assert rows[0]["group_id"] == created["id"]
|
||||
rows = watchlist.set_group("600000.SH", None)
|
||||
assert rows[0]["group_id"] is None
|
||||
|
||||
|
||||
def test_group_api_contract(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(settings, "data_dir", tmp_path)
|
||||
request = _request()
|
||||
created = watchlist_api.create_group(
|
||||
watchlist_api.GroupNameRequest(name="中线", color="teal")
|
||||
)
|
||||
group_id = created["group"]["id"]
|
||||
assert created["group"]["color"] == "teal"
|
||||
|
||||
added = watchlist_api.add_one(
|
||||
watchlist_api.AddRequest(symbol="600000.SH", group_id=group_id),
|
||||
request,
|
||||
)
|
||||
assert added["symbols"][0]["group_id"] == group_id
|
||||
|
||||
moved = watchlist_api.assign_group(
|
||||
"600000.SH",
|
||||
watchlist_api.GroupAssignRequest(group_id=None),
|
||||
request,
|
||||
)
|
||||
assert moved["symbols"][0]["group_id"] is None
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
watchlist_api.rename_group("missing", watchlist_api.GroupNameRequest(name="无效"))
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
|
||||
def test_historical_groups_default_to_sky(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(settings, "data_dir", tmp_path)
|
||||
path = tmp_path / "user_data" / "watchlist_groups.json"
|
||||
path.parent.mkdir(parents=True)
|
||||
path.write_text(
|
||||
'[{"id":"legacy","name":"旧分组"},'
|
||||
'{"id":"invalid","name":"未知颜色","color":"black"}]',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert watchlist.list_groups() == [
|
||||
{"id": "legacy", "name": "旧分组", "color": "sky"},
|
||||
{"id": "invalid", "name": "未知颜色", "color": "sky"},
|
||||
]
|
||||
Generated
+12
-1
@@ -1958,6 +1958,15 @@ wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pypinyin"
|
||||
version = "0.55.0"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b4/a4/784cf98c09e0dc22776b0d7d8a4a5b761218bcae4608c2416ce1e167c8af/pypinyin-0.55.0.tar.gz", hash = "sha256:b5711b3a0c6f76e67408ec6b2e3c4987a3a806b7c528076e7c7b86fcf0eaa66b", size = 839836, upload-time = "2025-07-20T12:01:50.657Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/b9/7b/4cabc76fcc21c3c7d5c671d8783984d30ac9d3bb387c4ba784fca3cdfa3a/pypinyin-0.55.0-py2.py3-none-any.whl", hash = "sha256:d53b1e8ad2cdb815fb2cb604ed3123372f5a28c6f447571244aca36fc62a286f", size = 840203, upload-time = "2025-07-20T12:01:48.535Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytesseract"
|
||||
version = "0.3.13"
|
||||
@@ -2504,7 +2513,7 @@ all = [
|
||||
|
||||
[[package]]
|
||||
name = "tickflow-stock-panel-backend"
|
||||
version = "0.1.83"
|
||||
version = "0.1.88"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "apscheduler" },
|
||||
@@ -2523,6 +2532,7 @@ dependencies = [
|
||||
{ name = "pyarrow" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "pypinyin" },
|
||||
{ name = "pytesseract" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "python-multipart" },
|
||||
@@ -2570,6 +2580,7 @@ requires-dist = [
|
||||
{ name = "pyarrow", specifier = ">=16.0" },
|
||||
{ name = "pydantic", specifier = ">=2.7" },
|
||||
{ name = "pydantic-settings", specifier = ">=2.4" },
|
||||
{ name = "pypinyin", specifier = ">=0.50" },
|
||||
{ name = "pytesseract", specifier = ">=0.3.10" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
|
||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" },
|
||||
|
||||
Reference in New Issue
Block a user