Restructure into modules/ with single-process CTP and config/ layout.
Move business code under modules/, env template to config/, PM2 single qihuo process, and _legacy shims for old imports. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
|
||||
from modules.market.routes import register
|
||||
|
||||
|
||||
def start_workers(deps) -> None:
|
||||
deps.start_background_threads()
|
||||
|
||||
|
||||
__all__ = ["register", "start_workers"]
|
||||
@@ -0,0 +1,558 @@
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
# 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
|
||||
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
||||
|
||||
"""复盘 K 线:新浪拉取 + matplotlib 生成截图。"""
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import requests
|
||||
|
||||
from modules.core.symbols import ths_to_codes
|
||||
from modules.core.db_conn import connect_db
|
||||
from modules.market.kline_store import ensure_kline_tables, get_cached_entry, save_bars
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
TZ = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
# CTP tick 聚合 bar 少于此数时,用新浪历史补齐走势
|
||||
MIN_CTP_KLINE_BARS = 15
|
||||
|
||||
PERIOD_MINUTES = {
|
||||
"1m": "1",
|
||||
"3m": "3",
|
||||
"5m": "5",
|
||||
"15m": "15",
|
||||
"30m": "30",
|
||||
"1h": "60",
|
||||
"4h": "240",
|
||||
}
|
||||
|
||||
MARKET_PERIODS = [
|
||||
{"key": "timeshare", "label": "分时"},
|
||||
{"key": "1m", "label": "1分"},
|
||||
{"key": "2m", "label": "2分"},
|
||||
{"key": "5m", "label": "5分"},
|
||||
{"key": "15m", "label": "15分"},
|
||||
{"key": "1h", "label": "1小时"},
|
||||
{"key": "2h", "label": "2小时"},
|
||||
{"key": "4h", "label": "4小时"},
|
||||
{"key": "d", "label": "日线"},
|
||||
{"key": "w", "label": "周线"},
|
||||
]
|
||||
|
||||
|
||||
def ths_to_sina_chart_symbol(symbol: str) -> Optional[str]:
|
||||
"""ag2608 -> AG2608(新浪 K 线接口合约代码)。"""
|
||||
code = (symbol or "").strip()
|
||||
if not code:
|
||||
return None
|
||||
codes = ths_to_codes(code)
|
||||
if codes:
|
||||
sina = codes.get("sina_code", "")
|
||||
if sina.startswith("nf_"):
|
||||
return sina[3:]
|
||||
if sina.startswith("CFF_RE_"):
|
||||
return sina[7:]
|
||||
ths = codes.get("ths_code", "")
|
||||
return ths.upper() if ths else None
|
||||
m = re.match(r"^([A-Za-z]+)(\d+)$", code)
|
||||
if m:
|
||||
return m.group(1).upper() + m.group(2)
|
||||
return None
|
||||
|
||||
|
||||
def _parse_jsonp(text: str) -> Optional[list]:
|
||||
m = re.search(r"\((.*)\)\s*;?\s*$", text.strip(), re.DOTALL)
|
||||
if not m:
|
||||
return None
|
||||
try:
|
||||
data = json.loads(m.group(1))
|
||||
return data if isinstance(data, list) else None
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
|
||||
def fetch_sina_klines(symbol: str, period: str) -> list:
|
||||
"""拉取新浪期货 K 线(原始 bar 列表)。"""
|
||||
chart_sym = ths_to_sina_chart_symbol(symbol)
|
||||
if not chart_sym:
|
||||
return []
|
||||
p = (period or "").lower()
|
||||
if p in ("1d", "d"):
|
||||
return _fetch_sina_daily(chart_sym)
|
||||
if p == "w":
|
||||
return _weekly_from_daily(_fetch_sina_daily(chart_sym))
|
||||
if p == "timeshare":
|
||||
bars = _fetch_few_min_line(chart_sym, "1")
|
||||
return _timeshare_session(bars)
|
||||
if p == "2m":
|
||||
return _aggregate_bars(_fetch_few_min_line(chart_sym, "1"), 2)
|
||||
if p == "2h":
|
||||
return _aggregate_bars(_fetch_few_min_line(chart_sym, "60"), 2)
|
||||
typ = PERIOD_MINUTES.get(p)
|
||||
if typ:
|
||||
return _fetch_few_min_line(chart_sym, typ)
|
||||
return []
|
||||
|
||||
|
||||
def _fetch_few_min_line(chart_sym: str, typ: str) -> list:
|
||||
ts = datetime.now(TZ).strftime("%Y%m%d%H%M%S")
|
||||
url = (
|
||||
"https://stock2.finance.sina.com.cn/futures/api/jsonp.php/"
|
||||
f"var_{chart_sym}_{typ}_{ts}=/InnerFuturesNewService.getFewMinLine"
|
||||
f"?symbol={chart_sym}&type={typ}"
|
||||
)
|
||||
try:
|
||||
resp = requests.get(
|
||||
url,
|
||||
timeout=20,
|
||||
headers={"Referer": "https://finance.sina.com.cn"},
|
||||
)
|
||||
bars = _parse_jsonp(resp.text)
|
||||
return _normalize_bars(bars or [])
|
||||
except Exception as exc:
|
||||
logger.warning("fetch kline failed %s %s: %s", chart_sym, typ, exc)
|
||||
return []
|
||||
|
||||
|
||||
def _normalize_bars(raw: list) -> list:
|
||||
out = []
|
||||
for row in raw:
|
||||
if isinstance(row, list) and len(row) >= 5:
|
||||
out.append({
|
||||
"d": str(row[0]),
|
||||
"o": float(row[1]),
|
||||
"h": float(row[2]),
|
||||
"l": float(row[3]),
|
||||
"c": float(row[4]),
|
||||
"v": float(row[5]) if len(row) > 5 and row[5] else 0.0,
|
||||
})
|
||||
elif isinstance(row, dict) and row.get("d"):
|
||||
out.append({
|
||||
"d": str(row["d"]),
|
||||
"o": float(row.get("o", 0) or 0),
|
||||
"h": float(row.get("h", 0) or 0),
|
||||
"l": float(row.get("l", 0) or 0),
|
||||
"c": float(row.get("c", 0) or 0),
|
||||
"v": float(row.get("v", 0) or 0),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _aggregate_bars(bars: list, n: int) -> list:
|
||||
if n <= 1 or not bars:
|
||||
return bars
|
||||
out = []
|
||||
chunk: list = []
|
||||
for bar in bars:
|
||||
chunk.append(bar)
|
||||
if len(chunk) >= n:
|
||||
out.append(_merge_bars(chunk))
|
||||
chunk = []
|
||||
if chunk:
|
||||
out.append(_merge_bars(chunk))
|
||||
return out
|
||||
|
||||
|
||||
def _merge_bars(chunk: list) -> dict:
|
||||
return {
|
||||
"d": chunk[0]["d"],
|
||||
"o": chunk[0]["o"],
|
||||
"h": max(b["h"] for b in chunk),
|
||||
"l": min(b["l"] for b in chunk),
|
||||
"c": chunk[-1]["c"],
|
||||
"v": sum(b.get("v", 0) for b in chunk),
|
||||
}
|
||||
|
||||
|
||||
def _merge_kline_bars(history: list, live: list) -> list:
|
||||
"""新浪历史 + CTP 实时尾部(去重叠)。"""
|
||||
if not history:
|
||||
return list(live or [])
|
||||
if not live:
|
||||
return list(history)
|
||||
first_live = _bar_datetime(live[0])
|
||||
if not first_live:
|
||||
return history + live
|
||||
trimmed = []
|
||||
for bar in history:
|
||||
dt = _bar_datetime(bar)
|
||||
if dt and dt < first_live:
|
||||
trimmed.append(bar)
|
||||
merged = trimmed + list(live)
|
||||
return merged if merged else list(history)
|
||||
|
||||
|
||||
def _weekly_from_daily(daily: list) -> list:
|
||||
if not daily:
|
||||
return []
|
||||
buckets: dict[tuple, list] = {}
|
||||
for bar in daily:
|
||||
dt = _bar_datetime(bar)
|
||||
if not dt:
|
||||
continue
|
||||
iso = dt.isocalendar()
|
||||
key = (iso[0], iso[1])
|
||||
buckets.setdefault(key, []).append(bar)
|
||||
out = []
|
||||
for key in sorted(buckets.keys()):
|
||||
chunk = buckets[key]
|
||||
out.append(_merge_bars(chunk))
|
||||
out[-1]["d"] = chunk[-1]["d"]
|
||||
return out
|
||||
|
||||
|
||||
def _timeshare_session(bars: list) -> list:
|
||||
if not bars:
|
||||
return []
|
||||
today = datetime.now(TZ).date()
|
||||
session = []
|
||||
for bar in bars:
|
||||
dt = _bar_datetime(bar)
|
||||
if dt and dt.date() == today:
|
||||
session.append(bar)
|
||||
if session:
|
||||
return session[-480:]
|
||||
return bars[-480:]
|
||||
|
||||
|
||||
def bars_to_api(bars: list) -> list[dict]:
|
||||
"""转为前端图表 JSON(去重、排序、数值规范化)。"""
|
||||
result: list[dict] = []
|
||||
seen: dict[int, dict] = {}
|
||||
for bar in bars:
|
||||
dt = _bar_datetime(bar)
|
||||
ts = int(dt.timestamp() * 1000) if dt else None
|
||||
try:
|
||||
o = float(bar.get("o") or 0)
|
||||
h = float(bar.get("h") or o)
|
||||
l = float(bar.get("l") or o)
|
||||
c = float(bar.get("c") or o)
|
||||
v = float(bar.get("v") or 0)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if h < l:
|
||||
h, l = l, h
|
||||
h = max(h, o, c)
|
||||
l = min(l, o, c)
|
||||
row = {
|
||||
"time": bar["d"],
|
||||
"timestamp": ts,
|
||||
"open": o,
|
||||
"high": h,
|
||||
"low": l,
|
||||
"close": c,
|
||||
"volume": v,
|
||||
}
|
||||
if ts is not None:
|
||||
seen[ts] = row
|
||||
else:
|
||||
result.append(row)
|
||||
if seen:
|
||||
result = [seen[k] for k in sorted(seen.keys())]
|
||||
return result
|
||||
|
||||
|
||||
def fetch_market_klines(
|
||||
symbol: str,
|
||||
period: str,
|
||||
db_path: Optional[str] = None,
|
||||
force_remote: bool = False,
|
||||
*,
|
||||
trading_mode: Optional[str] = None,
|
||||
prefer_ctp: bool = False,
|
||||
) -> dict:
|
||||
chart_sym = ths_to_sina_chart_symbol(symbol)
|
||||
p = (period or "15m").lower()
|
||||
if p == "timeshare":
|
||||
chart_type = "line"
|
||||
else:
|
||||
chart_type = "candle"
|
||||
|
||||
bars: list = []
|
||||
source = "remote"
|
||||
cached_at = None
|
||||
ctp_connected = False
|
||||
ctp_bars: list = []
|
||||
|
||||
if prefer_ctp:
|
||||
try:
|
||||
from modules.ctp.ctp_kline import fetch_ctp_klines
|
||||
from modules.ctp.vnpy_bridge import ctp_status
|
||||
|
||||
mode = trading_mode
|
||||
if not mode:
|
||||
try:
|
||||
from app import get_setting
|
||||
from modules.core.trading_context import get_trading_mode
|
||||
|
||||
mode = get_trading_mode(get_setting)
|
||||
except Exception:
|
||||
mode = "simulation"
|
||||
ctp_connected = bool(ctp_status(mode).get("connected"))
|
||||
if ctp_connected:
|
||||
ctp_bars = fetch_ctp_klines(symbol, p, mode) or []
|
||||
except Exception as exc:
|
||||
logger.debug("ctp kline fetch failed %s %s: %s", symbol, p, exc)
|
||||
|
||||
need_sina = force_remote or not prefer_ctp or not ctp_bars or len(ctp_bars) < MIN_CTP_KLINE_BARS
|
||||
|
||||
if ctp_bars and len(ctp_bars) >= MIN_CTP_KLINE_BARS:
|
||||
bars = ctp_bars
|
||||
source = "ctp"
|
||||
|
||||
if not bars and db_path and chart_sym and not force_remote and need_sina:
|
||||
try:
|
||||
conn = connect_db(db_path)
|
||||
cached = get_cached_entry(conn, chart_sym, p)
|
||||
conn.close()
|
||||
if cached and cached.get("fresh"):
|
||||
bars = cached["bars"]
|
||||
source = "local"
|
||||
cached_at = cached.get("updated_at")
|
||||
except Exception as exc:
|
||||
logger.warning("kline cache read failed %s %s: %s", chart_sym, p, exc)
|
||||
|
||||
if not bars or len(ctp_bars) < MIN_CTP_KLINE_BARS or not prefer_ctp:
|
||||
remote_bars = fetch_sina_klines(symbol, p)
|
||||
if remote_bars:
|
||||
if prefer_ctp and ctp_bars and ctp_connected:
|
||||
bars = _merge_kline_bars(remote_bars, ctp_bars)
|
||||
source = "ctp+remote"
|
||||
else:
|
||||
bars = remote_bars
|
||||
source = "remote"
|
||||
if db_path and chart_sym and not ctp_connected:
|
||||
try:
|
||||
conn = connect_db(db_path)
|
||||
ensure_kline_tables(conn)
|
||||
save_bars(conn, chart_sym, p, remote_bars)
|
||||
meta = conn.execute(
|
||||
"SELECT updated_at FROM kline_meta WHERE chart_symbol=? AND period=?",
|
||||
(chart_sym, p),
|
||||
).fetchone()
|
||||
conn.close()
|
||||
cached_at = meta[0] if meta else None
|
||||
except Exception as exc:
|
||||
logger.warning("kline cache write failed %s %s: %s", chart_sym, p, exc)
|
||||
elif not bars and db_path and chart_sym:
|
||||
try:
|
||||
conn = connect_db(db_path)
|
||||
cached = get_cached_entry(conn, chart_sym, p)
|
||||
conn.close()
|
||||
if cached and cached.get("bars"):
|
||||
bars = cached["bars"]
|
||||
source = "local"
|
||||
cached_at = cached.get("updated_at")
|
||||
except Exception as exc:
|
||||
logger.warning("kline cache fallback failed %s %s: %s", chart_sym, p, exc)
|
||||
|
||||
api_bars = bars_to_api(bars)
|
||||
prev_close = None
|
||||
if len(api_bars) >= 2:
|
||||
prev_close = api_bars[-2]["close"]
|
||||
|
||||
return {
|
||||
"symbol": symbol,
|
||||
"chart_symbol": chart_sym,
|
||||
"period": p,
|
||||
"chart_type": chart_type,
|
||||
"count": len(bars),
|
||||
"bars": api_bars,
|
||||
"prev_close": prev_close,
|
||||
"source": source,
|
||||
"cached_at": cached_at,
|
||||
"ctp_connected": ctp_connected,
|
||||
}
|
||||
|
||||
|
||||
def _fetch_sina_daily(chart_sym: str) -> list:
|
||||
url = (
|
||||
"https://stock2.finance.sina.com.cn/futures/api/json.php/"
|
||||
f"IndexService.getInnerFuturesDailyKLine?symbol={chart_sym}"
|
||||
)
|
||||
try:
|
||||
resp = requests.get(url, timeout=20, headers={"Referer": "https://finance.sina.com.cn"})
|
||||
raw = resp.json()
|
||||
if raw and isinstance(raw, list):
|
||||
bars = _normalize_bars(raw)
|
||||
if bars:
|
||||
return bars
|
||||
except Exception as exc:
|
||||
logger.warning("fetch daily kline failed %s: %s", chart_sym, exc)
|
||||
return _daily_from_minutes(chart_sym)
|
||||
|
||||
|
||||
def _daily_from_minutes(chart_sym: str) -> list:
|
||||
"""合约日线接口无数据时,由 60 分钟 K 线按日合成。"""
|
||||
bars_60 = _fetch_few_min_line(chart_sym, "60")
|
||||
if not bars_60:
|
||||
bars_60 = _fetch_few_min_line(chart_sym, "240")
|
||||
buckets: dict[str, list] = {}
|
||||
for bar in bars_60:
|
||||
dt = _bar_datetime(bar)
|
||||
if not dt:
|
||||
continue
|
||||
key = dt.strftime("%Y-%m-%d")
|
||||
buckets.setdefault(key, []).append(bar)
|
||||
out = []
|
||||
for day in sorted(buckets.keys()):
|
||||
chunk = buckets[day]
|
||||
merged = _merge_bars(chunk)
|
||||
merged["d"] = day + " 15:00:00"
|
||||
out.append(merged)
|
||||
return out
|
||||
|
||||
|
||||
def _parse_dt(value: str) -> Optional[datetime]:
|
||||
if not value:
|
||||
return None
|
||||
v = value.strip().replace("T", " ")
|
||||
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M"):
|
||||
try:
|
||||
return datetime.strptime(v, fmt).replace(tzinfo=TZ)
|
||||
except ValueError:
|
||||
continue
|
||||
try:
|
||||
return datetime.fromisoformat(value.strip()).replace(tzinfo=TZ)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _bar_datetime(bar: dict) -> Optional[datetime]:
|
||||
d = bar.get("d")
|
||||
if not d:
|
||||
return None
|
||||
try:
|
||||
return datetime.strptime(d, "%Y-%m-%d %H:%M:%S").replace(tzinfo=TZ)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _select_bars(
|
||||
bars: list,
|
||||
cutoff: datetime,
|
||||
count: int,
|
||||
) -> list:
|
||||
filtered = []
|
||||
for bar in bars:
|
||||
dt = _bar_datetime(bar)
|
||||
if dt and dt <= cutoff:
|
||||
filtered.append(bar)
|
||||
if not filtered:
|
||||
filtered = bars
|
||||
if count > 0 and len(filtered) > count:
|
||||
filtered = filtered[-count:]
|
||||
return filtered
|
||||
|
||||
|
||||
def generate_review_kline_chart(
|
||||
symbol: str,
|
||||
periods: list[str],
|
||||
count: int,
|
||||
cutoff_label: str,
|
||||
open_time: str,
|
||||
close_time: str,
|
||||
entry_price: Optional[float],
|
||||
stop_loss: Optional[float],
|
||||
take_profit: Optional[float],
|
||||
close_price: Optional[float],
|
||||
upload_dir: str,
|
||||
) -> Optional[str]:
|
||||
"""生成双周期 K 线复盘图,返回 uploads 目录下的文件名。"""
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
import matplotlib.dates as mdates
|
||||
|
||||
now = datetime.now(TZ)
|
||||
if cutoff_label == "开仓时间":
|
||||
cutoff = _parse_dt(open_time) or now
|
||||
elif cutoff_label == "当前时间":
|
||||
cutoff = now
|
||||
else:
|
||||
cutoff = _parse_dt(close_time) or now
|
||||
|
||||
open_dt = _parse_dt(open_time)
|
||||
close_dt = _parse_dt(close_time)
|
||||
|
||||
valid_periods = [p for p in periods if p]
|
||||
if not valid_periods:
|
||||
valid_periods = ["15m", "1h"]
|
||||
|
||||
fig, axes = plt.subplots(
|
||||
len(valid_periods), 1,
|
||||
figsize=(14, 4.5 * len(valid_periods)),
|
||||
facecolor="#0a0a10",
|
||||
squeeze=False,
|
||||
)
|
||||
|
||||
plotted = False
|
||||
for idx, period in enumerate(valid_periods):
|
||||
ax = axes[idx, 0]
|
||||
bars = fetch_sina_klines(symbol, period)
|
||||
bars = _select_bars(bars, cutoff, count)
|
||||
if not bars:
|
||||
ax.set_facecolor("#12121a")
|
||||
ax.text(0.5, 0.5, f"No {period} data", ha="center", va="center", color="#888")
|
||||
ax.set_xticks([])
|
||||
ax.set_yticks([])
|
||||
continue
|
||||
|
||||
times = [_bar_datetime(b) for b in bars]
|
||||
closes = [float(b["c"]) for b in bars]
|
||||
highs = [float(b["h"]) for b in bars]
|
||||
lows = [float(b["l"]) for b in bars]
|
||||
|
||||
ax.set_facecolor("#12121a")
|
||||
ax.plot(times, closes, color="#4cc2ff", linewidth=1.2)
|
||||
ax.fill_between(
|
||||
times, lows, highs,
|
||||
color="#4cc2ff", alpha=0.12,
|
||||
)
|
||||
|
||||
levels = [
|
||||
(entry_price, "#eac147", "Entry"),
|
||||
(stop_loss, "#ff6666", "SL"),
|
||||
(take_profit, "#4cd97f", "TP"),
|
||||
(close_price, "#c4c4ff", "Close"),
|
||||
]
|
||||
for price, color, label in levels:
|
||||
if price is not None:
|
||||
ax.axhline(price, color=color, linewidth=0.9, linestyle="--", alpha=0.85)
|
||||
ax.text(times[-1], price, label, color=color, fontsize=8, va="bottom")
|
||||
|
||||
if open_dt:
|
||||
ax.axvline(open_dt, color="#888", linewidth=0.8, linestyle=":", alpha=0.7)
|
||||
if close_dt:
|
||||
ax.axvline(close_dt, color="#aaa", linewidth=0.8, linestyle=":", alpha=0.7)
|
||||
|
||||
chart_sym = ths_to_sina_chart_symbol(symbol) or symbol
|
||||
ax.set_title(f"{chart_sym} {period}", color="#eaeaea", fontsize=11, pad=8)
|
||||
ax.tick_params(colors="#888", labelsize=8)
|
||||
for spine in ax.spines.values():
|
||||
spine.set_color("#2e2e45")
|
||||
ax.xaxis.set_major_formatter(mdates.DateFormatter("%m-%d %H:%M"))
|
||||
ax.grid(True, color="#1e1e30", linewidth=0.5)
|
||||
plotted = True
|
||||
|
||||
if not plotted:
|
||||
plt.close(fig)
|
||||
return None
|
||||
|
||||
fig.tight_layout()
|
||||
ts = datetime.now(TZ).strftime("%Y%m%d%H%M%S")
|
||||
chart_sym = ths_to_sina_chart_symbol(symbol) or "chart"
|
||||
filename = f"{ts}_kline_{chart_sym}.png"
|
||||
path = os.path.join(upload_dir, filename)
|
||||
fig.savefig(path, dpi=120, facecolor=fig.get_facecolor())
|
||||
plt.close(fig)
|
||||
return filename
|
||||
@@ -0,0 +1,175 @@
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
# 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
|
||||
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
||||
|
||||
"""K 线本地 SQLite 缓存。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
TZ = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
REFRESH_SECONDS = {
|
||||
"timeshare": 30,
|
||||
"1m": 30,
|
||||
"2m": 30,
|
||||
"5m": 60,
|
||||
"15m": 60,
|
||||
"1h": 120,
|
||||
"2h": 120,
|
||||
"4h": 180,
|
||||
"d": 300,
|
||||
"w": 600,
|
||||
}
|
||||
|
||||
|
||||
def ensure_kline_tables(conn: sqlite3.Connection) -> None:
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS kline_bars (
|
||||
chart_symbol TEXT NOT NULL,
|
||||
period TEXT NOT NULL,
|
||||
bar_time TEXT NOT NULL,
|
||||
open REAL NOT NULL,
|
||||
high REAL NOT NULL,
|
||||
low REAL NOT NULL,
|
||||
close REAL NOT NULL,
|
||||
volume REAL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (chart_symbol, period, bar_time)
|
||||
)"""
|
||||
)
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS kline_meta (
|
||||
chart_symbol TEXT NOT NULL,
|
||||
period TEXT NOT NULL,
|
||||
bar_count INTEGER DEFAULT 0,
|
||||
last_bar_time TEXT,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (chart_symbol, period)
|
||||
)"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_kline_bars_sym_period "
|
||||
"ON kline_bars(chart_symbol, period, bar_time)"
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _parse_updated_at(value: str) -> Optional[datetime]:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(value.strip()).replace(tzinfo=TZ)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def is_cache_fresh(period: str, updated_at: str) -> bool:
|
||||
dt = _parse_updated_at(updated_at)
|
||||
if not dt:
|
||||
return False
|
||||
ttl = REFRESH_SECONDS.get((period or "").lower(), 60)
|
||||
return datetime.now(TZ) - dt < timedelta(seconds=ttl)
|
||||
|
||||
|
||||
def load_bars(conn: sqlite3.Connection, chart_symbol: str, period: str) -> list[dict]:
|
||||
rows = conn.execute(
|
||||
"""SELECT bar_time, open, high, low, close, volume
|
||||
FROM kline_bars
|
||||
WHERE chart_symbol=? AND period=?
|
||||
ORDER BY bar_time ASC""",
|
||||
(chart_symbol, period),
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"d": row[0],
|
||||
"o": float(row[1]),
|
||||
"h": float(row[2]),
|
||||
"l": float(row[3]),
|
||||
"c": float(row[4]),
|
||||
"v": float(row[5] or 0),
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def load_meta(conn: sqlite3.Connection, chart_symbol: str, period: str) -> Optional[dict]:
|
||||
row = conn.execute(
|
||||
"SELECT bar_count, last_bar_time, updated_at FROM kline_meta "
|
||||
"WHERE chart_symbol=? AND period=?",
|
||||
(chart_symbol, period),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
"bar_count": row[0],
|
||||
"last_bar_time": row[1],
|
||||
"updated_at": row[2],
|
||||
}
|
||||
|
||||
|
||||
def save_bars(conn: sqlite3.Connection, chart_symbol: str, period: str, bars: list[dict]) -> int:
|
||||
if not bars:
|
||||
return 0
|
||||
ensure_kline_tables(conn)
|
||||
now = datetime.now(TZ).isoformat(timespec="seconds")
|
||||
for bar in bars:
|
||||
conn.execute(
|
||||
"""INSERT INTO kline_bars
|
||||
(chart_symbol, period, bar_time, open, high, low, close, volume, updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(chart_symbol, period, bar_time) DO UPDATE SET
|
||||
open=excluded.open,
|
||||
high=excluded.high,
|
||||
low=excluded.low,
|
||||
close=excluded.close,
|
||||
volume=excluded.volume,
|
||||
updated_at=excluded.updated_at""",
|
||||
(
|
||||
chart_symbol,
|
||||
period,
|
||||
str(bar["d"]),
|
||||
float(bar["o"]),
|
||||
float(bar["h"]),
|
||||
float(bar["l"]),
|
||||
float(bar["c"]),
|
||||
float(bar.get("v") or 0),
|
||||
now,
|
||||
),
|
||||
)
|
||||
last_time = str(bars[-1]["d"])
|
||||
conn.execute(
|
||||
"""INSERT INTO kline_meta (chart_symbol, period, bar_count, last_bar_time, updated_at)
|
||||
VALUES (?,?,?,?,?)
|
||||
ON CONFLICT(chart_symbol, period) DO UPDATE SET
|
||||
bar_count=excluded.bar_count,
|
||||
last_bar_time=excluded.last_bar_time,
|
||||
updated_at=excluded.updated_at""",
|
||||
(chart_symbol, period, len(bars), last_time, now),
|
||||
)
|
||||
conn.commit()
|
||||
return len(bars)
|
||||
|
||||
|
||||
def get_cached_entry(
|
||||
conn: sqlite3.Connection,
|
||||
chart_symbol: str,
|
||||
period: str,
|
||||
) -> Optional[dict]:
|
||||
if not chart_symbol:
|
||||
return None
|
||||
ensure_kline_tables(conn)
|
||||
meta = load_meta(conn, chart_symbol, period)
|
||||
bars = load_bars(conn, chart_symbol, period)
|
||||
if not bars:
|
||||
return None
|
||||
updated_at = meta["updated_at"] if meta else ""
|
||||
return {
|
||||
"bars": bars,
|
||||
"updated_at": updated_at,
|
||||
"fresh": is_cache_fresh(period, updated_at),
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
# 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
|
||||
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
||||
|
||||
"""K 线 SSE 推送与后台刷新。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Callable, Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from modules.market.kline_chart import fetch_market_klines, ths_to_sina_chart_symbol
|
||||
from modules.market.kline_store import is_cache_fresh, load_meta, ensure_kline_tables
|
||||
from modules.market.market_sessions import is_trading_session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
TZ = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
FAST_PERIODS = frozenset({
|
||||
"timeshare", "1m", "2m", "5m", "15m", "1h", "2h", "4h",
|
||||
})
|
||||
|
||||
|
||||
def sse_format(event: str, data: dict) -> str:
|
||||
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False, default=str)}\n\n"
|
||||
|
||||
|
||||
@dataclass
|
||||
class KlineSubscription:
|
||||
symbol: str
|
||||
period: str
|
||||
market_code: str = ""
|
||||
sina_code: str = ""
|
||||
queue: queue.Queue = field(default_factory=queue.Queue)
|
||||
|
||||
|
||||
class KlineStreamHub:
|
||||
def __init__(self):
|
||||
self._lock = threading.Lock()
|
||||
self._subs: list[KlineSubscription] = []
|
||||
|
||||
def subscribe(
|
||||
self,
|
||||
symbol: str,
|
||||
period: str,
|
||||
market_code: str = "",
|
||||
sina_code: str = "",
|
||||
) -> KlineSubscription:
|
||||
sub = KlineSubscription(
|
||||
symbol=symbol.strip(),
|
||||
period=(period or "15m").strip().lower(),
|
||||
market_code=market_code.strip(),
|
||||
sina_code=sina_code.strip(),
|
||||
)
|
||||
with self._lock:
|
||||
self._subs.append(sub)
|
||||
return sub
|
||||
|
||||
def unsubscribe(self, sub: KlineSubscription) -> None:
|
||||
with self._lock:
|
||||
try:
|
||||
self._subs.remove(sub)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def _snapshot_subs(self) -> list[KlineSubscription]:
|
||||
with self._lock:
|
||||
return list(self._subs)
|
||||
|
||||
def publish(self, sub: KlineSubscription, event: str, data: dict) -> None:
|
||||
try:
|
||||
sub.queue.put_nowait({"event": event, "data": data})
|
||||
except queue.Full:
|
||||
pass
|
||||
|
||||
def _should_refresh(self, sub: KlineSubscription, db_path: str) -> bool:
|
||||
chart_sym = ths_to_sina_chart_symbol(sub.symbol)
|
||||
if not chart_sym:
|
||||
return False
|
||||
if is_trading_session() and sub.period in FAST_PERIODS:
|
||||
return True
|
||||
try:
|
||||
from modules.core.db_conn import connect_db
|
||||
conn = connect_db(db_path)
|
||||
ensure_kline_tables(conn)
|
||||
meta = load_meta(conn, chart_sym, sub.period)
|
||||
conn.close()
|
||||
if not meta:
|
||||
return True
|
||||
return not is_cache_fresh(sub.period, meta.get("updated_at", ""))
|
||||
except Exception as exc:
|
||||
logger.warning("kline refresh check failed: %s", exc)
|
||||
return True
|
||||
|
||||
def worker_loop(
|
||||
self,
|
||||
db_path: str,
|
||||
quote_fn: Callable[..., dict],
|
||||
get_mode_fn: Optional[Callable[[], str]] = None,
|
||||
) -> None:
|
||||
while True:
|
||||
try:
|
||||
subs = self._snapshot_subs()
|
||||
for sub in subs:
|
||||
if not self._should_refresh(sub, db_path):
|
||||
continue
|
||||
try:
|
||||
kline_data = fetch_market_klines(
|
||||
sub.symbol,
|
||||
sub.period,
|
||||
db_path,
|
||||
force_remote=True,
|
||||
prefer_ctp=False,
|
||||
)
|
||||
if kline_data.get("bars"):
|
||||
self.publish(sub, "kline", kline_data)
|
||||
quote_data = quote_fn(
|
||||
sub.symbol, sub.market_code, sub.sina_code,
|
||||
)
|
||||
if quote_data:
|
||||
self.publish(sub, "quote", quote_data)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"kline stream refresh %s %s: %s",
|
||||
sub.symbol, sub.period, exc,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("kline stream worker: %s", exc)
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
kline_hub = KlineStreamHub()
|
||||
@@ -0,0 +1,248 @@
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
# 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
|
||||
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
||||
|
||||
"""
|
||||
行情拉取:默认新浪(免费,普通用户可用)。
|
||||
同花顺 iFinD HTTP 仅面向机构用户,需单独申请 token,可选开启。
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
THS_TOKEN_URL = "https://quantapi.51ifind.com/api/v1/get_access_token"
|
||||
THS_QUOTE_URL = "https://quantapi.51ifind.com/api/v1/real_time_quotation"
|
||||
|
||||
# iFinD HTTP 期货交易所后缀
|
||||
THS_EX_SUFFIX = {
|
||||
"SHFE": "SHFE",
|
||||
"DCE": "DCE",
|
||||
"CZCE": "CZCE",
|
||||
"CFFEX": "CFFEX",
|
||||
"INE": "INE",
|
||||
}
|
||||
|
||||
_token_cache: dict = {"token": "", "expires": 0.0, "refresh": ""}
|
||||
|
||||
|
||||
def _quote_source() -> str:
|
||||
return os.getenv("QUOTE_SOURCE", "sina").strip().lower()
|
||||
|
||||
|
||||
def _has_ths_token() -> bool:
|
||||
return bool(_get_refresh_token())
|
||||
|
||||
|
||||
def get_quote_source_label(*, ctp_connected: bool = False) -> str:
|
||||
"""界面展示用行情源说明。"""
|
||||
if ctp_connected:
|
||||
return "CTP 柜台(已连接)"
|
||||
source = _quote_source()
|
||||
if source == "sina":
|
||||
return "新浪(CTP 未连接时备用)"
|
||||
if source == "ths":
|
||||
return "同花顺 iFinD" if _has_ths_token() else "同花顺(未配置 token)"
|
||||
if _has_ths_token():
|
||||
return "同花顺优先,失败回退新浪"
|
||||
return "新浪(CTP 未连接时备用)"
|
||||
|
||||
|
||||
def _sina_headers() -> dict:
|
||||
return {"Referer": "https://finance.sina.com.cn"}
|
||||
|
||||
|
||||
def _parse_sina_futures_quote(parts: list) -> Optional[dict]:
|
||||
"""解析新浪 nf_/CFF_RE_ 期货行情字段。"""
|
||||
if len(parts) < 9:
|
||||
return None
|
||||
price = None
|
||||
for idx in (8, 7, 6, 5):
|
||||
if len(parts) > idx and parts[idx]:
|
||||
try:
|
||||
val = float(parts[idx])
|
||||
if val > 0:
|
||||
price = val
|
||||
break
|
||||
except ValueError:
|
||||
pass
|
||||
if price is None:
|
||||
price = 0.0
|
||||
|
||||
open_interest = 0.0
|
||||
volume = 0.0
|
||||
if len(parts) > 13 and parts[13]:
|
||||
try:
|
||||
open_interest = float(parts[13])
|
||||
except ValueError:
|
||||
pass
|
||||
if len(parts) > 14 and parts[14]:
|
||||
try:
|
||||
volume = float(parts[14])
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
prev_close = None
|
||||
if len(parts) > 9 and parts[9]:
|
||||
try:
|
||||
prev_close = float(parts[9])
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return {
|
||||
"name": parts[0],
|
||||
"price": price,
|
||||
"volume": volume,
|
||||
"open_interest": open_interest,
|
||||
"prev_close": prev_close,
|
||||
}
|
||||
|
||||
|
||||
def _fetch_sina_raw(sina_code: str) -> Optional[dict]:
|
||||
try:
|
||||
url = f"https://hq.sinajs.cn/list={sina_code}"
|
||||
resp = requests.get(url, headers=_sina_headers(), timeout=5)
|
||||
resp.encoding = "gbk"
|
||||
if '"' not in resp.text:
|
||||
return None
|
||||
body = resp.text.split('"')[1]
|
||||
if not body:
|
||||
return None
|
||||
parts = body.split(",")
|
||||
return _parse_sina_futures_quote(parts)
|
||||
except Exception as exc:
|
||||
logger.debug("sina fetch failed %s: %s", sina_code, exc)
|
||||
return None
|
||||
|
||||
|
||||
def get_sina_price(sina_code: str) -> Optional[float]:
|
||||
raw = _fetch_sina_raw(sina_code)
|
||||
return raw["price"] if raw else None
|
||||
|
||||
|
||||
_runtime_refresh_token: str = ""
|
||||
|
||||
|
||||
def set_ths_refresh_token(token: str):
|
||||
global _runtime_refresh_token
|
||||
_runtime_refresh_token = (token or "").strip()
|
||||
|
||||
|
||||
def _get_refresh_token() -> str:
|
||||
if _runtime_refresh_token:
|
||||
return _runtime_refresh_token
|
||||
return os.getenv("THS_REFRESH_TOKEN", "").strip()
|
||||
|
||||
|
||||
def _get_ths_access_token(refresh_token: str) -> Optional[str]:
|
||||
if not refresh_token:
|
||||
return None
|
||||
now = time.time()
|
||||
if (
|
||||
_token_cache["token"]
|
||||
and _token_cache["refresh"] == refresh_token
|
||||
and now < _token_cache["expires"]
|
||||
):
|
||||
return _token_cache["token"]
|
||||
try:
|
||||
resp = requests.post(
|
||||
THS_TOKEN_URL,
|
||||
headers={"Content-Type": "application/json", "refresh_token": refresh_token},
|
||||
timeout=10,
|
||||
)
|
||||
data = resp.json()
|
||||
if data.get("errorcode") != 0:
|
||||
logger.warning("THS token error: %s", data.get("errmsg"))
|
||||
return None
|
||||
access = data["data"]["access_token"]
|
||||
_token_cache.update({
|
||||
"token": access,
|
||||
"refresh": refresh_token,
|
||||
"expires": now + 3600 * 6,
|
||||
})
|
||||
return access
|
||||
except Exception as exc:
|
||||
logger.warning("THS token request failed: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def _parse_ths_quote(data: dict) -> Optional[float]:
|
||||
"""从同花顺实时行情响应解析最新价。"""
|
||||
try:
|
||||
tables = data.get("tables") or []
|
||||
for table in tables:
|
||||
t = table.get("table") or {}
|
||||
for key in ("latest", "new", "close", "trade", "last"):
|
||||
val = t.get(key)
|
||||
if val is None:
|
||||
continue
|
||||
if isinstance(val, list) and val:
|
||||
return float(val[0])
|
||||
if isinstance(val, (int, float, str)) and str(val):
|
||||
return float(val)
|
||||
# 部分响应嵌套在 data 字段
|
||||
if "data" in data and isinstance(data["data"], dict):
|
||||
return _parse_ths_quote(data["data"])
|
||||
except Exception as exc:
|
||||
logger.debug("parse ths quote failed: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def get_ths_price(ths_full_code: str, refresh_token: str = "") -> Optional[float]:
|
||||
"""ths_full_code 如 ag2608.SHFE、IF2606.CFFEX"""
|
||||
token = refresh_token or _get_refresh_token()
|
||||
access = _get_ths_access_token(token)
|
||||
if not access:
|
||||
return None
|
||||
try:
|
||||
resp = requests.post(
|
||||
THS_QUOTE_URL,
|
||||
headers={"Content-Type": "application/json", "access_token": access},
|
||||
json={"codes": ths_full_code, "indicators": "latest"},
|
||||
timeout=10,
|
||||
)
|
||||
data = resp.json()
|
||||
if data.get("errorcode") != 0:
|
||||
logger.warning("THS quote error %s: %s", ths_full_code, data.get("errmsg"))
|
||||
return None
|
||||
return _parse_ths_quote(data)
|
||||
except Exception as exc:
|
||||
logger.warning("THS quote failed %s: %s", ths_full_code, exc)
|
||||
return None
|
||||
|
||||
|
||||
def get_price(market_code: str, sina_fallback: str = "") -> Optional[float]:
|
||||
"""
|
||||
统一取价入口。
|
||||
sina_fallback: 新浪代码 nf_AG2608(普通用户默认使用)
|
||||
market_code: 同花顺完整代码 ag2608.SHFE(仅机构 token 可用时)
|
||||
"""
|
||||
source = _quote_source()
|
||||
|
||||
# 仅在有 token 且配置为 ths/auto 时才尝试同花顺
|
||||
use_ths = source == "ths" or (source == "auto" and _has_ths_token())
|
||||
if use_ths and market_code and "." in market_code:
|
||||
price = get_ths_price(market_code)
|
||||
if price is not None:
|
||||
return price
|
||||
if source == "ths":
|
||||
return None
|
||||
|
||||
if sina_fallback:
|
||||
return get_sina_price(sina_fallback)
|
||||
|
||||
if market_code.startswith("nf_") or market_code.startswith("CFF_RE_"):
|
||||
return get_sina_price(market_code)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def fetch_raw_for_volume(sina_code: str) -> Optional[dict]:
|
||||
"""主力合约扫描用(成交量),走新浪。"""
|
||||
return _fetch_sina_raw(sina_code)
|
||||
@@ -0,0 +1,287 @@
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
# 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
|
||||
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
||||
|
||||
"""国内期货交易时段与盘前连接窗口。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
TZ = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
# 各交易段开盘时刻 (时, 分)
|
||||
SESSION_OPENS = (
|
||||
(9, 0),
|
||||
(10, 30), # 上午小节休息后续盘
|
||||
(13, 30),
|
||||
(21, 0),
|
||||
)
|
||||
|
||||
# 日盘各连续交易段 (start_h, start_m, end_h, end_m),左闭右开
|
||||
_DAY_SEGMENTS = (
|
||||
(9, 0, 10, 15),
|
||||
(10, 30, 11, 30),
|
||||
(13, 30, 15, 0),
|
||||
)
|
||||
|
||||
|
||||
def _normalize_dt(now: Optional[datetime] = None) -> datetime:
|
||||
d = now or datetime.now(TZ)
|
||||
if d.tzinfo is None:
|
||||
return d.replace(tzinfo=TZ)
|
||||
return d.astimezone(TZ)
|
||||
|
||||
|
||||
def _minutes_of_day(d: datetime) -> int:
|
||||
return d.hour * 60 + d.minute
|
||||
|
||||
|
||||
def _in_time_range(t: int, sh: int, sm: int, eh: int, em: int) -> bool:
|
||||
return t >= sh * 60 + sm and t < eh * 60 + em
|
||||
|
||||
|
||||
def is_trading_session(now: Optional[datetime] = None) -> bool:
|
||||
d = _normalize_dt(now)
|
||||
wd = d.weekday()
|
||||
if wd == 6:
|
||||
return False
|
||||
if wd == 5 and d.hour < 21:
|
||||
return False
|
||||
t = _minutes_of_day(d)
|
||||
for sh, sm, eh, em in _DAY_SEGMENTS:
|
||||
if _in_time_range(t, sh, sm, eh, em):
|
||||
return True
|
||||
if _in_time_range(t, 21, 0, 24, 0):
|
||||
return True
|
||||
if _in_time_range(t, 0, 0, 2, 30):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def is_morning_break(now: Optional[datetime] = None) -> bool:
|
||||
"""10:15–10:30 上午小节休息。"""
|
||||
d = _normalize_dt(now)
|
||||
if d.weekday() >= 5:
|
||||
return False
|
||||
t = _minutes_of_day(d)
|
||||
return _in_time_range(t, 10, 15, 10, 30)
|
||||
|
||||
|
||||
def is_lunch_break(now: Optional[datetime] = None) -> bool:
|
||||
"""11:30–13:30 午间休盘。"""
|
||||
d = _normalize_dt(now)
|
||||
if d.weekday() >= 5:
|
||||
return False
|
||||
t = _minutes_of_day(d)
|
||||
return _in_time_range(t, 11, 30, 13, 30)
|
||||
|
||||
|
||||
def is_night_trading_session(now: Optional[datetime] = None) -> bool:
|
||||
"""当前是否处于夜盘时段(21:00–02:30,且整体仍在交易时段内)。"""
|
||||
if not is_trading_session(now):
|
||||
return False
|
||||
d = _normalize_dt(now)
|
||||
t = _minutes_of_day(d)
|
||||
return t >= 21 * 60 or t < 2 * 60 + 30
|
||||
|
||||
|
||||
def _session_open_allowed(day: datetime, hour: int, minute: int) -> bool:
|
||||
wd = day.weekday()
|
||||
if (hour, minute) in ((9, 0), (10, 30), (13, 30)):
|
||||
return wd < 5
|
||||
if (hour, minute) == (21, 0):
|
||||
if wd < 5:
|
||||
return True
|
||||
return wd == 5
|
||||
return False
|
||||
|
||||
|
||||
def iter_session_starts(
|
||||
start: datetime,
|
||||
*,
|
||||
hours_ahead: int = 36,
|
||||
) -> list[datetime]:
|
||||
"""列出 start 之后若干小时内的各段开盘时刻。"""
|
||||
if start.tzinfo is None:
|
||||
start = start.replace(tzinfo=TZ)
|
||||
else:
|
||||
start = start.astimezone(TZ)
|
||||
end = start + timedelta(hours=hours_ahead)
|
||||
out: list[datetime] = []
|
||||
day = start.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
while day <= end:
|
||||
for h, m in SESSION_OPENS:
|
||||
if not _session_open_allowed(day, h, m):
|
||||
continue
|
||||
dt = day.replace(hour=h, minute=m)
|
||||
if dt > start and dt <= end:
|
||||
out.append(dt)
|
||||
day += timedelta(days=1)
|
||||
out.sort()
|
||||
return out
|
||||
|
||||
|
||||
def minutes_until_next_session(now: Optional[datetime] = None) -> Optional[float]:
|
||||
d = _normalize_dt(now)
|
||||
starts = iter_session_starts(d, hours_ahead=48)
|
||||
if not starts:
|
||||
return None
|
||||
return (starts[0] - d).total_seconds() / 60.0
|
||||
|
||||
|
||||
def _session_open_label(dt: datetime) -> str:
|
||||
h, m = dt.hour, dt.minute
|
||||
if (h, m) == (9, 0):
|
||||
return "日盘开盘"
|
||||
if (h, m) == (10, 30):
|
||||
return "上午续盘"
|
||||
if (h, m) == (13, 30):
|
||||
return "午盘开盘"
|
||||
if (h, m) == (21, 0):
|
||||
return "夜盘开盘"
|
||||
return "开盘"
|
||||
|
||||
|
||||
def _session_status_label(d: datetime, in_sess: bool) -> str:
|
||||
if in_sess:
|
||||
return "交易时间段"
|
||||
if is_morning_break(d):
|
||||
return "上午休盘"
|
||||
if is_lunch_break(d):
|
||||
return "午间休盘"
|
||||
return "非交易时间段"
|
||||
|
||||
|
||||
def _fmt_countdown(seconds: int) -> str:
|
||||
s = max(0, int(seconds))
|
||||
h, rem = divmod(s, 3600)
|
||||
m, sec = divmod(rem, 60)
|
||||
if h > 0:
|
||||
return f"{h}小时{m:02d}分{sec:02d}秒"
|
||||
if m > 0:
|
||||
return f"{m}分{sec:02d}秒"
|
||||
return f"{sec}秒"
|
||||
|
||||
|
||||
def _day_close_dt(d: datetime) -> datetime:
|
||||
return d.replace(hour=15, minute=0, second=0, microsecond=0)
|
||||
|
||||
|
||||
def _night_close_dt(d: datetime) -> datetime:
|
||||
t = d.hour * 60 + d.minute
|
||||
if t >= 21 * 60:
|
||||
nxt = (d + timedelta(days=1)).replace(hour=2, minute=30, second=0, microsecond=0)
|
||||
return nxt
|
||||
return d.replace(hour=2, minute=30, second=0, microsecond=0)
|
||||
|
||||
|
||||
def _current_break_close(d: datetime) -> tuple[Optional[datetime], Optional[datetime], Optional[str], Optional[str]]:
|
||||
"""当前交易段内的休盘/收盘时刻与标签。"""
|
||||
t = _minutes_of_day(d)
|
||||
if _in_time_range(t, 9, 0, 10, 15):
|
||||
br = d.replace(hour=10, minute=15, second=0, microsecond=0)
|
||||
cl = _day_close_dt(d)
|
||||
return br, cl, "上午休盘", "日盘收盘"
|
||||
if _in_time_range(t, 10, 30, 11, 30):
|
||||
br = d.replace(hour=11, minute=30, second=0, microsecond=0)
|
||||
cl = _day_close_dt(d)
|
||||
return br, cl, "午间休盘", "日盘收盘"
|
||||
if _in_time_range(t, 13, 30, 15, 0):
|
||||
cl = _day_close_dt(d)
|
||||
return None, cl, None, "日盘收盘"
|
||||
if t >= 21 * 60 or t < 2 * 60 + 30:
|
||||
cl = _night_close_dt(d)
|
||||
return None, cl, None, "夜盘收盘"
|
||||
return None, None, None, None
|
||||
|
||||
|
||||
def trading_session_clock(now: Optional[datetime] = None) -> dict:
|
||||
"""顶栏展示:当前时间、交易状态、距开盘/休盘/收盘倒计时。"""
|
||||
d = _normalize_dt(now)
|
||||
in_sess = is_trading_session(d)
|
||||
out = {
|
||||
"now": d.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"now_time": d.strftime("%m-%d %H:%M:%S"),
|
||||
"in_session": in_sess,
|
||||
"status_label": _session_status_label(d, in_sess),
|
||||
}
|
||||
if not in_sess:
|
||||
starts = iter_session_starts(d, hours_ahead=72)
|
||||
if starts:
|
||||
nxt = starts[0]
|
||||
secs = int(max(0, (nxt - d).total_seconds()))
|
||||
out["next_open_at"] = nxt.strftime("%m-%d %H:%M")
|
||||
out["next_open_label"] = _session_open_label(nxt)
|
||||
out["secs_to_open"] = secs
|
||||
out["countdown_open"] = _fmt_countdown(secs)
|
||||
return out
|
||||
br, cl, br_label, cl_label = _current_break_close(d)
|
||||
if br and br > d:
|
||||
secs = int((br - d).total_seconds())
|
||||
out["break_at"] = br.strftime("%H:%M")
|
||||
out["break_label"] = br_label or "休盘"
|
||||
out["secs_to_break"] = secs
|
||||
out["countdown_break"] = _fmt_countdown(secs)
|
||||
if cl and cl > d:
|
||||
secs = int((cl - d).total_seconds())
|
||||
out["close_at"] = cl.strftime("%H:%M")
|
||||
out["close_label"] = cl_label or "收盘"
|
||||
out["secs_to_close"] = secs
|
||||
out["countdown_close"] = _fmt_countdown(secs)
|
||||
return out
|
||||
|
||||
|
||||
def in_premarket_connect_window(
|
||||
now: Optional[datetime] = None,
|
||||
*,
|
||||
minutes_before: int = 30,
|
||||
) -> bool:
|
||||
"""距下一段开盘 <= minutes_before 分钟,且当前尚未进入交易时段。"""
|
||||
if is_trading_session(now):
|
||||
return False
|
||||
mins = minutes_until_next_session(now)
|
||||
if mins is None:
|
||||
return False
|
||||
return 0 < mins <= float(minutes_before)
|
||||
|
||||
|
||||
def in_postmarket_grace_window(
|
||||
now: Optional[datetime] = None,
|
||||
*,
|
||||
minutes_after: int = 30,
|
||||
) -> bool:
|
||||
"""日盘 15:00 或夜盘 02:30 收盘后 minutes_after 分钟内(仍保持连接,便于收尾)。"""
|
||||
if is_trading_session(now):
|
||||
return False
|
||||
d = _normalize_dt(now)
|
||||
t = _minutes_of_day(d)
|
||||
wd = d.weekday()
|
||||
ma = max(1, int(minutes_after))
|
||||
day_close = 15 * 60
|
||||
night_close = 2 * 60 + 30
|
||||
# 日盘收盘 15:00 后宽限(周一至周五)
|
||||
if wd < 5 and day_close <= t < day_close + ma:
|
||||
return True
|
||||
# 夜盘收盘 02:30 后宽限(含周六凌晨结束周五夜盘)
|
||||
if night_close <= t < night_close + ma:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def should_keep_ctp_connected(
|
||||
now: Optional[datetime] = None,
|
||||
*,
|
||||
minutes_before: int = 30,
|
||||
minutes_after: int = 30,
|
||||
) -> bool:
|
||||
"""是否处于应连接 CTP 的窗口:交易时段 + 小节/午间休盘 + 盘前 + 盘后宽限。"""
|
||||
if is_trading_session(now):
|
||||
return True
|
||||
if is_morning_break(now) or is_lunch_break(now):
|
||||
return True
|
||||
if in_postmarket_grace_window(now, minutes_after=minutes_after):
|
||||
return True
|
||||
return in_premarket_connect_window(now, minutes_before=minutes_before)
|
||||
@@ -0,0 +1,230 @@
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
"""HTTP routes for market module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
from flask import (
|
||||
Response,
|
||||
flash,
|
||||
jsonify,
|
||||
redirect,
|
||||
render_template,
|
||||
request,
|
||||
send_file,
|
||||
session,
|
||||
stream_with_context,
|
||||
url_for,
|
||||
)
|
||||
|
||||
|
||||
def register(deps) -> None:
|
||||
app = deps.app
|
||||
login_required = deps.login_required
|
||||
require_nav = deps.require_nav
|
||||
get_db = deps.get_db
|
||||
get_setting = deps.get_setting
|
||||
set_setting = deps.set_setting
|
||||
fetch_price = deps.fetch_price
|
||||
send_wechat_msg = deps.send_wechat_msg
|
||||
touch_stats_cache = deps.touch_stats_cache
|
||||
get_stats_data = deps.get_stats_data
|
||||
build_market_quote_payload = deps.build_market_quote_payload
|
||||
today_str = deps.today_str
|
||||
expire_old_plans = deps.expire_old_plans
|
||||
TZ = deps.tz
|
||||
DB_PATH = deps.db_path
|
||||
UPLOAD_DIR = deps.upload_dir
|
||||
OPEN_TYPES = deps.open_types
|
||||
EXIT_TRIGGERS = deps.exit_triggers
|
||||
BEHAVIOR_TAGS = deps.behavior_tags
|
||||
KLINE_PERIODS = deps.kline_periods
|
||||
KLINE_CUTOFFS = deps.kline_cutoffs
|
||||
calc_holding_duration = deps.calc_holding_duration
|
||||
holding_to_minutes = deps.holding_to_minutes
|
||||
classify_close_result = deps.classify_close_result
|
||||
calc_rr_ratio = deps.calc_rr_ratio
|
||||
calc_theoretical_pnl = deps.calc_theoretical_pnl
|
||||
parse_review_date_filter = deps.parse_review_date_filter
|
||||
_trading_mode = deps.trading_mode
|
||||
_ua_is_phone = deps.ua_is_phone
|
||||
_static_asset_v = deps.static_asset_v
|
||||
|
||||
from modules.core.symbols import (
|
||||
list_main_contracts_grouped,
|
||||
list_recommended_symbols_grouped,
|
||||
search_symbols,
|
||||
)
|
||||
from modules.market.kline_chart import MARKET_PERIODS, fetch_market_klines
|
||||
from modules.market.kline_stream import kline_hub, sse_format
|
||||
from modules.market.market import get_quote_source_label
|
||||
from queue import Empty
|
||||
|
||||
@app.route("/api/symbols/search")
|
||||
@login_required
|
||||
def api_symbol_search():
|
||||
q = request.args.get("q", "")
|
||||
conn = get_db()
|
||||
try:
|
||||
from modules.core.trading_context import get_account_capital, is_ctp_connected
|
||||
capital = get_account_capital(conn, get_setting)
|
||||
ctp_connected = is_ctp_connected(get_setting)
|
||||
finally:
|
||||
conn.close()
|
||||
return jsonify(search_symbols(q, capital=capital, ctp_connected=ctp_connected))
|
||||
|
||||
|
||||
@app.route("/api/symbols/mains")
|
||||
@login_required
|
||||
def api_symbols_mains():
|
||||
return jsonify(list_main_contracts_grouped())
|
||||
|
||||
|
||||
@app.route("/api/symbols/recommended")
|
||||
@login_required
|
||||
def api_symbols_recommended():
|
||||
"""品种下拉:仅展示当前资金下可开仓品种(与下方可开仓品种表一致)。"""
|
||||
from modules.trading.recommend_store import recommend_payload
|
||||
from modules.core.trading_context import (
|
||||
get_fixed_lots,
|
||||
get_max_margin_pct,
|
||||
get_recommend_capital,
|
||||
get_sizing_mode,
|
||||
get_trading_mode,
|
||||
)
|
||||
|
||||
conn = get_db()
|
||||
try:
|
||||
capital = get_recommend_capital(conn, get_setting)
|
||||
payload = recommend_payload(
|
||||
conn,
|
||||
live_capital=capital,
|
||||
max_margin_pct=get_max_margin_pct(get_setting),
|
||||
trading_mode=get_trading_mode(get_setting),
|
||||
sizing_mode=get_sizing_mode(get_setting),
|
||||
fixed_lots=get_fixed_lots(get_setting),
|
||||
)
|
||||
return jsonify(list_recommended_symbols_grouped(payload.get("rows") or []))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@app.route("/market")
|
||||
@login_required
|
||||
@require_nav("market")
|
||||
def market_page():
|
||||
symbol = request.args.get("symbol", "").strip()
|
||||
period = request.args.get("period", "15m").strip()
|
||||
valid = {p["key"] for p in MARKET_PERIODS}
|
||||
if period not in valid:
|
||||
period = "15m"
|
||||
ctp_st = {}
|
||||
try:
|
||||
from modules.ctp.vnpy_bridge import ctp_status
|
||||
from modules.core.trading_context import get_trading_mode
|
||||
|
||||
ctp_st = ctp_status(get_trading_mode(get_setting))
|
||||
except Exception:
|
||||
pass
|
||||
return render_template(
|
||||
"market.html",
|
||||
symbol=symbol,
|
||||
period=period,
|
||||
market_periods=MARKET_PERIODS,
|
||||
quote_label=get_quote_source_label(ctp_connected=bool(ctp_st.get("connected"))),
|
||||
ctp_connected=bool(ctp_st.get("connected")),
|
||||
)
|
||||
|
||||
|
||||
@app.route("/api/kline")
|
||||
@login_required
|
||||
def api_kline():
|
||||
symbol = request.args.get("symbol", "").strip()
|
||||
period = request.args.get("period", "15m").strip()
|
||||
if not symbol:
|
||||
return jsonify({"error": "请提供合约代码"}), 400
|
||||
try:
|
||||
from modules.core.trading_context import get_trading_mode
|
||||
|
||||
data = fetch_market_klines(
|
||||
symbol, period, DB_PATH, prefer_ctp=False,
|
||||
)
|
||||
except Exception as exc:
|
||||
app.logger.warning("kline api failed: %s", exc)
|
||||
return jsonify({"error": str(exc)}), 500
|
||||
if not data.get("chart_symbol"):
|
||||
return jsonify({"error": "无法识别合约代码"}), 400
|
||||
if not data.get("bars"):
|
||||
return jsonify({"error": "未获取到K线数据,请稍后重试或更换合约"}), 404
|
||||
return jsonify(data)
|
||||
|
||||
|
||||
@app.route("/api/kline/stream")
|
||||
@login_required
|
||||
def api_kline_stream():
|
||||
from queue import Empty
|
||||
|
||||
symbol = request.args.get("symbol", "").strip()
|
||||
period = request.args.get("period", "15m").strip()
|
||||
market_code = request.args.get("market_code", "").strip()
|
||||
sina_code = request.args.get("sina_code", "").strip()
|
||||
if not symbol:
|
||||
return jsonify({"error": "请提供合约代码"}), 400
|
||||
|
||||
def generate():
|
||||
sub = kline_hub.subscribe(symbol, period, market_code, sina_code)
|
||||
try:
|
||||
kline_data = fetch_market_klines(
|
||||
symbol, period, DB_PATH, prefer_ctp=False,
|
||||
)
|
||||
if kline_data.get("bars"):
|
||||
yield sse_format("kline", kline_data)
|
||||
yield sse_format(
|
||||
"quote",
|
||||
build_market_quote_payload(
|
||||
symbol, market_code, sina_code, prefer_sina=True,
|
||||
),
|
||||
)
|
||||
while True:
|
||||
try:
|
||||
msg = sub.queue.get(timeout=20)
|
||||
yield sse_format(msg["event"], msg["data"])
|
||||
except Empty:
|
||||
yield ": heartbeat\n\n"
|
||||
finally:
|
||||
kline_hub.unsubscribe(sub)
|
||||
|
||||
return Response(
|
||||
stream_with_context(generate()),
|
||||
mimetype="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.route("/api/market_quote")
|
||||
@login_required
|
||||
def api_market_quote():
|
||||
symbol = request.args.get("symbol", "").strip()
|
||||
market_code = request.args.get("market_code", "").strip()
|
||||
sina_code = request.args.get("sina_code", "").strip()
|
||||
if not symbol and not market_code:
|
||||
return jsonify({"error": "请提供合约"}), 400
|
||||
return jsonify(build_market_quote_payload(
|
||||
symbol, market_code, sina_code, prefer_sina=True,
|
||||
))
|
||||
|
||||
|
||||
@app.route("/contract")
|
||||
@login_required
|
||||
def contract_profile_page():
|
||||
return redirect(url_for("positions"))
|
||||
|
||||
|
||||
@app.route("/api/contract_profile")
|
||||
@login_required
|
||||
def api_contract_profile():
|
||||
return jsonify({"error": "品种简介功能已移除"}), 404
|
||||
Reference in New Issue
Block a user