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:
dekun
2026-07-01 14:42:16 +08:00
parent b354d6c701
commit e5a586f903
209 changed files with 21962 additions and 20963 deletions
+10
View File
@@ -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"]
+558
View File
@@ -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
+175
View File
@@ -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),
}
+139
View File
@@ -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()
+248
View File
@@ -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)
+287
View File
@@ -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:1510: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:3013: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)
+230
View File
@@ -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