Restructure into modules/ with single-process CTP and config/ layout.
Move business code under modules/, env template to config/, PM2 single qihuo process, and _legacy shims for old imports. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,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"]
|
||||
@@ -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)
|
||||
@@ -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 "未知",
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
# 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
|
||||
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
||||
|
||||
"""数据库连接:开发默认 SQLite,生产推荐 PostgreSQL(DATABASE_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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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(
|
||||
"未找到可用 locale,vnpy_ctp 会在 CTP 登录后崩溃。"
|
||||
"请执行: apt install -y locales && locale-gen zh_CN.GB18030 en_US.UTF-8"
|
||||
) from last_err
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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 "期货公司实盘"
|
||||
Reference in New Issue
Block a user