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
+8
View File
@@ -0,0 +1,8 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
"""Core bootstrap and shared types."""
from modules.core.bootstrap import register_all_modules, start_module_workers
from modules.core.deps import AppDeps
__all__ = ["AppDeps", "register_all_modules", "start_module_workers"]
+55
View File
@@ -0,0 +1,55 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
"""Application module registry and startup wiring."""
from __future__ import annotations
import importlib
import logging
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from modules.core.deps import AppDeps
logger = logging.getLogger(__name__)
# Registration order: core services first, trading last among features.
_MODULE_NAMES = (
"modules.web",
"modules.market",
"modules.keys",
"modules.plans",
"modules.notify",
"modules.records",
"modules.stats",
"modules.fees",
"modules.backup",
"modules.settings",
"modules.risk",
"modules.strategy",
"modules.ctp",
"modules.trading",
)
def register_all_modules(deps: "AppDeps") -> None:
for name in _MODULE_NAMES:
mod = importlib.import_module(name)
register = getattr(mod, "register", None)
if not callable(register):
logger.warning("module %s has no register()", name)
continue
register(deps)
logger.debug("registered %s", name)
def start_module_workers(deps: "AppDeps") -> None:
"""Background threads owned by feature modules."""
from modules.ctp.vnpy_bridge import try_init_vnpy
try_init_vnpy({})
for name in ("modules.market",):
mod = importlib.import_module(name)
start = getattr(mod, "start_workers", None)
if callable(start):
start(deps)
+280
View File
@@ -0,0 +1,280 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""期货合约简介:东方财富 / 新浪 / AKShare。"""
import logging
import re
from typing import Any, Optional
import requests
from modules.core.contract_specs import get_contract_spec
from modules.core.symbols import ths_to_codes, search_symbols
logger = logging.getLogger(__name__)
EM_LABEL_MAP = {
"vname": "交易品种",
"vcode": "交易代码",
"jydw": "交易单位",
"bjdw": "报价单位",
"market": "交易所",
"zxbddw": "最小变动价位",
"zdtbfd": "涨跌停幅度",
"hyjgyf": "合约月份",
"jysj": "交易时间",
"zhjyr": "最后交易日",
"zhjgr": "交割日期",
"jgpj": "交割品级",
"zcjybzj": "最低交易保证金",
"jgfs": "交割方式",
"jgdd": "交割地点",
"ssrq": "上市日期",
}
DISPLAY_ORDER = [
"交易品种",
"交易代码",
"交易单位",
"报价单位",
"最小变动价位",
"最低交易保证金",
"涨跌停幅度",
"合约月份",
"交易时间",
"最后交易日",
"交割日期",
"交割方式",
"交割地点",
"交割品级",
"上市日期",
"交易所",
]
SKIP_ITEMS = {"", "-", "None", "nan", "null"}
def _normalize_ths_code(raw: str) -> Optional[str]:
code = (raw or "").strip()
if not code:
return None
# 已是完整合约
if re.match(r"^[A-Za-z]+\d{3,4}$", code):
return code
# 仅品种字母时尝试匹配主力
results = search_symbols(code)
if results:
return results[0].get("ths_code") or code
codes = ths_to_codes(code)
if codes:
return codes["ths_code"]
return code
def _to_sina_quote_symbol(ths_code: str) -> str:
m = re.match(r"^([A-Za-z]+)(\d+)$", ths_code.strip())
if not m:
return ths_code.upper()
return m.group(1).upper() + m.group(2)
def _to_em_page_symbol(ths_code: str) -> str:
return ths_code.strip().lower() + "F"
def _clean_value(val: Any) -> str:
if val is None:
return ""
s = str(val).strip()
if s in SKIP_ITEMS:
return ""
return s
def _rows_from_dict(data: dict[str, str]) -> list[dict]:
rows: list[dict] = []
seen: set[str] = set()
for label in DISPLAY_ORDER:
val = _clean_value(data.get(label))
if not val:
continue
hint = _clean_value(data.get(f"{label}_hint"))
rows.append({"label": label, "value": val, "hint": hint})
seen.add(label)
for label, val in data.items():
if label.endswith("_hint") or label in seen:
continue
val = _clean_value(val)
if val:
rows.append({"label": label, "value": val, "hint": ""})
return rows
def _add_computed_hints(ths_code: str, data: dict[str, str]) -> None:
spec = get_contract_spec(ths_code)
mult = spec.get("mult") or 0
tick_raw = data.get("最小变动价位", "")
m = re.search(r"([\d.]+)", tick_raw)
if m and mult:
tick = float(m.group(1))
data["最小变动价位_hint"] = f"一手合约最小波动{round(tick * mult, 2)}"
def _fetch_em_direct(em_symbol: str) -> dict[str, str]:
page_url = f"https://quote.eastmoney.com/qihuo/{em_symbol}.html"
r = requests.get(page_url, timeout=12)
r.encoding = r.apparent_encoding or "utf-8"
inner = None
for pat in [
r"futures_([A-Za-z0-9_]+)",
r"#(futures_[A-Za-z0-9_]+)",
r"/(futures_[A-Za-z0-9_]+)",
]:
m = re.search(pat, r.text)
if m:
inner = m.group(1).replace("futures_", "")
break
if not inner:
raise ValueError("无法解析东方财富合约标识")
info_url = f"https://futsse-static.eastmoney.com/redis?msgid={inner}_info"
r2 = requests.get(info_url, timeout=12)
payload = r2.json()
if not isinstance(payload, dict):
raise ValueError("东方财富返回数据无效")
out: dict[str, str] = {}
for key, label in EM_LABEL_MAP.items():
val = _clean_value(payload.get(key))
if val:
out[label] = val
if not out:
raise ValueError("东方财富合约字段为空")
return out
def _fetch_em_akshare(em_symbol: str) -> dict[str, str]:
import akshare as ak
df = ak.futures_contract_detail_em(symbol=em_symbol)
out: dict[str, str] = {}
for _, row in df.iterrows():
label = _clean_value(row.get("item"))
val = _clean_value(row.get("value"))
if label and val:
if label == "跌涨停板幅度":
label = "涨跌停幅度"
if label == "最后交割日":
label = "交割日期"
if label == "上市交易所":
label = "交易所"
if label == "合约交割月份":
label = "合约月份"
if label == "最初交易保证金":
label = "最低交易保证金"
if label == "最小变动价格":
label = "最小变动价位"
out[label] = val
return out
def _fetch_sina_direct(sina_symbol: str) -> dict[str, str]:
from io import StringIO
import pandas as pd
url = f"https://finance.sina.com.cn/futures/quotes/{sina_symbol}.shtml"
r = requests.get(url, timeout=12, headers={"Referer": "https://finance.sina.com.cn/"})
r.encoding = "gb2312"
tables = pd.read_html(StringIO(r.text))
if len(tables) < 7:
raise ValueError("新浪页面结构变化")
temp_df = tables[6]
parts = []
for ncol in [slice(0, 2), slice(2, 4), slice(4, None)]:
part = temp_df.iloc[:, ncol]
part.columns = ["item", "value"]
parts.append(part)
merged = pd.concat(parts, axis=0, ignore_index=True)
out: dict[str, str] = {}
for _, row in merged.iterrows():
label = _clean_value(row["item"])
val = _clean_value(row["value"])
if not label or not val or len(label) > 80 or "发帖" in val:
continue
out[label] = val
return out
def _fetch_sina_akshare(sina_symbol: str) -> dict[str, str]:
import akshare as ak
df = ak.futures_contract_detail(symbol=sina_symbol)
out: dict[str, str] = {}
for _, row in df.iterrows():
label = _clean_value(row.get("item"))
val = _clean_value(row.get("value"))
if label and val and "发帖" not in val:
out[label] = val
return out
def _merge_profile(primary: dict[str, str], secondary: dict[str, str]) -> dict[str, str]:
merged = dict(secondary)
merged.update(primary)
return merged
def get_contract_profile(raw_symbol: str) -> Optional[dict]:
ths_code = _normalize_ths_code(raw_symbol)
if not ths_code:
return None
em_symbol = _to_em_page_symbol(ths_code)
sina_symbol = _to_sina_quote_symbol(ths_code)
data: dict[str, str] = {}
source_parts: list[str] = []
# 东方财富(字段与看盘软件简介接近)
try:
try:
data = _fetch_em_akshare(em_symbol)
source_parts.append("东方财富")
except ImportError:
data = _fetch_em_direct(em_symbol)
source_parts.append("东方财富")
except Exception as exc:
logger.warning("eastmoney profile failed %s: %s", em_symbol, exc)
# 新浪补充交割地点、上市日期等
sina_data: dict[str, str] = {}
try:
try:
sina_data = _fetch_sina_akshare(sina_symbol)
except ImportError:
sina_data = _fetch_sina_direct(sina_symbol)
if sina_data:
source_parts.append("新浪")
except Exception as exc:
logger.warning("sina profile failed %s: %s", sina_symbol, exc)
if sina_data:
data = _merge_profile(data, sina_data)
if not data:
return None
_add_computed_hints(ths_code, data)
rows = _rows_from_dict(data)
if not rows:
return None
return {
"ths_code": ths_code,
"symbol_name": data.get("交易品种", ""),
"exchange": data.get("交易所", ""),
"rows": rows,
"source": " + ".join(source_parts) if source_parts else "未知",
}
+166
View File
@@ -0,0 +1,166 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""国内期货合约乘数与参考保证金比例(用于估算保证金与风险)。"""
import re
from typing import Optional
DEFAULT_SPEC = {"mult": 10, "margin_rate": 0.10, "tick_size": 1.0}
# 参考交易所常见规格(乘数 + 保证金比例 + 最小变动价位)
_SPEC_BY_THS: dict[str, dict] = {
"ag": {"mult": 15, "margin_rate": 0.14, "tick_size": 1.0},
"au": {"mult": 1000, "margin_rate": 0.10, "tick_size": 0.02},
"cu": {"mult": 5, "margin_rate": 0.10, "tick_size": 10.0},
"al": {"mult": 5, "margin_rate": 0.10},
"zn": {"mult": 5, "margin_rate": 0.10},
"pb": {"mult": 5, "margin_rate": 0.10},
"ni": {"mult": 1, "margin_rate": 0.12},
"sn": {"mult": 1, "margin_rate": 0.12},
"rb": {"mult": 10, "margin_rate": 0.09},
"hc": {"mult": 10, "margin_rate": 0.09},
"ss": {"mult": 5, "margin_rate": 0.11},
"sc": {"mult": 1000, "margin_rate": 0.11},
"fu": {"mult": 10, "margin_rate": 0.11},
"bu": {"mult": 10, "margin_rate": 0.11},
"ru": {"mult": 10, "margin_rate": 0.11},
"sp": {"mult": 10, "margin_rate": 0.10},
"i": {"mult": 100, "margin_rate": 0.11},
"j": {"mult": 100, "margin_rate": 0.12},
"jm": {"mult": 60, "margin_rate": 0.12},
"m": {"mult": 10, "margin_rate": 0.08},
"y": {"mult": 10, "margin_rate": 0.08},
"p": {"mult": 10, "margin_rate": 0.09},
"c": {"mult": 10, "margin_rate": 0.08},
"cs": {"mult": 10, "margin_rate": 0.08},
"jd": {"mult": 10, "margin_rate": 0.09},
"lh": {"mult": 16, "margin_rate": 0.12},
"l": {"mult": 5, "margin_rate": 0.09},
"pp": {"mult": 5, "margin_rate": 0.09},
"v": {"mult": 5, "margin_rate": 0.09},
"eg": {"mult": 10, "margin_rate": 0.09},
"eb": {"mult": 5, "margin_rate": 0.10},
"pg": {"mult": 20, "margin_rate": 0.10},
"RM": {"mult": 10, "margin_rate": 0.08},
"OI": {"mult": 10, "margin_rate": 0.08},
"SR": {"mult": 10, "margin_rate": 0.08},
"CF": {"mult": 5, "margin_rate": 0.08},
"MA": {"mult": 10, "margin_rate": 0.09},
"TA": {"mult": 5, "margin_rate": 0.09},
"FG": {"mult": 20, "margin_rate": 0.10},
"SA": {"mult": 20, "margin_rate": 0.10},
"UR": {"mult": 20, "margin_rate": 0.10},
"SF": {"mult": 5, "margin_rate": 0.10},
"SM": {"mult": 5, "margin_rate": 0.10},
"AP": {"mult": 10, "margin_rate": 0.10},
"CJ": {"mult": 5, "margin_rate": 0.10},
"PK": {"mult": 5, "margin_rate": 0.10},
"IF": {"mult": 300, "margin_rate": 0.12, "tick_size": 0.2},
"IH": {"mult": 300, "margin_rate": 0.12, "tick_size": 0.2},
"IC": {"mult": 200, "margin_rate": 0.12, "tick_size": 0.2},
"IM": {"mult": 200, "margin_rate": 0.12, "tick_size": 0.2},
}
_TICK_OVERRIDES: dict[str, float] = {
"sc": 0.1, "TA": 2.0, "CF": 5.0, "SF": 2.0, "SM": 2.0,
}
def get_contract_spec(ths_code: str) -> dict:
code = (ths_code or "").strip()
m = re.match(r"^([A-Za-z]+)", code)
if not m:
return dict(DEFAULT_SPEC)
letters = m.group(1)
spec = _SPEC_BY_THS.get(letters) or _SPEC_BY_THS.get(letters.upper()) or _SPEC_BY_THS.get(letters.lower())
if spec:
tick = spec.get("tick_size")
if tick is None:
tick = _TICK_OVERRIDES.get(letters) or _TICK_OVERRIDES.get(letters.upper()) or 1.0
return {"mult": spec["mult"], "margin_rate": spec["margin_rate"], "tick_size": float(tick)}
return dict(DEFAULT_SPEC)
def margin_one_lot(
ths_code: str,
price: float,
*,
direction: str = "long",
trading_mode: str | None = None,
) -> tuple[float, str, dict]:
"""1 手保证金。CTP 已连接时优先读柜台合约保证金率,否则用本地参考规格估算。
direction 可为 long / short / max(多空费率取较大值,用于可开仓品种表)。
返回 (保证金, 来源 estimate|ctp, 合约规格片段)。
"""
spec = get_contract_spec(ths_code)
est = 0.0
if price and price > 0:
est = round(float(price) * spec["mult"] * spec["margin_rate"], 2)
if trading_mode:
try:
from modules.ctp.vnpy_bridge import ctp_estimate_margin_one_lot, ctp_lookup_contract_spec, ctp_status
if ctp_status(trading_mode).get("connected"):
ctp_margin = ctp_estimate_margin_one_lot(
trading_mode, ths_code, float(price), direction=direction,
)
if ctp_margin and ctp_margin > 0:
merged = dict(spec)
ctp_spec = ctp_lookup_contract_spec(trading_mode, ths_code) or {}
if ctp_spec.get("mult"):
merged["mult"] = ctp_spec["mult"]
if ctp_spec.get("tick_size"):
merged["tick_size"] = ctp_spec["tick_size"]
if ctp_spec.get("margin_rate"):
merged["margin_rate"] = ctp_spec["margin_rate"]
return float(ctp_margin), "ctp", merged
except Exception:
pass
return est, "estimate", spec
def calc_position_metrics(
direction: str,
entry: float,
stop_loss: float,
take_profit: float,
lots: float,
mark_price: Optional[float],
capital: float,
ths_code: str,
) -> dict:
spec = get_contract_spec(ths_code)
mult = spec["mult"]
margin_rate = spec["margin_rate"]
lots = lots or 1.0
margin = entry * mult * lots * margin_rate
if direction == "long":
risk_amt = max(0.0, (entry - stop_loss) * mult * lots)
reward = max(0.0, (take_profit - entry) * mult * lots)
float_pnl = (mark_price - entry) * mult * lots if mark_price is not None else None
else:
risk_amt = max(0.0, (stop_loss - entry) * mult * lots)
reward = max(0.0, (entry - take_profit) * mult * lots)
float_pnl = (entry - mark_price) * mult * lots if mark_price is not None else None
risk_pct = (risk_amt / capital * 100) if capital > 0 else 0.0
pos_pct = (margin / capital * 100) if capital > 0 else 0.0
rr = (reward / risk_amt) if risk_amt > 0 else None
float_pct = (float_pnl / margin * 100) if margin > 0 and float_pnl is not None else None
return {
"mult": mult,
"margin_rate": margin_rate,
"margin": round(margin, 2),
"risk_amount": round(risk_amt, 2),
"risk_pct": round(risk_pct, 2),
"position_pct": round(pos_pct, 2),
"float_pnl": round(float_pnl, 2) if float_pnl is not None else None,
"float_pct": round(float_pct, 2) if float_pct is not None else None,
"reward_amount": round(reward, 2) if reward else None,
"rr_ratio": round(rr, 2) if rr is not None else None,
}
+345
View File
@@ -0,0 +1,345 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""数据库连接:开发默认 SQLite,生产推荐 PostgreSQLDATABASE_URL)。"""
from __future__ import annotations
import os
import re
import sqlite3
import threading
import time
from typing import Any, Iterable, Optional, Sequence
from modules.core.paths import DB_PATH as _ROOT_DB_PATH
DB_PATH = _ROOT_DB_PATH
_backend_lock = threading.Lock()
_backend: Optional[str] = None
try:
import psycopg
from psycopg import OperationalError as PgOperationalError
from psycopg import IntegrityError as PgIntegrityError
from psycopg.rows import dict_row
_PSYCOPG_OK = True
except ImportError:
psycopg = None # type: ignore[assignment]
PgOperationalError = Exception # type: ignore[misc,assignment]
PgIntegrityError = Exception # type: ignore[misc,assignment]
dict_row = None # type: ignore[assignment]
_PSYCOPG_OK = False
OperationalError = sqlite3.OperationalError
IntegrityError = sqlite3.IntegrityError
def db_backend() -> str:
"""``sqlite`` 或 ``postgres``。"""
global _backend
if _backend is not None:
return _backend
with _backend_lock:
if _backend is not None:
return _backend
url = (os.getenv("DATABASE_URL") or "").strip()
if url.startswith(("postgresql://", "postgres://")):
if not _PSYCOPG_OK:
raise RuntimeError(
"已配置 DATABASE_URL 但未安装 psycopg,请执行: pip install 'psycopg[binary]'"
)
_backend = "postgres"
else:
_backend = "sqlite"
return _backend
def is_postgres() -> bool:
return db_backend() == "postgres"
def database_label() -> str:
if is_postgres():
url = (os.getenv("DATABASE_URL") or "").strip()
host = url.split("@")[-1].split("/")[0] if "@" in url else "postgresql"
return f"PostgreSQL ({host})"
return f"SQLite ({DB_PATH})"
def adapt_sql(sql: str) -> str:
"""将 SQLite 风格 SQL 适配为当前后端。"""
if not is_postgres():
return sql
out = sql
out = re.sub(
r"\bINTEGER PRIMARY KEY AUTOINCREMENT\b",
"SERIAL PRIMARY KEY",
out,
flags=re.IGNORECASE,
)
out = re.sub(r"\bAUTOINCREMENT\b", "", out, flags=re.IGNORECASE)
out = re.sub(r'DEFAULT\s+"([^"]*)"', r"DEFAULT '\1'", out, flags=re.IGNORECASE)
if "?" in out:
out = out.replace("?", "%s")
return out
def is_benign_migration_error(exc: BaseException) -> bool:
"""ALTER TABLE 重复列等初始化迁移可忽略的错误。"""
if is_schema_migration_error(exc):
return True
return False
def is_schema_migration_error(exc: BaseException) -> bool:
"""init_db 增量迁移:缺表/缺列/重复列均可忽略。"""
msg = str(exc).lower()
if any(
x in msg
for x in (
"duplicate column",
"already exists",
"duplicate key",
"no such table",
"does not exist",
"undefined table",
"undefined column",
)
):
return True
if isinstance(exc, sqlite3.OperationalError) and (
"duplicate column" in msg or "no such table" in msg
):
return True
if _PSYCOPG_OK and isinstance(exc, PgOperationalError):
code = getattr(exc, "sqlstate", "") or ""
if code in ("42701", "42P07", "42P01", "42703"):
return True
return False
def is_missing_relation_error(exc: BaseException) -> bool:
"""表/视图不存在。"""
if is_schema_migration_error(exc):
msg = str(exc).lower()
return any(x in msg for x in ("no such table", "does not exist", "undefined table"))
return False
def rollback_if_postgres(conn: "DbConnection") -> None:
if is_postgres():
try:
conn.rollback()
except Exception:
pass
class DbCursor:
"""统一 cursor:兼容 sqlite3 的 execute / fetchone / lastrowid。"""
def __init__(self, backend: str, raw_cursor: Any, raw_conn: Any) -> None:
self._backend = backend
self._cur = raw_cursor
self._conn = raw_conn
self.lastrowid: Optional[int] = None
self.rowcount: int = 0
def execute(self, sql: str, params: Sequence[Any] | None = None) -> "DbCursor":
sql = adapt_sql(sql)
params = params or ()
self._cur.execute(sql, params)
self.rowcount = int(getattr(self._cur, "rowcount", 0) or 0)
self.lastrowid = getattr(self._cur, "lastrowid", None)
if self.lastrowid is None and is_postgres():
if re.match(r"^\s*INSERT\b", sql, re.IGNORECASE):
try:
row = self._cur.fetchone()
if row is not None:
if isinstance(row, dict):
self.lastrowid = int(row.get("id") or row.get("Id") or 0) or None
else:
self.lastrowid = int(row[0])
except Exception:
try:
self._cur.execute("SELECT lastval()")
lv = self._cur.fetchone()
if lv:
self.lastrowid = int(lv[0] if not isinstance(lv, dict) else lv["lastval"])
except Exception:
pass
return self
def fetchone(self) -> Any:
return self._cur.fetchone()
def fetchall(self) -> list[Any]:
return self._cur.fetchall()
def close(self) -> None:
try:
self._cur.close()
except Exception:
pass
class DbConnection:
"""统一连接:execute / commit / close,接口对齐 sqlite3.Connection。"""
def __init__(
self,
backend: str,
raw_conn: Any,
*,
from_pool: bool = False,
) -> None:
self._backend = backend
self._conn = raw_conn
self._from_pool = from_pool
self.row_factory = None
def execute(self, sql: str, params: Sequence[Any] | None = None) -> DbCursor:
cur = self.cursor()
try:
return cur.execute(sql, params)
except Exception:
rollback_if_postgres(self)
raise
def cursor(self) -> DbCursor:
if self._backend == "sqlite":
return DbCursor(self._backend, self._conn.cursor(), self._conn)
raw = self._conn.cursor(row_factory=dict_row)
return DbCursor(self._backend, raw, self._conn)
def commit(self) -> None:
self._conn.commit()
def rollback(self) -> None:
self._conn.rollback()
def close(self) -> None:
try:
self._conn.close()
except Exception:
pass
def __enter__(self) -> "DbConnection":
return self
def __exit__(self, exc_type, exc, tb) -> None:
if exc:
try:
self.rollback()
except Exception:
pass
else:
try:
self.commit()
except Exception:
pass
self.close()
def connect_db(path: str | None = None) -> DbConnection:
"""获取数据库连接。PostgreSQL / SQLite 均为每次新建连接(用毕 close)。"""
if is_postgres():
url = (os.getenv("DATABASE_URL") or "").strip()
raw = psycopg.connect(url, row_factory=dict_row)
try:
with raw.cursor() as cur:
cur.execute("SET TIME ZONE 'Asia/Shanghai'")
raw.commit()
except Exception:
pass
return DbConnection("postgres", raw, from_pool=False)
db_path = path or DB_PATH
raw = sqlite3.connect(db_path, timeout=30, check_same_thread=False)
raw.row_factory = sqlite3.Row
raw.execute("PRAGMA busy_timeout=30000")
try:
raw.execute("PRAGMA journal_mode=WAL")
except sqlite3.OperationalError:
pass
return DbConnection("sqlite", raw)
def close_pg_pool() -> None:
"""兼容旧调用;当前 PostgreSQL 使用直连,无全局连接池。"""
return
def execute_retry(
conn: DbConnection,
sql: str,
params: tuple = (),
*,
retries: int = 6,
base_delay: float = 0.05,
) -> DbCursor:
"""遇锁冲突时短暂退避重试(SQLite locked / PG serialization)。"""
last_exc: Exception | None = None
for attempt in range(retries):
try:
return conn.execute(sql, params)
except (OperationalError, PgOperationalError) as exc:
msg = str(exc).lower()
retryable = "locked" in msg or "serialize" in msg or "deadlock" in msg
if not retryable:
raise
last_exc = exc
if attempt < retries - 1:
time.sleep(base_delay * (attempt + 1))
if last_exc:
raise last_exc
raise OperationalError("database is locked")
def commit_retry(
conn: DbConnection,
*,
retries: int = 6,
base_delay: float = 0.05,
) -> None:
"""遇锁冲突时短暂退避重试 commit。"""
last_exc: Exception | None = None
for attempt in range(retries):
try:
conn.commit()
return
except (OperationalError, PgOperationalError) as exc:
msg = str(exc).lower()
retryable = "locked" in msg or "serialize" in msg or "deadlock" in msg
if not retryable:
raise
last_exc = exc
if attempt < retries - 1:
time.sleep(base_delay * (attempt + 1))
if last_exc:
raise last_exc
raise OperationalError("database is locked")
def is_db_contention_error(exc: BaseException) -> bool:
"""SQLite locked / PostgreSQL serialization / deadlock。"""
msg = str(exc).lower()
if isinstance(exc, sqlite3.OperationalError):
return "locked" in msg
if _PSYCOPG_OK and isinstance(exc, PgOperationalError):
code = getattr(exc, "sqlstate", "") or ""
if code in ("40001", "40P01", "55P03"):
return True
return any(x in msg for x in ("deadlock", "serialize", "lock"))
return False
def reset_backend_for_tests(backend: str | None = None) -> None:
"""测试用:重置后端检测。"""
global _backend
close_pg_pool()
with _backend_lock:
_backend = backend
+46
View File
@@ -0,0 +1,46 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
"""Shared dependencies passed into each feature module at register() time."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Callable, Optional
@dataclass
class AppDeps:
app: Any
get_db: Callable
get_setting: Callable
set_setting: Callable
login_required: Callable
require_nav: Callable
fetch_price: Callable
send_wechat_msg: Callable
touch_stats_cache: Callable
get_stats_data: Callable
build_market_quote_payload: Callable
today_str: Callable
expire_old_plans: Callable
check_order_plans: Callable
check_key_monitors: Callable
background_task: Callable
start_background_threads: Callable
tz: Any
db_path: str
upload_dir: str
open_types: list
exit_triggers: list
behavior_tags: list
kline_periods: list
kline_cutoffs: list
calc_holding_duration: Callable
holding_to_minutes: Callable
classify_close_result: Callable
calc_rr_ratio: Callable
calc_theoretical_pnl: Callable
parse_review_date_filter: Callable
trading_mode: Callable
static_asset_v: Callable
ua_is_phone: Callable
+170
View File
@@ -0,0 +1,170 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 详见 LICENSE.zh-CN.txt
"""将项目 docs 下的 Markdown 转为安全 HTML(无第三方依赖)。"""
from __future__ import annotations
import html
import re
from pathlib import Path
_DOCS_ROOT = Path(__file__).resolve().parent / "docs"
ALLOWED_DOCS: dict[str, str] = {
"risk-guide": "风控说明.md",
}
def docs_root() -> Path:
return _DOCS_ROOT
def read_doc(slug: str) -> tuple[str, str]:
"""返回 (title, raw_markdown)。"""
name = ALLOWED_DOCS.get(slug)
if not name:
raise FileNotFoundError(slug)
path = (_DOCS_ROOT / name).resolve()
if not path.is_file() or _DOCS_ROOT.resolve() not in path.parents:
raise FileNotFoundError(slug)
text = path.read_text(encoding="utf-8")
title = name
for line in text.splitlines():
s = line.strip()
if s.startswith("# "):
title = s[2:].strip()
break
return title, text
def _inline(text: str) -> str:
s = html.escape(text)
s = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", s)
s = re.sub(r"`([^`]+)`", r"<code>\1</code>", s)
s = re.sub(
r"\[([^\]]+)\]\(([^)]+)\)",
lambda m: _link_html(m.group(1), m.group(2)),
s,
)
return s
def _link_html(label: str, href: str) -> str:
h = html.escape(href)
lbl = _inline(label)
if href.startswith(("http://", "https://", "mailto:")):
return f'<a href="{h}" target="_blank" rel="noopener noreferrer">{lbl}</a>'
if href.endswith(".md") or href.startswith("./"):
return f'<span class="doc-xref">{lbl}</span>'
return f'<a href="{h}">{lbl}</a>'
def render_markdown(text: str) -> str:
lines = text.splitlines()
out: list[str] = []
i = 0
in_ul = False
in_ol = False
def close_lists() -> None:
nonlocal in_ul, in_ol
if in_ul:
out.append("</ul>")
in_ul = False
if in_ol:
out.append("</ol>")
in_ol = False
while i < len(lines):
line = lines[i]
stripped = line.strip()
if not stripped:
close_lists()
i += 1
continue
if stripped == "---":
close_lists()
out.append("<hr>")
i += 1
continue
if stripped.startswith("|") and stripped.endswith("|"):
close_lists()
table_lines: list[str] = []
while i < len(lines) and lines[i].strip().startswith("|"):
table_lines.append(lines[i].strip())
i += 1
out.append(_render_table(table_lines))
continue
if stripped.startswith("### "):
close_lists()
out.append(f"<h3>{_inline(stripped[4:])}</h3>")
i += 1
continue
if stripped.startswith("## "):
close_lists()
out.append(f"<h2>{_inline(stripped[3:])}</h2>")
i += 1
continue
if stripped.startswith("# "):
close_lists()
out.append(f"<h1>{_inline(stripped[2:])}</h1>")
i += 1
continue
if re.match(r"^[-*]\s+", stripped):
if not in_ul:
close_lists()
out.append("<ul>")
in_ul = True
item_text = re.sub(r"^[-*]\s+", "", stripped)
out.append(f"<li>{_inline(item_text)}</li>")
i += 1
continue
if re.match(r"^\d+\.\s+", stripped):
if not in_ol:
close_lists()
out.append("<ol>")
in_ol = True
item_text = re.sub(r"^\d+\.\s+", "", stripped)
out.append(f"<li>{_inline(item_text)}</li>")
i += 1
continue
close_lists()
para = stripped
i += 1
while i < len(lines):
nxt = lines[i].strip()
if not nxt or nxt == "---" or nxt.startswith("#") or nxt.startswith("|") or re.match(r"^[-*]\s+", nxt):
break
para += " " + nxt
i += 1
out.append(f"<p>{_inline(para)}</p>")
close_lists()
return "\n".join(out)
def _render_table(rows: list[str]) -> str:
if len(rows) < 2:
return ""
header = [c.strip() for c in rows[0].strip("|").split("|")]
body_rows = rows[2:] if len(rows) > 2 and re.match(r"^[\|\s:-]+$", rows[1]) else rows[1:]
parts = ["<table class=\"doc-table\">", "<thead><tr>"]
for cell in header:
parts.append(f"<th>{_inline(cell)}</th>")
parts.append("</tr></thead><tbody>")
for row in body_rows:
cells = [c.strip() for c in row.strip("|").split("|")]
parts.append("<tr>")
for cell in cells:
parts.append(f"<td>{_inline(cell)}</td>")
parts.append("</tr>")
parts.append("</tbody></table>")
return "".join(parts)
+74
View File
@@ -0,0 +1,74 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""读写项目根目录 .env 文件(更新指定键,保留其余行)。"""
from __future__ import annotations
import os
import re
from modules.core.paths import ENV_FILE, LEGACY_ENV_FILE
def _default_env_path() -> str:
if ENV_FILE.is_file():
return str(ENV_FILE)
if LEGACY_ENV_FILE.is_file():
return str(LEGACY_ENV_FILE)
return str(ENV_FILE)
ENV_PATH = _default_env_path()
_KEY_RE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)\s*=")
def env_file_path(path: str | None = None) -> str:
if path:
return path
from modules.core.paths import resolve_env_file
return resolve_env_file()
def _quote_env_value(value: str) -> str:
if value == "":
return '""'
if re.search(r'[\s#"\'\\=]', value):
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
return f'"{escaped}"'
return value
def update_env_vars(updates: dict[str, str], path: str | None = None) -> None:
"""更新或追加 KEY=value,不改动注释与其他配置项。"""
if not updates:
return
env_path = env_file_path(path)
lines: list[str] = []
if os.path.isfile(env_path):
with open(env_path, encoding="utf-8") as f:
lines = f.read().splitlines()
seen: set[str] = set()
out: list[str] = []
for line in lines:
stripped = line.strip()
if not stripped or stripped.startswith("#"):
out.append(line)
continue
m = _KEY_RE.match(stripped)
if m and m.group(1) in updates:
key = m.group(1)
out.append(f"{key}={_quote_env_value(updates[key])}")
seen.add(key)
else:
out.append(line)
for key, val in updates.items():
if key not in seen:
out.append(f"{key}={_quote_env_value(val)}")
with open(env_path, "w", encoding="utf-8", newline="\n") as f:
if out:
f.write("\n".join(out) + "\n")
+96
View File
@@ -0,0 +1,96 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""Linux 上 vnpy_ctp 连接 CTP 前须设置有效 locale(否则 C++ 层 abort)。"""
from __future__ import annotations
import locale
import logging
import os
import subprocess
logger = logging.getLogger(__name__)
_LOCALE_DONE = False
_LOCALE_NAME = ""
# CTP C++ API 登录回调依赖中文 locale(见 vnpy/vnpy_ctp#24
_CTP_REQUIRED_LOCALES = ("zh_CN.GB18030", "zh_CN.gb18030")
def _available_locales() -> set[str]:
try:
out = subprocess.check_output(["locale", "-a"], text=True, stderr=subprocess.DEVNULL)
return {line.strip() for line in out.splitlines() if line.strip()}
except (OSError, subprocess.SubprocessError):
return set()
def missing_ctp_locales() -> list[str]:
"""CTP 所需的 zh_CN.GB18030 是否已安装。"""
avail = {x.lower() for x in _available_locales()}
if any(x.lower() in avail for x in _CTP_REQUIRED_LOCALES):
return []
return ["zh_CN.GB18030"]
def _list_locale_candidates() -> list[str]:
avail = _available_locales()
names: list[str] = []
# CTP 回调优先尝试中文 locale
for item in (
"zh_CN.GB18030",
"zh_CN.gb18030",
"zh_CN.UTF-8",
"zh_CN.utf8",
"en_US.UTF-8",
"en_US.utf8",
"C.UTF-8",
"C.utf8",
"POSIX",
"C",
):
if item in avail and item not in names:
names.append(item)
for loc in sorted(avail):
low = loc.lower()
if "utf" in low and loc not in names:
names.append(loc)
return names
def ensure_process_locale() -> str:
"""强制设置进程 locale,覆盖系统里无效的旧值。"""
global _LOCALE_DONE, _LOCALE_NAME
if _LOCALE_DONE:
return _LOCALE_NAME
missing = missing_ctp_locales()
if missing:
raise RuntimeError(
"CTP 需要中文 locale zh_CN.GB18030,当前系统未安装。"
"请执行: sed -i '/^# zh_CN.GB18030/s/^# //' /etc/locale.gen && "
"locale-gen zh_CN.GB18030"
)
last_err: locale.Error | None = None
for name in _list_locale_candidates():
try:
locale.setlocale(locale.LC_ALL, name)
os.environ["LANG"] = name
os.environ["LC_ALL"] = name
os.environ["LC_CTYPE"] = name
_LOCALE_DONE = True
_LOCALE_NAME = name
logger.info("进程 locale 已设置: %s", name)
return name
except locale.Error as exc:
last_err = exc
continue
raise RuntimeError(
"未找到可用 localevnpy_ctp 会在 CTP 登录后崩溃。"
"请执行: apt install -y locales && locale-gen zh_CN.GB18030 en_US.UTF-8"
) from last_err
+37
View File
@@ -0,0 +1,37 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
"""Repository layout paths — single source for config, data, uploads."""
from __future__ import annotations
import os
from pathlib import Path
# .../qihuo/modules/core/paths.py -> repo root
ROOT = Path(__file__).resolve().parents[2]
CONFIG_DIR = ROOT / "config"
ENV_FILE = CONFIG_DIR / ".env"
LEGACY_ENV_FILE = ROOT / ".env"
DATA_DIR = ROOT / "data"
UPLOADS_DIR = ROOT / "uploads"
LOGS_DIR = ROOT / "logs"
DB_PATH = str(ROOT / "futures.db")
def ensure_runtime_dirs() -> None:
DATA_DIR.mkdir(parents=True, exist_ok=True)
UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
LOGS_DIR.mkdir(parents=True, exist_ok=True)
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
def resolve_env_file() -> str:
"""Prefer config/.env, fall back to legacy root .env."""
if ENV_FILE.is_file():
return str(ENV_FILE)
if LEGACY_ENV_FILE.is_file():
return str(LEGACY_ENV_FILE)
return str(ENV_FILE)
+683
View File
@@ -0,0 +1,683 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""
期货品种与同花顺代码映射。
展示同花顺合约代码(ag2608);行情默认新浪,机构用户可通过环境变量启用同花顺 iFinD。
"""
import re
import threading
import time
from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import date
from typing import Optional
from modules.market.market import fetch_raw_for_volume, get_price as market_get_price, THS_EX_SUFFIX
PRODUCTS = [
{"name": "白银", "ths": "ag", "sina": "AG", "exchange": "上期所", "ex": "SHFE"},
{"name": "黄金", "ths": "au", "sina": "AU", "exchange": "上期所", "ex": "SHFE"},
{"name": "", "ths": "cu", "sina": "CU", "exchange": "上期所", "ex": "SHFE"},
{"name": "", "ths": "al", "sina": "AL", "exchange": "上期所", "ex": "SHFE"},
{"name": "", "ths": "zn", "sina": "ZN", "exchange": "上期所", "ex": "SHFE"},
{"name": "", "ths": "pb", "sina": "PB", "exchange": "上期所", "ex": "SHFE"},
{"name": "", "ths": "ni", "sina": "NI", "exchange": "上期所", "ex": "SHFE"},
{"name": "", "ths": "sn", "sina": "SN", "exchange": "上期所", "ex": "SHFE"},
{"name": "螺纹钢", "ths": "rb", "sina": "RB", "exchange": "上期所", "ex": "SHFE"},
{"name": "热卷", "ths": "hc", "sina": "HC", "exchange": "上期所", "ex": "SHFE"},
{"name": "不锈钢", "ths": "ss", "sina": "SS", "exchange": "上期所", "ex": "SHFE"},
{"name": "原油", "ths": "sc", "sina": "SC", "exchange": "上期能源", "ex": "INE"},
{"name": "燃油", "ths": "fu", "sina": "FU", "exchange": "上期所", "ex": "SHFE"},
{"name": "沥青", "ths": "bu", "sina": "BU", "exchange": "上期所", "ex": "SHFE"},
{"name": "橡胶", "ths": "ru", "sina": "RU", "exchange": "上期所", "ex": "SHFE"},
{"name": "纸浆", "ths": "sp", "sina": "SP", "exchange": "上期所", "ex": "SHFE"},
{"name": "铁矿石", "ths": "i", "sina": "I", "exchange": "大商所", "ex": "DCE"},
{"name": "焦炭", "ths": "j", "sina": "J", "exchange": "大商所", "ex": "DCE"},
{"name": "焦煤", "ths": "jm", "sina": "JM", "exchange": "大商所", "ex": "DCE"},
{"name": "豆粕", "ths": "m", "sina": "M", "exchange": "大商所", "ex": "DCE"},
{"name": "豆油", "ths": "y", "sina": "Y", "exchange": "大商所", "ex": "DCE"},
{"name": "棕榈油", "ths": "p", "sina": "P", "exchange": "大商所", "ex": "DCE"},
{"name": "玉米", "ths": "c", "sina": "C", "exchange": "大商所", "ex": "DCE"},
{"name": "淀粉", "ths": "cs", "sina": "CS", "exchange": "大商所", "ex": "DCE"},
{"name": "鸡蛋", "ths": "jd", "sina": "JD", "exchange": "大商所", "ex": "DCE"},
{"name": "生猪", "ths": "lh", "sina": "LH", "exchange": "大商所", "ex": "DCE"},
{"name": "聚乙烯", "ths": "l", "sina": "L", "exchange": "大商所", "ex": "DCE"},
{"name": "聚丙烯", "ths": "pp", "sina": "PP", "exchange": "大商所", "ex": "DCE"},
{"name": "PVC", "ths": "v", "sina": "V", "exchange": "大商所", "ex": "DCE"},
{"name": "乙二醇", "ths": "eg", "sina": "EG", "exchange": "大商所", "ex": "DCE"},
{"name": "苯乙烯", "ths": "eb", "sina": "EB", "exchange": "大商所", "ex": "DCE"},
{"name": "液化气", "ths": "pg", "sina": "PG", "exchange": "大商所", "ex": "DCE"},
{"name": "菜粕", "ths": "RM", "sina": "RM", "exchange": "郑商所", "ex": "CZCE"},
{"name": "菜油", "ths": "OI", "sina": "OI", "exchange": "郑商所", "ex": "CZCE"},
{"name": "白糖", "ths": "SR", "sina": "SR", "exchange": "郑商所", "ex": "CZCE"},
{"name": "棉花", "ths": "CF", "sina": "CF", "exchange": "郑商所", "ex": "CZCE"},
{"name": "甲醇", "ths": "MA", "sina": "MA", "exchange": "郑商所", "ex": "CZCE"},
{"name": "PTA", "ths": "TA", "sina": "TA", "exchange": "郑商所", "ex": "CZCE"},
{"name": "玻璃", "ths": "FG", "sina": "FG", "exchange": "郑商所", "ex": "CZCE"},
{"name": "纯碱", "ths": "SA", "sina": "SA", "exchange": "郑商所", "ex": "CZCE"},
{"name": "尿素", "ths": "UR", "sina": "UR", "exchange": "郑商所", "ex": "CZCE"},
{"name": "硅铁", "ths": "SF", "sina": "SF", "exchange": "郑商所", "ex": "CZCE"},
{"name": "锰硅", "ths": "SM", "sina": "SM", "exchange": "郑商所", "ex": "CZCE"},
{"name": "苹果", "ths": "AP", "sina": "AP", "exchange": "郑商所", "ex": "CZCE"},
{"name": "红枣", "ths": "CJ", "sina": "CJ", "exchange": "郑商所", "ex": "CZCE"},
{"name": "花生", "ths": "PK", "sina": "PK", "exchange": "郑商所", "ex": "CZCE"},
{"name": "沪深300", "ths": "IF", "sina": "IF", "exchange": "中金所", "ex": "CFFEX"},
{"name": "上证50", "ths": "IH", "sina": "IH", "exchange": "中金所", "ex": "CFFEX"},
{"name": "中证500", "ths": "IC", "sina": "IC", "exchange": "中金所", "ex": "CFFEX"},
{"name": "中证1000", "ths": "IM", "sina": "IM", "exchange": "中金所", "ex": "CFFEX"},
]
PRODUCT_CATEGORY_MAP = {
"ag": "贵金属", "au": "贵金属",
"cu": "有色金属", "al": "有色金属", "zn": "有色金属", "pb": "有色金属", "ni": "有色金属", "sn": "有色金属",
"rb": "黑色金属", "hc": "黑色金属", "ss": "黑色金属", "i": "黑色金属", "j": "黑色金属", "jm": "黑色金属",
"SF": "黑色金属", "SM": "黑色金属",
"sc": "能源化工", "fu": "能源化工", "bu": "能源化工", "ru": "能源化工", "sp": "能源化工",
"l": "能源化工", "pp": "能源化工", "v": "能源化工", "eg": "能源化工", "eb": "能源化工", "pg": "能源化工",
"MA": "能源化工", "TA": "能源化工", "SA": "能源化工", "UR": "能源化工", "FG": "能源化工",
"m": "农产品", "y": "农产品", "p": "农产品", "c": "农产品", "cs": "农产品", "jd": "农产品", "lh": "农产品",
"RM": "农产品", "OI": "农产品", "SR": "农产品", "CF": "农产品", "AP": "农产品", "CJ": "农产品", "PK": "农产品",
"IF": "金融期货", "IH": "金融期货", "IC": "金融期货", "IM": "金融期货",
}
PRODUCT_CATEGORIES = ["贵金属", "有色金属", "黑色金属", "能源化工", "农产品", "金融期货"]
for _p in PRODUCTS:
_p["category"] = PRODUCT_CATEGORY_MAP.get(_p["ths"], "其他")
# 无夜盘品种(日盘-only):中金所股指、大商所鸡蛋/生猪等
NO_NIGHT_SESSION_THS = frozenset({"IF", "IH", "IC", "IM", "jd", "lh"})
def product_has_night_session(ths_or_product) -> bool:
"""品种是否参与夜盘交易。"""
if isinstance(ths_or_product, dict):
ths = (ths_or_product.get("ths") or "").strip()
else:
ths = (ths_or_product or "").strip()
if not ths:
return True
m = re.match(r"^([A-Za-z]+)", ths)
letters = m.group(1) if m else ths
return letters not in NO_NIGHT_SESSION_THS and letters.upper() not in NO_NIGHT_SESSION_THS
def filter_for_trading_session(rows: list[dict]) -> list[dict]:
"""夜盘时段隐藏无夜盘品种。"""
from modules.market.market_sessions import is_night_trading_session
if not is_night_trading_session():
return rows
out: list[dict] = []
for row in rows:
if row.get("has_night_session") is False:
continue
ths = row.get("ths") or row.get("ths_code") or ""
if row.get("has_night_session") is True or product_has_night_session(ths):
out.append(row)
return out
def product_category(ths: str) -> str:
return PRODUCT_CATEGORY_MAP.get((ths or "").strip(), "其他")
EXCHANGE_ORDER = ["上期所", "上期能源", "大商所", "郑商所", "中金所"]
_MAIN_CACHE: dict[str, tuple[float, dict]] = {}
_CACHE_TTL = 300
_main_index_lock = threading.Lock()
_main_index: dict[str, dict] = {}
_main_index_ts = 0.0
_index_refresh_lock = threading.Lock()
def build_ths_code(product: dict, year: int, month: int) -> str:
"""同花顺软件内显示的合约代码。"""
ex = product["ex"]
letters = product["ths"]
if ex == "CZCE":
return f"{letters}{year % 10}{month:02d}"
return f"{letters}{year % 100:02d}{month:02d}"
def build_ths_full_code(product: dict, year: int, month: int) -> str:
"""同花顺 iFinD HTTP API 代码,如 ag2608.SHFE"""
ths = build_ths_code(product, year, month)
suffix = THS_EX_SUFFIX.get(product["ex"], product["ex"])
return f"{ths}.{suffix}"
def build_sina_code(product: dict, year: int, month: int) -> str:
letters = product["sina"]
suffix = f"{year % 100:02d}{month:02d}"
if product["ex"] == "CFFEX":
return f"CFF_RE_{letters}{suffix}"
return f"nf_{letters}{suffix}"
def build_sina_main_code(product: dict) -> str:
letters = product["sina"]
if product["ex"] == "CFFEX":
return f"CFF_RE_{letters}0"
return f"nf_{letters}0"
def _find_product_by_letters(letters: str) -> Optional[dict]:
letters_up = letters.upper()
for p in PRODUCTS:
if p["ths"].upper() == letters_up or p["sina"] == letters_up:
return p
return None
def _product_codes(product: dict, ths_code: str, market_code: str, sina_code: str) -> dict:
return {
"ths_code": ths_code,
"market_code": market_code,
"sina_code": sina_code,
"ex": product["ex"],
"name": product["name"],
"exchange": product["exchange"],
}
def ths_to_codes(ths_code: str) -> Optional[dict]:
"""同花顺合约代码 -> ths_full + sina 回退代码。"""
code = ths_code.strip()
if not code:
return None
m4 = re.match(r"^([A-Za-z]+)(\d{4})$", code)
if m4:
letters, digits = m4.group(1), m4.group(2)
year = 2000 + int(digits[:2])
month = int(digits[2:])
if not 1 <= month <= 12:
return None
product = _find_product_by_letters(letters)
if product:
ths = build_ths_code(product, year, month)
return _product_codes(
product,
ths,
build_ths_full_code(product, year, month),
build_sina_code(product, year, month),
)
letters_up = letters.upper()
if letters_up in ("IF", "IH", "IC", "IM", "T", "TF", "TS"):
ths = f"{letters_up}{digits}"
return {
"ths_code": ths,
"market_code": f"{ths}.CFFEX",
"sina_code": f"CFF_RE_{letters_up}{digits}",
"ex": "CFFEX",
"name": letters_up,
"exchange": "中金所",
}
m3 = re.match(r"^([A-Za-z]+)(\d{3})$", code)
if m3:
letters, digits = m3.group(1), m3.group(2)
y_digit = int(digits[0])
month = int(digits[1:])
if not 1 <= month <= 12:
return None
year = date.today().year
decade = year // 10 * 10
candidate = decade + y_digit
if candidate < year - 1:
candidate += 10
product = _find_product_by_letters(letters)
if product:
ths = build_ths_code(product, candidate, month)
return _product_codes(
product,
ths,
build_ths_full_code(product, candidate, month),
build_sina_code(product, candidate, month),
)
return None
def ths_to_sina_code(ths_code: str) -> Optional[str]:
codes = ths_to_codes(ths_code)
return codes["sina_code"] if codes else None
def parse_contract_year_month(ths_code: str) -> Optional[tuple[int, int]]:
"""从同花顺合约代码解析交割年月。"""
code = (ths_code or "").strip()
if not code or "888" in code:
return None
m4 = re.match(r"^([A-Za-z]+)(\d{4})$", code)
if m4:
digits = m4.group(2)
year = 2000 + int(digits[:2])
month = int(digits[2:])
if 1 <= month <= 12:
return year, month
m3 = re.match(r"^([A-Za-z]+)(\d{3})$", code)
if m3:
letters, digits = m3.group(1), m3.group(2)
month = int(digits[1:])
if not 1 <= month <= 12:
return None
y_digit = int(digits[0])
year = date.today().year
decade = year // 10 * 10
candidate = decade + y_digit
if candidate < year - 1:
candidate += 10
product = _find_product_by_letters(letters)
if product:
return candidate, month
return None
def is_near_expiry_main(ths_code: str) -> bool:
"""主力合约交割月为当月或下月时视为临期。"""
ym = parse_contract_year_month(ths_code)
if not ym:
return False
cy, cm = ym
today = date.today()
months_ahead = (cy - today.year) * 12 + (cm - today.month)
return months_ahead <= 1
def _main_contract_score(raw: dict) -> float:
"""主力判定:优先持仓量,其次成交量。"""
oi = float(raw.get("open_interest") or 0)
vol = float(raw.get("volume") or 0)
return oi if oi > 0 else vol
def _make_symbol_item(
product: dict,
year: int,
month: int,
volume: float,
open_interest: float = 0,
) -> dict:
ths = build_ths_code(product, year, month)
name = product["name"]
return {
"name": name,
"ths_code": ths,
"market_code": build_ths_full_code(product, year, month),
"sina_code": build_sina_code(product, year, month),
"exchange": product["exchange"],
"contract": f"主力 {ths}",
"display": f"{name} 主力 {ths}",
"input_label": f"{name} {ths}",
"volume": volume,
"open_interest": open_interest,
}
def resolve_main_contract(product: dict) -> Optional[dict]:
cache_key = product["sina"]
now = time.time()
cached = _MAIN_CACHE.get(cache_key)
if cached and now - cached[0] < _CACHE_TTL:
return cached[1]
today = date.today()
y, m = today.year, today.month
best = None
best_score = 0.0
for i in range(14):
cy, cm = y, m + i
while cm > 12:
cm -= 12
cy += 1
sina = build_sina_code(product, cy, cm)
raw = fetch_raw_for_volume(sina)
if not raw:
continue
score = _main_contract_score(raw)
if score <= 0:
continue
item = _make_symbol_item(
product, cy, cm, raw["volume"], raw.get("open_interest", 0),
)
if score > best_score:
best_score = score
best = item
if best is None:
sina_main = build_sina_main_code(product)
raw = fetch_raw_for_volume(sina_main)
if raw:
ths_letters = product["ths"]
ths_main = (
f"{ths_letters}888"
if product["ex"] != "CFFEX"
else f"{ths_letters.upper()}888"
)
suffix = THS_EX_SUFFIX.get(product["ex"], product["ex"])
best = {
"name": product["name"],
"ths_code": ths_main,
"market_code": f"{ths_main}.{suffix}",
"sina_code": sina_main,
"exchange": product["exchange"],
"contract": f"主力连续 {ths_main}",
"display": f"{product['name']} 主力连续 {ths_main}",
"input_label": f"{product['name']} {ths_main}",
"volume": raw.get("volume", 0),
}
if best:
best = _enrich_item(best, product)
_MAIN_CACHE[cache_key] = (now, best)
return best
def _enrich_item(item: dict, product: Optional[dict] = None) -> dict:
out = dict(item)
if not out.get("input_label"):
out["input_label"] = f"{out.get('name', '')} {out.get('ths_code', '')}".strip()
out["near_expiry"] = is_near_expiry_main(out.get("ths_code", ""))
if product is None and out.get("ths_code"):
product = _product_for_contract_code(out["ths_code"])
if product is not None:
out["has_night_session"] = product_has_night_session(product)
elif "has_night_session" not in out:
out["has_night_session"] = product_has_night_session(out.get("ths_code") or "")
return out
def refresh_main_index():
"""后台预热全部品种主力合约,搜索时只读本地缓存。"""
global _main_index, _main_index_ts
with _index_refresh_lock:
new_idx: dict[str, dict] = {}
with ThreadPoolExecutor(max_workers=10) as pool:
futures = {pool.submit(resolve_main_contract, p): p for p in PRODUCTS}
for fut in as_completed(futures):
product = futures[fut]
try:
main = fut.result()
if main:
new_idx[product["sina"]] = _enrich_item(main, product)
except Exception:
pass
with _main_index_lock:
_main_index = new_idx
_main_index_ts = time.time()
def _warm_loop():
while True:
try:
refresh_main_index()
except Exception:
pass
time.sleep(_CACHE_TTL)
def _start_warm_thread():
threading.Thread(target=_warm_loop, daemon=True).start()
def _stub_main_contract(product: dict) -> dict:
"""缓存未就绪时的快速占位(当月合约),避免首次打开搜索为空。"""
today = date.today()
return _enrich_item(_make_symbol_item(product, today.year, today.month, 0), product)
def _product_matches(product: dict, q_lower: str) -> bool:
name_lower = product["name"].lower()
if q_lower in name_lower:
return True
if len(q_lower) >= 2:
ths_lower = product["ths"].lower()
sina_lower = product["sina"].lower()
if q_lower in ths_lower or q_lower in sina_lower:
return True
return False
def _match_score(product: dict, q_lower: str) -> int:
name_lower = product["name"].lower()
if name_lower == q_lower:
return 200
if name_lower.startswith(q_lower):
return 150
if q_lower in name_lower:
return 100
ths_lower = product["ths"].lower()
if ths_lower == q_lower:
return 90
if ths_lower.startswith(q_lower):
return 70
if product["sina"].lower() == q_lower:
return 80
return 10
def search_symbols(query: str, *, capital: float | None = None, ctp_connected: bool = True) -> list:
q = query.strip()
if not q:
return []
q_lower = q.lower()
from modules.market.market_sessions import is_night_trading_session
from modules.trading.product_recommend import filter_products_for_capital, should_apply_small_account_scope
night_only = is_night_trading_session()
product_pool = PRODUCTS
if capital is not None and should_apply_small_account_scope(capital, ctp_connected=ctp_connected):
product_pool = filter_products_for_capital(
PRODUCTS, capital, ctp_connected=ctp_connected,
)
with _main_index_lock:
index = dict(_main_index)
index_ready = bool(index)
scored: list[tuple[int, dict]] = []
for p in product_pool:
if night_only and not product_has_night_session(p):
continue
if not _product_matches(p, q_lower):
continue
main = index.get(p["sina"])
if not main and not index_ready:
main = _stub_main_contract(p)
if main:
scored.append((_match_score(p, q_lower), main))
scored.sort(key=lambda x: -x[0])
results = [item for _, item in scored[:12]]
results = filter_for_trading_session(results)
if not results and len(q) >= 3:
codes = ths_to_codes(q)
if codes:
product = _product_for_contract_code(codes["ths_code"])
if capital is not None and should_apply_small_account_scope(
capital, ctp_connected=ctp_connected,
):
from modules.trading.product_recommend import product_in_small_account_whitelist
if not product or not product_in_small_account_whitelist(product):
return results
raw = fetch_raw_for_volume(codes["sina_code"])
name = raw["name"] if raw else q
results.append(_enrich_item({
"name": name,
"ths_code": codes["ths_code"],
"market_code": codes["market_code"],
"sina_code": codes["sina_code"],
"exchange": "",
"contract": codes["ths_code"],
"display": f"{name} ({codes['ths_code']})",
"volume": raw.get("volume", 0) if raw else 0,
}))
results = filter_for_trading_session(results)
return results
def enrich_recommend_row(row: dict) -> dict:
"""补全推荐行字段(含是否夜盘)。"""
out = dict(row)
ths = out.get("ths") or ""
out["has_night_session"] = product_has_night_session(ths)
return out
_THS_TO_PRODUCT = {p["ths"]: p for p in PRODUCTS}
for _p in PRODUCTS:
_THS_TO_PRODUCT.setdefault(_p["ths"].lower(), _p)
def _product_for_ths(ths: str) -> Optional[dict]:
key = (ths or "").strip()
if not key:
return None
return _THS_TO_PRODUCT.get(key) or _THS_TO_PRODUCT.get(key.lower())
def _product_for_contract_code(ths_code: str) -> Optional[dict]:
sym = (ths_code or "").strip()
if not sym:
return None
m = re.match(r"^([A-Za-z]+)", sym)
if not m:
return None
return _find_product_by_letters(m.group(1))
def position_symbol_meta(ths_code: str) -> dict:
"""持仓/委托展示:品种名、交易所、是否主力合约。"""
sym = (ths_code or "").strip()
if not sym:
return {"name": "", "exchange": "", "is_main": False}
product = _product_for_contract_code(sym)
if not product:
return {"name": sym, "exchange": "", "is_main": False}
codes = ths_to_codes(sym)
norm = (codes["ths_code"] if codes else sym).strip().lower()
is_main = False
with _main_index_lock:
main_item = _main_index.get(product["sina"])
if main_item:
main_ths = (main_item.get("ths_code") or "").strip().lower()
is_main = main_ths == norm or main_ths == sym.lower()
return {
"name": product["name"],
"exchange": product.get("exchange") or "",
"is_main": is_main,
}
def _item_from_recommend_row(row: dict, product: dict) -> Optional[dict]:
"""由可开仓缓存行快速构造下拉项(不在 HTTP 请求中解析主力)。"""
name = row.get("name") or product["name"]
main_code = (row.get("main_code") or "").strip()
max_lots = row.get("max_lots")
if main_code:
codes = ths_to_codes(main_code)
if codes:
ths = codes["ths_code"]
item = {
"name": name,
"ths_code": ths,
"market_code": codes.get("market_code") or "",
"sina_code": codes.get("sina_code") or "",
"exchange": product["exchange"],
"contract": f"主力 {ths}",
"display": f"{name} 主力 {ths}",
"input_label": f"{name} {ths}",
}
if max_lots is not None:
item["max_lots"] = max_lots
return _enrich_item(item, product)
with _main_index_lock:
main = _main_index.get(product["sina"])
if main:
item = dict(main)
if max_lots is not None:
item["max_lots"] = max_lots
return _enrich_item(item, product)
item = _stub_main_contract(product)
if max_lots is not None:
item["max_lots"] = max_lots
return item
def list_recommended_symbols_grouped(recommend_rows: list[dict]) -> list[dict]:
"""按交易所分类返回可开仓品种对应的主力合约(品种选择下拉用)。"""
if not recommend_rows:
return []
buckets: dict[str, list] = defaultdict(list)
seen: set[str] = set()
for row in recommend_rows:
if row.get("status") not in ("ok", "margin_ok"):
continue
ths_key = (row.get("ths") or "").strip()
if not ths_key or ths_key in seen:
continue
product = _product_for_ths(ths_key)
if not product:
continue
if not product_has_night_session(product):
from modules.market.market_sessions import is_night_trading_session
if is_night_trading_session():
continue
seen.add(ths_key)
item = _item_from_recommend_row(row, product)
if not item:
continue
buckets[product["exchange"]].append(item)
groups: list[dict] = []
for cat in EXCHANGE_ORDER:
items = buckets.get(cat)
if items:
groups.append({"category": cat, "items": items})
return groups
def list_main_contracts_grouped() -> list[dict]:
"""按交易所分类返回全部品种主力合约(行情页下拉用)。"""
with _main_index_lock:
index = dict(_main_index)
if len(index) < len(PRODUCTS) // 2:
refresh_main_index()
with _main_index_lock:
index = dict(_main_index)
buckets: dict[str, list] = defaultdict(list)
for p in PRODUCTS:
main = index.get(p["sina"])
if not main:
resolved = resolve_main_contract(p)
if resolved:
main = _enrich_item(resolved)
if main:
buckets[p["exchange"]].append(main)
groups: list[dict] = []
for cat in EXCHANGE_ORDER:
items = buckets.get(cat)
if items:
groups.append({"category": cat, "items": items})
return groups
_start_warm_thread()
def get_price(market_code: str, sina_code: str = "") -> Optional[float]:
return market_get_price(market_code, sina_code)
+184
View File
@@ -0,0 +1,184 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""交易上下文:设置读取、资金、模式。"""
from __future__ import annotations
from typing import Callable, Optional
TRADING_MODE_SIM = "simulation" # SimNow CTP
TRADING_MODE_LIVE = "live" # 期货公司 CTP
def get_trading_mode(get_setting: Callable[[str, str], str]) -> str:
m = (get_setting("trading_mode", TRADING_MODE_SIM) or TRADING_MODE_SIM).strip().lower()
return m if m in (TRADING_MODE_SIM, TRADING_MODE_LIVE) else TRADING_MODE_SIM
def get_sizing_mode(get_setting: Callable[[str, str], str]) -> str:
from modules.trading.position_sizing import normalize_sizing_mode
return normalize_sizing_mode(get_setting("position_sizing_mode", "fixed"))
def get_fixed_lots(get_setting: Callable[[str, str], str]) -> int:
try:
return max(1, int(float(get_setting("fixed_lots", "1") or 1)))
except (TypeError, ValueError):
return 1
def get_fixed_amount(get_setting: Callable[[str, str], str]) -> float:
try:
return max(1.0, float(get_setting("fixed_amount", "5000") or 5000))
except (TypeError, ValueError):
return 5000.0
def get_risk_percent(get_setting: Callable[[str, str], str]) -> float:
try:
return max(0.1, float(get_setting("risk_percent", "1") or 1))
except (TypeError, ValueError):
return 1.0
def get_max_margin_pct(get_setting: Callable[[str, str], str]) -> float:
"""单笔/总仓位保证金占权益上限(%),默认 30。"""
try:
return max(1.0, min(100.0, float(get_setting("max_margin_pct", "30") or 30)))
except (TypeError, ValueError):
return 30.0
def get_roll_max_margin_pct(get_setting: Callable[[str, str], str]) -> float:
"""滚仓后总保证金占权益上限(%),默认 50。"""
try:
return max(1.0, min(100.0, float(get_setting("roll_max_margin_pct", "50") or 50)))
except (TypeError, ValueError):
return 50.0
def get_trailing_be_tick_buffer(get_setting: Callable[[str, str], str]) -> int:
"""移动保本:止损移至开仓价 ± N 个最小变动价位(默认 2)。"""
try:
return max(1, min(20, int(float(get_setting("trailing_be_tick_buffer", "2") or 2))))
except (TypeError, ValueError):
return 2
def get_pending_order_timeout_min(get_setting: Callable[[str, str], str]) -> int:
"""开仓限价委托未成交自动撤单时间(分钟),默认 5。"""
try:
return max(1, min(60, int(float(get_setting("pending_order_timeout_min", "5") or 5))))
except (TypeError, ValueError):
return 5
def get_pending_order_timeout_sec(get_setting: Callable[[str, str], str]) -> int:
return get_pending_order_timeout_min(get_setting) * 60
def _cached_ctp_account(mode: str) -> dict[str, float]:
"""CTP 未连接时,用最近一次 worker/持仓快照里的账户权益。"""
import json
try:
from modules.trading.position_stream import position_hub
snap = position_hub.get_snapshot() or {}
cap = float(snap.get("capital") or 0)
if cap > 0:
return {"balance": cap}
except Exception:
pass
try:
from modules.core.db_conn import connect_db
conn = connect_db()
try:
row = conn.execute(
"SELECT value FROM ctp_worker_snapshots WHERE key='account' LIMIT 1"
).fetchone()
finally:
conn.close()
if row and row["value"]:
acc = json.loads(row["value"])
balance = float(acc.get("balance") or 0)
available = acc.get("available")
out: dict[str, float] = {}
if balance > 0:
out["balance"] = balance
if available is not None:
out["available"] = float(available)
return out
except Exception:
pass
del mode
return {}
def _ctp_status_from_snapshot(mode: str) -> Optional[dict]:
"""读持仓快照中的 CTP 状态,避免页面渲染同步 IPC。"""
try:
from modules.trading.position_stream import position_hub
snap = position_hub.get_snapshot() or {}
st = snap.get("ctp_status")
if isinstance(st, dict) and st:
return st
except Exception:
pass
del mode
return None
def get_account_capital(conn, get_setting: Callable[[str, str], str]) -> float:
"""优先读持仓/Worker 快照权益;无快照时才同步问 CTP。"""
del conn
mode = get_trading_mode(get_setting)
cached = _cached_ctp_account(mode)
balance = float(cached.get("balance") or 0)
if balance > 0:
return balance
try:
from modules.ctp.vnpy_bridge import ctp_status, get_ctp_balance
st = ctp_status(mode)
if st.get("connected"):
bal = get_ctp_balance(mode)
if bal and bal > 0:
return float(bal)
except Exception:
pass
try:
return float(get_setting("live_capital", "0") or 0)
except (TypeError, ValueError):
return 0.0
def get_recommend_capital(conn, get_setting: Callable[[str, str], str]) -> float:
"""可开仓品种表用权益:已连接 CTP 用柜台权益,未连接固定 10 万。"""
from modules.trading.product_recommend import DISCONNECTED_RECOMMEND_CAPITAL
if is_ctp_connected(get_setting):
return get_account_capital(conn, get_setting)
return float(DISCONNECTED_RECOMMEND_CAPITAL)
def is_ctp_connected(get_setting: Callable[[str, str], str]) -> bool:
"""当前交易模式(SimNow / 实盘)是否已连接 CTP。"""
mode = get_trading_mode(get_setting)
st = _ctp_status_from_snapshot(mode)
if st is not None:
return bool(st.get("connected"))
try:
from modules.ctp.vnpy_bridge import ctp_status
return bool(ctp_status(mode).get("connected"))
except Exception:
return False
def trading_mode_label(get_setting: Callable[[str, str], str]) -> str:
return "SimNow" if get_trading_mode(get_setting) == TRADING_MODE_SIM else "期货公司实盘"