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
+3
View File
@@ -0,0 +1,3 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
"""Qihuo feature modules package."""
+5
View File
@@ -0,0 +1,5 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
from modules.backup.routes import register
__all__ = ["register"]
+403
View File
@@ -0,0 +1,403 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""数据库备份:SQLite futures.db 或 PostgreSQL pg_dump,含 uploads 与一键恢复脚本。"""
from __future__ import annotations
import json
import logging
import os
import re
import shutil
import sqlite3
import subprocess
import tarfile
import tempfile
import threading
import time
from datetime import datetime
from pathlib import Path
from typing import Callable, Optional
from zoneinfo import ZoneInfo
from modules.core.db_conn import DB_PATH, db_backend
logger = logging.getLogger(__name__)
TZ = ZoneInfo("Asia/Shanghai")
BACKUP_FILENAME_RE = re.compile(r"^qihuo_backup_\d{8}_\d{6}\.tar\.gz$")
BACKUP_LAST_KEY = "backup_last_at"
BACKUP_KEEP_KEY = "backup_keep_count"
BACKUP_AUTO_KEY = "backup_auto_enabled"
BACKUP_HOUR_KEY = "backup_auto_hour"
DEFAULT_KEEP_COUNT = 30
DEFAULT_AUTO_HOUR = 3
CHECK_INTERVAL_SEC = 3600
_backup_lock = threading.Lock()
RESTORE_MD = """# qihuo 备份恢复说明
本压缩包由 qihuo 系统自动生成,可在另一台 Linux 服务器上恢复交易数据。
## 包内文件
| 文件/目录 | 说明 |
|-----------|------|
| `futures.db` | SQLite 主库(仅 SQLite 模式备份) |
| `postgres_dump.sql` | PostgreSQL 逻辑备份(仅 PostgreSQL 模式) |
| `uploads/` | 复盘截图与 K 线图(若备份时存在) |
| `manifest.json` | 备份元数据(含 `backend` 字段) |
| `restore.sh` | 一键恢复脚本 |
## 快速恢复(推荐)
1. 将本压缩包上传到目标服务器(例如 `/root/`)
2. 解压并执行恢复脚本:
```bash
cd /root
tar -xzf qihuo_backup_YYYYMMDD_HHMMSS.tar.gz
cd qihuo_backup_YYYYMMDD_HHMMSS
chmod +x restore.sh
./restore.sh
```
默认恢复到 **`/root/qihuo`**SQLite)或导入到 `.env` 中的 PostgreSQL(见 manifest)。
指定应用目录:
```bash
RESTORE_DIR=/opt/qihuo ./restore.sh
```
3. 在新服务器部署 qihuo 代码与 Python 环境(见 `docs/POSTGRES.md` / `docs/DEPLOY.md`
4. 配置 `.env``DATABASE_URL` 或 SQLite、`SECRET_KEY`、CTP 账号等)
5. 重启服务:`pm2 restart qihuo`
## PostgreSQL 恢复
若 `manifest.json` 中 `"backend": "postgres"`
1. 确保目标机已安装 PostgreSQL,且 `.env` 中 `DATABASE_URL` 指向空库或待覆盖库
2. 执行 `./restore.sh`(会调用 `psql` 导入 `postgres_dump.sql`
手工导入:
```bash
export DATABASE_URL=postgresql://qihuo:密码@127.0.0.1:5432/qihuo
psql "$DATABASE_URL" -f postgres_dump.sql
```
## SQLite 手工恢复
```bash
mkdir -p /opt/qihuo/uploads
cp futures.db /opt/qihuo/futures.db
cp -a uploads/. /opt/qihuo/uploads/
```
## 注意
- 恢复前请停止 qihuo 进程
- `.env` 含敏感信息,请单独安全传输
- 详见 `docs/POSTGRES.md` 与 `docs/BACKUP.md`
"""
def _app_root() -> Path:
from modules.core.paths import ROOT
return ROOT
def default_backup_dir() -> str:
env = (os.getenv("QIHUO_BACKUP_DIR") or "").strip()
if env:
return env
if os.name == "nt":
return str(_app_root() / "qihuo_backup")
return "/root/qihuo_backup"
def default_restore_dir() -> str:
env = (os.getenv("QIHUO_RESTORE_DIR") or "").strip()
if env:
return env
if os.name == "nt":
return str(_app_root())
return "/root/qihuo"
def backup_dir() -> Path:
path = Path(default_backup_dir())
path.mkdir(parents=True, exist_ok=True)
return path
def backup_in_progress() -> bool:
return _backup_lock.locked()
def get_backup_last_at(get_setting: Callable[[str, str], str]) -> str:
return (get_setting(BACKUP_LAST_KEY, "") or "").strip()
def _backup_sqlite(src_path: str, dst_path: str) -> None:
src = sqlite3.connect(src_path, timeout=30)
try:
try:
src.execute("PRAGMA wal_checkpoint(TRUNCATE)")
except sqlite3.OperationalError:
pass
dst = sqlite3.connect(dst_path)
try:
src.backup(dst)
dst.commit()
finally:
dst.close()
finally:
src.close()
def _backup_postgres(dst_path: str) -> None:
url = (os.getenv("DATABASE_URL") or "").strip()
if not url:
raise RuntimeError("PostgreSQL 备份需要 DATABASE_URL")
env = os.environ.copy()
proc = subprocess.run(
["pg_dump", "--no-owner", "--no-acl", "-f", dst_path, url],
capture_output=True,
text=True,
env=env,
check=False,
)
if proc.returncode != 0:
raise RuntimeError(f"pg_dump 失败: {proc.stderr.strip() or proc.stdout.strip()}")
def _write_restore_script(dest: Path, *, backend: str) -> None:
pg_block = ""
if backend == "postgres":
pg_block = """
if [ -f "$SCRIPT_DIR/postgres_dump.sql" ]; then
if [ -z "${DATABASE_URL:-}" ]; then
if [ -f "$RESTORE_DIR/.env" ]; then
set -a
# shellcheck disable=SC1090
source "$RESTORE_DIR/.env"
set +a
fi
fi
if [ -z "${DATABASE_URL:-}" ]; then
echo "错误: PostgreSQL 恢复需要 DATABASE_URL(环境变量或 $RESTORE_DIR/.env"
exit 1
fi
if ! command -v psql >/dev/null; then
echo "错误: 未找到 psql,请先安装 PostgreSQL 客户端"
exit 1
fi
echo "导入 PostgreSQL: postgres_dump.sql"
psql "$DATABASE_URL" -f "$SCRIPT_DIR/postgres_dump.sql"
echo "PostgreSQL 导入完成"
fi
"""
script = f"""#!/bin/bash
set -euo pipefail
RESTORE_DIR="${{RESTORE_DIR:-{default_restore_dir()}}}"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
mkdir -p "$RESTORE_DIR/uploads"
{pg_block}
if [ -f "$SCRIPT_DIR/futures.db" ]; then
cp -f "$SCRIPT_DIR/futures.db" "$RESTORE_DIR/futures.db"
echo "已复制 futures.db -> $RESTORE_DIR/futures.db"
fi
if [ -d "$SCRIPT_DIR/uploads" ]; then
cp -a "$SCRIPT_DIR/uploads/." "$RESTORE_DIR/uploads/"
echo "已复制 uploads -> $RESTORE_DIR/uploads/"
fi
echo ""
echo "恢复完成。目标目录: $RESTORE_DIR"
echo "下一步: 确认 .env、pm2 restart qihuo"
echo "详见 RESTORE.md 与 docs/POSTGRES.md"
"""
dest.write_text(script, encoding="utf-8")
def create_backup(*, include_uploads: bool = True) -> tuple[str, str]:
"""创建 tar.gz 备份,返回 (文件名, 说明)。"""
backend = db_backend()
if backend == "sqlite" and not os.path.isfile(DB_PATH):
raise FileNotFoundError(f"数据库不存在: {DB_PATH}")
if backend == "postgres" and not (os.getenv("DATABASE_URL") or "").strip():
raise RuntimeError("PostgreSQL 模式需要 DATABASE_URL")
with _backup_lock:
stamp = datetime.now(TZ).strftime("%Y%m%d_%H%M%S")
folder_name = f"qihuo_backup_{stamp}"
filename = f"{folder_name}.tar.gz"
out_path = backup_dir() / filename
app_root = _app_root()
upload_src = app_root / "uploads"
with tempfile.TemporaryDirectory(prefix="qihuo_bak_") as tmp:
work = Path(tmp) / folder_name
work.mkdir()
if backend == "postgres":
_backup_postgres(str(work / "postgres_dump.sql"))
else:
_backup_sqlite(DB_PATH, str(work / "futures.db"))
if include_uploads and upload_src.is_dir():
shutil.copytree(upload_src, work / "uploads", dirs_exist_ok=True)
manifest = {
"app": "qihuo",
"backend": backend,
"created_at": datetime.now(TZ).isoformat(timespec="seconds"),
"db_path": DB_PATH if backend == "sqlite" else (os.getenv("DATABASE_URL") or ""),
"includes_uploads": include_uploads and upload_src.is_dir(),
"default_restore_dir": default_restore_dir(),
"files": sorted(p.name for p in work.iterdir()),
}
(work / "manifest.json").write_text(
json.dumps(manifest, ensure_ascii=False, indent=2),
encoding="utf-8",
)
(work / "RESTORE.md").write_text(RESTORE_MD, encoding="utf-8")
_write_restore_script(work / "restore.sh", backend=backend)
with tarfile.open(out_path, "w:gz") as tar:
tar.add(work, arcname=folder_name)
size_mb = out_path.stat().st_size / (1024 * 1024)
label = "PostgreSQL" if backend == "postgres" else "SQLite"
return filename, f"备份已生成 {filename}{label}{size_mb:.2f} MB"
def list_backups() -> list[dict]:
items: list[dict] = []
for path in sorted(backup_dir().glob("qihuo_backup_*.tar.gz"), reverse=True):
if not BACKUP_FILENAME_RE.match(path.name):
continue
stat = path.stat()
items.append(
{
"name": path.name,
"size": stat.st_size,
"size_mb": round(stat.st_size / (1024 * 1024), 2),
"mtime": datetime.fromtimestamp(stat.st_mtime, TZ).isoformat(timespec="seconds"),
}
)
return items
def resolve_backup_file(filename: str) -> Path:
name = (filename or "").strip()
if not BACKUP_FILENAME_RE.match(name):
raise ValueError("无效的备份文件名")
path = (backup_dir() / name).resolve()
root = backup_dir().resolve()
if not str(path).startswith(str(root) + os.sep) and path != root:
raise ValueError("无效的备份路径")
if not path.is_file():
raise FileNotFoundError("备份文件不存在")
return path
def prune_old_backups(keep: int) -> int:
keep_n = max(1, int(keep or DEFAULT_KEEP_COUNT))
files = list_backups()
removed = 0
for item in files[keep_n:]:
try:
resolve_backup_file(item["name"]).unlink()
removed += 1
except Exception as exc:
logger.warning("prune backup %s: %s", item["name"], exc)
return removed
def run_backup_job(
*,
get_setting: Callable[[str, str], str],
set_setting: Callable[[str, str], None],
include_uploads: bool = True,
) -> tuple[str, str]:
keep = DEFAULT_KEEP_COUNT
try:
keep = max(5, min(200, int(get_setting(BACKUP_KEEP_KEY, str(DEFAULT_KEEP_COUNT)) or DEFAULT_KEEP_COUNT)))
except ValueError:
pass
filename, msg = create_backup(include_uploads=include_uploads)
set_setting(BACKUP_LAST_KEY, datetime.now(TZ).isoformat(timespec="seconds"))
removed = prune_old_backups(keep)
if removed:
msg = f"{msg},已清理 {removed} 个旧备份"
return filename, msg
def schedule_backup(
*,
get_setting: Callable[[str, str], str],
set_setting: Callable[[str, str], None],
include_uploads: bool = True,
) -> tuple[bool, str]:
if _backup_lock.locked():
return False, "备份进行中,请稍后再试"
def _run() -> None:
try:
run_backup_job(
get_setting=get_setting,
set_setting=set_setting,
include_uploads=include_uploads,
)
except Exception as exc:
logger.exception("backup failed: %s", exc)
threading.Thread(target=_run, daemon=True, name="qihuo-backup-run").start()
return True, "已在后台开始备份,请稍后刷新本页查看"
def _should_auto_backup(get_setting: Callable[[str, str], str]) -> bool:
if (get_setting(BACKUP_AUTO_KEY, "1") or "1").strip() not in ("1", "true", "yes"):
return False
try:
hour = int(get_setting(BACKUP_HOUR_KEY, str(DEFAULT_AUTO_HOUR)) or DEFAULT_AUTO_HOUR)
except ValueError:
hour = DEFAULT_AUTO_HOUR
hour = max(0, min(23, hour))
now = datetime.now(TZ)
if now.hour != hour:
return False
last = get_backup_last_at(get_setting)
if last and last[:10] == now.date().isoformat():
return False
return True
def start_backup_worker(
*,
get_setting_fn: Callable[[str, str], str],
set_setting_fn: Callable[[str, str], None],
interval: int = CHECK_INTERVAL_SEC,
) -> None:
"""后台线程:按设定小时每日自动备份。"""
def _loop() -> None:
time.sleep(30)
while True:
try:
if _should_auto_backup(get_setting_fn):
filename, msg = run_backup_job(
get_setting=get_setting_fn,
set_setting=set_setting_fn,
include_uploads=True,
)
logger.info("auto backup: %s%s", filename, msg)
except Exception as exc:
logger.warning("backup worker: %s", exc)
time.sleep(max(300, interval))
threading.Thread(target=_loop, daemon=True, name="qihuo-backup-worker").start()
+78
View File
@@ -0,0 +1,78 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
"""HTTP routes for backup module."""
from __future__ import annotations
from datetime import date, datetime
from flask import (
Response,
flash,
jsonify,
redirect,
render_template,
request,
send_file,
session,
stream_with_context,
url_for,
)
def register(deps) -> None:
app = deps.app
login_required = deps.login_required
require_nav = deps.require_nav
get_db = deps.get_db
get_setting = deps.get_setting
set_setting = deps.set_setting
fetch_price = deps.fetch_price
send_wechat_msg = deps.send_wechat_msg
touch_stats_cache = deps.touch_stats_cache
get_stats_data = deps.get_stats_data
build_market_quote_payload = deps.build_market_quote_payload
today_str = deps.today_str
expire_old_plans = deps.expire_old_plans
TZ = deps.tz
DB_PATH = deps.db_path
UPLOAD_DIR = deps.upload_dir
OPEN_TYPES = deps.open_types
EXIT_TRIGGERS = deps.exit_triggers
BEHAVIOR_TAGS = deps.behavior_tags
KLINE_PERIODS = deps.kline_periods
KLINE_CUTOFFS = deps.kline_cutoffs
calc_holding_duration = deps.calc_holding_duration
holding_to_minutes = deps.holding_to_minutes
classify_close_result = deps.classify_close_result
calc_rr_ratio = deps.calc_rr_ratio
calc_theoretical_pnl = deps.calc_theoretical_pnl
parse_review_date_filter = deps.parse_review_date_filter
_trading_mode = deps.trading_mode
_ua_is_phone = deps.ua_is_phone
_static_asset_v = deps.static_asset_v
from modules.backup.db_backup import list_backups, resolve_backup_file
@app.route("/api/backup/list")
@login_required
def api_backup_list():
return jsonify(
{
"dir": str(backup_dir()),
"last_at": get_backup_last_at(get_setting),
"running": backup_in_progress(),
"items": list_backups(),
}
)
@app.route("/api/backup/download/<filename>")
@login_required
def api_backup_download(filename):
from flask import send_file
try:
path = resolve_backup_file(filename)
except (ValueError, FileNotFoundError) as exc:
return jsonify({"error": str(exc)}), 404
return send_file(path, as_attachment=True, download_name=path.name)
+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 "期货公司实盘"
+10
View File
@@ -0,0 +1,10 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
"""CTP / vn.py integration — single-process mode."""
def register(deps) -> None:
del deps
__all__ = ["register"]
+63
View File
@@ -0,0 +1,63 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 详见 LICENSE.zh-CN.txt
"""CTP 持仓均价:仅使用柜台持仓回报(vnpy pos.price = PositionCost 加权)。"""
from __future__ import annotations
from typing import Any, Optional
from modules.core.contract_specs import get_contract_spec
from modules.ctp.ctp_symbol import ths_to_vnpy_symbol
from modules.core.symbols import ths_to_codes
def symbols_match(ctp_sym: str, ths: str) -> bool:
a = (ctp_sym or "").lower()
b = (ths or "").lower()
if a == b:
return True
if a and b and a.split(".")[0] == b.split(".")[0]:
return True
try:
vnpy_sym, _ = ths_to_vnpy_symbol(ths)
if a == vnpy_sym.lower():
return True
except Exception:
pass
try:
vnpy_sym, _ = ths_to_vnpy_symbol(ctp_sym)
if vnpy_sym.lower() == b.split(".")[0]:
return True
except Exception:
pass
return False
def _ths_code(sym: str) -> str:
codes = ths_to_codes(sym) or {}
return codes.get("ths_code") or sym
def round_to_tick(price: float, sym: str) -> float:
tick = float(get_contract_spec(_ths_code(sym)).get("tick_size") or 1.0)
if tick <= 0:
return round(price, 2)
return round(round(price / tick) * tick, 4)
def resolve_ctp_entry(
sym: str,
direction: str,
ctp: Optional[dict[str, Any]],
trades: Optional[list[dict[str, Any]]] = None,
*,
tick: Optional[float] = None,
) -> tuple[float, str]:
"""均价:仅柜台持仓价(trades/tick 参数保留兼容,不参与计算)。"""
del direction, trades, tick
if not ctp:
return 0.0, "none"
pos_avg = float(ctp.get("avg_price") or 0)
if pos_avg > 0:
return round_to_tick(pos_avg, sym), "ctp"
return 0.0, "none"
+144
View File
@@ -0,0 +1,144 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""从 CTP 柜台同步手续费率(SimNow / 期货公司)。"""
from __future__ import annotations
import logging
import re
import time
from typing import Optional
from modules.core.contract_specs import get_contract_spec
from modules.fees.fee_specs import upsert_fee_rate
from modules.ctp.vnpy_bridge import get_bridge
logger = logging.getLogger(__name__)
def _product_from_instrument(instrument_id: str) -> str:
m = re.match(r"^([A-Za-z]+)", instrument_id or "")
return m.group(1).lower() if m else ""
def ctp_commission_to_fee_fields(data: dict, ths_code: str) -> dict:
"""CTP OnRspQryInstrumentCommissionRate → fee_rates 字段。"""
mult = int(get_contract_spec(ths_code)["mult"])
exchange = str(data.get("ExchangeID") or "").strip()
return {
"exchange": exchange,
"mult": mult,
"open_fixed": float(data.get("OpenRatioByVolume") or 0),
"open_ratio": float(data.get("OpenRatioByMoney") or 0),
"close_yesterday_fixed": float(data.get("CloseRatioByVolume") or 0),
"close_yesterday_ratio": float(data.get("CloseRatioByMoney") or 0),
"close_today_fixed": float(data.get("CloseTodayRatioByVolume") or 0),
"close_today_ratio": float(data.get("CloseTodayRatioByMoney") or 0),
"source": "ctp",
}
def _collect_main_ths_codes() -> list[str]:
"""从主力列表收集同花顺合约代码(供 CTP 手续费查询)。"""
from datetime import date
from modules.core.symbols import PRODUCTS, build_ths_code, list_main_contracts_grouped
symbols: list[str] = []
for group in list_main_contracts_grouped():
for item in group.get("items") or []:
ths = (item.get("ths_code") or item.get("ths") or item.get("code") or "").strip()
if ths and not ths.endswith("888"):
symbols.append(ths)
if symbols:
return symbols
today = date.today()
for p in PRODUCTS:
symbols.append(build_ths_code(p, today.year, today.month))
return symbols
def sync_fees_from_ctp(mode: str, *, max_symbols: int = 80) -> tuple[int, str]:
"""CTP 已连接时查询手续费并写入 fee_rates(source=ctp,覆盖同品种旧数据)。"""
bridge = get_bridge()
if not bridge.available():
return 0, "vnpy 未安装"
if bridge.connected_mode != mode:
return 0, "请先连接 CTP"
if not bridge.ping():
return 0, "CTP 连接无效,请重连"
seen: set[str] = set()
ok = 0
errors = 0
batch = bridge.query_all_commissions(mode=mode)
if batch:
for raw in batch:
inst = str(raw.get("InstrumentID") or "").strip()
product = _product_from_instrument(inst)
if not product or product in seen:
continue
seen.add(product)
try:
fields = ctp_commission_to_fee_fields(raw, inst or product)
upsert_fee_rate(product, fields)
ok += 1
except Exception as exc:
logger.debug("CTP fee batch %s: %s", inst, exc)
errors += 1
if ok > 0:
msg = f"已从 CTP 批量同步 {ok} 个品种手续费"
if errors:
msg += f"{errors} 个跳过)"
return ok, msg
symbols = _collect_main_ths_codes()[:max_symbols]
if not symbols:
return 0, "无主力合约列表"
for ths in symbols:
product = _product_from_instrument(ths)
if not product or product in seen:
continue
seen.add(product)
try:
raw = bridge.query_instrument_commission(ths, mode=mode)
if not raw:
errors += 1
continue
fields = ctp_commission_to_fee_fields(raw, ths)
upsert_fee_rate(product, fields)
ok += 1
time.sleep(0.35)
except Exception as exc:
logger.debug("CTP fee sync %s: %s", ths, exc)
errors += 1
if ok == 0:
return 0, f"CTP 未返回手续费率(失败 {errors} 次),请确认柜台支持查询"
msg = f"已从 CTP 同步 {ok} 个品种手续费"
if errors:
msg += f"{errors} 个跳过)"
return ok, msg
def sync_fee_for_symbol(mode: str, ths_code: str) -> Optional[dict]:
"""单品种按需从 CTP 拉取并缓存。"""
bridge = get_bridge()
if bridge.connected_mode != mode or not bridge.ping():
return None
raw = bridge.query_instrument_commission(ths_code, mode=mode)
if not raw:
return None
product = _product_from_instrument(ths_code)
if not product:
return None
fields = ctp_commission_to_fee_fields(raw, ths_code)
upsert_fee_rate(product, fields)
return fields
+131
View File
@@ -0,0 +1,131 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""CTP 手续费后台同步:每日一次写入数据库,前端只读展示。"""
from __future__ import annotations
import logging
import threading
import time
from datetime import date, datetime
from typing import Callable, Optional
from zoneinfo import ZoneInfo
logger = logging.getLogger(__name__)
TZ = ZoneInfo("Asia/Shanghai")
FEE_SYNC_KEY = "ctp_fee_last_sync"
CHECK_INTERVAL_SEC = 3600
_sync_lock = threading.Lock()
def fee_sync_in_progress() -> bool:
return _sync_lock.locked()
def _today_str() -> str:
return datetime.now(TZ).date().isoformat()
def get_fee_last_sync(get_setting: Callable[[str, str], str]) -> str:
return (get_setting(FEE_SYNC_KEY, "") or "").strip()
def fees_synced_today(get_setting: Callable[[str, str], str]) -> bool:
last = get_fee_last_sync(get_setting)
return bool(last) and last[:10] == _today_str()
def mark_fees_synced(set_setting: Callable[[str, str], None]) -> None:
set_setting(FEE_SYNC_KEY, datetime.now(TZ).isoformat(timespec="seconds"))
def try_daily_ctp_fee_sync(
mode: str,
*,
get_setting: Callable[[str, str], str],
set_setting: Callable[[str, str], None],
force: bool = False,
) -> tuple[int, str]:
"""CTP 已连接且今日未同步时拉取费率入库;force=True 忽略日期限制。"""
if not force and fees_synced_today(get_setting):
return 0, "今日已从 CTP 同步过,无需重复(可点「立即同步」强制刷新)"
with _sync_lock:
if not force and fees_synced_today(get_setting):
return 0, "今日已从 CTP 同步过"
t0 = time.monotonic()
from modules.ctp.ctp_fee_sync import sync_fees_from_ctp
count, msg = sync_fees_from_ctp(mode)
elapsed = time.monotonic() - t0
if count > 0:
mark_fees_synced(set_setting)
msg = f"{msg}(耗时 {elapsed:.1f} 秒)"
logger.info("CTP 手续费每日同步: %s", msg)
elif force:
msg = f"{msg}(耗时 {elapsed:.1f} 秒)"
logger.warning("CTP 手续费强制同步未写入: %s", msg)
return count, msg
def schedule_ctp_fee_sync(
mode: str,
*,
get_setting: Callable[[str, str], str],
set_setting: Callable[[str, str], None],
force: bool = False,
) -> tuple[bool, str]:
"""后台线程同步,避免阻塞 Web 请求。"""
if _sync_lock.locked():
return False, "手续费同步进行中,请稍后再试(约 1~3 分钟)"
def _run() -> None:
try:
try_daily_ctp_fee_sync(
mode,
get_setting=get_setting,
set_setting=set_setting,
force=force,
)
except Exception as exc:
logger.exception("CTP 手续费后台同步失败: %s", exc)
threading.Thread(target=_run, daemon=True, name="ctp-fee-sync-run").start()
if force:
return True, "已在后台开始同步,约 30 秒~2 分钟完成,请稍后刷新本页查看"
return True, "已在后台检查同步,请稍后刷新本页"
def start_ctp_fee_worker(
*,
get_mode_fn: Callable[[], str],
get_setting_fn: Callable[[str, str], str],
set_setting_fn: Callable[[str, str], None],
interval: int = CHECK_INTERVAL_SEC,
) -> None:
"""后台线程:每小时检查,CTP 已连接且当日未同步则自动同步。"""
def _loop() -> None:
time.sleep(20)
while True:
try:
from modules.ctp.vnpy_bridge import ctp_status
mode = get_mode_fn()
st = ctp_status(mode)
if st.get("connected") and not fees_synced_today(get_setting_fn):
try_daily_ctp_fee_sync(
mode,
get_setting=get_setting_fn,
set_setting=set_setting_fn,
force=False,
)
except Exception as exc:
logger.warning("CTP fee worker: %s", exc)
time.sleep(max(300, interval))
threading.Thread(target=_loop, daemon=True, name="ctp-fee-worker").start()
+226
View File
@@ -0,0 +1,226 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""Local HTTP client for the isolated CTP worker process."""
from __future__ import annotations
import json
import os
import time
import urllib.error
import urllib.request
from typing import Any, Optional
DEFAULT_BASE_URL = "http://127.0.0.1:6601"
DEFAULT_TIMEOUT_SEC = 2.5
STATUS_TIMEOUT_SEC = 5.0
MUTATION_TIMEOUT_SEC = 8.0
class CtpWorkerUnavailable(RuntimeError):
"""Raised when the local CTP worker cannot be reached."""
def ctp_role() -> str:
return (os.getenv("QIHUO_CTP_ROLE", "client") or "client").strip().lower()
def is_worker_role() -> bool:
return ctp_role() == "worker"
def worker_base_url() -> str:
return (os.getenv("QIHUO_CTP_WORKER_URL", DEFAULT_BASE_URL) or DEFAULT_BASE_URL).rstrip("/")
def worker_token() -> str:
token = (os.getenv("QIHUO_CTP_WORKER_TOKEN", "") or "").strip()
if token:
return token
# Localhost-only default keeps old deployments working; PM2 sets a shared token.
return "qihuo-local-ctp"
def _request(
method: str,
path: str,
payload: Optional[dict[str, Any]] = None,
*,
timeout: float = DEFAULT_TIMEOUT_SEC,
) -> dict[str, Any]:
url = f"{worker_base_url()}{path}"
body = None
headers = {
"Accept": "application/json",
"X-Qihuo-CTP-Token": worker_token(),
}
if payload is not None:
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=body, headers=headers, method=method.upper())
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read().decode("utf-8", errors="replace")
except (urllib.error.URLError, TimeoutError, OSError) as exc:
raise CtpWorkerUnavailable(f"CTP worker unavailable: {exc}") from exc
if not raw:
return {}
try:
data = json.loads(raw)
except json.JSONDecodeError as exc:
raise CtpWorkerUnavailable(f"CTP worker returned invalid JSON: {raw[:120]}") from exc
if not isinstance(data, dict):
raise CtpWorkerUnavailable("CTP worker returned non-object JSON")
if data.get("ok") is False:
raise RuntimeError(str(data.get("error") or "CTP worker request failed"))
return data
def get(path: str, *, timeout: float = DEFAULT_TIMEOUT_SEC) -> dict[str, Any]:
return _request("GET", path, timeout=timeout)
def post(
path: str,
payload: Optional[dict[str, Any]] = None,
*,
timeout: float = DEFAULT_TIMEOUT_SEC,
) -> dict[str, Any]:
return _request("POST", path, payload or {}, timeout=timeout)
def health() -> dict[str, Any]:
try:
return get("/health", timeout=1.0)
except Exception as exc:
return {
"ok": False,
"worker_online": False,
"error": str(exc),
"ts": time.time(),
}
def status(mode: str) -> dict[str, Any]:
try:
data = get(f"/ctp/status?mode={mode}", timeout=STATUS_TIMEOUT_SEC)
return dict(data.get("status") or {})
except Exception as exc:
return {
"connected": False,
"connecting": False,
"worker_online": False,
"last_error": f"CTP worker 离线或重启中:{exc}",
}
def connect(mode: str, *, force: bool = False) -> dict[str, Any]:
data = post(
"/ctp/connect",
{"mode": mode, "force": bool(force)},
timeout=MUTATION_TIMEOUT_SEC,
)
return dict(data.get("status") or data)
def start_connect(mode: str, *, force: bool = False, scheduled: bool = False) -> dict[str, Any]:
return post(
"/ctp/start_connect",
{"mode": mode, "force": bool(force), "scheduled": bool(scheduled)},
timeout=MUTATION_TIMEOUT_SEC,
)
def disconnect(*, set_disabled_hint: bool = False) -> None:
post(
"/ctp/disconnect",
{"set_disabled_hint": bool(set_disabled_hint)},
timeout=MUTATION_TIMEOUT_SEC,
)
def account(mode: str) -> dict[str, Any]:
data = get(f"/ctp/account?mode={mode}")
return dict(data.get("account") or {})
def positions(
mode: str,
*,
refresh_if_empty: bool = True,
refresh_margin: bool = False,
) -> list[dict[str, Any]]:
data = post(
"/ctp/positions",
{
"mode": mode,
"refresh_if_empty": bool(refresh_if_empty),
"refresh_margin": bool(refresh_margin),
},
)
return list(data.get("positions") or [])
def trades(mode: str, *, refresh: bool = False) -> list[dict[str, Any]]:
data = post("/ctp/trades", {"mode": mode, "refresh": bool(refresh)})
return list(data.get("trades") or [])
def active_orders(mode: str) -> list[dict[str, Any]]:
data = get(f"/ctp/active_orders?mode={mode}")
return list(data.get("orders") or [])
def tick_price(mode: str, symbol: str) -> Optional[float]:
data = post("/ctp/tick_price", {"mode": mode, "symbol": symbol})
value = data.get("price")
return float(value) if value not in (None, "") else None
def tick_detail(mode: str, symbol: str) -> dict[str, Any]:
data = post("/ctp/tick_detail", {"mode": mode, "symbol": symbol})
return dict(data.get("detail") or {})
def estimate_margin_one_lot(
mode: str,
symbol: str,
price: float,
*,
direction: str = "long",
) -> Optional[float]:
data = post(
"/ctp/estimate_margin_one_lot",
{"mode": mode, "symbol": symbol, "price": price, "direction": direction},
)
value = data.get("margin")
return float(value) if value not in (None, "") else None
def contract_spec(mode: str, symbol: str) -> Optional[dict[str, Any]]:
data = post("/ctp/contract_spec", {"mode": mode, "symbol": symbol})
spec = data.get("spec")
return dict(spec) if isinstance(spec, dict) else None
def send_order(payload: dict[str, Any]) -> dict[str, Any]:
return post("/ctp/order", payload, timeout=MUTATION_TIMEOUT_SEC)
def cancel_order(mode: str, vt_orderid: str) -> bool:
data = post(
"/ctp/cancel",
{"mode": mode, "vt_orderid": vt_orderid},
timeout=MUTATION_TIMEOUT_SEC,
)
return bool(data.get("cancelled"))
def bridge_action(action: str, payload: Optional[dict[str, Any]] = None) -> dict[str, Any]:
return post(
f"/ctp/bridge/{action}",
payload or {},
timeout=MUTATION_TIMEOUT_SEC,
)
+89
View File
@@ -0,0 +1,89 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""CTP tick 聚合 K 线(1 分钟为基础,再合成各周期)。"""
from __future__ import annotations
import logging
from typing import Optional
from modules.market.kline_chart import (
PERIOD_MINUTES,
_aggregate_bars,
_bar_datetime,
_merge_bars,
_timeshare_session,
_weekly_from_daily,
)
logger = logging.getLogger(__name__)
PERIOD_AGG = {
"2m": 2,
"3m": 3,
"5m": 5,
"15m": 15,
"30m": 30,
"1h": 60,
"2h": 120,
"4h": 240,
}
def _daily_from_1m(bars_1m: list) -> list:
if not bars_1m:
return []
buckets: dict[str, list] = {}
for bar in bars_1m:
dt = _bar_datetime(bar)
if not dt:
continue
key = dt.strftime("%Y-%m-%d")
buckets.setdefault(key, []).append(bar)
out = []
for day in sorted(buckets.keys()):
chunk = buckets[day]
merged = _merge_bars(chunk)
merged["d"] = day + " 15:00:00"
out.append(merged)
return out
def compose_period_bars(bars_1m: list, period: str) -> list:
p = (period or "15m").lower()
if p == "timeshare":
return _timeshare_session(bars_1m)
if p in ("1d", "d"):
return _daily_from_1m(bars_1m)
if p == "w":
return _weekly_from_daily(_daily_from_1m(bars_1m))
if p == "1m":
return list(bars_1m)
n = PERIOD_AGG.get(p)
if n:
return _aggregate_bars(bars_1m, n)
if p in PERIOD_MINUTES:
try:
n = int(PERIOD_MINUTES[p])
return _aggregate_bars(bars_1m, n)
except (TypeError, ValueError):
pass
return list(bars_1m)
def fetch_ctp_klines(symbol: str, period: str, mode: str) -> Optional[list]:
"""CTP 已连接时由 tick 聚合 K 线;失败返回 None。"""
try:
from modules.ctp.vnpy_bridge import ctp_status, get_bridge
if not ctp_status(mode).get("connected"):
return None
bars_1m = get_bridge().get_kline_bars_1m(symbol, mode=mode)
if not bars_1m:
return None
return compose_period_bars(bars_1m, period)
except Exception as exc:
logger.debug("fetch_ctp_klines %s %s: %s", symbol, period, exc)
return None
+116
View File
@@ -0,0 +1,116 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""CTP 按计划自动连接:盘前 30 分钟检查;交易时段断线后台重连;不自动强制断开。"""
from __future__ import annotations
import logging
import os
import threading
import time
from typing import Callable
from modules.market.market_sessions import (
in_premarket_connect_window,
in_postmarket_grace_window,
is_trading_session,
should_keep_ctp_connected,
)
from modules.ctp.vnpy_bridge import ctp_start_connect, ctp_status
logger = logging.getLogger(__name__)
CHECK_INTERVAL_SEC = 60
TRADING_CHECK_INTERVAL_SEC = 15
PREMARKET_CHECK_INTERVAL_SEC = 30
DEFAULT_MINUTES_BEFORE = 30
DEFAULT_MINUTES_AFTER = 30
def premarket_minutes_before() -> int:
try:
return max(5, int(os.getenv("CTP_PREMARKET_MINUTES", str(DEFAULT_MINUTES_BEFORE))))
except (TypeError, ValueError):
return DEFAULT_MINUTES_BEFORE
def postmarket_minutes_after() -> int:
try:
return max(5, int(os.getenv("CTP_POSTMARKET_MINUTES", str(DEFAULT_MINUTES_AFTER))))
except (TypeError, ValueError):
return DEFAULT_MINUTES_AFTER
def _scheduled_connect_enabled() -> bool:
return (os.getenv("CTP_PREMARKET_CONNECT", "true") or "true").strip().lower() in (
"1",
"true",
"yes",
)
def should_auto_connect_now(*, minutes_before: int | None = None) -> bool:
"""是否应保持/发起 CTP 连接(供重连、权限判断复用)。"""
mins_b = premarket_minutes_before() if minutes_before is None else minutes_before
mins_a = postmarket_minutes_after()
if not _scheduled_connect_enabled() and not is_trading_session():
if not in_postmarket_grace_window(minutes_after=mins_a):
return False
return should_keep_ctp_connected(
minutes_before=mins_b,
minutes_after=mins_a,
)
def start_ctp_premarket_connect_worker(
*,
get_mode_fn: Callable[[], str],
get_setting_fn: Callable[[str, str], str] | None = None,
interval: int = CHECK_INTERVAL_SEC,
) -> None:
"""盘前 30 分钟:未连接则自动连;已连接则不重复发起。不自动强制断开。"""
def _loop() -> None:
time.sleep(10)
while True:
sleep_sec = max(30, interval)
try:
mins_b = premarket_minutes_before()
mins_a = postmarket_minutes_after()
keep = should_auto_connect_now()
mode = get_mode_fn()
st = ctp_status(mode)
if keep:
if (
not st.get("connected")
and not st.get("connecting")
and int(st.get("login_cooldown_sec") or 0) <= 0
):
info = ctp_start_connect(mode, force=False, scheduled=True)
if info.get("started"):
if is_trading_session():
logger.info("交易时段内自动连接 CTP [%s]", mode)
elif in_postmarket_grace_window(minutes_after=mins_a):
logger.info(
"盘后宽限期内恢复 CTP 连接 [%s](收盘后 %d 分钟内)",
mode,
mins_a,
)
else:
logger.info(
"盘前自动连接 CTP [%s](开盘前 %d 分钟)",
mode,
mins_b,
)
if is_trading_session():
sleep_sec = TRADING_CHECK_INTERVAL_SEC
elif in_premarket_connect_window(minutes_before=mins_b):
sleep_sec = PREMARKET_CHECK_INTERVAL_SEC
except Exception as exc:
logger.warning("CTP scheduled connect worker: %s", exc)
time.sleep(sleep_sec)
threading.Thread(target=_loop, daemon=True, name="ctp-premarket-connect").start()
+59
View File
@@ -0,0 +1,59 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""CTP 断线自动重连(后台线程)。"""
from __future__ import annotations
import logging
import os
import threading
import time
from typing import Callable
from modules.ctp.ctp_premarket_connect import premarket_minutes_before, should_auto_connect_now
from modules.market.market_sessions import in_premarket_connect_window, is_trading_session
from modules.ctp.vnpy_bridge import ctp_try_auto_reconnect
logger = logging.getLogger(__name__)
RECONNECT_INTERVAL_SEC = 60
TRADING_RECONNECT_INTERVAL_SEC = 15
PREMARKET_RECONNECT_INTERVAL_SEC = 30
def _auto_reconnect_enabled() -> bool:
return (os.getenv("CTP_AUTO_RECONNECT", "true") or "true").strip().lower() in (
"1",
"true",
"yes",
)
def start_ctp_reconnect_worker(
*,
get_mode_fn: Callable[[], str],
get_setting_fn: Callable[[str, str], str] | None = None,
interval: int = RECONNECT_INTERVAL_SEC,
) -> None:
"""交易时段 / 盘前窗口内检测 CTP;断线则后台自动重连。"""
def _loop() -> None:
while True:
sleep_sec = max(5, interval)
try:
if _auto_reconnect_enabled() and should_auto_connect_now():
mode = get_mode_fn()
ctp_try_auto_reconnect(mode)
if is_trading_session():
sleep_sec = TRADING_RECONNECT_INTERVAL_SEC
elif in_premarket_connect_window(
minutes_before=premarket_minutes_before(),
):
sleep_sec = PREMARKET_RECONNECT_INTERVAL_SEC
except Exception as exc:
logger.warning("CTP reconnect worker: %s", exc)
time.sleep(sleep_sec)
threading.Thread(target=_loop, daemon=True, name="ctp-reconnect-worker").start()
+154
View File
@@ -0,0 +1,154 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""CTP / SimNow 配置:系统设置优先,.env 作兜底。"""
from __future__ import annotations
import os
from typing import Any, Callable
# (db_key, env_key, vnpy字段名, 默认值)
SIMNOW_FIELDS: tuple[tuple[str, str, str, str], ...] = (
("simnow_user", "SIMNOW_USER", "用户名", ""),
("simnow_password", "SIMNOW_PASSWORD", "密码", ""),
("simnow_broker_id", "SIMNOW_BROKER_ID", "经纪商代码", "9999"),
("simnow_td_address", "SIMNOW_TD_ADDRESS", "交易服务器", "tcp://180.168.146.187:10201"),
("simnow_md_address", "SIMNOW_MD_ADDRESS", "行情服务器", "tcp://180.168.146.187:10211"),
("simnow_app_id", "SIMNOW_APP_ID", "产品名称", "simnow_client_test"),
("simnow_auth_code", "SIMNOW_AUTH_CODE", "授权编码", "0000000000000000"),
("simnow_env", "SIMNOW_ENV", "柜台环境", "实盘"),
)
LIVE_FIELDS: tuple[tuple[str, str, str, str], ...] = (
("ctp_live_user", "CTP_LIVE_USER", "用户名", ""),
("ctp_live_password", "CTP_LIVE_PASSWORD", "密码", ""),
("ctp_live_broker_id", "CTP_LIVE_BROKER_ID", "经纪商代码", ""),
("ctp_live_td_address", "CTP_LIVE_TD_ADDRESS", "交易服务器", ""),
("ctp_live_md_address", "CTP_LIVE_MD_ADDRESS", "行情服务器", ""),
("ctp_live_app_id", "CTP_LIVE_APP_ID", "产品名称", ""),
("ctp_live_auth_code", "CTP_LIVE_AUTH_CODE", "授权编码", ""),
("ctp_live_env", "CTP_LIVE_ENV", "柜台环境", "实盘"),
)
PASSWORD_DB_KEYS = frozenset({"simnow_password", "ctp_live_password"})
CTP_AUTO_CONNECT_KEY = "ctp_auto_connect"
CTP_DISABLED_HINT = "CTP 自动连接已关闭(非交易时段不重连;开盘前 30 分钟及交易时段仍会按计划连接;断开请手动操作)"
def is_ctp_auto_connect_enabled(get_setting=None) -> bool:
"""系统设置:是否允许手动连接及非交易时段自动重连(盘前/交易时段计划连接不受此限制)。"""
if get_setting is None:
from modules.fees.fee_specs import get_setting as _gs
get_setting = _gs
val = (get_setting(CTP_AUTO_CONNECT_KEY, "1") or "1").strip().lower()
return val in ("1", "true", "yes", "on")
def save_ctp_auto_connect(form: Any, set_setting: Callable[[str, str], None]) -> bool:
enabled = (form.get("ctp_auto_connect") or "").strip().lower() in (
"1",
"on",
"true",
"yes",
)
set_setting(CTP_AUTO_CONNECT_KEY, "1" if enabled else "0")
return enabled
def _get_db_setting(key: str, default: str = "") -> str:
from modules.fees.fee_specs import get_setting
return (get_setting(key, default) or default).strip()
def resolve_ctp_value(db_key: str, env_key: str, default: str = "") -> str:
v = _get_db_setting(db_key, "")
if v:
return v
return (os.getenv(env_key) or default).strip()
def _build_setting_dict(fields: tuple[tuple[str, str, str, str], ...]) -> dict[str, str]:
out: dict[str, str] = {}
for db_key, env_key, vnpy_key, default in fields:
out[vnpy_key] = resolve_ctp_value(db_key, env_key, default)
return out
def simnow_setting_dict() -> dict[str, str]:
return _build_setting_dict(SIMNOW_FIELDS)
def live_setting_dict() -> dict[str, str]:
return _build_setting_dict(LIVE_FIELDS)
def seed_ctp_settings_from_env(set_setting: Callable[[str, str], None]) -> None:
"""首次启动:将 .env 中已有 CTP 配置写入 settings 表。"""
for db_key, env_key, _, _ in (*SIMNOW_FIELDS, *LIVE_FIELDS):
if _get_db_setting(db_key, ""):
continue
env_val = (os.getenv(env_key) or "").strip()
if env_val:
set_setting(db_key, env_val)
def get_ctp_settings_for_ui() -> dict[str, Any]:
ui: dict[str, Any] = {}
for db_key, env_key, _, default in SIMNOW_FIELDS:
ui[db_key] = resolve_ctp_value(db_key, env_key, default)
if db_key in PASSWORD_DB_KEYS:
ui[f"{db_key}_set"] = bool(ui[db_key])
ui[db_key] = ""
for db_key, env_key, _, default in LIVE_FIELDS:
ui[db_key] = resolve_ctp_value(db_key, env_key, default)
if db_key in PASSWORD_DB_KEYS:
ui[f"{db_key}_set"] = bool(ui[db_key])
ui[db_key] = ""
ui["ctp_auto_connect"] = is_ctp_auto_connect_enabled()
return ui
def save_ctp_settings_from_form(
form: Any,
set_setting: Callable[[str, str], None],
) -> dict[str, Any]:
"""保存 CTP 配置;密码留空表示不修改。返回摘要供页面提示。"""
passwords_updated: list[str] = []
passwords_submitted_empty: list[str] = []
for db_key, _, _, default in SIMNOW_FIELDS:
if db_key in PASSWORD_DB_KEYS:
raw = form.get(db_key)
val = (raw or "").strip()
if val:
set_setting(db_key, val)
passwords_updated.append(db_key)
else:
passwords_submitted_empty.append(db_key)
continue
val = (form.get(db_key) or "").strip()
set_setting(db_key, val or default)
for db_key, _, _, default in LIVE_FIELDS:
if db_key in PASSWORD_DB_KEYS:
raw = form.get(db_key)
val = (raw or "").strip()
if val:
set_setting(db_key, val)
passwords_updated.append(db_key)
else:
passwords_submitted_empty.append(db_key)
continue
val = (form.get(db_key) or "").strip()
if default or val:
set_setting(db_key, val or default)
return {
"passwords_updated": passwords_updated,
"passwords_submitted_empty": passwords_submitted_empty,
}
+66
View File
@@ -0,0 +1,66 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""同花顺合约代码 → vnpy Symbol + Exchange。"""
from __future__ import annotations
import re
from typing import Optional, Tuple
from modules.core.symbols import ths_to_codes
try:
from vnpy.trader.constant import Exchange
except ImportError:
Exchange = None # type: ignore
_EX_MAP = {
"SHFE": "SHFE",
"DCE": "DCE",
"CZCE": "CZCE",
"CFFEX": "CFFEX",
"INE": "INE",
}
def ths_to_vnpy_symbol(ths_code: str) -> Tuple[str, str]:
"""
返回 (symbol, exchange_enum_name)。
例:rb2610 → rb2610, SHFESR609 → SR609, CZCE
"""
code = (ths_code or "").strip()
codes = ths_to_codes(code)
ex = (codes.get("ex") if codes else None)
if not ex and codes:
mc = (codes.get("market_code") or "")
if "." in mc:
ex = mc.rsplit(".", 1)[-1]
ex = _EX_MAP.get(ex or "SHFE", "SHFE")
m = re.match(r"^([A-Za-z]+)(\d+)$", code)
if not m:
return code, ex
letters, digits = m.group(1), m.group(2)
if ex == "CZCE":
# 郑商所 CTP 常为大写 + 3 位年月(如 SR509);4 位则取后 3 位
sym = letters.upper() + (digits[-3:] if len(digits) >= 3 else digits)
else:
sym = letters.lower() + digits
return sym, ex
def to_vnpy_exchange(ex_name: str):
if Exchange is None:
raise ImportError("vnpy 未安装")
mapping = {
"SHFE": Exchange.SHFE,
"DCE": Exchange.DCE,
"CZCE": Exchange.CZCE,
"CFFEX": Exchange.CFFEX,
"INE": Exchange.INE,
}
ex = mapping.get((ex_name or "").upper())
if ex is None:
raise ValueError(f"未知交易所: {ex_name}")
return ex
+337
View File
@@ -0,0 +1,337 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""从 CTP 柜台同步成交,写入 trade_logs(以交易所成交为准)。"""
from __future__ import annotations
import logging
from collections import defaultdict
from datetime import datetime
from typing import Any, Callable, Optional
from zoneinfo import ZoneInfo
from modules.core.contract_specs import calc_position_metrics
from modules.ctp.ctp_symbol import ths_to_vnpy_symbol
from modules.fees.fee_specs import calc_round_trip_fee
from modules.core.symbols import ths_to_codes
from modules.trading.trade_log_lib import (
calc_equity_after,
purge_duplicate_local_trade_logs,
ensure_trade_log_columns,
refresh_trade_log_equity_chain,
)
from modules.ctp.vnpy_bridge import ctp_list_trades, ctp_status
logger = logging.getLogger(__name__)
TZ = ZoneInfo("Asia/Shanghai")
def _match_symbol(ctp_sym: str, ths: str) -> bool:
a = (ctp_sym or "").lower()
b = (ths or "").lower()
if a == b:
return True
if a and b and a.split(".")[0] == b.split(".")[0]:
return True
try:
vnpy_sym, _ = ths_to_vnpy_symbol(ths)
if a == vnpy_sym.lower():
return True
except Exception:
pass
return False
def _to_ths_code(symbol: str) -> str:
sym = (symbol or "").strip()
if not sym:
return ""
codes = ths_to_codes(sym)
if codes:
return codes.get("ths_code") or sym
return sym.lower()
def _allocate_commission(total_comm: float, matched: int, total_lots: int) -> float:
if total_comm <= 0 or matched <= 0 or total_lots <= 0:
return 0.0
return round(total_comm * matched / total_lots, 2)
def build_round_trips(trades: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""按 FIFO 将开/平仓成交配对为完整回合。"""
stacks: dict[tuple[str, str], list[dict[str, Any]]] = defaultdict(list)
trips: list[dict[str, Any]] = []
ordered = sorted(
trades,
key=lambda t: ((t.get("datetime") or ""), str(t.get("trade_id") or "")),
)
for t in ordered:
sym = (t.get("symbol") or "").lower()
pos_dir = (t.get("position_direction") or "long").strip().lower()
offset = (t.get("offset") or "open").strip().lower()
lots = int(t.get("lots") or 0)
if not sym or lots <= 0:
continue
key = (sym, pos_dir)
if offset == "open":
stacks[key].append({
**t,
"remaining": lots,
"commission_remaining": float(t.get("commission") or 0),
})
continue
close_lots_total = lots
close_lots_left = lots
close_price = float(t.get("price") or 0)
close_time = t.get("datetime") or ""
close_trade_id = str(t.get("trade_id") or "")
close_comm_total = float(t.get("commission") or 0)
while close_lots_left > 0 and stacks[key]:
open_t = stacks[key][0]
open_rem = int(open_t.get("remaining") or 0)
matched = min(close_lots_left, open_rem)
if matched <= 0:
stacks[key].pop(0)
continue
open_comm_rem = float(open_t.get("commission_remaining") or 0)
open_comm_share = (
_allocate_commission(open_comm_rem, matched, open_rem)
if open_rem > 0 else 0.0
)
close_comm_share = _allocate_commission(
close_comm_total, matched, close_lots_total,
)
open_t["remaining"] = open_rem - matched
open_t["commission_remaining"] = round(
max(0.0, open_comm_rem - open_comm_share), 2,
)
if open_t["remaining"] <= 0:
stacks[key].pop(0)
close_lots_left -= matched
open_trade_id = str(open_t.get("trade_id") or "")
ctp_key = f"{open_trade_id}|{close_trade_id}|{sym}|{pos_dir}|{matched}"
trip_fee = round(open_comm_share + close_comm_share, 2)
trips.append({
"ctp_trade_key": ctp_key,
"symbol": sym,
"ths_code": _to_ths_code(sym),
"direction": pos_dir,
"lots": matched,
"entry_price": float(open_t.get("price") or 0),
"close_price": close_price,
"open_time": open_t.get("datetime") or "",
"close_time": close_time,
"open_trade_id": open_trade_id,
"close_trade_id": close_trade_id,
"fee": trip_fee,
"fee_from_ctp": trip_fee > 0,
})
return trips
def _find_monitor_meta(
conn,
*,
symbol: str,
direction: str,
open_time: str,
match_symbol_fn: Callable[[str, str], bool] | None = None,
) -> dict[str, Any]:
match = match_symbol_fn or _match_symbol
direction = (direction or "long").strip().lower()
best: Optional[dict[str, Any]] = None
for r in conn.execute(
"SELECT * FROM trade_order_monitors ORDER BY id DESC LIMIT 200"
).fetchall():
row = dict(r)
if (row.get("direction") or "long").strip().lower() != direction:
continue
if not match(symbol, row.get("symbol") or ""):
continue
if best is None:
best = row
continue
ot = (row.get("open_time") or "").strip()
if open_time and ot and abs(len(ot) - len(open_time)) <= 2 and ot[:16] == open_time[:16]:
return row
return best or {}
def _holding_minutes(open_time: str, close_time: str) -> int:
try:
from app import holding_to_minutes
return int(holding_to_minutes(open_time, close_time) or 0)
except Exception:
return 0
def sync_trade_logs_from_ctp(
conn,
mode: str,
*,
capital: float = 0.0,
trading_mode: str = "simulation",
) -> dict[str, Any]:
"""查询 CTP 成交并 upsert 到 trade_logs。返回同步摘要。"""
stats = {"synced": 0, "updated": 0, "skipped": 0, "connected": False}
if not ctp_status(mode).get("connected"):
return stats
stats["connected"] = True
ensure_trade_log_columns(conn)
try:
conn.execute("ALTER TABLE trade_logs ADD COLUMN source TEXT DEFAULT 'local'")
except Exception:
pass
try:
conn.execute("ALTER TABLE trade_logs ADD COLUMN ctp_trade_key TEXT")
except Exception:
pass
trades = ctp_list_trades(mode, refresh=True)
trips = build_round_trips(trades)
for trip in trips:
key = trip.get("ctp_trade_key") or ""
if not key:
stats["skipped"] += 1
continue
existing = conn.execute(
"SELECT id FROM trade_logs WHERE ctp_trade_key=?",
(key,),
).fetchone()
ths = trip.get("ths_code") or trip.get("symbol") or ""
codes = ths_to_codes(ths) or {}
direction = trip.get("direction") or "long"
entry = float(trip.get("entry_price") or 0)
close_px = float(trip.get("close_price") or 0)
lots = float(trip.get("lots") or 0)
open_time = trip.get("open_time") or ""
close_time = trip.get("close_time") or datetime.now(TZ).strftime("%Y-%m-%dT%H:%M")
mon = _find_monitor_meta(
conn,
symbol=trip.get("symbol") or ths,
direction=direction,
open_time=open_time,
)
sl = mon.get("stop_loss")
tp = mon.get("take_profit")
try:
sl_f = float(sl) if sl is not None else entry
tp_f = float(tp) if tp is not None else entry
except (TypeError, ValueError):
sl_f, tp_f = entry, entry
metrics = calc_position_metrics(
direction, entry, sl_f, tp_f, lots, close_px, capital, ths,
)
pnl = float(metrics.get("float_pnl") or 0)
trip_fee = float(trip.get("fee") or 0)
if trip_fee > 0:
fee = round(trip_fee, 2)
else:
fee = calc_round_trip_fee(
ths, entry, close_px, lots, open_time, close_time, trading_mode=trading_mode,
)
pnl_net = round(pnl - fee, 2)
margin_pct = metrics.get("position_pct")
equity_after = calc_equity_after(capital, pnl_net)
minutes = _holding_minutes(open_time, close_time)
result = "CTP同步"
monitor_type = mon.get("monitor_type") or "CTP同步"
row_vals = (
ths,
codes.get("name") or mon.get("symbol_name") or ths,
codes.get("market_code") or mon.get("market_code") or "",
codes.get("sina_code") or mon.get("sina_code") or "",
monitor_type,
direction,
entry,
sl if sl is not None else None,
tp if tp is not None else None,
close_px,
lots,
metrics.get("margin"),
margin_pct,
minutes,
open_time,
close_time,
pnl,
fee,
pnl_net,
equity_after,
result,
)
if existing:
conn.execute(
"""UPDATE trade_logs SET
symbol=?, symbol_name=?, market_code=?, sina_code=?, monitor_type=?,
direction=?, entry_price=?, stop_loss=?, take_profit=?, close_price=?,
lots=?, margin=?, margin_pct=?, holding_minutes=?, open_time=?, close_time=?,
pnl=?, fee=?, pnl_net=?, equity_after=?, result=?, source='ctp', verified=1
WHERE ctp_trade_key=?""",
row_vals + (key,),
)
stats["updated"] += 1
else:
conn.execute(
"""INSERT INTO trade_logs
(symbol, symbol_name, market_code, sina_code, monitor_type, direction,
entry_price, stop_loss, take_profit, close_price, lots, margin,
margin_pct, holding_minutes, open_time, close_time, pnl, fee, pnl_net,
equity_after, result, source, ctp_trade_key, verified)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
row_vals + ("ctp", key, 1),
)
stats["synced"] += 1
try:
from modules.trading.trade_notify import notify_trade_log_close
from modules.core.trading_context import trading_mode_label
from app import get_setting, send_wechat_msg
from modules.notify.ai_worker import schedule_ai_event_analysis
from modules.core.db_conn import DB_PATH
notify_trade_log_close(
send_wechat=send_wechat_msg,
get_setting=get_setting,
mode_label=trading_mode_label(get_setting),
capital=capital,
sym=ths,
symbol_name=codes.get("name") or mon.get("symbol_name") or ths,
direction=direction,
entry=entry,
close_price=close_px,
sl=float(sl) if sl is not None else None,
tp=float(tp) if tp is not None else None,
lots=lots,
pnl_net=pnl_net,
equity_after=equity_after,
holding_minutes=minutes,
result=result,
monitor_type=monitor_type,
schedule_ai_fn=schedule_ai_event_analysis,
db_path=DB_PATH,
)
except Exception as exc:
logger.debug("ctp close notify: %s", exc)
if stats["synced"] or stats["updated"]:
try:
from modules.stats.stats_engine import refresh_stats_cache
refresh_stats_cache(conn, capital)
except Exception as exc:
logger.debug("stats refresh after ctp trade sync: %s", exc)
purged = purge_duplicate_local_trade_logs(conn)
if purged:
stats["purged"] = purged
try:
refresh_trade_log_equity_chain(conn)
except Exception as exc:
logger.debug("equity chain refresh after ctp sync: %s", exc)
return stats
+270
View File
@@ -0,0 +1,270 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 详见 LICENSE.zh-CN.txt
"""CTP 权威内存簿:委托、持仓、同步状态(事件增量 + 定期全量校准)。"""
from __future__ import annotations
import logging
import threading
import time
from typing import Any, Callable, Optional
logger = logging.getLogger(__name__)
CALIBRATE_INTERVAL_SEC = 30.0
def position_key(exchange: str, symbol: str, direction: str) -> str:
"""统一持仓键:exchange|symbol|direction"""
ex = (exchange or "").strip().upper()
sym = (symbol or "").strip().lower()
d = (direction or "long").strip().lower()
if ex:
return f"{ex}|{sym}|{d}"
return f"{sym}|{d}"
def parse_position_key(key: str) -> tuple[str, str, str]:
parts = (key or "").split("|")
if len(parts) >= 3:
return parts[0], parts[1], parts[2]
if len(parts) == 2:
return "", parts[0], parts[1]
return "", (key or "").lower(), "long"
def reconcile_position_avg(
old: Optional[dict[str, Any]],
new: dict[str, Any],
tick: Optional[float],
*,
trades: Optional[list[dict[str, Any]]] = None,
ths_sym: str = "",
) -> dict[str, Any]:
"""手数变化时采用柜台回报均价;手数不变时保持已锁定柜台价。"""
del tick, trades
from modules.ctp.ctp_entry_price import round_to_tick
row = dict(new)
lots = int(row.get("lots") or 0)
if lots <= 0:
return row
old_lots = int(old.get("lots") or 0) if old else 0
lots_changed = not old or old_lots != lots
sym = ths_sym or (row.get("symbol") or "")
pos_avg = float(row.get("avg_price") or 0)
if pos_avg > 0:
row["avg_price"] = round_to_tick(pos_avg, sym)
row["avg_price_locked"] = True
return row
if not lots_changed and old and float(old.get("avg_price") or 0) > 0:
row["avg_price"] = float(old["avg_price"])
row["avg_price_locked"] = True
return row
class CtpTradingState:
"""进程内 CTP 快照:柜台回报为准,SQLite 仅挂 SL/TP 元数据。"""
def __init__(self) -> None:
self._lock = threading.RLock()
self._orders: dict[str, dict[str, Any]] = {}
self._positions: dict[str, dict[str, Any]] = {}
self._tick_prices: dict[str, float] = {}
self._sync_state = "idle"
self._last_event_ts: float = 0.0
self._last_calibrate_ts: float = 0.0
self._on_change: Optional[Callable[[], None]] = None
def set_change_callback(self, fn: Optional[Callable[[], None]]) -> None:
self._on_change = fn
def _notify(self) -> None:
self._last_event_ts = time.time()
fn = self._on_change
if fn:
try:
fn()
except Exception as exc:
logger.debug("trading state change callback: %s", exc)
@property
def sync_state(self) -> str:
with self._lock:
return self._sync_state
def sync_label(self) -> str:
st = self.sync_state
if st == "syncing":
return "同步中…"
if st == "ready":
return "已同步"
return ""
def begin_sync(self) -> None:
with self._lock:
self._sync_state = "syncing"
def finish_sync(self) -> None:
with self._lock:
self._sync_state = "ready"
self._last_calibrate_ts = time.time()
def needs_calibrate(self) -> bool:
with self._lock:
if self._sync_state == "idle":
return True
return (time.time() - self._last_calibrate_ts) >= CALIBRATE_INTERVAL_SEC
def upsert_order(self, row: dict[str, Any], *, notify: bool = True) -> None:
oid = str(row.get("order_id") or row.get("vt_order_id") or "").strip()
if not oid:
return
with self._lock:
self._orders[oid] = dict(row)
if notify:
self._notify()
def remove_order(self, order_id: str, *, notify: bool = True) -> None:
oid = (order_id or "").strip()
if not oid:
return
removed = False
with self._lock:
if oid in self._orders:
del self._orders[oid]
removed = True
else:
for k in list(self._orders.keys()):
if k == oid or k.endswith(oid) or oid.endswith(k):
del self._orders[k]
removed = True
break
if removed and notify:
self._notify()
def get_position(self, pk: str) -> Optional[dict[str, Any]]:
with self._lock:
row = self._positions.get(pk)
return dict(row) if row else None
def try_lock_entry_prices(self) -> bool:
"""均价以柜台为准,不按 tick 反推(避免均价随行情跳动)。"""
return False
def upsert_position(
self,
row: dict[str, Any],
*,
notify: bool = True,
trades: Optional[list[dict[str, Any]]] = None,
ths_sym: str = "",
) -> None:
lots = int(row.get("lots") or 0)
ex = row.get("exchange") or ""
sym = row.get("symbol") or ""
direction = row.get("direction") or "long"
pk = position_key(ex, sym, direction)
tick = self.get_tick_price(ex, sym)
with self._lock:
if lots <= 0:
self._positions.pop(pk, None)
else:
old = self._positions.get(pk)
row = reconcile_position_avg(
old, dict(row), tick, trades=trades, ths_sym=ths_sym or sym,
)
row["position_key"] = pk
self._positions[pk] = row
if notify:
self._notify()
def remove_position(self, pk: str, *, notify: bool = True) -> None:
with self._lock:
self._positions.pop(pk, None)
if notify:
self._notify()
def set_tick_price(self, exchange: str, symbol: str, price: float) -> None:
if not symbol or price <= 0:
return
key = f"{(exchange or '').upper()}|{symbol.lower()}"
with self._lock:
self._tick_prices[key] = float(price)
def get_tick_price(self, exchange: str, symbol: str) -> Optional[float]:
key = f"{(exchange or '').upper()}|{symbol.lower()}"
with self._lock:
return self._tick_prices.get(key)
def get_active_orders(self) -> list[dict[str, Any]]:
with self._lock:
return list(self._orders.values())
def get_positions(self) -> list[dict[str, Any]]:
with self._lock:
return list(self._positions.values())
def position_keys(self) -> set[str]:
with self._lock:
return set(self._positions.keys())
def clear(self) -> None:
with self._lock:
self._orders.clear()
self._positions.clear()
self._tick_prices.clear()
self._sync_state = "idle"
def calibrate_from_lists(
self,
orders: list[dict[str, Any]],
positions: list[dict[str, Any]],
*,
trades: Optional[list[dict[str, Any]]] = None,
ths_for_vnpy_sym: Optional[Callable[[str, str], str]] = None,
preserve_positions_if_margin: float = 0.0,
) -> None:
"""全量校准:以 vnpy 内存为准重建订单/持仓簿。"""
self.begin_sync()
new_orders: dict[str, dict[str, Any]] = {}
for o in orders or []:
oid = str(o.get("order_id") or o.get("vt_order_id") or "").strip()
if oid:
new_orders[oid] = dict(o)
new_positions: dict[str, dict[str, Any]] = {}
for p in positions or []:
lots = int(p.get("lots") or 0)
if lots <= 0:
continue
ex = p.get("exchange") or ""
sym = p.get("symbol") or ""
direction = p.get("direction") or "long"
pk = position_key(ex, sym, direction)
row = dict(p)
row["position_key"] = pk
old = self._positions.get(pk)
tick = self.get_tick_price(ex, sym)
ths = sym
if ths_for_vnpy_sym:
try:
ths = ths_for_vnpy_sym(sym, ex) or sym
except Exception:
ths = sym
new_positions[pk] = reconcile_position_avg(
old, row, tick, trades=trades, ths_sym=ths,
)
if not new_positions and self._positions and preserve_positions_if_margin > 0:
with self._lock:
new_positions = {k: dict(v) for k, v in self._positions.items()}
with self._lock:
self._orders = new_orders
self._positions = new_positions
self.finish_sync()
self._notify()
trading_state = CtpTradingState()
+494
View File
@@ -0,0 +1,494 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""Isolated local CTP worker.
This process is the only process that should instantiate vn.py / vnpy_ctp.
The Flask web app talks to it through localhost HTTP via ctp_ipc_client.py.
"""
from __future__ import annotations
import logging
import os
import threading
import time
from typing import Any
os.environ.setdefault("QIHUO_CTP_ROLE", "worker")
from flask import Flask, jsonify, request
from modules.ctp.ctp_ipc_client import worker_token
from modules.core.db_conn import DB_PATH, commit_retry, connect_db
from modules.fees.fee_specs import get_setting, set_setting
from modules.core.locale_fix import ensure_process_locale
from modules.market.market_sessions import is_trading_session
from modules.trading.sl_tp_guard import check_sl_tp_on_tick, ensure_monitor_order_columns, start_sl_tp_guard_worker
from strategy.strategy_db import init_strategy_tables
from modules.core.trading_context import get_account_capital, get_trading_mode, get_trailing_be_tick_buffer
from modules.ctp.vnpy_bridge import (
_ctp_td_lock,
ctp_cancel_order,
ctp_disconnect,
ctp_estimate_margin_one_lot,
ctp_get_account,
ctp_get_tick_detail,
ctp_get_tick_price,
ctp_list_active_orders,
ctp_list_positions,
ctp_list_trades,
ctp_lookup_contract_spec,
ctp_start_connect,
ctp_status,
ctp_try_auto_reconnect,
execute_order,
get_bridge,
set_ctp_connected_callback,
set_position_refresh_callback,
set_tick_quote_callback,
set_tick_sl_tp_callback,
try_init_vnpy,
)
logging.basicConfig(
level=os.getenv("LOG_LEVEL", "INFO"),
format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
)
logger = logging.getLogger(__name__)
app = Flask(__name__)
_started_workers = False
_last_snapshot_ts = 0.0
_snapshot_lock = threading.Lock()
def _json_ok(**payload: Any):
return jsonify({"ok": True, **payload})
def _json_error(exc: Exception, *, status_code: int = 500):
return jsonify({"ok": False, "error": str(exc)}), status_code
def _require_token() -> None:
expected = worker_token()
got = request.headers.get("X-Qihuo-CTP-Token", "")
if expected and got != expected:
raise PermissionError("unauthorized")
@app.before_request
def _auth():
_require_token()
@app.errorhandler(Exception)
def _handle_error(exc: Exception):
code = 401 if isinstance(exc, PermissionError) else 500
logger.warning("ctp worker request failed: %s", exc)
return _json_error(exc, status_code=code)
def _mode_from_request() -> str:
data = request.get_json(silent=True) or {}
return (
data.get("mode")
or request.args.get("mode")
or get_trading_mode(get_setting)
or "simulation"
)
def _fast_status(mode: str) -> dict[str, Any]:
"""Return worker/native bridge state without slow network probing."""
from modules.ctp.ctp_settings import CTP_DISABLED_HINT, is_ctp_auto_connect_enabled
try:
st = dict(get_bridge().status(mode) or {})
except Exception as exc:
st = {
"connected": False,
"connecting": False,
"connected_mode": None,
"last_error": str(exc),
"mode_label": "SimNow" if mode == "simulation" else "期货公司实盘",
}
auto = is_ctp_auto_connect_enabled()
st["auto_connect_enabled"] = auto
st["worker_online"] = True
if not auto:
st["disabled_hint"] = CTP_DISABLED_HINT
if not st.get("connected") and not st.get("connecting"):
st["last_error"] = ""
st["td_reachable"] = None
return st
def _send_wechat_msg(content: str) -> None:
webhook = get_setting("wechat_webhook", "")
if not webhook:
return
try:
import requests
requests.post(
webhook,
json={"msgtype": "text", "text": {"content": f"【国内期货】\n{content}"}},
timeout=10,
)
except Exception as exc:
logger.debug("wechat notify failed: %s", exc)
def _init_worker_tables(conn) -> None:
init_strategy_tables(conn)
ensure_monitor_order_columns(conn)
def _capital(conn) -> float:
try:
return float(get_account_capital(get_setting, conn=conn) or 0)
except Exception:
return 0.0
def _persist_snapshot(mode: str) -> None:
global _last_snapshot_ts
with _snapshot_lock:
now = time.time()
if now - _last_snapshot_ts < 0.25:
return
_last_snapshot_ts = now
try:
import json
st = _fast_status(mode)
positions = ctp_list_positions(mode, refresh_if_empty=False, refresh_margin=False)
account = ctp_get_account(mode) if st.get("connected") else {}
conn = connect_db(DB_PATH)
try:
conn.execute(
"""CREATE TABLE IF NOT EXISTS ctp_worker_snapshots (
key TEXT PRIMARY KEY,
value TEXT,
updated_at REAL
)"""
)
for key, value in (
("status", st),
("positions", positions),
("account", account),
):
conn.execute(
"""INSERT INTO ctp_worker_snapshots(key, value, updated_at)
VALUES(?,?,?)
ON CONFLICT(key) DO UPDATE SET
value=excluded.value,
updated_at=excluded.updated_at""",
(key, json.dumps(value, ensure_ascii=False), now),
)
commit_retry(conn)
finally:
conn.close()
except Exception as exc:
logger.debug("persist ctp snapshot: %s", exc)
def _on_position_refresh() -> None:
try:
_persist_snapshot(get_trading_mode(get_setting))
except Exception as exc:
logger.debug("position refresh callback: %s", exc)
def _on_tick_quote() -> None:
_on_position_refresh()
def _on_tick_sl_tp(exchange: str, symbol: str, price: float) -> None:
mode = get_trading_mode(get_setting)
if not ctp_status(mode).get("connected"):
return
conn = connect_db(DB_PATH)
try:
_init_worker_tables(conn)
capital = _capital(conn)
n = check_sl_tp_on_tick(
conn,
mode,
exchange,
symbol,
price,
capital=capital,
notify_fn=_send_wechat_msg,
be_tick_mult=get_trailing_be_tick_buffer(get_setting),
)
if n:
commit_retry(conn)
_persist_snapshot(mode)
except Exception as exc:
logger.warning("worker tick sl/tp: %s", exc)
finally:
conn.close()
def _on_ctp_connected(mode: str) -> None:
try:
with _ctp_td_lock:
get_bridge().request_position_snapshot(force=True)
get_bridge().calibrate_trading_state()
_persist_snapshot(mode)
except Exception as exc:
logger.debug("worker ctp connected callback: %s", exc)
def _start_background_workers() -> None:
global _started_workers
if _started_workers:
return
_started_workers = True
set_position_refresh_callback(_on_position_refresh)
set_tick_quote_callback(_on_tick_quote)
set_tick_sl_tp_callback(_on_tick_sl_tp)
set_ctp_connected_callback(_on_ctp_connected)
from modules.ctp.ctp_fee_worker import start_ctp_fee_worker
from modules.ctp.ctp_premarket_connect import start_ctp_premarket_connect_worker
from modules.ctp.ctp_reconnect import start_ctp_reconnect_worker
from modules.trading.order_pending import reconcile_pending_orders
from modules.trading.pending_order_worker import start_pending_order_worker
def _mode() -> str:
return get_trading_mode(get_setting)
start_ctp_reconnect_worker(get_mode_fn=_mode, get_setting_fn=get_setting)
start_ctp_premarket_connect_worker(get_mode_fn=_mode, get_setting_fn=get_setting)
start_ctp_fee_worker(
get_mode_fn=_mode,
get_setting_fn=get_setting,
set_setting_fn=set_setting,
)
start_pending_order_worker(
db_path=DB_PATH,
get_mode_fn=_mode,
init_tables_fn=_init_worker_tables,
get_capital_fn=_capital,
reconcile_fn=reconcile_pending_orders,
on_changed_fn=lambda: _persist_snapshot(_mode()),
)
start_sl_tp_guard_worker(
db_path=DB_PATH,
get_mode_fn=_mode,
init_tables_fn=_init_worker_tables,
get_capital_fn=_capital,
get_be_tick_buffer_fn=lambda: get_trailing_be_tick_buffer(get_setting),
notify_fn=_send_wechat_msg,
)
def _snapshot_loop() -> None:
time.sleep(3)
while True:
try:
mode = _mode()
if _fast_status(mode).get("connected"):
_persist_snapshot(mode)
except Exception as exc:
logger.debug("worker snapshot loop: %s", exc)
time.sleep(2 if is_trading_session() else 15)
threading.Thread(target=_snapshot_loop, daemon=True, name="ctp-worker-snapshot").start()
@app.route("/health")
def health():
mode = request.args.get("mode") or get_trading_mode(get_setting)
st = _fast_status(mode)
return _json_ok(
worker_online=True,
role=os.getenv("QIHUO_CTP_ROLE", "worker"),
mode=mode,
status=st,
ts=time.time(),
)
@app.route("/ctp/status")
def api_status():
mode = _mode_from_request()
return _json_ok(status=_fast_status(mode))
@app.route("/ctp/connect", methods=["POST"])
def api_connect():
data = request.get_json(silent=True) or {}
mode = data.get("mode") or get_trading_mode(get_setting)
info = ctp_start_connect(mode, force=bool(data.get("force")))
st = info.get("status") or _fast_status(mode)
return _json_ok(status=st, **{k: v for k, v in info.items() if k != "status"})
@app.route("/ctp/start_connect", methods=["POST"])
def api_start_connect():
data = request.get_json(silent=True) or {}
mode = data.get("mode") or get_trading_mode(get_setting)
return _json_ok(**ctp_start_connect(
mode,
force=bool(data.get("force")),
scheduled=bool(data.get("scheduled")),
))
@app.route("/ctp/disconnect", methods=["POST"])
def api_disconnect():
data = request.get_json(silent=True) or {}
ctp_disconnect(set_disabled_hint=bool(data.get("set_disabled_hint")))
return _json_ok(disconnected=True)
@app.route("/ctp/account")
def api_account():
mode = _mode_from_request()
if not _fast_status(mode).get("connected"):
return _json_ok(account={})
return _json_ok(account=ctp_get_account(mode))
@app.route("/ctp/positions", methods=["POST"])
def api_positions():
data = request.get_json(silent=True) or {}
mode = data.get("mode") or get_trading_mode(get_setting)
return _json_ok(positions=ctp_list_positions(
mode,
refresh_if_empty=bool(data.get("refresh_if_empty", True)),
refresh_margin=bool(data.get("refresh_margin", False)),
))
@app.route("/ctp/trades", methods=["POST"])
def api_trades():
data = request.get_json(silent=True) or {}
mode = data.get("mode") or get_trading_mode(get_setting)
return _json_ok(trades=ctp_list_trades(mode, refresh=bool(data.get("refresh"))))
@app.route("/ctp/active_orders")
def api_active_orders():
mode = _mode_from_request()
return _json_ok(orders=ctp_list_active_orders(mode))
@app.route("/ctp/tick_price", methods=["POST"])
def api_tick_price():
data = request.get_json(silent=True) or {}
return _json_ok(price=ctp_get_tick_price(
data.get("mode") or get_trading_mode(get_setting),
data.get("symbol") or "",
))
@app.route("/ctp/tick_detail", methods=["POST"])
def api_tick_detail():
data = request.get_json(silent=True) or {}
return _json_ok(detail=ctp_get_tick_detail(
data.get("mode") or get_trading_mode(get_setting),
data.get("symbol") or "",
))
@app.route("/ctp/estimate_margin_one_lot", methods=["POST"])
def api_estimate_margin():
data = request.get_json(silent=True) or {}
return _json_ok(margin=ctp_estimate_margin_one_lot(
data.get("mode") or get_trading_mode(get_setting),
data.get("symbol") or "",
float(data.get("price") or 0),
direction=data.get("direction") or "long",
))
@app.route("/ctp/contract_spec", methods=["POST"])
def api_contract_spec():
data = request.get_json(silent=True) or {}
return _json_ok(spec=ctp_lookup_contract_spec(
data.get("mode") or get_trading_mode(get_setting),
data.get("symbol") or "",
))
@app.route("/ctp/order", methods=["POST"])
def api_order():
data = request.get_json(silent=True) or {}
mode = data.get("mode") or get_trading_mode(get_setting)
result = execute_order(
None,
mode=mode,
offset=data.get("offset") or "open",
symbol=data.get("symbol") or "",
direction=data.get("direction") or "long",
lots=int(data.get("lots") or 1),
price=float(data.get("price") or 0),
settings=data.get("settings") or {},
order_type=data.get("order_type") or "limit",
)
_persist_snapshot(mode)
return _json_ok(**result)
@app.route("/ctp/cancel", methods=["POST"])
def api_cancel():
data = request.get_json(silent=True) or {}
mode = data.get("mode") or get_trading_mode(get_setting)
cancelled = ctp_cancel_order(mode, data.get("vt_orderid") or "")
_persist_snapshot(mode)
return _json_ok(cancelled=cancelled)
@app.route("/ctp/bridge/<action>", methods=["POST"])
def api_bridge_action(action: str):
data = request.get_json(silent=True) or {}
b = get_bridge()
if action == "calibrate_trading_state":
return _json_ok(result=b.calibrate_trading_state())
if action == "request_position_snapshot":
return _json_ok(result=b.request_position_snapshot(force=bool(data.get("force"))))
if action == "subscribe_symbol":
return _json_ok(result=b.subscribe_symbol(data.get("symbol") or ""))
if action == "refresh_positions":
return _json_ok(result=b.refresh_positions())
if action == "connect_in_progress":
return _json_ok(result=b.connect_in_progress())
if action == "reconnect_after_settings_saved":
mode = data.get("mode") or get_trading_mode(get_setting)
return _json_ok(result=b.reconnect_after_settings_saved(mode))
if action == "query_all_commissions":
return _json_ok(result=b.query_all_commissions(
mode=data.get("mode") or get_trading_mode(get_setting),
))
if action == "query_instrument_commission":
return _json_ok(result=b.query_instrument_commission(
data.get("symbol") or "",
mode=data.get("mode") or get_trading_mode(get_setting),
))
if action == "get_kline_bars_1m":
return _json_ok(result=b.get_kline_bars_1m(
data.get("symbol") or "",
mode=data.get("mode") or get_trading_mode(get_setting),
))
return _json_error(ValueError(f"unsupported bridge action: {action}"), status_code=404)
def main() -> None:
ensure_process_locale()
try_init_vnpy({})
_start_background_workers()
host = os.getenv("QIHUO_CTP_WORKER_HOST", "127.0.0.1")
port = int(os.getenv("QIHUO_CTP_WORKER_PORT", "6601") or 6601)
logger.info("starting qihuo-ctp worker on %s:%s", host, port)
app.run(host=host, port=port, debug=False, threaded=True, use_reloader=False)
if __name__ == "__main__":
main()
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
from modules.fees.routes import register
__all__ = ["register"]
+385
View File
@@ -0,0 +1,385 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""期货手续费:仅 CTP 柜台同步入库,前端只读展示。"""
import json
import os
import re
from datetime import datetime
from typing import Optional
from modules.core.contract_specs import get_contract_spec
from modules.core.db_conn import connect_db, is_benign_migration_error
DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "futures.db")
DATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data")
DEFAULT_JSON = os.path.join(DATA_DIR, "fee_rates.json")
# 无配置时的兜底(已为交易所标准约 2 倍)
DEFAULT_FEE = {
"open_fixed": 2.0,
"open_ratio": 0.0,
"close_yesterday_fixed": 2.0,
"close_yesterday_ratio": 0.0,
"close_today_fixed": 4.0,
"close_today_ratio": 0.0,
}
_INDEX_PRODUCTS = {"if", "ih", "ic", "im"}
def product_from_code(ths_code: str) -> str:
code = (ths_code or "").strip()
m = re.match(r"^([A-Za-z]+)", code)
return m.group(1).lower() if m else ""
def _get_db():
return connect_db()
def ensure_fee_rates_schema(conn=None) -> None:
"""补齐 fee_rates 表结构(旧库可能缺少 source 列)。"""
close = False
if conn is None:
conn = _get_db()
close = True
try:
for sql in (
"ALTER TABLE fee_rates ADD COLUMN source TEXT DEFAULT 'local'",
):
try:
conn.execute(sql)
except Exception as exc:
if not is_benign_migration_error(exc):
raise
conn.commit()
finally:
if close:
conn.close()
def get_setting(key: str, default: str = "") -> str:
conn = _get_db()
row = conn.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone()
conn.close()
if not row:
return default
return (row["value"] or default) if row["value"] is not None else default
def set_setting(key: str, value: str) -> None:
conn = _get_db()
conn.execute(
"""INSERT INTO settings (key, value) VALUES (?,?)
ON CONFLICT(key) DO UPDATE SET value=excluded.value""",
(key, value),
)
conn.commit()
conn.close()
def get_fee_multiplier() -> float:
conn = _get_db()
row = conn.execute(
"SELECT value FROM settings WHERE key='fee_multiplier'"
).fetchone()
conn.close()
if row and row["value"]:
try:
return max(0.0, float(row["value"]))
except ValueError:
pass
return 2.0
def get_fee_source_mode() -> str:
"""固定 CTP 柜台。"""
return "ctp"
def purge_non_ctp_fee_rates() -> int:
"""删除非 CTP 来源的费率缓存。"""
conn = _get_db()
cur = conn.execute(
"DELETE FROM fee_rates WHERE COALESCE(source, '') != 'ctp'"
)
n = cur.rowcount
conn.commit()
conn.close()
return n
def _row_to_spec(row, mult: int) -> dict:
return {
"product": row["product"],
"exchange": row["exchange"] or "",
"mult": int(row["mult"] or mult),
"open_fixed": float(row["open_fixed"] or 0),
"open_ratio": float(row["open_ratio"] or 0),
"close_yesterday_fixed": float(row["close_yesterday_fixed"] or 0),
"close_yesterday_ratio": float(row["close_yesterday_ratio"] or 0),
"close_today_fixed": float(row["close_today_fixed"] or 0),
"close_today_ratio": float(row["close_today_ratio"] or 0),
"source": row["source"] if "source" in row.keys() else "local",
}
def get_fee_spec(ths_code: str, *, trading_mode: str = "simulation") -> dict:
product = product_from_code(ths_code)
if not product:
spec = get_contract_spec(ths_code)
return {**DEFAULT_FEE, "mult": spec["mult"], "product": "", "exchange": "", "source": "default"}
mult = get_contract_spec(ths_code)["mult"]
conn = _get_db()
ensure_fee_rates_schema(conn)
row = conn.execute(
"SELECT * FROM fee_rates WHERE product=? AND source='ctp'",
(product,),
).fetchone()
conn.close()
if row:
return _row_to_spec(row, mult)
try:
from modules.ctp.ctp_fee_sync import sync_fee_for_symbol
fields = sync_fee_for_symbol(trading_mode, ths_code)
if fields:
return {"product": product, **fields}
except Exception:
pass
if product in _INDEX_PRODUCTS:
return {
"product": product,
"exchange": "CFFEX",
"mult": mult,
"open_fixed": 0.0,
"open_ratio": 0.000092,
"close_yesterday_fixed": 0.0,
"close_yesterday_ratio": 0.000092,
"close_today_fixed": 0.0,
"close_today_ratio": 0.000276,
}
return {
"product": product,
"exchange": "",
"mult": mult,
**DEFAULT_FEE,
"source": "default",
}
def calc_side_fee(
price: float,
lots: float,
mult: int,
fixed: float,
ratio: float,
) -> float:
lots = lots or 1.0
fixed = fixed or 0.0
ratio = ratio or 0.0
return fixed * lots + ratio * price * mult * lots
def is_same_day(open_time: str, close_time: str) -> bool:
if not open_time or not close_time:
return True
o = open_time.strip().replace(" ", "T")[:10]
c = close_time.strip().replace(" ", "T")[:10]
return o == c
def calc_round_trip_fee(
ths_code: str,
entry_price: float,
close_price: float,
lots: float,
open_time: str = "",
close_time: str = "",
trading_mode: str = "simulation",
) -> float:
if not entry_price or not close_price:
return 0.0
spec = get_fee_spec(ths_code, trading_mode=trading_mode)
mult = spec["mult"]
lots = lots or 1.0
open_fee = calc_side_fee(
entry_price, lots, mult,
spec["open_fixed"], spec["open_ratio"],
)
if is_same_day(open_time, close_time):
close_fee = calc_side_fee(
close_price, lots, mult,
spec["close_today_fixed"], spec["close_today_ratio"],
)
else:
close_fee = calc_side_fee(
close_price, lots, mult,
spec["close_yesterday_fixed"], spec["close_yesterday_ratio"],
)
return round(open_fee + close_fee, 2)
def calc_fee_breakdown(
ths_code: str,
entry_price: float,
close_price: float,
lots: float,
open_time: str = "",
close_time: str = "",
trading_mode: str = "simulation",
) -> dict:
spec = get_fee_spec(ths_code, trading_mode=trading_mode)
mult = spec["mult"]
lots = lots or 1.0
open_fee = calc_side_fee(
entry_price, lots, mult, spec["open_fixed"], spec["open_ratio"],
)
same_day = is_same_day(open_time, close_time)
if same_day:
close_fee = calc_side_fee(
close_price, lots, mult,
spec["close_today_fixed"], spec["close_today_ratio"],
)
close_type = "平今"
else:
close_fee = calc_side_fee(
close_price, lots, mult,
spec["close_yesterday_fixed"], spec["close_yesterday_ratio"],
)
close_type = "平昨"
total = round(open_fee + close_fee, 2)
return {
"open_fee": round(open_fee, 2),
"close_fee": round(close_fee, 2),
"close_type": close_type,
"total_fee": total,
"same_day": same_day,
"fee_source": spec.get("source", "local"),
}
def load_fee_rates_from_json(path: Optional[str] = None) -> int:
path = path or DEFAULT_JSON
if not os.path.isfile(path):
return 0
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
conn = _get_db()
now = datetime.now().isoformat(timespec="seconds")
count = 0
for product, item in data.items():
if not isinstance(item, dict):
continue
conn.execute(
"""INSERT INTO fee_rates
(product, exchange, mult,
open_fixed, open_ratio,
close_yesterday_fixed, close_yesterday_ratio,
close_today_fixed, close_today_ratio, updated_at, source)
VALUES (?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(product) DO UPDATE SET
exchange=excluded.exchange, mult=excluded.mult,
open_fixed=excluded.open_fixed, open_ratio=excluded.open_ratio,
close_yesterday_fixed=excluded.close_yesterday_fixed,
close_yesterday_ratio=excluded.close_yesterday_ratio,
close_today_fixed=excluded.close_today_fixed,
close_today_ratio=excluded.close_today_ratio,
updated_at=excluded.updated_at,
source=excluded.source""",
(
product.lower(),
item.get("exchange", ""),
int(item.get("mult") or get_contract_spec(product)["mult"]),
float(item.get("open_fixed") or 0),
float(item.get("open_ratio") or 0),
float(item.get("close_yesterday_fixed") or 0),
float(item.get("close_yesterday_ratio") or 0),
float(item.get("close_today_fixed") or 0),
float(item.get("close_today_ratio") or 0),
now,
item.get("source", "json"),
),
)
count += 1
conn.commit()
conn.close()
return count
def list_ctp_fee_rates() -> list:
"""手续费页:仅展示 CTP 同步结果。"""
conn = _get_db()
rows = conn.execute(
"SELECT * FROM fee_rates WHERE source='ctp' ORDER BY product"
).fetchall()
conn.close()
return [dict(r) for r in rows]
def list_all_fee_rates() -> list:
conn = _get_db()
rows = conn.execute(
"SELECT * FROM fee_rates ORDER BY product"
).fetchall()
conn.close()
return [dict(r) for r in rows]
def list_fee_rates_for_ui() -> list:
return list_ctp_fee_rates()
def count_fee_rates_by_source() -> dict[str, int]:
conn = _get_db()
n = conn.execute(
"SELECT COUNT(*) FROM fee_rates WHERE source='ctp'"
).fetchone()[0]
conn.close()
return {"ctp": int(n or 0)}
def upsert_fee_rate(product: str, fields: dict) -> None:
product = product.lower().strip()
conn = _get_db()
now = datetime.now().isoformat(timespec="seconds")
source = fields.get("source", "manual")
conn.execute(
"""INSERT INTO fee_rates
(product, exchange, mult,
open_fixed, open_ratio,
close_yesterday_fixed, close_yesterday_ratio,
close_today_fixed, close_today_ratio, updated_at, source)
VALUES (?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(product) DO UPDATE SET
exchange=excluded.exchange, mult=excluded.mult,
open_fixed=excluded.open_fixed, open_ratio=excluded.open_ratio,
close_yesterday_fixed=excluded.close_yesterday_fixed,
close_yesterday_ratio=excluded.close_yesterday_ratio,
close_today_fixed=excluded.close_today_fixed,
close_today_ratio=excluded.close_today_ratio,
updated_at=excluded.updated_at,
source=excluded.source""",
(
product,
fields.get("exchange", ""),
int(fields.get("mult") or 10),
float(fields.get("open_fixed") or 0),
float(fields.get("open_ratio") or 0),
float(fields.get("close_yesterday_fixed") or 0),
float(fields.get("close_yesterday_ratio") or 0),
float(fields.get("close_today_fixed") or 0),
float(fields.get("close_today_ratio") or 0),
now,
source,
),
)
conn.commit()
conn.close()
+91
View File
@@ -0,0 +1,91 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""从第三方(AKShare)同步交易所参考手续费,并按倍率写入本地表。"""
import re
from typing import Any, Optional
from modules.core.contract_specs import get_contract_spec
from modules.fees.fee_specs import get_fee_multiplier, upsert_fee_rate
def _to_float(val: Any) -> float:
if val is None:
return 0.0
s = str(val).strip().replace(",", "")
if not s or s in ("-", "None", "nan"):
return 0.0
try:
return float(s)
except ValueError:
return 0.0
def _parse_akshare_row(row: dict, multiplier: float) -> Optional[dict]:
code = str(row.get("合约代码") or row.get("代码") or "").strip()
if not code:
return None
m = re.match(r"^([A-Za-z]+)", code)
if not m:
return None
product = m.group(1).lower()
open_ratio = _to_float(row.get("手续费标准-开仓-万分之")) / 10000.0
open_fixed = _to_float(row.get("手续费标准-开仓-元"))
if open_fixed == 0 and row.get("开仓"):
open_fixed = _to_float(row.get("开仓"))
close_y_ratio = _to_float(row.get("手续费标准-平昨-万分之")) / 10000.0
close_y_fixed = _to_float(row.get("手续费标准-平昨-元"))
if close_y_fixed == 0 and row.get("平昨"):
close_y_fixed = _to_float(row.get("平昨"))
close_t_ratio = _to_float(row.get("手续费标准-平今-万分之")) / 10000.0
close_t_fixed = _to_float(row.get("手续费标准-平今-元"))
if close_t_fixed == 0 and row.get("平今"):
close_t_fixed = _to_float(row.get("平今"))
mult = int(get_contract_spec(code)["mult"])
exchange = str(row.get("交易所名称") or row.get("交易所") or "").strip()
return {
"product": product,
"exchange": exchange,
"mult": mult,
"open_fixed": round(open_fixed * multiplier, 6),
"open_ratio": round(open_ratio * multiplier, 8),
"close_yesterday_fixed": round(close_y_fixed * multiplier, 6),
"close_yesterday_ratio": round(close_y_ratio * multiplier, 8),
"close_today_fixed": round(close_t_fixed * multiplier, 6),
"close_today_ratio": round(close_t_ratio * multiplier, 8),
"source": "akshare",
}
def sync_fees_from_akshare(multiplier: Optional[float] = None) -> tuple[int, str]:
multiplier = multiplier if multiplier is not None else get_fee_multiplier()
try:
import akshare as ak
except ImportError:
return 0, "未安装 akshare,请执行 pip install akshare 后重试,或使用默认费率表"
try:
df = ak.futures_comm_info(symbol="所有")
except Exception as exc:
return 0, f"拉取第三方数据失败: {exc}"
if df is None or df.empty:
return 0, "第三方返回空数据"
seen: set[str] = set()
count = 0
for _, series in df.iterrows():
row = series.to_dict()
parsed = _parse_akshare_row(row, multiplier)
if not parsed or parsed["product"] in seen:
continue
seen.add(parsed["product"])
upsert_fee_rate(parsed["product"], parsed)
count += 1
return count, f"已同步 {count} 个品种(标准费率 × {multiplier}"
+95
View File
@@ -0,0 +1,95 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
"""HTTP routes for fees module."""
from __future__ import annotations
from datetime import date, datetime
from flask import (
Response,
flash,
jsonify,
redirect,
render_template,
request,
send_file,
session,
stream_with_context,
url_for,
)
def register(deps) -> None:
app = deps.app
login_required = deps.login_required
require_nav = deps.require_nav
get_db = deps.get_db
get_setting = deps.get_setting
set_setting = deps.set_setting
fetch_price = deps.fetch_price
send_wechat_msg = deps.send_wechat_msg
touch_stats_cache = deps.touch_stats_cache
get_stats_data = deps.get_stats_data
build_market_quote_payload = deps.build_market_quote_payload
today_str = deps.today_str
expire_old_plans = deps.expire_old_plans
TZ = deps.tz
DB_PATH = deps.db_path
UPLOAD_DIR = deps.upload_dir
OPEN_TYPES = deps.open_types
EXIT_TRIGGERS = deps.exit_triggers
BEHAVIOR_TAGS = deps.behavior_tags
KLINE_PERIODS = deps.kline_periods
KLINE_CUTOFFS = deps.kline_cutoffs
calc_holding_duration = deps.calc_holding_duration
holding_to_minutes = deps.holding_to_minutes
classify_close_result = deps.classify_close_result
calc_rr_ratio = deps.calc_rr_ratio
calc_theoretical_pnl = deps.calc_theoretical_pnl
parse_review_date_filter = deps.parse_review_date_filter
_trading_mode = deps.trading_mode
_ua_is_phone = deps.ua_is_phone
_static_asset_v = deps.static_asset_v
from modules.fees.fee_specs import count_fee_rates_by_source, list_fee_rates_for_ui
@app.route("/fees", methods=["GET", "POST"])
@login_required
@require_nav("fees")
def fees():
from modules.core.trading_context import get_trading_mode
from modules.ctp.ctp_fee_worker import (
schedule_ctp_fee_sync,
get_fee_last_sync,
fees_synced_today,
fee_sync_in_progress,
)
from modules.ctp.vnpy_bridge import ctp_status
mode = get_trading_mode(get_setting)
if request.method == "POST":
action = request.form.get("action")
if action == "sync_ctp":
force = request.form.get("force") == "1"
_, msg = schedule_ctp_fee_sync(
mode,
get_setting=get_setting,
set_setting=set_setting,
force=force,
)
flash(msg)
return redirect(url_for("fees"))
rates = list_fee_rates_for_ui()
fee_counts = count_fee_rates_by_source()
ctp_st = ctp_status(mode)
return render_template(
"fees.html",
rates=rates,
fee_counts=fee_counts,
fee_last_sync=get_fee_last_sync(get_setting),
fee_synced_today=fees_synced_today(get_setting),
fee_sync_running=fee_sync_in_progress(),
ctp_connected=bool(ctp_st.get("connected")),
)
+5
View File
@@ -0,0 +1,5 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
from modules.keys.routes import register
__all__ = ["register"]
+406
View File
@@ -0,0 +1,406 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""关键位监控:5 分钟收盘触发、支阻区微信提醒、箱体/收敛自动单。"""
from __future__ import annotations
import logging
from datetime import datetime, timedelta
from typing import Any, Callable, Optional
from zoneinfo import ZoneInfo
from modules.core.contract_specs import get_contract_spec
from modules.market.kline_chart import fetch_market_klines
logger = logging.getLogger(__name__)
TZ = ZoneInfo("Asia/Shanghai")
TYPE_BOX = "箱体突破"
TYPE_CONV = "收敛突破"
TYPE_ZONE = "关键支阻区"
AUTO_TYPES = (TYPE_BOX, TYPE_CONV)
ZONE_TYPES = (TYPE_ZONE, "关键阻力位", "关键支撑位")
ALERT_MAX_PUSH = 3
ALERT_INTERVAL_SEC = 300
SL_TICK_BUFFER = 2
DEFAULT_BAR_PERIOD = "5m"
PERIOD_MINUTES_MAP = {
"1m": 1, "2m": 2, "3m": 3, "5m": 5, "15m": 15, "30m": 30,
"1h": 60, "2h": 120, "4h": 240, "d": 1440, "1d": 1440,
}
def key_monitor_periods() -> list[dict[str, str]]:
"""关键位监控可选 K 线周期(触发用)。"""
from modules.market.kline_chart import MARKET_PERIODS
allowed = frozenset({"5m", "15m", "30m", "1h", "2h", "4h", "d"})
return [p for p in MARKET_PERIODS if p["key"] in allowed]
def normalize_bar_period(raw: str) -> str:
valid = {p["key"] for p in key_monitor_periods()}
k = (raw or DEFAULT_BAR_PERIOD).strip()
return k if k in valid else DEFAULT_BAR_PERIOD
def bar_period_label(key: str) -> str:
k = normalize_bar_period(key)
for p in key_monitor_periods():
if p["key"] == k:
return p["label"]
return k
def bar_period_minutes(period: str) -> int:
return PERIOD_MINUTES_MAP.get(normalize_bar_period(period), 5)
def normalize_monitor_type(raw: str) -> str:
t = (raw or "").strip()
if t in ("关键阻力位", "关键支撑位"):
return TYPE_ZONE
return t
def is_auto_trade_type(typ: str) -> bool:
return normalize_monitor_type(typ) in AUTO_TYPES
def is_zone_type(typ: str) -> bool:
return normalize_monitor_type(typ) == TYPE_ZONE
def resolve_order_direction(break_side: str, trade_mode: str) -> str:
"""突破方向 + 顺势/反转 → 下单方向。"""
side = (break_side or "").strip().lower()
mode = (trade_mode or "顺势").strip()
if mode == "反转":
return "short" if side == "upper" else "long"
return "long" if side == "upper" else "short"
def break_direction_label(break_side: str) -> tuple[str, str]:
if break_side == "upper":
return "向上突破上沿", "long"
return "向下突破下沿", "short"
def calc_breakout_sl_tp(
*,
sym: str,
direction: str,
entry: float,
bar: dict,
risk_reward: float,
) -> tuple[float, float]:
tick = float(get_contract_spec(sym).get("tick_size") or 1.0)
bar_high = float(bar.get("high") or entry)
bar_low = float(bar.get("low") or entry)
if direction == "long":
sl = bar_low - SL_TICK_BUFFER * tick
risk = max(entry - sl, tick)
tp = entry + risk * risk_reward
else:
sl = bar_high + SL_TICK_BUFFER * tick
risk = max(sl - entry, tick)
tp = entry - risk * risk_reward
return sl, tp
def _parse_bar_time(raw: str) -> Optional[datetime]:
s = (raw or "").strip().replace("T", " ")
if not s:
return None
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M"):
try:
return datetime.strptime(s[:19], fmt).replace(tzinfo=TZ)
except ValueError:
continue
return None
def last_closed_bar(
bars: list[dict],
period_minutes: int = 5,
now: Optional[datetime] = None,
) -> Optional[dict]:
"""取最近一根已收盘 K 线。"""
dnow = now or datetime.now(TZ)
mins = max(1, int(period_minutes or 5))
for bar in reversed(bars or []):
dt = _parse_bar_time(str(bar.get("time") or ""))
if not dt:
continue
bar_end = dt + timedelta(minutes=mins)
if dnow >= bar_end:
return bar
return None
def detect_break_side(close: float, upper: float, lower: float) -> Optional[str]:
if close > upper:
return "upper"
if close < lower:
return "lower"
return None
def fetch_closed_bar(
sym: str,
period: str,
*,
db_path: str,
trading_mode: str,
) -> Optional[dict]:
p = normalize_bar_period(period)
try:
data = fetch_market_klines(
sym,
p,
db_path=db_path,
trading_mode=trading_mode,
prefer_ctp=False,
)
bars = data.get("bars") or []
return last_closed_bar(bars, bar_period_minutes(p))
except Exception as exc:
logger.debug("key monitor kline %s %s: %s", sym, p, exc)
return None
def _now_iso() -> str:
return datetime.now(TZ).strftime("%Y-%m-%d %H:%M:%S")
def archive_monitor(conn, pid: int) -> None:
conn.execute(
"UPDATE key_monitors SET status='archived', archived_at=? WHERE id=?",
(_now_iso(), pid),
)
def format_zone_alert(
row: dict,
*,
break_side: str,
close_price: float,
bar_time: str,
push_index: int,
max_push: int = ALERT_MAX_PUSH,
) -> str:
name = row.get("symbol_name") or row.get("symbol") or ""
upper = float(row.get("upper") or 0)
lower = float(row.get("lower") or 0)
break_label, alert_dir = break_direction_label(break_side)
dir_cn = "多头(long" if alert_dir == "long" else "空头(short"
boundary = upper if break_side == "upper" else lower
lines = [
f"📌 {name} 关键位突破提醒({push_index}/{max_push}",
"",
"🧾 突破概要",
"📌 类型:关键支阻区",
f"⏱ 触发时间:{bar_time}",
f"📊 上沿:{upper:g}|下沿:{lower:g}",
f"💹 触发收盘:{close_price:g}",
f"🎯 {break_label}{dir_cn}",
f"📍 突破价位:{boundary:g}",
"",
"📎 说明",
f"· 人工盯盘,共推送 {max_push} 次(间隔约 {ALERT_INTERVAL_SEC // 60} 分钟)",
"· 推送完毕后本条监控自动结案",
"· 不参与自动开仓",
]
return "\n".join(lines)
def format_auto_breakout_msg(
row: dict,
*,
break_side: str,
direction: str,
entry: float,
sl: float,
tp: float,
lots: int,
bar_time: str,
ok: bool,
detail: str = "",
) -> str:
name = row.get("symbol_name") or row.get("symbol") or ""
typ = normalize_monitor_type(row.get("monitor_type") or "")
trade_mode = row.get("trade_mode") or "顺势"
break_label, _ = break_direction_label(break_side)
dir_cn = "做多" if direction == "long" else "做空"
rr = float(row.get("risk_reward") or 2)
period_label = bar_period_label(row.get("bar_period") or DEFAULT_BAR_PERIOD)
lines = [
f"{'' if ok else ''} {name} {typ}自动单",
f"{period_label} 收盘:{bar_time}",
f"🎯 {break_label} · {trade_mode} · {dir_cn}",
f"💹 入场:{entry:g} 止损:{sl:g} 止盈:{tp:g}(盈亏比 {rr:g}",
f"📦 手数:{lots}",
]
if int(row.get("trailing_be") or 0):
lines.append("🛡 已开启移动保本(达目标盈亏比自动止盈)")
if detail:
lines.append(detail)
return "\n".join(lines)
def _should_send_followup_push(row: dict, now: datetime) -> bool:
count = int(row.get("alert_push_count") or 0)
if count <= 0 or count >= ALERT_MAX_PUSH:
return False
last_raw = (row.get("alert_last_push_at") or "").strip()
if not last_raw:
return True
try:
last = datetime.fromisoformat(last_raw.replace("Z", "")).replace(tzinfo=TZ)
except ValueError:
return True
return (now - last).total_seconds() >= ALERT_INTERVAL_SEC
def _record_zone_push(conn, pid: int, *, break_side: str, bar_time: str, now_iso: str) -> int:
row = conn.execute(
"SELECT alert_push_count FROM key_monitors WHERE id=?", (pid,),
).fetchone()
count = int(row["alert_push_count"] or 0) + 1
conn.execute(
"""UPDATE key_monitors SET
alert_push_count=?, alert_last_push_at=?, alert_break_side=?,
breakout_bar_time=?, upper_triggered=?, lower_triggered=?
WHERE id=?""",
(
count,
now_iso,
break_side,
bar_time,
1 if break_side == "upper" else 0,
1 if break_side == "lower" else 0,
pid,
),
)
return count
def _handle_zone_alert(
conn,
row: dict,
*,
break_side: str,
bar: dict,
send_wechat: Callable[[str], None],
) -> None:
pid = int(row["id"])
now_iso = _now_iso()
bar_time = str(bar.get("time") or "")[:19]
close_price = float(bar.get("close") or 0)
bar_key = bar_time
last_bar = (row.get("last_trigger_bar") or "").strip()
if last_bar == bar_key and int(row.get("alert_push_count") or 0) > 0:
return
push_n = _record_zone_push(conn, pid, break_side=break_side, bar_time=bar_time, now_iso=now_iso)
conn.execute(
"UPDATE key_monitors SET last_trigger_bar=?, alert_close_price=? WHERE id=?",
(bar_key, close_price, pid),
)
send_wechat(format_zone_alert(
row, break_side=break_side, close_price=close_price, bar_time=bar_time, push_index=push_n,
))
if push_n >= ALERT_MAX_PUSH:
archive_monitor(conn, pid)
def run_key_monitor_check(
conn,
*,
db_path: str,
get_trading_mode_fn: Callable[[], str],
send_wechat: Callable[[str], None],
execute_breakout_fn: Callable[[Any, dict, str], tuple[bool, str]] | None = None,
) -> None:
"""扫描 active 关键位监控(5m 收盘触发)。"""
rows = conn.execute(
"SELECT * FROM key_monitors WHERE status='active' OR status IS NULL"
).fetchall()
mode = get_trading_mode_fn()
now = datetime.now(TZ)
for r in rows:
row = dict(r)
pid = int(row["id"])
sym = (row.get("symbol") or "").strip()
typ = normalize_monitor_type(row.get("monitor_type") or "")
if not sym:
continue
try:
upper = float(row.get("upper") or 0)
lower = float(row.get("lower") or 0)
except (TypeError, ValueError):
continue
if upper <= lower:
continue
alert_count = int(row.get("alert_push_count") or 0)
if is_zone_type(typ) and alert_count > 0:
if alert_count >= ALERT_MAX_PUSH:
archive_monitor(conn, pid)
continue
if _should_send_followup_push(row, now):
break_side = (row.get("alert_break_side") or "upper").strip()
bar_time = (row.get("breakout_bar_time") or row.get("last_trigger_bar") or "")[:19]
close_price = float(row.get("alert_close_price") or 0)
if close_price <= 0:
close_price = float(row.get("upper") if break_side == "upper" else row.get("lower") or 0)
push_n = _record_zone_push(
conn, pid, break_side=break_side, bar_time=bar_time, now_iso=_now_iso(),
)
send_wechat(format_zone_alert(
row, break_side=break_side, close_price=close_price, bar_time=bar_time, push_index=push_n,
))
if push_n >= ALERT_MAX_PUSH:
archive_monitor(conn, pid)
continue
bar_period = normalize_bar_period(row.get("bar_period") or DEFAULT_BAR_PERIOD)
bar = fetch_closed_bar(sym, bar_period, db_path=db_path, trading_mode=mode)
if not bar:
continue
bar_time = str(bar.get("time") or "")[:19]
if not bar_time:
continue
if (row.get("last_trigger_bar") or "").strip() == bar_time:
continue
try:
close_price = float(bar.get("close") or 0)
except (TypeError, ValueError):
continue
break_side = detect_break_side(close_price, upper, lower)
if not break_side:
continue
if is_zone_type(typ):
_handle_zone_alert(conn, row, break_side=break_side, bar=bar, send_wechat=send_wechat)
continue
if is_auto_trade_type(typ):
if not execute_breakout_fn:
logger.warning("key monitor auto trade skipped: no executor")
continue
ok, detail = execute_breakout_fn(conn, row, bar, break_side)
conn.execute(
"UPDATE key_monitors SET last_trigger_bar=?, breakout_bar_time=?, alert_break_side=? WHERE id=?",
(bar_time, bar_time, break_side, pid),
)
if ok:
archive_monitor(conn, pid)
+185
View File
@@ -0,0 +1,185 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
"""HTTP routes for keys module."""
from __future__ import annotations
from datetime import date, datetime
from flask import (
Response,
flash,
jsonify,
redirect,
render_template,
request,
send_file,
session,
stream_with_context,
url_for,
)
def register(deps) -> None:
app = deps.app
login_required = deps.login_required
require_nav = deps.require_nav
get_db = deps.get_db
get_setting = deps.get_setting
set_setting = deps.set_setting
fetch_price = deps.fetch_price
send_wechat_msg = deps.send_wechat_msg
touch_stats_cache = deps.touch_stats_cache
get_stats_data = deps.get_stats_data
build_market_quote_payload = deps.build_market_quote_payload
today_str = deps.today_str
expire_old_plans = deps.expire_old_plans
TZ = deps.tz
DB_PATH = deps.db_path
UPLOAD_DIR = deps.upload_dir
OPEN_TYPES = deps.open_types
EXIT_TRIGGERS = deps.exit_triggers
BEHAVIOR_TAGS = deps.behavior_tags
KLINE_PERIODS = deps.kline_periods
KLINE_CUTOFFS = deps.kline_cutoffs
calc_holding_duration = deps.calc_holding_duration
holding_to_minutes = deps.holding_to_minutes
classify_close_result = deps.classify_close_result
calc_rr_ratio = deps.calc_rr_ratio
calc_theoretical_pnl = deps.calc_theoretical_pnl
parse_review_date_filter = deps.parse_review_date_filter
_trading_mode = deps.trading_mode
_ua_is_phone = deps.ua_is_phone
_static_asset_v = deps.static_asset_v
@app.route("/api/key_prices")
@login_required
def api_key_prices():
"""关键位监控列表:批量现价与距上/下沿距离。"""
conn = get_db()
rows = conn.execute(
"SELECT id, symbol, market_code, sina_code, upper, lower "
"FROM key_monitors WHERE status='active' OR status IS NULL"
).fetchall()
conn.close()
out = []
for r in rows:
sym = r["symbol"]
market = r["market_code"] or ""
sina = r["sina_code"] or ""
upper = float(r["upper"])
lower = float(r["lower"])
price = fetch_price(sym, market, sina)
dist_upper = None
dist_lower = None
if price is not None:
dist_upper = round(upper - price, 2)
dist_lower = round(price - lower, 2)
out.append({
"id": r["id"],
"price": price,
"dist_upper": dist_upper,
"dist_lower": dist_lower,
})
return jsonify(out)
@app.route("/keys")
@login_required
def keys():
from modules.keys.key_monitor_lib import key_monitor_periods
conn = get_db()
key_list = conn.execute(
"SELECT * FROM key_monitors WHERE status='active' OR status IS NULL ORDER BY id DESC"
).fetchall()
history = conn.execute(
"SELECT * FROM key_monitors WHERE status='archived' ORDER BY archived_at DESC LIMIT 100"
).fetchall()
conn.close()
return render_template(
"keys.html",
keys=key_list,
history=history,
key_periods=key_monitor_periods(),
)
@app.route("/add_key", methods=["POST"])
@login_required
def add_key():
d = request.form
symbol = d.get("symbol", "").strip()
symbol_name = d.get("symbol_name", "").strip()
market_code = d.get("market_code", "").strip()
sina_code = d.get("sina_code", "").strip()
monitor_type = (d.get("type") or "").strip()
if not symbol or not market_code:
flash("请从下拉列表选择品种(同花顺合约代码)")
return redirect(url_for("keys"))
try:
upper = float(d.get("upper") or 0)
lower = float(d.get("lower") or 0)
except (TypeError, ValueError):
flash("上沿/下沿价格无效")
return redirect(url_for("keys"))
if upper <= lower:
flash("上沿必须大于下沿")
return redirect(url_for("keys"))
trade_mode = (d.get("trade_mode") or "顺势").strip()
if trade_mode not in ("顺势", "反转"):
trade_mode = "顺势"
try:
risk_reward = float(d.get("risk_reward") or 2)
except (TypeError, ValueError):
risk_reward = 2.0
risk_reward = max(0.5, min(10.0, risk_reward))
trailing_be = 1 if d.get("trailing_be") else 0
if trailing_be and risk_reward < 3:
risk_reward = 3.0
from modules.keys.key_monitor_lib import normalize_bar_period
bar_period = normalize_bar_period(d.get("bar_period") or "5m")
direction = (d.get("direction") or "").strip().lower()
if monitor_type == "箱体突破":
if direction not in ("long", "short"):
flash("箱体突破须选择上方向(做多/做空)")
return redirect(url_for("keys"))
else:
direction = ""
conn = get_db()
conn.execute(
"""INSERT INTO key_monitors
(symbol, symbol_name, market_code, sina_code, monitor_type, direction,
upper, lower, trade_mode, risk_reward, trailing_be, bar_period)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""",
(
symbol, symbol_name, market_code, sina_code, monitor_type, direction,
upper, lower, trade_mode, risk_reward, trailing_be, bar_period,
),
)
conn.commit()
conn.close()
flash("关键位监控已添加")
return redirect(url_for("keys"))
@app.route("/add_position", methods=["POST"])
@login_required
def add_position():
flash("持仓由策略交易或 CTP 自动同步,无需手工录入")
return redirect(url_for("positions"))
@app.route("/del_key/<int:pid>")
@login_required
def del_key(pid):
conn = get_db()
conn.execute(
"UPDATE key_monitors SET status='archived', archived_at=? WHERE id=?",
(datetime.now(TZ).isoformat(), pid),
)
conn.commit()
conn.close()
flash("已移入监控历史")
return redirect(url_for("keys"))
+10
View File
@@ -0,0 +1,10 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
from modules.market.routes import register
def start_workers(deps) -> None:
deps.start_background_threads()
__all__ = ["register", "start_workers"]
+558
View File
@@ -0,0 +1,558 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""复盘 K 线:新浪拉取 + matplotlib 生成截图。"""
import json
import logging
import os
import re
import sqlite3
from datetime import datetime
from typing import Optional
from zoneinfo import ZoneInfo
import requests
from modules.core.symbols import ths_to_codes
from modules.core.db_conn import connect_db
from modules.market.kline_store import ensure_kline_tables, get_cached_entry, save_bars
logger = logging.getLogger(__name__)
TZ = ZoneInfo("Asia/Shanghai")
# CTP tick 聚合 bar 少于此数时,用新浪历史补齐走势
MIN_CTP_KLINE_BARS = 15
PERIOD_MINUTES = {
"1m": "1",
"3m": "3",
"5m": "5",
"15m": "15",
"30m": "30",
"1h": "60",
"4h": "240",
}
MARKET_PERIODS = [
{"key": "timeshare", "label": "分时"},
{"key": "1m", "label": "1分"},
{"key": "2m", "label": "2分"},
{"key": "5m", "label": "5分"},
{"key": "15m", "label": "15分"},
{"key": "1h", "label": "1小时"},
{"key": "2h", "label": "2小时"},
{"key": "4h", "label": "4小时"},
{"key": "d", "label": "日线"},
{"key": "w", "label": "周线"},
]
def ths_to_sina_chart_symbol(symbol: str) -> Optional[str]:
"""ag2608 -> AG2608(新浪 K 线接口合约代码)。"""
code = (symbol or "").strip()
if not code:
return None
codes = ths_to_codes(code)
if codes:
sina = codes.get("sina_code", "")
if sina.startswith("nf_"):
return sina[3:]
if sina.startswith("CFF_RE_"):
return sina[7:]
ths = codes.get("ths_code", "")
return ths.upper() if ths else None
m = re.match(r"^([A-Za-z]+)(\d+)$", code)
if m:
return m.group(1).upper() + m.group(2)
return None
def _parse_jsonp(text: str) -> Optional[list]:
m = re.search(r"\((.*)\)\s*;?\s*$", text.strip(), re.DOTALL)
if not m:
return None
try:
data = json.loads(m.group(1))
return data if isinstance(data, list) else None
except json.JSONDecodeError:
return None
def fetch_sina_klines(symbol: str, period: str) -> list:
"""拉取新浪期货 K 线(原始 bar 列表)。"""
chart_sym = ths_to_sina_chart_symbol(symbol)
if not chart_sym:
return []
p = (period or "").lower()
if p in ("1d", "d"):
return _fetch_sina_daily(chart_sym)
if p == "w":
return _weekly_from_daily(_fetch_sina_daily(chart_sym))
if p == "timeshare":
bars = _fetch_few_min_line(chart_sym, "1")
return _timeshare_session(bars)
if p == "2m":
return _aggregate_bars(_fetch_few_min_line(chart_sym, "1"), 2)
if p == "2h":
return _aggregate_bars(_fetch_few_min_line(chart_sym, "60"), 2)
typ = PERIOD_MINUTES.get(p)
if typ:
return _fetch_few_min_line(chart_sym, typ)
return []
def _fetch_few_min_line(chart_sym: str, typ: str) -> list:
ts = datetime.now(TZ).strftime("%Y%m%d%H%M%S")
url = (
"https://stock2.finance.sina.com.cn/futures/api/jsonp.php/"
f"var_{chart_sym}_{typ}_{ts}=/InnerFuturesNewService.getFewMinLine"
f"?symbol={chart_sym}&type={typ}"
)
try:
resp = requests.get(
url,
timeout=20,
headers={"Referer": "https://finance.sina.com.cn"},
)
bars = _parse_jsonp(resp.text)
return _normalize_bars(bars or [])
except Exception as exc:
logger.warning("fetch kline failed %s %s: %s", chart_sym, typ, exc)
return []
def _normalize_bars(raw: list) -> list:
out = []
for row in raw:
if isinstance(row, list) and len(row) >= 5:
out.append({
"d": str(row[0]),
"o": float(row[1]),
"h": float(row[2]),
"l": float(row[3]),
"c": float(row[4]),
"v": float(row[5]) if len(row) > 5 and row[5] else 0.0,
})
elif isinstance(row, dict) and row.get("d"):
out.append({
"d": str(row["d"]),
"o": float(row.get("o", 0) or 0),
"h": float(row.get("h", 0) or 0),
"l": float(row.get("l", 0) or 0),
"c": float(row.get("c", 0) or 0),
"v": float(row.get("v", 0) or 0),
})
return out
def _aggregate_bars(bars: list, n: int) -> list:
if n <= 1 or not bars:
return bars
out = []
chunk: list = []
for bar in bars:
chunk.append(bar)
if len(chunk) >= n:
out.append(_merge_bars(chunk))
chunk = []
if chunk:
out.append(_merge_bars(chunk))
return out
def _merge_bars(chunk: list) -> dict:
return {
"d": chunk[0]["d"],
"o": chunk[0]["o"],
"h": max(b["h"] for b in chunk),
"l": min(b["l"] for b in chunk),
"c": chunk[-1]["c"],
"v": sum(b.get("v", 0) for b in chunk),
}
def _merge_kline_bars(history: list, live: list) -> list:
"""新浪历史 + CTP 实时尾部(去重叠)。"""
if not history:
return list(live or [])
if not live:
return list(history)
first_live = _bar_datetime(live[0])
if not first_live:
return history + live
trimmed = []
for bar in history:
dt = _bar_datetime(bar)
if dt and dt < first_live:
trimmed.append(bar)
merged = trimmed + list(live)
return merged if merged else list(history)
def _weekly_from_daily(daily: list) -> list:
if not daily:
return []
buckets: dict[tuple, list] = {}
for bar in daily:
dt = _bar_datetime(bar)
if not dt:
continue
iso = dt.isocalendar()
key = (iso[0], iso[1])
buckets.setdefault(key, []).append(bar)
out = []
for key in sorted(buckets.keys()):
chunk = buckets[key]
out.append(_merge_bars(chunk))
out[-1]["d"] = chunk[-1]["d"]
return out
def _timeshare_session(bars: list) -> list:
if not bars:
return []
today = datetime.now(TZ).date()
session = []
for bar in bars:
dt = _bar_datetime(bar)
if dt and dt.date() == today:
session.append(bar)
if session:
return session[-480:]
return bars[-480:]
def bars_to_api(bars: list) -> list[dict]:
"""转为前端图表 JSON(去重、排序、数值规范化)。"""
result: list[dict] = []
seen: dict[int, dict] = {}
for bar in bars:
dt = _bar_datetime(bar)
ts = int(dt.timestamp() * 1000) if dt else None
try:
o = float(bar.get("o") or 0)
h = float(bar.get("h") or o)
l = float(bar.get("l") or o)
c = float(bar.get("c") or o)
v = float(bar.get("v") or 0)
except (TypeError, ValueError):
continue
if h < l:
h, l = l, h
h = max(h, o, c)
l = min(l, o, c)
row = {
"time": bar["d"],
"timestamp": ts,
"open": o,
"high": h,
"low": l,
"close": c,
"volume": v,
}
if ts is not None:
seen[ts] = row
else:
result.append(row)
if seen:
result = [seen[k] for k in sorted(seen.keys())]
return result
def fetch_market_klines(
symbol: str,
period: str,
db_path: Optional[str] = None,
force_remote: bool = False,
*,
trading_mode: Optional[str] = None,
prefer_ctp: bool = False,
) -> dict:
chart_sym = ths_to_sina_chart_symbol(symbol)
p = (period or "15m").lower()
if p == "timeshare":
chart_type = "line"
else:
chart_type = "candle"
bars: list = []
source = "remote"
cached_at = None
ctp_connected = False
ctp_bars: list = []
if prefer_ctp:
try:
from modules.ctp.ctp_kline import fetch_ctp_klines
from modules.ctp.vnpy_bridge import ctp_status
mode = trading_mode
if not mode:
try:
from app import get_setting
from modules.core.trading_context import get_trading_mode
mode = get_trading_mode(get_setting)
except Exception:
mode = "simulation"
ctp_connected = bool(ctp_status(mode).get("connected"))
if ctp_connected:
ctp_bars = fetch_ctp_klines(symbol, p, mode) or []
except Exception as exc:
logger.debug("ctp kline fetch failed %s %s: %s", symbol, p, exc)
need_sina = force_remote or not prefer_ctp or not ctp_bars or len(ctp_bars) < MIN_CTP_KLINE_BARS
if ctp_bars and len(ctp_bars) >= MIN_CTP_KLINE_BARS:
bars = ctp_bars
source = "ctp"
if not bars and db_path and chart_sym and not force_remote and need_sina:
try:
conn = connect_db(db_path)
cached = get_cached_entry(conn, chart_sym, p)
conn.close()
if cached and cached.get("fresh"):
bars = cached["bars"]
source = "local"
cached_at = cached.get("updated_at")
except Exception as exc:
logger.warning("kline cache read failed %s %s: %s", chart_sym, p, exc)
if not bars or len(ctp_bars) < MIN_CTP_KLINE_BARS or not prefer_ctp:
remote_bars = fetch_sina_klines(symbol, p)
if remote_bars:
if prefer_ctp and ctp_bars and ctp_connected:
bars = _merge_kline_bars(remote_bars, ctp_bars)
source = "ctp+remote"
else:
bars = remote_bars
source = "remote"
if db_path and chart_sym and not ctp_connected:
try:
conn = connect_db(db_path)
ensure_kline_tables(conn)
save_bars(conn, chart_sym, p, remote_bars)
meta = conn.execute(
"SELECT updated_at FROM kline_meta WHERE chart_symbol=? AND period=?",
(chart_sym, p),
).fetchone()
conn.close()
cached_at = meta[0] if meta else None
except Exception as exc:
logger.warning("kline cache write failed %s %s: %s", chart_sym, p, exc)
elif not bars and db_path and chart_sym:
try:
conn = connect_db(db_path)
cached = get_cached_entry(conn, chart_sym, p)
conn.close()
if cached and cached.get("bars"):
bars = cached["bars"]
source = "local"
cached_at = cached.get("updated_at")
except Exception as exc:
logger.warning("kline cache fallback failed %s %s: %s", chart_sym, p, exc)
api_bars = bars_to_api(bars)
prev_close = None
if len(api_bars) >= 2:
prev_close = api_bars[-2]["close"]
return {
"symbol": symbol,
"chart_symbol": chart_sym,
"period": p,
"chart_type": chart_type,
"count": len(bars),
"bars": api_bars,
"prev_close": prev_close,
"source": source,
"cached_at": cached_at,
"ctp_connected": ctp_connected,
}
def _fetch_sina_daily(chart_sym: str) -> list:
url = (
"https://stock2.finance.sina.com.cn/futures/api/json.php/"
f"IndexService.getInnerFuturesDailyKLine?symbol={chart_sym}"
)
try:
resp = requests.get(url, timeout=20, headers={"Referer": "https://finance.sina.com.cn"})
raw = resp.json()
if raw and isinstance(raw, list):
bars = _normalize_bars(raw)
if bars:
return bars
except Exception as exc:
logger.warning("fetch daily kline failed %s: %s", chart_sym, exc)
return _daily_from_minutes(chart_sym)
def _daily_from_minutes(chart_sym: str) -> list:
"""合约日线接口无数据时,由 60 分钟 K 线按日合成。"""
bars_60 = _fetch_few_min_line(chart_sym, "60")
if not bars_60:
bars_60 = _fetch_few_min_line(chart_sym, "240")
buckets: dict[str, list] = {}
for bar in bars_60:
dt = _bar_datetime(bar)
if not dt:
continue
key = dt.strftime("%Y-%m-%d")
buckets.setdefault(key, []).append(bar)
out = []
for day in sorted(buckets.keys()):
chunk = buckets[day]
merged = _merge_bars(chunk)
merged["d"] = day + " 15:00:00"
out.append(merged)
return out
def _parse_dt(value: str) -> Optional[datetime]:
if not value:
return None
v = value.strip().replace("T", " ")
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M"):
try:
return datetime.strptime(v, fmt).replace(tzinfo=TZ)
except ValueError:
continue
try:
return datetime.fromisoformat(value.strip()).replace(tzinfo=TZ)
except ValueError:
return None
def _bar_datetime(bar: dict) -> Optional[datetime]:
d = bar.get("d")
if not d:
return None
try:
return datetime.strptime(d, "%Y-%m-%d %H:%M:%S").replace(tzinfo=TZ)
except ValueError:
return None
def _select_bars(
bars: list,
cutoff: datetime,
count: int,
) -> list:
filtered = []
for bar in bars:
dt = _bar_datetime(bar)
if dt and dt <= cutoff:
filtered.append(bar)
if not filtered:
filtered = bars
if count > 0 and len(filtered) > count:
filtered = filtered[-count:]
return filtered
def generate_review_kline_chart(
symbol: str,
periods: list[str],
count: int,
cutoff_label: str,
open_time: str,
close_time: str,
entry_price: Optional[float],
stop_loss: Optional[float],
take_profit: Optional[float],
close_price: Optional[float],
upload_dir: str,
) -> Optional[str]:
"""生成双周期 K 线复盘图,返回 uploads 目录下的文件名。"""
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
now = datetime.now(TZ)
if cutoff_label == "开仓时间":
cutoff = _parse_dt(open_time) or now
elif cutoff_label == "当前时间":
cutoff = now
else:
cutoff = _parse_dt(close_time) or now
open_dt = _parse_dt(open_time)
close_dt = _parse_dt(close_time)
valid_periods = [p for p in periods if p]
if not valid_periods:
valid_periods = ["15m", "1h"]
fig, axes = plt.subplots(
len(valid_periods), 1,
figsize=(14, 4.5 * len(valid_periods)),
facecolor="#0a0a10",
squeeze=False,
)
plotted = False
for idx, period in enumerate(valid_periods):
ax = axes[idx, 0]
bars = fetch_sina_klines(symbol, period)
bars = _select_bars(bars, cutoff, count)
if not bars:
ax.set_facecolor("#12121a")
ax.text(0.5, 0.5, f"No {period} data", ha="center", va="center", color="#888")
ax.set_xticks([])
ax.set_yticks([])
continue
times = [_bar_datetime(b) for b in bars]
closes = [float(b["c"]) for b in bars]
highs = [float(b["h"]) for b in bars]
lows = [float(b["l"]) for b in bars]
ax.set_facecolor("#12121a")
ax.plot(times, closes, color="#4cc2ff", linewidth=1.2)
ax.fill_between(
times, lows, highs,
color="#4cc2ff", alpha=0.12,
)
levels = [
(entry_price, "#eac147", "Entry"),
(stop_loss, "#ff6666", "SL"),
(take_profit, "#4cd97f", "TP"),
(close_price, "#c4c4ff", "Close"),
]
for price, color, label in levels:
if price is not None:
ax.axhline(price, color=color, linewidth=0.9, linestyle="--", alpha=0.85)
ax.text(times[-1], price, label, color=color, fontsize=8, va="bottom")
if open_dt:
ax.axvline(open_dt, color="#888", linewidth=0.8, linestyle=":", alpha=0.7)
if close_dt:
ax.axvline(close_dt, color="#aaa", linewidth=0.8, linestyle=":", alpha=0.7)
chart_sym = ths_to_sina_chart_symbol(symbol) or symbol
ax.set_title(f"{chart_sym} {period}", color="#eaeaea", fontsize=11, pad=8)
ax.tick_params(colors="#888", labelsize=8)
for spine in ax.spines.values():
spine.set_color("#2e2e45")
ax.xaxis.set_major_formatter(mdates.DateFormatter("%m-%d %H:%M"))
ax.grid(True, color="#1e1e30", linewidth=0.5)
plotted = True
if not plotted:
plt.close(fig)
return None
fig.tight_layout()
ts = datetime.now(TZ).strftime("%Y%m%d%H%M%S")
chart_sym = ths_to_sina_chart_symbol(symbol) or "chart"
filename = f"{ts}_kline_{chart_sym}.png"
path = os.path.join(upload_dir, filename)
fig.savefig(path, dpi=120, facecolor=fig.get_facecolor())
plt.close(fig)
return filename
+175
View File
@@ -0,0 +1,175 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""K 线本地 SQLite 缓存。"""
from __future__ import annotations
import sqlite3
from datetime import datetime, timedelta
from typing import Optional
from zoneinfo import ZoneInfo
TZ = ZoneInfo("Asia/Shanghai")
REFRESH_SECONDS = {
"timeshare": 30,
"1m": 30,
"2m": 30,
"5m": 60,
"15m": 60,
"1h": 120,
"2h": 120,
"4h": 180,
"d": 300,
"w": 600,
}
def ensure_kline_tables(conn: sqlite3.Connection) -> None:
conn.execute(
"""CREATE TABLE IF NOT EXISTS kline_bars (
chart_symbol TEXT NOT NULL,
period TEXT NOT NULL,
bar_time TEXT NOT NULL,
open REAL NOT NULL,
high REAL NOT NULL,
low REAL NOT NULL,
close REAL NOT NULL,
volume REAL DEFAULT 0,
updated_at TEXT NOT NULL,
PRIMARY KEY (chart_symbol, period, bar_time)
)"""
)
conn.execute(
"""CREATE TABLE IF NOT EXISTS kline_meta (
chart_symbol TEXT NOT NULL,
period TEXT NOT NULL,
bar_count INTEGER DEFAULT 0,
last_bar_time TEXT,
updated_at TEXT NOT NULL,
PRIMARY KEY (chart_symbol, period)
)"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_kline_bars_sym_period "
"ON kline_bars(chart_symbol, period, bar_time)"
)
conn.commit()
def _parse_updated_at(value: str) -> Optional[datetime]:
if not value:
return None
try:
return datetime.fromisoformat(value.strip()).replace(tzinfo=TZ)
except ValueError:
return None
def is_cache_fresh(period: str, updated_at: str) -> bool:
dt = _parse_updated_at(updated_at)
if not dt:
return False
ttl = REFRESH_SECONDS.get((period or "").lower(), 60)
return datetime.now(TZ) - dt < timedelta(seconds=ttl)
def load_bars(conn: sqlite3.Connection, chart_symbol: str, period: str) -> list[dict]:
rows = conn.execute(
"""SELECT bar_time, open, high, low, close, volume
FROM kline_bars
WHERE chart_symbol=? AND period=?
ORDER BY bar_time ASC""",
(chart_symbol, period),
).fetchall()
return [
{
"d": row[0],
"o": float(row[1]),
"h": float(row[2]),
"l": float(row[3]),
"c": float(row[4]),
"v": float(row[5] or 0),
}
for row in rows
]
def load_meta(conn: sqlite3.Connection, chart_symbol: str, period: str) -> Optional[dict]:
row = conn.execute(
"SELECT bar_count, last_bar_time, updated_at FROM kline_meta "
"WHERE chart_symbol=? AND period=?",
(chart_symbol, period),
).fetchone()
if not row:
return None
return {
"bar_count": row[0],
"last_bar_time": row[1],
"updated_at": row[2],
}
def save_bars(conn: sqlite3.Connection, chart_symbol: str, period: str, bars: list[dict]) -> int:
if not bars:
return 0
ensure_kline_tables(conn)
now = datetime.now(TZ).isoformat(timespec="seconds")
for bar in bars:
conn.execute(
"""INSERT INTO kline_bars
(chart_symbol, period, bar_time, open, high, low, close, volume, updated_at)
VALUES (?,?,?,?,?,?,?,?,?)
ON CONFLICT(chart_symbol, period, bar_time) DO UPDATE SET
open=excluded.open,
high=excluded.high,
low=excluded.low,
close=excluded.close,
volume=excluded.volume,
updated_at=excluded.updated_at""",
(
chart_symbol,
period,
str(bar["d"]),
float(bar["o"]),
float(bar["h"]),
float(bar["l"]),
float(bar["c"]),
float(bar.get("v") or 0),
now,
),
)
last_time = str(bars[-1]["d"])
conn.execute(
"""INSERT INTO kline_meta (chart_symbol, period, bar_count, last_bar_time, updated_at)
VALUES (?,?,?,?,?)
ON CONFLICT(chart_symbol, period) DO UPDATE SET
bar_count=excluded.bar_count,
last_bar_time=excluded.last_bar_time,
updated_at=excluded.updated_at""",
(chart_symbol, period, len(bars), last_time, now),
)
conn.commit()
return len(bars)
def get_cached_entry(
conn: sqlite3.Connection,
chart_symbol: str,
period: str,
) -> Optional[dict]:
if not chart_symbol:
return None
ensure_kline_tables(conn)
meta = load_meta(conn, chart_symbol, period)
bars = load_bars(conn, chart_symbol, period)
if not bars:
return None
updated_at = meta["updated_at"] if meta else ""
return {
"bars": bars,
"updated_at": updated_at,
"fresh": is_cache_fresh(period, updated_at),
}
+139
View File
@@ -0,0 +1,139 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""K 线 SSE 推送与后台刷新。"""
from __future__ import annotations
import json
import logging
import queue
import threading
import time
from dataclasses import dataclass, field
from datetime import datetime
from typing import Callable, Optional
from zoneinfo import ZoneInfo
from modules.market.kline_chart import fetch_market_klines, ths_to_sina_chart_symbol
from modules.market.kline_store import is_cache_fresh, load_meta, ensure_kline_tables
from modules.market.market_sessions import is_trading_session
logger = logging.getLogger(__name__)
TZ = ZoneInfo("Asia/Shanghai")
FAST_PERIODS = frozenset({
"timeshare", "1m", "2m", "5m", "15m", "1h", "2h", "4h",
})
def sse_format(event: str, data: dict) -> str:
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False, default=str)}\n\n"
@dataclass
class KlineSubscription:
symbol: str
period: str
market_code: str = ""
sina_code: str = ""
queue: queue.Queue = field(default_factory=queue.Queue)
class KlineStreamHub:
def __init__(self):
self._lock = threading.Lock()
self._subs: list[KlineSubscription] = []
def subscribe(
self,
symbol: str,
period: str,
market_code: str = "",
sina_code: str = "",
) -> KlineSubscription:
sub = KlineSubscription(
symbol=symbol.strip(),
period=(period or "15m").strip().lower(),
market_code=market_code.strip(),
sina_code=sina_code.strip(),
)
with self._lock:
self._subs.append(sub)
return sub
def unsubscribe(self, sub: KlineSubscription) -> None:
with self._lock:
try:
self._subs.remove(sub)
except ValueError:
pass
def _snapshot_subs(self) -> list[KlineSubscription]:
with self._lock:
return list(self._subs)
def publish(self, sub: KlineSubscription, event: str, data: dict) -> None:
try:
sub.queue.put_nowait({"event": event, "data": data})
except queue.Full:
pass
def _should_refresh(self, sub: KlineSubscription, db_path: str) -> bool:
chart_sym = ths_to_sina_chart_symbol(sub.symbol)
if not chart_sym:
return False
if is_trading_session() and sub.period in FAST_PERIODS:
return True
try:
from modules.core.db_conn import connect_db
conn = connect_db(db_path)
ensure_kline_tables(conn)
meta = load_meta(conn, chart_sym, sub.period)
conn.close()
if not meta:
return True
return not is_cache_fresh(sub.period, meta.get("updated_at", ""))
except Exception as exc:
logger.warning("kline refresh check failed: %s", exc)
return True
def worker_loop(
self,
db_path: str,
quote_fn: Callable[..., dict],
get_mode_fn: Optional[Callable[[], str]] = None,
) -> None:
while True:
try:
subs = self._snapshot_subs()
for sub in subs:
if not self._should_refresh(sub, db_path):
continue
try:
kline_data = fetch_market_klines(
sub.symbol,
sub.period,
db_path,
force_remote=True,
prefer_ctp=False,
)
if kline_data.get("bars"):
self.publish(sub, "kline", kline_data)
quote_data = quote_fn(
sub.symbol, sub.market_code, sub.sina_code,
)
if quote_data:
self.publish(sub, "quote", quote_data)
except Exception as exc:
logger.warning(
"kline stream refresh %s %s: %s",
sub.symbol, sub.period, exc,
)
except Exception as exc:
logger.warning("kline stream worker: %s", exc)
time.sleep(1)
kline_hub = KlineStreamHub()
+248
View File
@@ -0,0 +1,248 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""
行情拉取默认新浪免费普通用户可用
同花顺 iFinD HTTP 仅面向机构用户需单独申请 token可选开启
"""
import os
import time
import json
import logging
from typing import Optional
import requests
logger = logging.getLogger(__name__)
THS_TOKEN_URL = "https://quantapi.51ifind.com/api/v1/get_access_token"
THS_QUOTE_URL = "https://quantapi.51ifind.com/api/v1/real_time_quotation"
# iFinD HTTP 期货交易所后缀
THS_EX_SUFFIX = {
"SHFE": "SHFE",
"DCE": "DCE",
"CZCE": "CZCE",
"CFFEX": "CFFEX",
"INE": "INE",
}
_token_cache: dict = {"token": "", "expires": 0.0, "refresh": ""}
def _quote_source() -> str:
return os.getenv("QUOTE_SOURCE", "sina").strip().lower()
def _has_ths_token() -> bool:
return bool(_get_refresh_token())
def get_quote_source_label(*, ctp_connected: bool = False) -> str:
"""界面展示用行情源说明。"""
if ctp_connected:
return "CTP 柜台(已连接)"
source = _quote_source()
if source == "sina":
return "新浪(CTP 未连接时备用)"
if source == "ths":
return "同花顺 iFinD" if _has_ths_token() else "同花顺(未配置 token"
if _has_ths_token():
return "同花顺优先,失败回退新浪"
return "新浪(CTP 未连接时备用)"
def _sina_headers() -> dict:
return {"Referer": "https://finance.sina.com.cn"}
def _parse_sina_futures_quote(parts: list) -> Optional[dict]:
"""解析新浪 nf_/CFF_RE_ 期货行情字段。"""
if len(parts) < 9:
return None
price = None
for idx in (8, 7, 6, 5):
if len(parts) > idx and parts[idx]:
try:
val = float(parts[idx])
if val > 0:
price = val
break
except ValueError:
pass
if price is None:
price = 0.0
open_interest = 0.0
volume = 0.0
if len(parts) > 13 and parts[13]:
try:
open_interest = float(parts[13])
except ValueError:
pass
if len(parts) > 14 and parts[14]:
try:
volume = float(parts[14])
except ValueError:
pass
prev_close = None
if len(parts) > 9 and parts[9]:
try:
prev_close = float(parts[9])
except ValueError:
pass
return {
"name": parts[0],
"price": price,
"volume": volume,
"open_interest": open_interest,
"prev_close": prev_close,
}
def _fetch_sina_raw(sina_code: str) -> Optional[dict]:
try:
url = f"https://hq.sinajs.cn/list={sina_code}"
resp = requests.get(url, headers=_sina_headers(), timeout=5)
resp.encoding = "gbk"
if '"' not in resp.text:
return None
body = resp.text.split('"')[1]
if not body:
return None
parts = body.split(",")
return _parse_sina_futures_quote(parts)
except Exception as exc:
logger.debug("sina fetch failed %s: %s", sina_code, exc)
return None
def get_sina_price(sina_code: str) -> Optional[float]:
raw = _fetch_sina_raw(sina_code)
return raw["price"] if raw else None
_runtime_refresh_token: str = ""
def set_ths_refresh_token(token: str):
global _runtime_refresh_token
_runtime_refresh_token = (token or "").strip()
def _get_refresh_token() -> str:
if _runtime_refresh_token:
return _runtime_refresh_token
return os.getenv("THS_REFRESH_TOKEN", "").strip()
def _get_ths_access_token(refresh_token: str) -> Optional[str]:
if not refresh_token:
return None
now = time.time()
if (
_token_cache["token"]
and _token_cache["refresh"] == refresh_token
and now < _token_cache["expires"]
):
return _token_cache["token"]
try:
resp = requests.post(
THS_TOKEN_URL,
headers={"Content-Type": "application/json", "refresh_token": refresh_token},
timeout=10,
)
data = resp.json()
if data.get("errorcode") != 0:
logger.warning("THS token error: %s", data.get("errmsg"))
return None
access = data["data"]["access_token"]
_token_cache.update({
"token": access,
"refresh": refresh_token,
"expires": now + 3600 * 6,
})
return access
except Exception as exc:
logger.warning("THS token request failed: %s", exc)
return None
def _parse_ths_quote(data: dict) -> Optional[float]:
"""从同花顺实时行情响应解析最新价。"""
try:
tables = data.get("tables") or []
for table in tables:
t = table.get("table") or {}
for key in ("latest", "new", "close", "trade", "last"):
val = t.get(key)
if val is None:
continue
if isinstance(val, list) and val:
return float(val[0])
if isinstance(val, (int, float, str)) and str(val):
return float(val)
# 部分响应嵌套在 data 字段
if "data" in data and isinstance(data["data"], dict):
return _parse_ths_quote(data["data"])
except Exception as exc:
logger.debug("parse ths quote failed: %s", exc)
return None
def get_ths_price(ths_full_code: str, refresh_token: str = "") -> Optional[float]:
"""ths_full_code 如 ag2608.SHFE、IF2606.CFFEX"""
token = refresh_token or _get_refresh_token()
access = _get_ths_access_token(token)
if not access:
return None
try:
resp = requests.post(
THS_QUOTE_URL,
headers={"Content-Type": "application/json", "access_token": access},
json={"codes": ths_full_code, "indicators": "latest"},
timeout=10,
)
data = resp.json()
if data.get("errorcode") != 0:
logger.warning("THS quote error %s: %s", ths_full_code, data.get("errmsg"))
return None
return _parse_ths_quote(data)
except Exception as exc:
logger.warning("THS quote failed %s: %s", ths_full_code, exc)
return None
def get_price(market_code: str, sina_fallback: str = "") -> Optional[float]:
"""
统一取价入口
sina_fallback: 新浪代码 nf_AG2608普通用户默认使用
market_code: 同花顺完整代码 ag2608.SHFE仅机构 token 可用时
"""
source = _quote_source()
# 仅在有 token 且配置为 ths/auto 时才尝试同花顺
use_ths = source == "ths" or (source == "auto" and _has_ths_token())
if use_ths and market_code and "." in market_code:
price = get_ths_price(market_code)
if price is not None:
return price
if source == "ths":
return None
if sina_fallback:
return get_sina_price(sina_fallback)
if market_code.startswith("nf_") or market_code.startswith("CFF_RE_"):
return get_sina_price(market_code)
return None
def fetch_raw_for_volume(sina_code: str) -> Optional[dict]:
"""主力合约扫描用(成交量),走新浪。"""
return _fetch_sina_raw(sina_code)
+287
View File
@@ -0,0 +1,287 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""国内期货交易时段与盘前连接窗口。"""
from __future__ import annotations
from datetime import datetime, timedelta
from typing import Optional
from zoneinfo import ZoneInfo
TZ = ZoneInfo("Asia/Shanghai")
# 各交易段开盘时刻 (时, 分)
SESSION_OPENS = (
(9, 0),
(10, 30), # 上午小节休息后续盘
(13, 30),
(21, 0),
)
# 日盘各连续交易段 (start_h, start_m, end_h, end_m),左闭右开
_DAY_SEGMENTS = (
(9, 0, 10, 15),
(10, 30, 11, 30),
(13, 30, 15, 0),
)
def _normalize_dt(now: Optional[datetime] = None) -> datetime:
d = now or datetime.now(TZ)
if d.tzinfo is None:
return d.replace(tzinfo=TZ)
return d.astimezone(TZ)
def _minutes_of_day(d: datetime) -> int:
return d.hour * 60 + d.minute
def _in_time_range(t: int, sh: int, sm: int, eh: int, em: int) -> bool:
return t >= sh * 60 + sm and t < eh * 60 + em
def is_trading_session(now: Optional[datetime] = None) -> bool:
d = _normalize_dt(now)
wd = d.weekday()
if wd == 6:
return False
if wd == 5 and d.hour < 21:
return False
t = _minutes_of_day(d)
for sh, sm, eh, em in _DAY_SEGMENTS:
if _in_time_range(t, sh, sm, eh, em):
return True
if _in_time_range(t, 21, 0, 24, 0):
return True
if _in_time_range(t, 0, 0, 2, 30):
return True
return False
def is_morning_break(now: Optional[datetime] = None) -> bool:
"""10:1510:30 上午小节休息。"""
d = _normalize_dt(now)
if d.weekday() >= 5:
return False
t = _minutes_of_day(d)
return _in_time_range(t, 10, 15, 10, 30)
def is_lunch_break(now: Optional[datetime] = None) -> bool:
"""11:3013:30 午间休盘。"""
d = _normalize_dt(now)
if d.weekday() >= 5:
return False
t = _minutes_of_day(d)
return _in_time_range(t, 11, 30, 13, 30)
def is_night_trading_session(now: Optional[datetime] = None) -> bool:
"""当前是否处于夜盘时段(21:00–02:30,且整体仍在交易时段内)。"""
if not is_trading_session(now):
return False
d = _normalize_dt(now)
t = _minutes_of_day(d)
return t >= 21 * 60 or t < 2 * 60 + 30
def _session_open_allowed(day: datetime, hour: int, minute: int) -> bool:
wd = day.weekday()
if (hour, minute) in ((9, 0), (10, 30), (13, 30)):
return wd < 5
if (hour, minute) == (21, 0):
if wd < 5:
return True
return wd == 5
return False
def iter_session_starts(
start: datetime,
*,
hours_ahead: int = 36,
) -> list[datetime]:
"""列出 start 之后若干小时内的各段开盘时刻。"""
if start.tzinfo is None:
start = start.replace(tzinfo=TZ)
else:
start = start.astimezone(TZ)
end = start + timedelta(hours=hours_ahead)
out: list[datetime] = []
day = start.replace(hour=0, minute=0, second=0, microsecond=0)
while day <= end:
for h, m in SESSION_OPENS:
if not _session_open_allowed(day, h, m):
continue
dt = day.replace(hour=h, minute=m)
if dt > start and dt <= end:
out.append(dt)
day += timedelta(days=1)
out.sort()
return out
def minutes_until_next_session(now: Optional[datetime] = None) -> Optional[float]:
d = _normalize_dt(now)
starts = iter_session_starts(d, hours_ahead=48)
if not starts:
return None
return (starts[0] - d).total_seconds() / 60.0
def _session_open_label(dt: datetime) -> str:
h, m = dt.hour, dt.minute
if (h, m) == (9, 0):
return "日盘开盘"
if (h, m) == (10, 30):
return "上午续盘"
if (h, m) == (13, 30):
return "午盘开盘"
if (h, m) == (21, 0):
return "夜盘开盘"
return "开盘"
def _session_status_label(d: datetime, in_sess: bool) -> str:
if in_sess:
return "交易时间段"
if is_morning_break(d):
return "上午休盘"
if is_lunch_break(d):
return "午间休盘"
return "非交易时间段"
def _fmt_countdown(seconds: int) -> str:
s = max(0, int(seconds))
h, rem = divmod(s, 3600)
m, sec = divmod(rem, 60)
if h > 0:
return f"{h}小时{m:02d}{sec:02d}"
if m > 0:
return f"{m}{sec:02d}"
return f"{sec}"
def _day_close_dt(d: datetime) -> datetime:
return d.replace(hour=15, minute=0, second=0, microsecond=0)
def _night_close_dt(d: datetime) -> datetime:
t = d.hour * 60 + d.minute
if t >= 21 * 60:
nxt = (d + timedelta(days=1)).replace(hour=2, minute=30, second=0, microsecond=0)
return nxt
return d.replace(hour=2, minute=30, second=0, microsecond=0)
def _current_break_close(d: datetime) -> tuple[Optional[datetime], Optional[datetime], Optional[str], Optional[str]]:
"""当前交易段内的休盘/收盘时刻与标签。"""
t = _minutes_of_day(d)
if _in_time_range(t, 9, 0, 10, 15):
br = d.replace(hour=10, minute=15, second=0, microsecond=0)
cl = _day_close_dt(d)
return br, cl, "上午休盘", "日盘收盘"
if _in_time_range(t, 10, 30, 11, 30):
br = d.replace(hour=11, minute=30, second=0, microsecond=0)
cl = _day_close_dt(d)
return br, cl, "午间休盘", "日盘收盘"
if _in_time_range(t, 13, 30, 15, 0):
cl = _day_close_dt(d)
return None, cl, None, "日盘收盘"
if t >= 21 * 60 or t < 2 * 60 + 30:
cl = _night_close_dt(d)
return None, cl, None, "夜盘收盘"
return None, None, None, None
def trading_session_clock(now: Optional[datetime] = None) -> dict:
"""顶栏展示:当前时间、交易状态、距开盘/休盘/收盘倒计时。"""
d = _normalize_dt(now)
in_sess = is_trading_session(d)
out = {
"now": d.strftime("%Y-%m-%d %H:%M:%S"),
"now_time": d.strftime("%m-%d %H:%M:%S"),
"in_session": in_sess,
"status_label": _session_status_label(d, in_sess),
}
if not in_sess:
starts = iter_session_starts(d, hours_ahead=72)
if starts:
nxt = starts[0]
secs = int(max(0, (nxt - d).total_seconds()))
out["next_open_at"] = nxt.strftime("%m-%d %H:%M")
out["next_open_label"] = _session_open_label(nxt)
out["secs_to_open"] = secs
out["countdown_open"] = _fmt_countdown(secs)
return out
br, cl, br_label, cl_label = _current_break_close(d)
if br and br > d:
secs = int((br - d).total_seconds())
out["break_at"] = br.strftime("%H:%M")
out["break_label"] = br_label or "休盘"
out["secs_to_break"] = secs
out["countdown_break"] = _fmt_countdown(secs)
if cl and cl > d:
secs = int((cl - d).total_seconds())
out["close_at"] = cl.strftime("%H:%M")
out["close_label"] = cl_label or "收盘"
out["secs_to_close"] = secs
out["countdown_close"] = _fmt_countdown(secs)
return out
def in_premarket_connect_window(
now: Optional[datetime] = None,
*,
minutes_before: int = 30,
) -> bool:
"""距下一段开盘 <= minutes_before 分钟,且当前尚未进入交易时段。"""
if is_trading_session(now):
return False
mins = minutes_until_next_session(now)
if mins is None:
return False
return 0 < mins <= float(minutes_before)
def in_postmarket_grace_window(
now: Optional[datetime] = None,
*,
minutes_after: int = 30,
) -> bool:
"""日盘 15:00 或夜盘 02:30 收盘后 minutes_after 分钟内(仍保持连接,便于收尾)。"""
if is_trading_session(now):
return False
d = _normalize_dt(now)
t = _minutes_of_day(d)
wd = d.weekday()
ma = max(1, int(minutes_after))
day_close = 15 * 60
night_close = 2 * 60 + 30
# 日盘收盘 15:00 后宽限(周一至周五)
if wd < 5 and day_close <= t < day_close + ma:
return True
# 夜盘收盘 02:30 后宽限(含周六凌晨结束周五夜盘)
if night_close <= t < night_close + ma:
return True
return False
def should_keep_ctp_connected(
now: Optional[datetime] = None,
*,
minutes_before: int = 30,
minutes_after: int = 30,
) -> bool:
"""是否处于应连接 CTP 的窗口:交易时段 + 小节/午间休盘 + 盘前 + 盘后宽限。"""
if is_trading_session(now):
return True
if is_morning_break(now) or is_lunch_break(now):
return True
if in_postmarket_grace_window(now, minutes_after=minutes_after):
return True
return in_premarket_connect_window(now, minutes_before=minutes_before)
+230
View File
@@ -0,0 +1,230 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
"""HTTP routes for market module."""
from __future__ import annotations
from datetime import date, datetime
from flask import (
Response,
flash,
jsonify,
redirect,
render_template,
request,
send_file,
session,
stream_with_context,
url_for,
)
def register(deps) -> None:
app = deps.app
login_required = deps.login_required
require_nav = deps.require_nav
get_db = deps.get_db
get_setting = deps.get_setting
set_setting = deps.set_setting
fetch_price = deps.fetch_price
send_wechat_msg = deps.send_wechat_msg
touch_stats_cache = deps.touch_stats_cache
get_stats_data = deps.get_stats_data
build_market_quote_payload = deps.build_market_quote_payload
today_str = deps.today_str
expire_old_plans = deps.expire_old_plans
TZ = deps.tz
DB_PATH = deps.db_path
UPLOAD_DIR = deps.upload_dir
OPEN_TYPES = deps.open_types
EXIT_TRIGGERS = deps.exit_triggers
BEHAVIOR_TAGS = deps.behavior_tags
KLINE_PERIODS = deps.kline_periods
KLINE_CUTOFFS = deps.kline_cutoffs
calc_holding_duration = deps.calc_holding_duration
holding_to_minutes = deps.holding_to_minutes
classify_close_result = deps.classify_close_result
calc_rr_ratio = deps.calc_rr_ratio
calc_theoretical_pnl = deps.calc_theoretical_pnl
parse_review_date_filter = deps.parse_review_date_filter
_trading_mode = deps.trading_mode
_ua_is_phone = deps.ua_is_phone
_static_asset_v = deps.static_asset_v
from modules.core.symbols import (
list_main_contracts_grouped,
list_recommended_symbols_grouped,
search_symbols,
)
from modules.market.kline_chart import MARKET_PERIODS, fetch_market_klines
from modules.market.kline_stream import kline_hub, sse_format
from modules.market.market import get_quote_source_label
from queue import Empty
@app.route("/api/symbols/search")
@login_required
def api_symbol_search():
q = request.args.get("q", "")
conn = get_db()
try:
from modules.core.trading_context import get_account_capital, is_ctp_connected
capital = get_account_capital(conn, get_setting)
ctp_connected = is_ctp_connected(get_setting)
finally:
conn.close()
return jsonify(search_symbols(q, capital=capital, ctp_connected=ctp_connected))
@app.route("/api/symbols/mains")
@login_required
def api_symbols_mains():
return jsonify(list_main_contracts_grouped())
@app.route("/api/symbols/recommended")
@login_required
def api_symbols_recommended():
"""品种下拉:仅展示当前资金下可开仓品种(与下方可开仓品种表一致)。"""
from modules.trading.recommend_store import recommend_payload
from modules.core.trading_context import (
get_fixed_lots,
get_max_margin_pct,
get_recommend_capital,
get_sizing_mode,
get_trading_mode,
)
conn = get_db()
try:
capital = get_recommend_capital(conn, get_setting)
payload = recommend_payload(
conn,
live_capital=capital,
max_margin_pct=get_max_margin_pct(get_setting),
trading_mode=get_trading_mode(get_setting),
sizing_mode=get_sizing_mode(get_setting),
fixed_lots=get_fixed_lots(get_setting),
)
return jsonify(list_recommended_symbols_grouped(payload.get("rows") or []))
finally:
conn.close()
@app.route("/market")
@login_required
@require_nav("market")
def market_page():
symbol = request.args.get("symbol", "").strip()
period = request.args.get("period", "15m").strip()
valid = {p["key"] for p in MARKET_PERIODS}
if period not in valid:
period = "15m"
ctp_st = {}
try:
from modules.ctp.vnpy_bridge import ctp_status
from modules.core.trading_context import get_trading_mode
ctp_st = ctp_status(get_trading_mode(get_setting))
except Exception:
pass
return render_template(
"market.html",
symbol=symbol,
period=period,
market_periods=MARKET_PERIODS,
quote_label=get_quote_source_label(ctp_connected=bool(ctp_st.get("connected"))),
ctp_connected=bool(ctp_st.get("connected")),
)
@app.route("/api/kline")
@login_required
def api_kline():
symbol = request.args.get("symbol", "").strip()
period = request.args.get("period", "15m").strip()
if not symbol:
return jsonify({"error": "请提供合约代码"}), 400
try:
from modules.core.trading_context import get_trading_mode
data = fetch_market_klines(
symbol, period, DB_PATH, prefer_ctp=False,
)
except Exception as exc:
app.logger.warning("kline api failed: %s", exc)
return jsonify({"error": str(exc)}), 500
if not data.get("chart_symbol"):
return jsonify({"error": "无法识别合约代码"}), 400
if not data.get("bars"):
return jsonify({"error": "未获取到K线数据,请稍后重试或更换合约"}), 404
return jsonify(data)
@app.route("/api/kline/stream")
@login_required
def api_kline_stream():
from queue import Empty
symbol = request.args.get("symbol", "").strip()
period = request.args.get("period", "15m").strip()
market_code = request.args.get("market_code", "").strip()
sina_code = request.args.get("sina_code", "").strip()
if not symbol:
return jsonify({"error": "请提供合约代码"}), 400
def generate():
sub = kline_hub.subscribe(symbol, period, market_code, sina_code)
try:
kline_data = fetch_market_klines(
symbol, period, DB_PATH, prefer_ctp=False,
)
if kline_data.get("bars"):
yield sse_format("kline", kline_data)
yield sse_format(
"quote",
build_market_quote_payload(
symbol, market_code, sina_code, prefer_sina=True,
),
)
while True:
try:
msg = sub.queue.get(timeout=20)
yield sse_format(msg["event"], msg["data"])
except Empty:
yield ": heartbeat\n\n"
finally:
kline_hub.unsubscribe(sub)
return Response(
stream_with_context(generate()),
mimetype="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@app.route("/api/market_quote")
@login_required
def api_market_quote():
symbol = request.args.get("symbol", "").strip()
market_code = request.args.get("market_code", "").strip()
sina_code = request.args.get("sina_code", "").strip()
if not symbol and not market_code:
return jsonify({"error": "请提供合约"}), 400
return jsonify(build_market_quote_payload(
symbol, market_code, sina_code, prefer_sina=True,
))
@app.route("/contract")
@login_required
def contract_profile_page():
return redirect(url_for("positions"))
@app.route("/api/contract_profile")
@login_required
def api_contract_profile():
return jsonify({"error": "品种简介功能已移除"}), 404
+5
View File
@@ -0,0 +1,5 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
from modules.notify.routes import register
__all__ = ["register"]
+102
View File
@@ -0,0 +1,102 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""AI 接口:Ollama / OpenAI 兼容 API。"""
from __future__ import annotations
import json
import logging
from typing import Callable, Optional
import requests
logger = logging.getLogger(__name__)
def is_ai_enabled(get_setting: Callable[[str, str], str]) -> bool:
return (get_setting("ai_enabled", "0") or "0").strip() in ("1", "true", "yes")
def get_ai_config(get_setting: Callable[[str, str], str]) -> dict:
provider = (get_setting("ai_provider", "ollama") or "ollama").strip().lower()
if provider not in ("ollama", "openai"):
provider = "ollama"
return {
"enabled": is_ai_enabled(get_setting),
"provider": provider,
"ollama_base_url": (get_setting("ai_ollama_base_url", "http://127.0.0.1:11434") or "").strip().rstrip("/"),
"ollama_model": (get_setting("ai_ollama_model", "qwen2.5:7b") or "qwen2.5:7b").strip(),
"openai_base_url": (get_setting("ai_openai_base_url", "https://api.openai.com/v1") or "").strip().rstrip("/"),
"openai_api_key": (get_setting("ai_openai_api_key", "") or "").strip(),
"openai_model": (get_setting("ai_openai_model", "gpt-4o-mini") or "gpt-4o-mini").strip(),
}
def chat_completion(
*,
get_setting: Callable[[str, str], str],
system_prompt: str,
user_prompt: str,
timeout: int = 120,
) -> tuple[bool, str]:
cfg = get_ai_config(get_setting)
if not cfg["enabled"]:
return False, "AI 未启用"
provider = cfg["provider"]
try:
if provider == "ollama":
url = f"{cfg['ollama_base_url']}/api/chat"
payload = {
"model": cfg["ollama_model"],
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
"stream": False,
}
resp = requests.post(url, json=payload, timeout=timeout)
resp.raise_for_status()
data = resp.json()
msg = (data.get("message") or {}).get("content") or ""
return True, (msg or "").strip() or "AI 无回复)"
url = f"{cfg['openai_base_url']}/chat/completions"
headers = {
"Authorization": f"Bearer {cfg['openai_api_key']}",
"Content-Type": "application/json",
}
payload = {
"model": cfg["openai_model"],
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
"temperature": 0.4,
}
resp = requests.post(url, headers=headers, json=payload, timeout=timeout)
resp.raise_for_status()
data = resp.json()
choices = data.get("choices") or []
if not choices:
return False, "AI 返回为空"
msg = (choices[0].get("message") or {}).get("content") or ""
return True, (msg or "").strip() or "AI 无回复)"
except Exception as exc:
logger.warning("AI chat failed (%s): %s", provider, exc)
return False, f"AI 调用失败:{exc}"
def analyze_trading_event(
*,
get_setting: Callable[[str, str], str],
event_kind: str,
payload: dict,
) -> tuple[bool, str]:
system = (
"你是国内期货交易复盘助手。根据提供的结构化交易数据,"
"用简洁中文给出 3~6 条要点:风险、纪律、改进建议。"
"不要编造未提供的数据;金额单位为元。"
)
user = f"事件类型:{event_kind}\n\n数据:\n{json.dumps(payload, ensure_ascii=False, indent=2)}"
return chat_completion(get_setting=get_setting, system_prompt=system, user_prompt=user)
+70
View File
@@ -0,0 +1,70 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""AI 消息存储与展示。"""
from __future__ import annotations
import json
from datetime import datetime
from typing import Any, Optional
from zoneinfo import ZoneInfo
TZ = ZoneInfo("Asia/Shanghai")
CREATE_SQL = """
CREATE TABLE IF NOT EXISTS ai_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kind TEXT NOT NULL,
title TEXT,
content TEXT NOT NULL,
meta_json TEXT,
created_at TEXT NOT NULL
)
"""
def ensure_ai_messages_table(conn) -> None:
conn.execute(CREATE_SQL)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_ai_messages_created ON ai_messages(created_at DESC)"
)
def insert_ai_message(
conn,
*,
kind: str,
title: str,
content: str,
meta: Optional[dict[str, Any]] = None,
) -> int:
ensure_ai_messages_table(conn)
now = datetime.now(TZ).strftime("%Y-%m-%d %H:%M:%S")
cur = conn.execute(
"""INSERT INTO ai_messages (kind, title, content, meta_json, created_at)
VALUES (?,?,?,?,?) RETURNING id""",
(kind, title, content, json.dumps(meta or {}, ensure_ascii=False), now),
)
row = cur.fetchone()
if row is not None:
return int(row["id"] if isinstance(row, dict) else row[0])
return int(cur.lastrowid or 0)
def list_ai_messages(conn, *, limit: int = 100) -> list[dict]:
ensure_ai_messages_table(conn)
rows = conn.execute(
"SELECT * FROM ai_messages ORDER BY id DESC LIMIT ?",
(max(1, min(500, int(limit))),),
).fetchall()
out = []
for r in rows:
item = dict(r)
try:
item["meta"] = json.loads(item.get("meta_json") or "{}")
except Exception:
item["meta"] = {}
out.append(item)
return out
+173
View File
@@ -0,0 +1,173 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""AI 后台:开仓/平仓分析、日终持仓报告。"""
from __future__ import annotations
import json
import logging
import threading
from datetime import datetime
from typing import Callable, Optional
from zoneinfo import ZoneInfo
logger = logging.getLogger(__name__)
TZ = ZoneInfo("Asia/Shanghai")
DAILY_REPORT_KEY = "ai_daily_report_last_date"
def schedule_ai_event_analysis(
*,
db_path: str,
get_setting_fn: Callable[[str, str], str],
kind: str,
title: str,
payload: dict,
send_wechat_fn: Callable[[str], None] | None = None,
) -> None:
"""后台线程:调用 AI 并写入 ai_messages。"""
if not (get_setting_fn("ai_enabled", "0") or "0").strip() in ("1", "true", "yes"):
return
def _run() -> None:
from modules.notify.ai_client import analyze_trading_event
from modules.notify.ai_messages import insert_ai_message
from modules.core.db_conn import connect_db
ok, content = analyze_trading_event(
get_setting=get_setting_fn,
event_kind=kind,
payload=payload,
)
if not ok:
content = f"{content}"
try:
conn = connect_db(db_path)
try:
insert_ai_message(
conn,
kind=kind,
title=title,
content=content,
meta=payload,
)
conn.commit()
finally:
conn.close()
if send_wechat_fn and ok:
send_wechat_fn(f"🤖 AI 分析 · {title}\n\n{content[:1800]}")
except Exception as exc:
logger.warning("AI event analysis failed: %s", exc)
threading.Thread(target=_run, daemon=True, name="ai-event").start()
def _today_trading_summary(conn, day: str) -> dict:
rows = conn.execute(
"""SELECT symbol, symbol_name, direction, pnl_net, result, close_time
FROM trade_logs WHERE close_time LIKE ? ORDER BY id ASC""",
(f"{day}%",),
).fetchall()
wins = losses = 0
pnl_sum = 0.0
trades = []
for r in rows:
pnl = float(r["pnl_net"] or 0)
pnl_sum += pnl
if pnl >= 0:
wins += 1
else:
losses += 1
trades.append(dict(r))
positions = conn.execute(
"""SELECT symbol, symbol_name, direction, lots, entry_price, stop_loss, take_profit, monitor_type
FROM trade_order_monitors WHERE status='active'"""
).fetchall()
return {
"date": day,
"trade_count": len(trades),
"wins": wins,
"losses": losses,
"pnl_net_total": round(pnl_sum, 2),
"trades": trades[:20],
"active_positions": [dict(p) for p in positions],
}
def maybe_run_daily_ai_report(
*,
db_path: str,
get_setting_fn: Callable[[str, str], str],
set_setting_fn: Callable[[str, str], None],
send_wechat_fn: Callable[[str], None] | None = None,
) -> None:
if not (get_setting_fn("ai_enabled", "0") or "0").strip() in ("1", "true", "yes"):
return
if (get_setting_fn("ai_daily_report_enabled", "1") or "1").strip() not in ("1", "true", "yes"):
return
now = datetime.now(TZ)
day = now.strftime("%Y-%m-%d")
if get_setting_fn(DAILY_REPORT_KEY, "") == day:
return
try:
hour = int(float(get_setting_fn("ai_daily_report_hour", "15") or 15))
minute = int(float(get_setting_fn("ai_daily_report_minute", "5") or 5))
except (TypeError, ValueError):
hour, minute = 15, 5
if (now.hour, now.minute) < (hour, minute):
return
from modules.notify.ai_client import analyze_trading_event
from modules.notify.ai_messages import insert_ai_message
from modules.core.db_conn import connect_db
try:
conn = connect_db(db_path)
try:
summary = _today_trading_summary(conn, day)
ok, content = analyze_trading_event(
get_setting=get_setting_fn,
event_kind="daily_report",
payload=summary,
)
title = f"{day} 日终持仓与交易报告"
if not ok:
content = f"{content}"
insert_ai_message(conn, kind="daily_report", title=title, content=content, meta=summary)
conn.commit()
set_setting_fn(DAILY_REPORT_KEY, day)
if send_wechat_fn and ok:
send_wechat_fn(f"🤖 {title}\n\n{content[:1800]}")
finally:
conn.close()
except Exception as exc:
logger.warning("AI daily report failed: %s", exc)
def start_ai_worker(
*,
db_path: str,
get_setting_fn: Callable[[str, str], str],
set_setting_fn: Callable[[str, str], None],
send_wechat_fn: Callable[[str], None] | None = None,
interval_sec: int = 60,
) -> None:
import time
def _loop() -> None:
time.sleep(30)
while True:
try:
maybe_run_daily_ai_report(
db_path=db_path,
get_setting_fn=get_setting_fn,
set_setting_fn=set_setting_fn,
send_wechat_fn=send_wechat_fn,
)
except Exception as exc:
logger.debug("ai worker: %s", exc)
time.sleep(max(30, interval_sec))
threading.Thread(target=_loop, daemon=True, name="ai-worker").start()
+65
View File
@@ -0,0 +1,65 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
"""HTTP routes for notify module."""
from __future__ import annotations
from datetime import date, datetime
from flask import (
Response,
flash,
jsonify,
redirect,
render_template,
request,
send_file,
session,
stream_with_context,
url_for,
)
def register(deps) -> None:
app = deps.app
login_required = deps.login_required
require_nav = deps.require_nav
get_db = deps.get_db
get_setting = deps.get_setting
set_setting = deps.set_setting
fetch_price = deps.fetch_price
send_wechat_msg = deps.send_wechat_msg
touch_stats_cache = deps.touch_stats_cache
get_stats_data = deps.get_stats_data
build_market_quote_payload = deps.build_market_quote_payload
today_str = deps.today_str
expire_old_plans = deps.expire_old_plans
TZ = deps.tz
DB_PATH = deps.db_path
UPLOAD_DIR = deps.upload_dir
OPEN_TYPES = deps.open_types
EXIT_TRIGGERS = deps.exit_triggers
BEHAVIOR_TAGS = deps.behavior_tags
KLINE_PERIODS = deps.kline_periods
KLINE_CUTOFFS = deps.kline_cutoffs
calc_holding_duration = deps.calc_holding_duration
holding_to_minutes = deps.holding_to_minutes
classify_close_result = deps.classify_close_result
calc_rr_ratio = deps.calc_rr_ratio
calc_theoretical_pnl = deps.calc_theoretical_pnl
parse_review_date_filter = deps.parse_review_date_filter
_trading_mode = deps.trading_mode
_ua_is_phone = deps.ua_is_phone
_static_asset_v = deps.static_asset_v
@app.route("/ai")
@login_required
@require_nav("ai")
def ai_messages_page():
from modules.notify.ai_messages import list_ai_messages
conn = get_db()
try:
messages = list_ai_messages(conn, limit=100)
finally:
conn.close()
return render_template("ai_messages.html", messages=messages)
+183
View File
@@ -0,0 +1,183 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""企业微信推送:开仓 / 平仓 / 关键位 结构化消息。"""
from __future__ import annotations
from typing import Optional
def _dir_label(direction: str) -> str:
d = (direction or "long").strip().lower()
return "多头(long" if d == "long" else "空头(short"
def _dir_emoji(direction: str) -> str:
d = (direction or "long").strip().lower()
return "📈" if d == "long" else "📉"
def fmt_holding(minutes: int) -> str:
m = max(0, int(minutes or 0))
if m >= 1440:
return f"{m // 1440}{m % 1440 // 60}小时{m % 60}分钟"
if m >= 60:
return f"{m // 60}小时{m % 60}分钟"
return f"{m}分钟"
def calc_rr(entry: float, sl: float, tp: float, direction: str) -> Optional[float]:
try:
entry_f, sl_f, tp_f = float(entry), float(sl), float(tp)
except (TypeError, ValueError):
return None
risk = abs(entry_f - sl_f)
if risk <= 0:
return None
reward = (tp_f - entry_f) if direction == "long" else (entry_f - tp_f)
if reward <= 0:
return None
return round(reward / risk, 2)
def format_open_success(
*,
symbol_name: str,
symbol: str,
direction: str,
mode_label: str,
order_id: str = "",
entry: float,
stop_loss: float,
take_profit: Optional[float],
lots: int,
capital: float,
margin: Optional[float],
margin_pct: Optional[float],
risk_percent: float,
risk_amount: Optional[float],
trailing_be: bool = False,
be_tick_buffer: int = 2,
tick_size: float = 1.0,
source: str = "期货下单",
extra_lines: Optional[list[str]] = None,
) -> str:
"""正常 / 关键位开仓成功推送。"""
name = symbol_name or symbol
emoji = _dir_emoji(direction)
rr = calc_rr(entry, stop_loss, take_profit, direction) if take_profit else None
lines = [
f"{emoji} {name} 开仓成功",
f"💼 账户:{mode_label}",
"",
"🧾 订单基础信息",
f"📌 来源:{source}",
]
if order_id:
lines.append(f"🔖 委托号:{order_id}")
lines.extend([
f"📈 方向:{_dir_label(direction)}",
f"⚠ 单笔风控:{risk_percent:g}%"
+ (f"{risk_amount:.2f}" if risk_amount is not None else ""),
"",
"📊 仓位配置",
f"账户权益:{capital:.2f}",
f"开仓手数:{lots}",
])
if margin is not None:
lines.append(f"占用保证金:{margin:.2f}")
if margin_pct is not None:
lines.append(f"仓位占比:{margin_pct:.2f}%")
lines.extend(["", "🎯 价位 & 盈亏比", f"开仓价:{entry:g}", f"止损价:{stop_loss:g}"])
if take_profit is not None:
lines.append(f"止盈价:{take_profit:g}")
if rr is not None:
lines.append(f"计划盈亏比:RR {rr:g} : 1")
if trailing_be:
be_px = entry - be_tick_buffer * tick_size if direction == "long" else entry + be_tick_buffer * tick_size
lines.append(f"移动保本:1.0R → {be_px:g}(缓冲 {be_tick_buffer} 跳)")
lines.extend(["", "📌 状态", "✅ 已进入下单监控,本地 SL/TP 守护"])
if extra_lines:
lines.extend(extra_lines)
return "\n".join(lines)
def format_key_open_success(
*,
symbol_name: str,
symbol: str,
monitor_type: str,
trade_mode: str,
bar_time: str,
break_side: str,
**kwargs,
) -> str:
side_label = "向上突破" if break_side == "upper" else "向下突破"
extra = [
"",
"📎 关键位触发",
f"类型:{monitor_type}",
f"模式:{trade_mode} · {side_label}",
f"5m 收盘:{bar_time}",
]
source = f"{monitor_type}·{trade_mode}"
return format_open_success(
symbol_name=symbol_name,
symbol=symbol,
source=source,
extra_lines=extra,
**kwargs,
)
def format_close_done(
*,
symbol_name: str,
symbol: str,
mode_label: str,
direction: str,
result: str,
pnl_net: float,
equity_after: Optional[float],
capital: float,
entry: float,
close_price: float,
stop_loss: Optional[float],
take_profit: Optional[float],
lots: float,
holding_minutes: int = 0,
order_id: str = "",
note: str = "",
) -> str:
"""平仓完成推送。"""
name = symbol_name or symbol
emoji = "📈" if pnl_net >= 0 else "📉"
pnl_sign = "+" if pnl_net >= 0 else ""
lines = [
f"{emoji} {name} 平仓完成",
f"💼 账户:{mode_label}",
"",
"🧾 平仓概要",
]
if order_id:
lines.append(f"🔖 平仓单号:{order_id}")
lines.extend([
f"📌 方向:{_dir_label(direction)}",
f"📌 平仓结果:{result}",
f"💰 本单净盈亏:{pnl_sign}{pnl_net:.2f}",
f"⏱ 持仓时长:{fmt_holding(holding_minutes)}",
f"💵 账户权益:{equity_after if equity_after is not None else capital:.2f}",
"",
"🎯 价位(计划)",
f"开仓价:{entry:g}",
f"平仓价:{close_price:g}",
])
if take_profit is not None:
lines.append(f"止盈价:{take_profit:g}")
if stop_loss is not None:
lines.append(f"止损价:{stop_loss:g}")
if note:
lines.extend(["", "📎 备注", note])
return "\n".join(lines)
+5
View File
@@ -0,0 +1,5 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
from modules.plans.routes import register
__all__ = ["register"]
+167
View File
@@ -0,0 +1,167 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
"""HTTP routes for plans module."""
from __future__ import annotations
from datetime import date, datetime
from flask import (
Response,
flash,
jsonify,
redirect,
render_template,
request,
send_file,
session,
stream_with_context,
url_for,
)
def register(deps) -> None:
app = deps.app
login_required = deps.login_required
require_nav = deps.require_nav
get_db = deps.get_db
get_setting = deps.get_setting
set_setting = deps.set_setting
fetch_price = deps.fetch_price
send_wechat_msg = deps.send_wechat_msg
touch_stats_cache = deps.touch_stats_cache
get_stats_data = deps.get_stats_data
build_market_quote_payload = deps.build_market_quote_payload
today_str = deps.today_str
expire_old_plans = deps.expire_old_plans
TZ = deps.tz
DB_PATH = deps.db_path
UPLOAD_DIR = deps.upload_dir
OPEN_TYPES = deps.open_types
EXIT_TRIGGERS = deps.exit_triggers
BEHAVIOR_TAGS = deps.behavior_tags
KLINE_PERIODS = deps.kline_periods
KLINE_CUTOFFS = deps.kline_cutoffs
calc_holding_duration = deps.calc_holding_duration
holding_to_minutes = deps.holding_to_minutes
classify_close_result = deps.classify_close_result
calc_rr_ratio = deps.calc_rr_ratio
calc_theoretical_pnl = deps.calc_theoretical_pnl
parse_review_date_filter = deps.parse_review_date_filter
_trading_mode = deps.trading_mode
_ua_is_phone = deps.ua_is_phone
_static_asset_v = deps.static_asset_v
@app.route("/api/plan_prices")
@login_required
def api_plan_prices():
"""今日计划:批量现价与距决策区间上/下沿距离。"""
today = today_str()
conn = get_db()
rows = conn.execute(
"SELECT id, symbol, market_code, sina_code, zone_upper, zone_lower "
"FROM order_plans WHERE plan_date=? AND status IN ('planned', 'active')",
(today,),
).fetchall()
conn.close()
out = []
for r in rows:
sym = r["symbol"]
market = r["market_code"] or ""
sina = r["sina_code"] or ""
upper = float(r["zone_upper"])
lower = float(r["zone_lower"])
price = fetch_price(sym, market, sina)
dist_upper = None
dist_lower = None
in_zone = False
if price is not None:
dist_upper = round(upper - price, 2)
dist_lower = round(price - lower, 2)
in_zone = lower <= price <= upper
out.append({
"id": r["id"],
"price": price,
"dist_upper": dist_upper,
"dist_lower": dist_lower,
"in_zone": in_zone,
})
return jsonify(out)
@app.route("/plans")
@login_required
@require_nav("plans")
def plans():
today = today_str()
start = request.args.get("start", "")
end = request.args.get("end", "")
conn = get_db()
plan_list = conn.execute(
"SELECT * FROM order_plans WHERE plan_date=? AND status IN ('planned', 'active') ORDER BY id DESC",
(today,),
).fetchall()
sql = "SELECT * FROM order_plans WHERE plan_date < ? OR status IN ('closed', 'expired')"
params: list = [today]
if start:
sql += " AND plan_date >= ?"
params.append(start)
if end:
sql += " AND plan_date <= ?"
params.append(end)
sql += " ORDER BY plan_date DESC, id DESC LIMIT 200"
history = conn.execute(sql, params).fetchall()
conn.close()
return render_template(
"plans.html",
plans=plan_list,
history=history,
today=today,
start=start,
end=end,
)
@app.route("/add_plan", methods=["POST"])
@login_required
def add_plan():
d = request.form
direction = d.get("direction")
symbol = d.get("symbol", "").strip()
symbol_name = d.get("symbol_name", "").strip()
market_code = d.get("market_code", "").strip()
sina_code = d.get("sina_code", "").strip()
if not direction:
flash("请选择多空方向")
return redirect(url_for("plans"))
if not symbol or not market_code:
flash("请从下拉列表选择品种(同花顺合约代码)")
return redirect(url_for("plans"))
conn = get_db()
conn.execute(
"""INSERT INTO order_plans
(symbol, symbol_name, market_code, sina_code, direction,
zone_upper, zone_lower, stop_loss, take_profit, plan_date, decision_reason)
VALUES (?,?,?,?,?,?,?,?,?,?,?)""",
(
symbol, symbol_name, market_code, sina_code, direction,
float(d["zone_upper"]), float(d["zone_lower"]),
float(d["stop_loss"]), float(d["take_profit"]),
today_str(),
d.get("decision_reason", "").strip(),
),
)
conn.commit()
conn.close()
flash("开单计划已添加")
return redirect(url_for("plans"))
@app.route("/del_plan/<int:pid>")
@login_required
def del_plan(pid):
conn = get_db()
conn.execute("DELETE FROM order_plans WHERE id=?", (pid,))
conn.commit()
conn.close()
flash("已删除")
return redirect(url_for("plans"))
+5
View File
@@ -0,0 +1,5 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
from modules.records.routes import register
__all__ = ["register"]
+554
View File
@@ -0,0 +1,554 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
"""HTTP routes for records module."""
from __future__ import annotations
from datetime import date, datetime
from flask import (
Response,
flash,
jsonify,
redirect,
render_template,
request,
send_file,
session,
stream_with_context,
url_for,
)
def register(deps) -> None:
app = deps.app
login_required = deps.login_required
require_nav = deps.require_nav
get_db = deps.get_db
get_setting = deps.get_setting
set_setting = deps.set_setting
fetch_price = deps.fetch_price
send_wechat_msg = deps.send_wechat_msg
touch_stats_cache = deps.touch_stats_cache
get_stats_data = deps.get_stats_data
build_market_quote_payload = deps.build_market_quote_payload
today_str = deps.today_str
expire_old_plans = deps.expire_old_plans
TZ = deps.tz
DB_PATH = deps.db_path
UPLOAD_DIR = deps.upload_dir
OPEN_TYPES = deps.open_types
EXIT_TRIGGERS = deps.exit_triggers
BEHAVIOR_TAGS = deps.behavior_tags
KLINE_PERIODS = deps.kline_periods
KLINE_CUTOFFS = deps.kline_cutoffs
calc_holding_duration = deps.calc_holding_duration
holding_to_minutes = deps.holding_to_minutes
classify_close_result = deps.classify_close_result
calc_rr_ratio = deps.calc_rr_ratio
calc_theoretical_pnl = deps.calc_theoretical_pnl
parse_review_date_filter = deps.parse_review_date_filter
_trading_mode = deps.trading_mode
_ua_is_phone = deps.ua_is_phone
_static_asset_v = deps.static_asset_v
from werkzeug.utils import secure_filename
from modules.core.contract_specs import calc_position_metrics
from modules.fees.fee_specs import calc_fee_breakdown, calc_round_trip_fee
from modules.market.kline_chart import generate_review_kline_chart
@app.route("/api/position_live")
@login_required
def api_position_live():
capital = float(get_setting("live_capital", "0") or 0)
now_iso = datetime.now(TZ).strftime("%Y-%m-%dT%H:%M")
conn = get_db()
rows = conn.execute(
"SELECT * FROM position_monitors WHERE status='active' ORDER BY id DESC"
).fetchall()
conn.close()
out = []
for r in rows:
sym = r["symbol"]
market = r["market_code"] or ""
sina = r["sina_code"] or ""
direction = r["direction"]
entry = float(r["entry_price"])
sl = float(r["stop_loss"])
tp = float(r["take_profit"])
lots = float(r["lots"] or 1)
mark = fetch_price(sym, market, sina)
metrics = calc_position_metrics(
direction, entry, sl, tp, lots, mark, capital, sym,
)
holding = calc_holding_duration(r["open_time"] or "", now_iso)
close_est = mark if mark is not None else entry
fee_info = calc_fee_breakdown(
sym, entry, close_est, lots, r["open_time"] or "", now_iso,
trading_mode=_trading_mode(),
)
est_net = None
if metrics.get("float_pnl") is not None:
est_net = round(metrics["float_pnl"] - fee_info["total_fee"], 2)
out.append({
"id": r["id"],
"symbol": r["symbol_name"] or sym,
"symbol_code": sym,
"direction": "做多" if direction == "long" else "做空",
"lots": lots,
"entry_price": entry,
"stop_loss": sl,
"take_profit": tp,
"open_time": r["open_time"],
"mark_price": mark,
"holding_duration": holding,
"est_fee": fee_info["total_fee"],
"est_fee_open": fee_info["open_fee"],
"est_fee_close": fee_info["close_fee"],
"est_fee_close_type": fee_info["close_type"],
"est_pnl_net": est_net,
**metrics,
})
return jsonify(out)
@app.route("/close_position/<int:pid>", methods=["POST"])
@login_required
def close_position(pid):
conn = get_db()
row = conn.execute("SELECT * FROM position_monitors WHERE id=?", (pid,)).fetchone()
if not row:
conn.close()
flash("持仓不存在")
return redirect(url_for("positions"))
sym = row["symbol"]
market = row["market_code"] or ""
sina = row["sina_code"] or ""
direction = row["direction"]
entry = float(row["entry_price"])
sl = float(row["stop_loss"])
tp = float(row["take_profit"])
lots = float(row["lots"] or 1)
open_time = row["open_time"] or ""
close_time = datetime.now(TZ).strftime("%Y-%m-%dT%H:%M")
close_price = fetch_price(sym, market, sina)
if close_price is None:
conn.close()
flash("无法获取现价,平仓失败")
return redirect(url_for("positions"))
capital = float(get_setting("live_capital", "0") or 0)
metrics = calc_position_metrics(direction, entry, sl, tp, lots, close_price, capital, sym)
pnl = metrics.get("float_pnl") or 0.0
fee = calc_round_trip_fee(sym, entry, close_price, lots, open_time, close_time, trading_mode=_trading_mode())
pnl_net = round(pnl - fee, 2)
result = classify_close_result(direction, close_price, sl, tp)
minutes = holding_to_minutes(open_time, close_time)
margin_pct = metrics.get("position_pct")
from modules.trading.trade_log_lib import calc_equity_after, refresh_trade_log_equity_chain
equity_after = calc_equity_after(capital, pnl_net)
conn.execute(
"""INSERT INTO trade_logs
(symbol, symbol_name, market_code, sina_code, monitor_type, direction,
entry_price, stop_loss, take_profit, close_price, lots, margin,
margin_pct, holding_minutes, open_time, close_time, pnl, fee, pnl_net,
equity_after, result)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
sym, row["symbol_name"], market, sina, "持仓监控", direction,
entry, sl, tp, close_price, lots, metrics["margin"],
margin_pct,
minutes, open_time, close_time, pnl, fee, pnl_net, equity_after, result,
),
)
conn.execute("DELETE FROM position_monitors WHERE id=?", (pid,))
try:
refresh_trade_log_equity_chain(conn, capital if capital > 0 else None)
except Exception as exc:
app.logger.debug("equity chain refresh after close: %s", exc)
conn.commit()
conn.close()
touch_stats_cache()
flash(f"已平仓,盈亏 {pnl:.2f} 元(扣费后 {pnl_net:.2f} 元),已记入交易记录")
return redirect(url_for("positions"))
@app.route("/trades")
@login_required
def trades():
return redirect(url_for("records"))
@app.route("/update_trade/<int:tid>", methods=["POST"])
@login_required
def update_trade(tid):
d = request.form
conn = get_db()
row = conn.execute("SELECT * FROM trade_logs WHERE id=?", (tid,)).fetchone()
if not row:
conn.close()
flash("记录不存在")
return redirect(url_for("records"))
row = dict(row)
entry = float(d.get("entry_price") or 0)
close_px = float(d.get("close_price") or 0)
lots = float(d.get("lots") or 0)
sl_raw = d.get("stop_loss")
tp_raw = d.get("take_profit")
stop_loss = float(sl_raw) if sl_raw not in (None, "") else None
take_profit = float(tp_raw) if tp_raw not in (None, "") else None
open_time = (d.get("open_time") or row.get("open_time") or "").strip()
close_time = (d.get("close_time") or row.get("close_time") or "").strip()
direction = (d.get("direction") or row.get("direction") or "long").strip()
from modules.trading.trade_log_lib import recalc_trade_log_pnl, refresh_trade_log_equity_chain, _read_initial_capital
from modules.core.trading_context import get_trading_mode
pnl = float(row.get("pnl") or 0)
fee = float(row.get("fee") or 0)
pnl_net = float(row.get("pnl_net") or 0)
old_entry = float(row.get("entry_price") or 0)
old_close = float(row.get("close_price") or 0)
old_lots = float(row.get("lots") or 0)
prices_changed = (
abs(entry - old_entry) > 0.0001
or abs(close_px - old_close) > 0.0001
or abs(lots - old_lots) > 0.0001
)
if prices_changed and close_px > 0 and entry > 0 and lots > 0:
calc = recalc_trade_log_pnl(
symbol=row.get("symbol") or "",
direction=direction,
entry_price=entry,
close_price=close_px,
lots=lots,
stop_loss=stop_loss,
take_profit=take_profit,
open_time=open_time,
close_time=close_time,
trading_mode=get_trading_mode(get_setting),
)
pnl = calc["pnl"]
fee = calc["fee"]
pnl_net = calc["pnl_net"]
form_pnl_raw = d.get("pnl")
if form_pnl_raw not in (None, ""):
pnl = float(form_pnl_raw)
pnl_net = round(pnl - fee, 2)
try:
holding_to_minutes = deps.holding_to_minutes
minutes = int(holding_to_minutes(open_time, close_time) or 0)
except Exception:
minutes = int(d.get("holding_minutes") or row.get("holding_minutes") or 0)
conn.execute(
"""UPDATE trade_logs SET
symbol_name=?, monitor_type=?, direction=?,
entry_price=?, stop_loss=?, take_profit=?, close_price=?,
lots=?, margin=?, holding_minutes=?, open_time=?, close_time=?,
pnl=?, fee=?, pnl_net=?, result=?, verified=1
WHERE id=?""",
(
d.get("symbol_name", "").strip(),
d.get("monitor_type", "").strip(),
direction,
entry,
stop_loss,
take_profit,
close_px,
lots,
float(d.get("margin") or 0),
minutes,
open_time,
close_time,
pnl,
fee,
pnl_net,
d.get("result", "").strip(),
tid,
),
)
try:
refresh_trade_log_equity_chain(conn, _read_initial_capital(conn))
except Exception as exc:
app.logger.debug("equity chain refresh after trade edit: %s", exc)
conn.commit()
conn.close()
touch_stats_cache()
flash("交易记录已核对保存")
return redirect(url_for("records"))
@app.route("/del_trade/<int:tid>")
@login_required
def del_trade(tid):
conn = get_db()
conn.execute("DELETE FROM trade_logs WHERE id=?", (tid,))
conn.commit()
conn.close()
touch_stats_cache()
flash("已删除")
return redirect(url_for("records"))
@app.route("/fill_review/<int:tid>")
@login_required
def fill_review_from_trade(tid):
conn = get_db()
row = conn.execute("SELECT * FROM trade_logs WHERE id=?", (tid,)).fetchone()
conn.close()
if not row:
flash("记录不存在")
return redirect(url_for("records"))
q = {
"symbol": row["symbol"],
"symbol_name": row["symbol_name"] or row["symbol"],
"market_code": row["market_code"] or "",
"sina_code": row["sina_code"] or "",
"direction": row["direction"],
"entry_price": row["entry_price"],
"stop_loss": row["stop_loss"],
"take_profit": row["take_profit"],
"close_price": row["close_price"],
"lots": row["lots"],
"open_time": row["open_time"],
"close_time": row["close_time"],
"pnl": row["pnl"],
}
params = {k: v for k, v in q.items() if v is not None}
return redirect(url_for("records", **params) + "#review-panel")
@app.route("/records")
@login_required
def records():
preset = request.args.get("preset", "")
start = request.args.get("start", "")
end = request.args.get("end", "")
if preset:
start, end = parse_review_date_filter(preset, start, end)
conn = get_db()
ctp_sync_info = None
sql = "SELECT * FROM review_records WHERE 1=1"
params: list = []
if start:
sql += " AND date(close_time) >= ?"
params.append(start)
if end:
sql += " AND date(close_time) <= ?"
params.append(end)
sql += " ORDER BY id DESC LIMIT 200"
review_list = conn.execute(sql, params).fetchall()
auto_list = conn.execute(
"SELECT * FROM trade_records ORDER BY id DESC LIMIT 30"
).fetchall()
trade_list = conn.execute(
"SELECT * FROM trade_logs ORDER BY id DESC LIMIT 500"
).fetchall()
from modules.trading.trade_log_lib import enrich_trades_for_records, _read_initial_capital
try:
initial_capital = _read_initial_capital(conn)
except Exception:
initial_capital = 100_000.0
trades, equity_curve = enrich_trades_for_records(
[dict(r) for r in trade_list],
initial_capital=initial_capital,
)
conn.close()
trade_prefill_keys = (
"symbol", "symbol_name", "market_code", "sina_code", "direction",
"entry_price", "stop_loss", "take_profit", "close_price",
"lots", "open_time", "close_time", "pnl",
)
prefill = {k: request.args.get(k) for k in trade_prefill_keys if request.args.get(k)}
return render_template(
"records.html",
reviews=review_list,
trades=trades,
equity_curve=equity_curve,
auto_records=auto_list,
ctp_sync_info=ctp_sync_info,
preset=preset,
start=start,
end=end,
prefill=prefill,
open_types=OPEN_TYPES,
exit_triggers=EXIT_TRIGGERS,
behavior_tags=BEHAVIOR_TAGS,
kline_periods=KLINE_PERIODS,
kline_cutoffs=KLINE_CUTOFFS,
)
@app.route("/add_review", methods=["POST"])
@login_required
def add_review():
d = request.form
open_type = d.get("open_type", "").strip()
exit_trigger = d.get("exit_trigger", "").strip()
if not open_type:
flash("请选择开仓类型")
return redirect(url_for("records"))
if not exit_trigger:
flash("请选择离场触发")
return redirect(url_for("records"))
symbol = d.get("symbol", "").strip()
symbol_name = d.get("symbol_name", "").strip()
market_code = d.get("market_code", "").strip()
sina_code = d.get("sina_code", "").strip()
if not symbol or not market_code:
flash("请从下拉列表选择品种(同花顺合约代码)")
return redirect(url_for("records"))
screenshot = ""
f = request.files.get("screenshot")
if f and f.filename:
fname = secure_filename(f.filename)
ts = datetime.now(TZ).strftime("%Y%m%d%H%M%S")
screenshot = f"{ts}_{fname}"
f.save(os.path.join(UPLOAD_DIR, screenshot))
tags = [t for t in BEHAVIOR_TAGS if d.get(f"tag_{t}")]
is_emotion = 1 if tags else 0
def num(key: str) -> Optional[float]:
v = d.get(key, "").strip()
if not v:
return None
return float(v)
open_time = d.get("open_time", "").strip()
close_time = d.get("close_time", "").strip()
direction = d.get("direction", "").strip()
entry_price = num("entry_price")
stop_loss = num("stop_loss")
take_profit = num("take_profit")
close_price = num("close_price")
lots = num("lots") or 1.0
holding = calc_holding_duration(open_time, close_time)
initial_pnl = calc_rr_ratio(direction, entry_price, stop_loss, take_profit)
actual_pnl = calc_rr_ratio(direction, entry_price, stop_loss, close_price)
gross_pnl = num("pnl")
if gross_pnl is None and entry_price and close_price:
spec_mult = calc_position_metrics(
direction, entry_price, stop_loss, take_profit,
lots, close_price, 0, symbol,
)
gross_pnl = spec_mult.get("float_pnl")
fee = calc_round_trip_fee(
symbol, entry_price or 0, close_price or 0, lots, open_time, close_time,
trading_mode=_trading_mode(),
)
pnl_net = round((gross_pnl or 0) - fee, 2) if gross_pnl is not None else None
auto_kline = bool(d.get("auto_kline"))
if auto_kline and not screenshot:
try:
generated = generate_review_kline_chart(
symbol=symbol,
periods=[d.get("kline_period1", "15m"), d.get("kline_period2", "1h")],
count=int(d.get("kline_count") or 300),
cutoff_label=d.get("kline_cutoff", "平仓时间"),
open_time=open_time,
close_time=close_time,
entry_price=entry_price,
stop_loss=stop_loss,
take_profit=take_profit,
close_price=close_price,
upload_dir=UPLOAD_DIR,
)
if generated:
screenshot = generated
except Exception as exc:
app.logger.warning("auto kline failed: %s", exc)
conn = get_db()
conn.execute(
"""INSERT INTO review_records
(open_time, close_time, symbol, symbol_name, market_code, sina_code,
timeframe, direction,
entry_price, stop_loss, take_profit, close_price, lots,
holding_duration, initial_pnl, actual_pnl, pnl, fee, pnl_net,
open_type, expected_rr, actual_rr, exit_trigger, exit_supplement,
watch_after_breakeven, new_position_while_occupied, screenshot,
auto_kline, kline_period1, kline_period2, kline_count, kline_cutoff,
behavior_tags, is_emotion, notes)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
open_time, close_time,
symbol, symbol_name, market_code, sina_code,
d.get("timeframe", "").strip(),
direction,
entry_price, stop_loss, take_profit, close_price, lots,
holding, initial_pnl, actual_pnl, gross_pnl, fee, pnl_net,
open_type,
None,
None,
exit_trigger,
d.get("exit_supplement", "").strip(),
d.get("watch_after_breakeven", ""),
d.get("new_position_while_occupied", ""),
screenshot,
1 if auto_kline else 0,
d.get("kline_period1", "15m"),
d.get("kline_period2", "1h"),
int(d.get("kline_count") or 300),
d.get("kline_cutoff", "平仓时间"),
",".join(tags),
is_emotion,
d.get("notes", "").strip(),
),
)
hook = getattr(app, "_risk_review_hook", None)
if hook:
hook(
conn,
",".join(tags),
exit_trigger,
d.get("exit_supplement", "").strip(),
)
conn.commit()
conn.close()
touch_stats_cache()
flash("复盘记录已保存")
return redirect(url_for("records"))
@app.route("/del_review/<int:rid>")
@login_required
def del_review(rid):
conn = get_db()
row = conn.execute("SELECT screenshot FROM review_records WHERE id=?", (rid,)).fetchone()
if row and row["screenshot"]:
path = os.path.join(UPLOAD_DIR, row["screenshot"])
if os.path.isfile(path):
os.remove(path)
conn.execute("DELETE FROM review_records WHERE id=?", (rid,))
conn.commit()
conn.close()
touch_stats_cache()
flash("已删除")
return redirect(url_for("records"))
@app.route("/uploads/<path:filename>")
@login_required
def uploaded_file(filename):
from flask import send_from_directory
return send_from_directory(UPLOAD_DIR, filename)
@app.route("/del_record/<int:rid>")
@login_required
def del_record(rid):
conn = get_db()
conn.execute("DELETE FROM trade_records WHERE id=?", (rid,))
conn.commit()
conn.close()
flash("已删除")
return redirect(url_for("records"))
+12
View File
@@ -0,0 +1,12 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
"""Account risk rules."""
from modules.risk.account_risk_lib import * # noqa: F401,F403
def register(deps) -> None:
del deps
__all__ = ["register"]
+450
View File
@@ -0,0 +1,450 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""账户冷静期 / 日冻结(自 crypto_monitor 复制并简化为单账户期货版)。"""
from __future__ import annotations
import os
import time
from datetime import datetime
from typing import Any, Callable, Optional, TypeVar
from zoneinfo import ZoneInfo
from modules.core.db_conn import OperationalError, is_missing_relation_error, rollback_if_postgres
T = TypeVar("T")
STATUS_NORMAL = "normal"
STATUS_FREEZE_1H = "freeze_1h"
STATUS_FREEZE_4H = "freeze_4h"
STATUS_DAILY = "freeze_daily"
STATUS_FREEZE_POSITION = "freeze_position"
STATUS_LABELS = {
STATUS_NORMAL: "正常",
STATUS_FREEZE_1H: "1h冻结",
STATUS_FREEZE_4H: "4h冻结",
STATUS_DAILY: "日冻结",
STATUS_FREEZE_POSITION: "仓位上限冻结",
}
MOOD_ISSUE_OPTIONS = (
"怕踏空", "报复开仓", "盈利飘了", "拿不住单", "扛单", "重仓违规",
)
CLOSE_SOURCE_USER = "user_instance"
CLOSE_SOURCE_TREND_STOP = "user_trend_stop"
def _app_tz():
name = (os.getenv("APP_TIMEZONE") or "Asia/Shanghai").strip()
try:
return ZoneInfo(name)
except Exception:
return ZoneInfo("Asia/Shanghai")
def risk_control_enabled() -> bool:
raw = (os.getenv("RISK_CONTROL_ENABLED") or "true").strip().lower()
return raw in ("1", "true", "yes", "on")
def cooling_hours_manual() -> float:
"""期货版不使用应用层冷静期(交易所自有规则),恒为 0。"""
return 0.0
def cooling_hours_manual_journal() -> float:
return 0.0
def manual_close_daily_limit() -> int:
try:
return max(1, int(os.getenv("RISK_MANUAL_CLOSE_DAILY_LIMIT", "2")))
except (TypeError, ValueError):
return 2
def max_active_positions() -> int:
try:
return max(1, int(os.getenv("MAX_ACTIVE_POSITIONS", "1")))
except (TypeError, ValueError):
return 1
def daily_position_limit() -> int:
"""当日最多开仓次数(含已平)。"""
try:
return max(1, int(os.getenv("RISK_DAILY_POSITION_LIMIT", "5")))
except (TypeError, ValueError):
return 5
def daily_trading_risk_pct_limit() -> float:
"""当日累计止损风险占权益上限(%)。"""
try:
return max(0.1, float(os.getenv("RISK_DAILY_TRADING_RISK_PCT", "2")))
except (TypeError, ValueError):
return 2.0
def trading_day_reset_hour() -> int:
try:
return max(0, min(23, int(os.getenv("TRADING_DAY_RESET_HOUR", "8"))))
except (TypeError, ValueError):
return 8
_SCHEMA_READY = False
ACCOUNT_RISK_STATE_SQL = """
CREATE TABLE IF NOT EXISTS account_risk_state (
id INTEGER PRIMARY KEY,
trading_day TEXT,
manual_close_count INTEGER DEFAULT 0,
cooloff_until_ms INTEGER,
cooloff_hours INTEGER,
daily_frozen INTEGER DEFAULT 0,
last_close_at_ms INTEGER,
updated_at TEXT
)
"""
def _account_risk_table_exists(conn) -> bool:
try:
conn.execute("SELECT 1 FROM account_risk_state WHERE id=1")
return True
except Exception as exc:
if is_missing_relation_error(exc):
rollback_if_postgres(conn)
return False
raise
def _db_retry(action: Callable[[], T], *, retries: int = 8, base_delay: float = 0.03) -> T:
last: OperationalError | None = None
for i in range(retries):
try:
return action()
except OperationalError as exc:
msg = str(exc).lower()
if "locked" not in msg and "serialize" not in msg and "deadlock" not in msg:
raise
last = exc
time.sleep(base_delay * (2 ** i))
if last is not None:
raise last
raise RuntimeError("db retry failed")
def ensure_account_risk_schema(conn) -> None:
global _SCHEMA_READY
if _SCHEMA_READY and _account_risk_table_exists(conn):
return
_SCHEMA_READY = False
conn.execute(ACCOUNT_RISK_STATE_SQL)
conn.commit()
if not conn.execute("SELECT 1 FROM account_risk_state WHERE id=1").fetchone():
conn.execute(
"INSERT INTO account_risk_state (id, trading_day, manual_close_count, daily_frozen) "
"VALUES (1, '', 0, 0)"
)
conn.commit()
_SCHEMA_READY = True
def _row_get(row, key, default=None):
if row is None:
return default
try:
return row[key]
except (KeyError, IndexError, TypeError):
return default
def _now_ms(now: Optional[datetime] = None) -> int:
dt = now or datetime.now(_app_tz())
if dt.tzinfo is None:
dt = dt.replace(tzinfo=_app_tz())
return int(dt.timestamp() * 1000)
def trading_day_label(now: Optional[datetime] = None) -> str:
dt = now or datetime.now(_app_tz())
if dt.hour < trading_day_reset_hour():
from datetime import timedelta
dt = dt - timedelta(days=1)
return dt.date().isoformat()
def trading_day_start(now: Optional[datetime] = None) -> datetime:
dt = now or datetime.now(_app_tz())
if dt.tzinfo is None:
dt = dt.replace(tzinfo=_app_tz())
reset_h = trading_day_reset_hour()
start = dt.replace(hour=reset_h, minute=0, second=0, microsecond=0)
if dt.hour < reset_h:
from datetime import timedelta
start = start - timedelta(days=1)
return start
def _parse_open_time_ms(open_time: str) -> Optional[int]:
s = (open_time or "").strip().replace("T", " ")[:19]
if not s:
return None
try:
dt = datetime.strptime(s, "%Y-%m-%d %H:%M:%S")
if dt.tzinfo is None:
dt = dt.replace(tzinfo=_app_tz())
return int(dt.timestamp() * 1000)
except ValueError:
try:
dt = datetime.strptime(s[:10], "%Y-%m-%d").replace(tzinfo=_app_tz())
return int(dt.timestamp() * 1000)
except ValueError:
return None
def _opened_in_trading_day(open_time: str, now: Optional[datetime] = None) -> bool:
oms = _parse_open_time_ms(open_time)
if oms is None:
return False
return oms >= int(trading_day_start(now).timestamp() * 1000)
def count_daily_opens(conn, now: Optional[datetime] = None) -> int:
rows = conn.execute(
"SELECT open_time FROM trade_order_monitors "
"WHERE open_time IS NOT NULL AND trim(open_time) <> ''"
).fetchall()
return sum(1 for r in rows if _opened_in_trading_day(r["open_time"], now))
def daily_trading_risk_used_pct(
conn, equity: float, now: Optional[datetime] = None,
) -> Optional[float]:
if equity <= 0:
return None
from modules.core.contract_specs import calc_position_metrics
total = 0.0
rows = conn.execute(
"""SELECT symbol, direction, lots, entry_price, stop_loss, take_profit, open_time
FROM trade_order_monitors
WHERE open_time IS NOT NULL AND trim(open_time) <> ''"""
).fetchall()
for r in rows:
if not _opened_in_trading_day(r["open_time"], now):
continue
entry = float(r["entry_price"] or 0)
if entry <= 0:
continue
sl = float(r["stop_loss"] if r["stop_loss"] is not None else entry)
tp = float(r["take_profit"] if r["take_profit"] is not None else entry)
lots = int(r["lots"] or 0)
if lots <= 0:
continue
m = calc_position_metrics(
r["direction"] or "long",
entry,
sl,
tp,
lots,
entry,
equity,
r["symbol"] or "",
)
total += float(m.get("risk_amount") or 0)
if total <= 0:
return 0.0
return round(total / equity * 100, 2)
def count_active_trade_monitors(conn) -> int:
try:
n = conn.execute(
"SELECT COUNT(*) FROM trade_order_monitors WHERE status='active'"
).fetchone()[0]
return int(n or 0)
except Exception:
return 0
def parse_mood_issues(raw: Any) -> list[str]:
if raw is None:
return []
if isinstance(raw, (list, tuple)):
parts = [str(x).strip() for x in raw if str(x).strip()]
else:
parts = [x.strip() for x in str(raw).split(",") if x.strip()]
return [p for p in parts if p in MOOD_ISSUE_OPTIONS]
def on_user_initiated_close(conn, *, trading_day: str, now: Optional[datetime] = None) -> None:
if not risk_control_enabled():
return
ensure_account_risk_schema(conn)
row = conn.execute("SELECT * FROM account_risk_state WHERE id=1").fetchone()
td = (trading_day or trading_day_label(now)).strip()
stored = str(_row_get(row, "trading_day") or "")
count = int(_row_get(row, "manual_close_count") or 0)
if stored != td:
count = 0
count += 1
close_ms = _now_ms(now)
if count >= manual_close_daily_limit():
conn.execute(
"""UPDATE account_risk_state SET trading_day=?, manual_close_count=?,
daily_frozen=1, cooloff_until_ms=NULL, cooloff_hours=NULL,
last_close_at_ms=?, updated_at=? WHERE id=1""",
(td, count, close_ms, datetime.now().strftime("%Y-%m-%d %H:%M:%S")),
)
return
conn.execute(
"""UPDATE account_risk_state SET trading_day=?, manual_close_count=?,
daily_frozen=0, cooloff_until_ms=NULL, cooloff_hours=NULL,
last_close_at_ms=?, updated_at=? WHERE id=1""",
(td, count, close_ms, datetime.now().strftime("%Y-%m-%d %H:%M:%S")),
)
def on_mood_journal_freeze(conn, *, trading_day: str) -> None:
if not risk_control_enabled():
return
ensure_account_risk_schema(conn)
td = (trading_day or trading_day_label()).strip()
conn.execute(
"UPDATE account_risk_state SET trading_day=?, daily_frozen=1, updated_at=? WHERE id=1",
(td, datetime.now().strftime("%Y-%m-%d %H:%M:%S")),
)
def reduce_cooloff_after_journal(conn, *, trading_day: str, now: Optional[datetime] = None) -> None:
"""期货版无应用层冷静期,保留空实现兼容旧复盘钩子。"""
del conn, trading_day, now
return
def get_risk_status(
conn,
*,
now: Optional[datetime] = None,
active_count: Optional[int] = None,
equity: Optional[float] = None,
) -> dict:
def _load() -> dict:
ensure_account_risk_schema(conn)
try:
row = conn.execute("SELECT * FROM account_risk_state WHERE id=1").fetchone()
except Exception as exc:
if is_missing_relation_error(exc):
global _SCHEMA_READY
_SCHEMA_READY = False
rollback_if_postgres(conn)
ensure_account_risk_schema(conn)
row = conn.execute("SELECT * FROM account_risk_state WHERE id=1").fetchone()
else:
raise
td = trading_day_label(now)
stored = str(_row_get(row, "trading_day") or "")
if stored != td:
conn.execute(
"UPDATE account_risk_state SET trading_day=?, manual_close_count=0, daily_frozen=0 WHERE id=1 AND trading_day<>?",
(td, td),
)
conn.commit()
row = conn.execute("SELECT * FROM account_risk_state WHERE id=1").fetchone()
now_ms = _now_ms(now)
daily = int(_row_get(row, "daily_frozen") or 0) == 1
until = _row_get(row, "cooloff_until_ms")
if until:
conn.execute(
"UPDATE account_risk_state SET cooloff_until_ms=NULL, cooloff_hours=NULL WHERE id=1"
)
conn.commit()
active = count_active_trade_monitors(conn) if active_count is None else int(active_count)
mx = max_active_positions()
pos_limit = active >= mx
daily_opens = count_daily_opens(conn, now)
daily_pos_lim = daily_position_limit()
daily_open_limit = daily_opens >= daily_pos_lim
daily_risk_used: Optional[float] = None
daily_risk_lim = daily_trading_risk_pct_limit()
daily_risk_limit_hit = False
if equity and float(equity) > 0:
daily_risk_used = daily_trading_risk_used_pct(conn, float(equity), now)
if daily_risk_used is not None and daily_risk_used >= daily_risk_lim:
daily_risk_limit_hit = True
base = {
"active_count": active,
"max_active_positions": mx,
"daily_open_count": daily_opens,
"daily_position_limit": daily_pos_lim,
"daily_risk_used_pct": daily_risk_used,
"daily_trading_risk_pct_limit": daily_risk_lim,
}
if daily:
return {
**base,
"status": STATUS_DAILY,
"status_label": STATUS_LABELS[STATUS_DAILY],
"can_trade": False,
"can_roll": False,
"reason": "当日日冻结,禁止新开仓",
}
if daily_risk_limit_hit:
return {
**base,
"status": STATUS_DAILY,
"status_label": STATUS_LABELS[STATUS_DAILY],
"can_trade": False,
"can_roll": pos_limit,
"reason": f"已达日交易风险上限 {daily_risk_used:.2f}%/{daily_risk_lim:.2f}%",
}
if daily_open_limit:
return {
**base,
"status": STATUS_DAILY,
"status_label": STATUS_LABELS[STATUS_DAILY],
"can_trade": False,
"can_roll": pos_limit,
"reason": f"已达日持仓上限 {daily_opens}/{daily_pos_lim}",
}
if pos_limit:
return {
**base,
"status": STATUS_FREEZE_POSITION,
"status_label": STATUS_LABELS[STATUS_FREEZE_POSITION],
"can_trade": False,
"can_roll": True,
"reason": f"已达仓位上限 {active}/{mx}",
}
return {
**base,
"status": STATUS_NORMAL,
"status_label": STATUS_LABELS[STATUS_NORMAL],
"can_trade": True,
"can_roll": True,
"reason": "可新开仓",
}
return _db_retry(_load)
def assert_can_open(
conn,
*,
active_count: Optional[int] = None,
equity: Optional[float] = None,
) -> Optional[str]:
rs = get_risk_status(conn, active_count=active_count, equity=equity)
if not rs.get("can_trade"):
return rs.get("reason") or "当前不可开仓"
return None
+5
View File
@@ -0,0 +1,5 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
from modules.settings.routes import register
__all__ = ["register"]
+86
View File
@@ -0,0 +1,86 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""Web 登录账号:settings 表 + .env 同步。"""
from __future__ import annotations
import os
import re
from typing import Callable
from werkzeug.security import check_password_hash, generate_password_hash
from modules.core.env_file import update_env_vars
ADMIN_USERNAME_KEY = "ADMIN_USERNAME"
ADMIN_PASSWORD_KEY = "ADMIN_PASSWORD"
def save_admin_credentials(
*,
username: str,
old_password: str,
new_password: str,
new_password2: str,
get_setting: Callable[[str, str], str],
set_setting: Callable[[str, str], None],
) -> tuple[bool, str, dict[str, str]]:
"""
校验原密码后更新用户名/密码写入 settings .env
返回 (成功, 提示, env_updates)
"""
username = (username or "").strip()
old_password = old_password or ""
new_password = new_password or ""
new_password2 = new_password2 or ""
if not username:
return False, "用户名不能为空", {}
if len(username) > 64:
return False, "用户名过长(最多 64 字符)", {}
if not re.match(r"^[A-Za-z0-9_.@-]+$", username):
return False, "用户名仅支持字母、数字及 _ . @ -", {}
admin_hash = get_setting("admin_password_hash")
if not admin_hash or not check_password_hash(admin_hash, old_password):
return False, "原密码错误", {}
current_username = (get_setting("admin_username") or "").strip()
password_change = bool(new_password or new_password2)
if password_change:
if not new_password or not new_password2:
return False, "请同时填写新密码与确认密码", {}
if len(new_password) < 6:
return False, "新密码至少 6 位", {}
if new_password != new_password2:
return False, "两次新密码不一致", {}
username_changed = username != current_username
if not username_changed and not password_change:
return False, "未修改任何内容", {}
set_setting("admin_username", username)
env_updates: dict[str, str] = {ADMIN_USERNAME_KEY: username}
if password_change:
set_setting("admin_password_hash", generate_password_hash(new_password))
env_updates[ADMIN_PASSWORD_KEY] = new_password
try:
update_env_vars(env_updates)
except OSError as exc:
return False, f"数据库已更新,但写入 .env 失败:{exc}", env_updates
for key, val in env_updates.items():
os.environ[key] = val
parts: list[str] = []
if username_changed:
parts.append("用户名已更新")
if password_change:
parts.append("密码已更新")
parts.append("已同步至 .env")
return True, "".join(parts), env_updates
+53
View File
@@ -0,0 +1,53 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""顶栏导航项显示开关(系统设置)。"""
from __future__ import annotations
import json
from typing import Callable
# 可在系统设置中开关的导航项
NAV_TOGGLES: dict[str, str] = {
"dashboard": "数据看板",
"risk_guide": "风控说明",
"fees": "手续费配置",
"plans": "开单计划",
"market": "行情K线",
"strategy": "策略交易",
"ai": "AI 分析",
}
DEFAULT_NAV: dict[str, bool] = {k: True for k in NAV_TOGGLES}
def get_nav_items(get_setting: Callable[[str, str], str]) -> dict[str, bool]:
raw = (get_setting("nav_items", "") or "").strip()
out = dict(DEFAULT_NAV)
if not raw:
return out
try:
data = json.loads(raw)
if isinstance(data, dict):
for k in NAV_TOGGLES:
if k in data:
out[k] = bool(data[k])
except json.JSONDecodeError:
pass
return out
def save_nav_items(set_setting: Callable[[str, str], None], items: dict[str, bool]) -> None:
merged = dict(DEFAULT_NAV)
for k in NAV_TOGGLES:
if k in items:
merged[k] = bool(items[k])
set_setting("nav_items", json.dumps(merged, ensure_ascii=False))
def nav_enabled(get_setting: Callable[[str, str], str], key: str) -> bool:
if key not in NAV_TOGGLES:
return True
return get_nav_items(get_setting).get(key, True)
+314
View File
@@ -0,0 +1,314 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
"""HTTP routes for settings module."""
from __future__ import annotations
from datetime import date, datetime
from flask import (
Response,
flash,
jsonify,
redirect,
render_template,
request,
send_file,
session,
stream_with_context,
url_for,
)
def register(deps) -> None:
app = deps.app
login_required = deps.login_required
require_nav = deps.require_nav
get_db = deps.get_db
get_setting = deps.get_setting
set_setting = deps.set_setting
fetch_price = deps.fetch_price
send_wechat_msg = deps.send_wechat_msg
touch_stats_cache = deps.touch_stats_cache
get_stats_data = deps.get_stats_data
build_market_quote_payload = deps.build_market_quote_payload
today_str = deps.today_str
expire_old_plans = deps.expire_old_plans
TZ = deps.tz
DB_PATH = deps.db_path
UPLOAD_DIR = deps.upload_dir
OPEN_TYPES = deps.open_types
EXIT_TRIGGERS = deps.exit_triggers
BEHAVIOR_TAGS = deps.behavior_tags
KLINE_PERIODS = deps.kline_periods
KLINE_CUTOFFS = deps.kline_cutoffs
calc_holding_duration = deps.calc_holding_duration
holding_to_minutes = deps.holding_to_minutes
classify_close_result = deps.classify_close_result
calc_rr_ratio = deps.calc_rr_ratio
calc_theoretical_pnl = deps.calc_theoretical_pnl
parse_review_date_filter = deps.parse_review_date_filter
_trading_mode = deps.trading_mode
_ua_is_phone = deps.ua_is_phone
_static_asset_v = deps.static_asset_v
from modules.settings.nav_settings import NAV_TOGGLES, get_nav_items, save_nav_items
from modules.settings.admin_settings import save_admin_credentials
from modules.backup.db_backup import (
backup_dir,
backup_in_progress,
default_restore_dir,
get_backup_last_at,
list_backups,
schedule_backup,
)
from modules.market.market import get_quote_source_label
from modules.trading.product_recommend import small_account_margin_recommendations
@app.route("/settings", methods=["GET", "POST"])
@login_required
def settings():
if request.method == "POST":
action = request.form.get("action")
if action == "backup_now":
ok, msg = schedule_backup(
get_setting=get_setting,
set_setting=set_setting,
include_uploads=True,
)
flash(msg if ok else msg)
elif action == "backup_config":
auto = request.form.get("backup_auto_enabled") == "1"
set_setting("backup_auto_enabled", "1" if auto else "0")
try:
hour = int(request.form.get("backup_auto_hour", "3") or 3)
set_setting("backup_auto_hour", str(max(0, min(23, hour))))
except ValueError:
flash("自动备份小时无效")
return redirect(url_for("settings"))
try:
keep = int(request.form.get("backup_keep_count", "30") or 30)
set_setting("backup_keep_count", str(max(5, min(200, keep))))
except ValueError:
flash("保留份数无效")
return redirect(url_for("settings"))
flash("备份策略已保存")
elif action == "wechat":
webhook = request.form.get("wechat_webhook", "").strip()
set_setting("wechat_webhook", webhook)
flash("企业微信配置已保存")
elif action == "ai":
set_setting("ai_enabled", "1" if request.form.get("ai_enabled") else "0")
provider = (request.form.get("ai_provider") or "ollama").strip().lower()
if provider not in ("ollama", "openai"):
provider = "ollama"
set_setting("ai_provider", provider)
set_setting("ai_ollama_base_url", (request.form.get("ai_ollama_base_url") or "").strip())
set_setting("ai_ollama_model", (request.form.get("ai_ollama_model") or "").strip())
set_setting("ai_openai_base_url", (request.form.get("ai_openai_base_url") or "").strip())
key = (request.form.get("ai_openai_api_key") or "").strip()
if key:
set_setting("ai_openai_api_key", key)
set_setting("ai_openai_model", (request.form.get("ai_openai_model") or "").strip())
set_setting("ai_daily_report_enabled", "1" if request.form.get("ai_daily_report_enabled") else "0")
try:
set_setting("ai_daily_report_hour", str(max(0, min(23, int(request.form.get("ai_daily_report_hour", "15") or 15)))))
except ValueError:
pass
try:
set_setting("ai_daily_report_minute", str(max(0, min(59, int(request.form.get("ai_daily_report_minute", "5") or 5)))))
except ValueError:
pass
flash("AI 配置已保存")
elif action == "trading":
mode = request.form.get("trading_mode", "simulation").strip()
if mode not in ("simulation", "live"):
mode = "simulation"
sizing = request.form.get("position_sizing_mode", "fixed").strip()
if sizing == "risk":
sizing = "amount"
if sizing not in ("fixed", "amount"):
sizing = "fixed"
set_setting("trading_mode", mode)
set_setting("position_sizing_mode", sizing)
try:
fl = int(float(request.form.get("fixed_lots", "1") or 1))
set_setting("fixed_lots", str(max(1, fl)))
except ValueError:
flash("固定手数无效")
return redirect(url_for("settings"))
try:
fa = float(request.form.get("fixed_amount", "5000") or 5000)
set_setting("fixed_amount", str(max(1.0, fa)))
except ValueError:
flash("固定金额无效")
return redirect(url_for("settings"))
try:
rp = float(request.form.get("risk_percent", "1") or 1)
set_setting("risk_percent", str(max(0.1, min(100.0, rp))))
except ValueError:
pass
try:
mp = float(request.form.get("max_margin_pct", "30") or 30)
set_setting("max_margin_pct", str(max(1.0, min(100.0, mp))))
except ValueError:
flash("保证金比例无效")
return redirect(url_for("settings"))
try:
rmp = float(request.form.get("roll_max_margin_pct", "50") or 50)
set_setting("roll_max_margin_pct", str(max(1.0, min(100.0, rmp))))
except ValueError:
flash("滚仓保证金比例无效")
return redirect(url_for("settings"))
try:
tb = int(float(request.form.get("trailing_be_tick_buffer", "2") or 2))
set_setting("trailing_be_tick_buffer", str(max(1, min(20, tb))))
except ValueError:
flash("移动保本缓冲无效")
return redirect(url_for("settings"))
try:
pt = int(float(request.form.get("pending_order_timeout_min", "5") or 5))
set_setting("pending_order_timeout_min", str(max(1, min(60, pt))))
except ValueError:
flash("挂单超时无效")
return redirect(url_for("settings"))
flash("交易模式已保存")
elif action == "ctp":
from modules.ctp.ctp_settings import save_ctp_auto_connect, is_ctp_auto_connect_enabled
from modules.ctp.ctp_settings import save_ctp_settings_from_form
from modules.ctp.vnpy_bridge import ctp_disconnect
was_enabled = is_ctp_auto_connect_enabled(get_setting)
auto_enabled = save_ctp_auto_connect(request.form, set_setting)
save_result = save_ctp_settings_from_form(request.form, set_setting)
if not auto_enabled:
ctp_disconnect(set_disabled_hint=True)
elif not was_enabled and auto_enabled:
try:
from modules.ctp.vnpy_bridge import get_bridge
from modules.core.trading_context import get_trading_mode
mode = get_trading_mode(get_setting)
get_bridge().reconnect_after_settings_saved(mode)
except Exception as exc:
app.logger.debug("CTP connect after enable auto: %s", exc)
pwd_updated = save_result.get("passwords_updated") or []
pwd_empty = save_result.get("passwords_submitted_empty") or []
simnow_pwd_len = len((request.form.get("simnow_password") or "").strip())
live_pwd_len = len((request.form.get("ctp_live_password") or "").strip())
print(
f"CTP settings save: simnow_password_len={simnow_pwd_len} "
f"live_password_len={live_pwd_len} updated={pwd_updated}",
flush=True,
)
app.logger.info(
"CTP settings save: simnow_password_len=%s live_password_len=%s updated=%s",
simnow_pwd_len,
live_pwd_len,
pwd_updated,
)
if "simnow_password" in pwd_updated:
pwd_note = f"SimNow 交易密码已更新({simnow_pwd_len} 位)"
elif "simnow_password" in pwd_empty:
pwd_note = "SimNow 交易密码未改:提交为空,请在「交易密码」框手打后再保存"
elif "ctp_live_password" in pwd_updated:
pwd_note = "实盘交易密码已更新"
elif "ctp_live_password" in pwd_empty:
pwd_note = "实盘交易密码未改(提交为空)"
else:
pwd_note = ""
if not auto_enabled:
flash("CTP 配置已保存;自动连接已关闭,所有 CTP 连接已断开")
return redirect(url_for("settings"))
if not was_enabled:
flash("CTP 配置已保存;自动连接已开启,正在连接…")
return redirect(url_for("settings"))
flash_msg = "CTP 配置已保存,正在使用新地址重连…"
if pwd_note:
flash_msg = f"CTP 配置已保存;{pwd_note},正在重连…"
try:
from modules.ctp.vnpy_bridge import get_bridge
from modules.core.trading_context import get_trading_mode
b = get_bridge()
if pwd_updated:
b._clear_login_cooldown()
mode = get_trading_mode(get_setting)
info = b.reconnect_after_settings_saved(mode)
if info.get("cooldown"):
flash_msg = f"CTP 配置已保存;{pwd_note or '请稍后再连'}"
elif not info.get("started") and info.get("connected"):
flash_msg = f"CTP 配置已保存;{pwd_note or '当前连接正常'}"
except Exception as exc:
app.logger.warning("CTP reconnect after settings save: %s", exc)
flash_msg = f"CTP 配置已保存;{pwd_note or '请稍后在持仓监控页重连'}"
flash(flash_msg)
elif action == "nav":
items = {k: request.form.get(f"nav_{k}") == "on" for k in NAV_TOGGLES}
save_nav_items(set_setting, items)
flash("导航显示已保存")
elif action == "password":
ok, msg, _ = save_admin_credentials(
username=request.form.get("admin_username", ""),
old_password=request.form.get("old_password", ""),
new_password=request.form.get("new_password", ""),
new_password2=request.form.get("new_password2", ""),
get_setting=get_setting,
set_setting=set_setting,
)
if ok and session.get("logged_in"):
session["username"] = (request.form.get("admin_username") or "").strip()
flash(msg)
return redirect(url_for("settings"))
webhook = get_setting("wechat_webhook")
username = get_setting("admin_username")
ctp_st = {}
try:
from modules.ctp.vnpy_bridge import ctp_status
from modules.core.trading_context import get_trading_mode
ctp_st = ctp_status(get_trading_mode(get_setting))
except Exception:
pass
from modules.ctp.ctp_settings import get_ctp_settings_for_ui, is_ctp_auto_connect_enabled
from modules.trading.product_recommend import small_account_margin_recommendations
return render_template(
"settings.html",
webhook=webhook,
username=username,
quote_label=get_quote_source_label(ctp_connected=bool(ctp_st.get("connected"))),
ctp_status=ctp_st,
ctp_cfg=get_ctp_settings_for_ui(),
ctp_auto_connect=is_ctp_auto_connect_enabled(get_setting),
trading_mode=get_setting("trading_mode", "simulation"),
position_sizing_mode=get_setting("position_sizing_mode", "fixed"),
fixed_lots=get_setting("fixed_lots", "1"),
fixed_amount=get_setting("fixed_amount", "5000"),
risk_percent=get_setting("risk_percent", "1"),
max_margin_pct=get_setting("max_margin_pct", "30"),
roll_max_margin_pct=get_setting("roll_max_margin_pct", "50"),
small_account_margin_rec=small_account_margin_recommendations(),
trailing_be_tick_buffer=get_setting("trailing_be_tick_buffer", "2"),
pending_order_timeout_min=get_setting("pending_order_timeout_min", "5"),
nav_items=get_nav_items(get_setting),
nav_toggles=NAV_TOGGLES,
backup_dir=str(backup_dir()),
backup_last_at=get_backup_last_at(get_setting),
backup_running=backup_in_progress(),
backup_items=list_backups(),
backup_auto_enabled=get_setting("backup_auto_enabled", "1") == "1",
backup_auto_hour=get_setting("backup_auto_hour", "3"),
backup_keep_count=get_setting("backup_keep_count", "30"),
backup_restore_dir=default_restore_dir(),
ai_enabled=get_setting("ai_enabled", "0") == "1",
ai_provider=get_setting("ai_provider", "ollama"),
ai_ollama_base_url=get_setting("ai_ollama_base_url", "http://127.0.0.1:11434"),
ai_ollama_model=get_setting("ai_ollama_model", "qwen2.5:7b"),
ai_openai_base_url=get_setting("ai_openai_base_url", "https://api.openai.com/v1"),
ai_openai_api_key=get_setting("ai_openai_api_key", ""),
ai_openai_model=get_setting("ai_openai_model", "gpt-4o-mini"),
ai_daily_report_enabled=get_setting("ai_daily_report_enabled", "1") == "1",
ai_daily_report_hour=get_setting("ai_daily_report_hour", "15"),
ai_daily_report_minute=get_setting("ai_daily_report_minute", "5"),
)
+5
View File
@@ -0,0 +1,5 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
from modules.stats.routes import register
__all__ = ["register"]
+288
View File
@@ -0,0 +1,288 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""数据看板:账户、关键位、平仓记录聚合。"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Callable, Optional
from zoneinfo import ZoneInfo
_TZ = ZoneInfo("Asia/Shanghai")
_PRICE_CACHE: dict[str, tuple[float, float]] = {}
_PRICE_CACHE_TTL = 2.0
def _cached_fetch_price(
fetch_price: Callable[[str, str, str], Optional[float]],
sym: str,
market: str,
sina: str,
) -> Optional[float]:
key = sym or ""
now = datetime.now().timestamp()
hit = _PRICE_CACHE.get(key)
if hit and (now - hit[1]) < _PRICE_CACHE_TTL:
return hit[0]
price = fetch_price(sym, market, sina)
if price is not None:
_PRICE_CACHE[key] = (float(price), now)
return price
def _direction_label(direction: str) -> str:
return "做多" if (direction or "").strip().lower() == "long" else "做空"
def _symbol_fields(ths_code: str) -> dict[str, Any]:
from modules.core.symbols import position_symbol_meta
sym = (ths_code or "").strip()
meta = position_symbol_meta(sym)
return {
"symbol_code": sym,
"symbol_name": meta.get("name") or sym,
"symbol_exchange": meta.get("exchange") or "",
"symbol_is_main": bool(meta.get("is_main")),
}
def build_risk_overview(
conn,
get_setting: Callable[[str, str], str],
*,
equity: Optional[float] = None,
margin_used: Optional[float] = None,
) -> dict[str, Any]:
from risk.account_risk_lib import (
cooling_hours_manual,
cooling_hours_manual_journal,
count_daily_opens,
daily_position_limit,
daily_trading_risk_pct_limit,
daily_trading_risk_used_pct,
ensure_account_risk_schema,
get_risk_status,
manual_close_daily_limit,
max_active_positions,
risk_control_enabled,
trading_day_label,
trading_day_reset_hour,
)
from modules.core.trading_context import (
get_fixed_amount,
get_fixed_lots,
get_max_margin_pct,
get_roll_max_margin_pct,
get_sizing_mode,
)
ensure_account_risk_schema(conn)
risk = dict(get_risk_status(conn, equity=equity) or {})
row = conn.execute("SELECT * FROM account_risk_state WHERE id=1").fetchone()
td = trading_day_label()
stored_td = str(row["trading_day"] or "") if row else ""
manual_count = int(row["manual_close_count"] or 0) if row and stored_td == td else 0
margin_pct_used: Optional[float] = None
if equity and equity > 0 and margin_used is not None and margin_used >= 0:
margin_pct_used = round(float(margin_used) / float(equity) * 100, 2)
max_margin = get_max_margin_pct(get_setting)
sizing = get_sizing_mode(get_setting)
sizing_label = "固定金额" if sizing == "amount" else "固定手数"
daily_opens = int(risk.get("daily_open_count") or count_daily_opens(conn))
daily_risk_used = risk.get("daily_risk_used_pct")
if daily_risk_used is None and equity and equity > 0:
daily_risk_used = daily_trading_risk_used_pct(conn, float(equity))
return {
"enabled": risk_control_enabled(),
"status": risk,
"manual_close_count_today": manual_count,
"margin_pct_used": margin_pct_used,
"daily_open_count": daily_opens,
"daily_risk_used_pct": daily_risk_used,
"limits": {
"max_active_positions": max_active_positions(),
"position_mode": "single" if max_active_positions() <= 1 else "multi",
"position_mode_label": "单仓模式" if max_active_positions() <= 1 else "多仓模式",
"daily_position_limit": daily_position_limit(),
"daily_trading_risk_pct_limit": daily_trading_risk_pct_limit(),
"manual_close_daily_limit": manual_close_daily_limit(),
"cooling_hours_manual": cooling_hours_manual(),
"cooling_hours_manual_journal": cooling_hours_manual_journal(),
"trading_day_reset_hour": trading_day_reset_hour(),
"max_margin_pct": max_margin,
"roll_max_margin_pct": get_roll_max_margin_pct(get_setting),
"sizing_mode": sizing,
"sizing_label": sizing_label,
"fixed_lots": get_fixed_lots(get_setting),
"fixed_amount": get_fixed_amount(get_setting),
},
}
def build_dashboard_payload(
*,
get_db: Callable,
get_setting: Callable[[str, str], str],
fetch_price: Callable[[str, str, str], Optional[float]],
closes_limit: int = 40,
sync_ctp_trades: bool = False,
) -> dict[str, Any]:
from modules.core.trading_context import get_account_capital, get_trading_mode, trading_mode_label
from modules.ctp.vnpy_bridge import ctp_account_margin_used, ctp_status, get_bridge
mode = get_trading_mode(get_setting)
ctp_st = dict(ctp_status(mode) or {})
conn = get_db()
try:
capital = float(get_account_capital(conn, get_setting) or 0)
equity = capital
available: Optional[float] = None
margin_used: Optional[float] = None
if ctp_st.get("connected"):
if sync_ctp_trades:
try:
from modules.ctp.ctp_trade_sync import sync_trade_logs_from_ctp
sync_trade_logs_from_ctp(
conn, mode, capital=capital, trading_mode=mode,
)
conn.commit()
except Exception:
pass
try:
b = get_bridge()
if b.connected_mode == mode and b.ping():
acc = b.get_account() or {}
else:
acc = {}
balance = float(acc.get("balance") or 0)
if balance > 0:
equity = balance
avail = acc.get("available")
if avail is not None:
available = round(float(avail), 2)
mu = ctp_account_margin_used(mode)
if mu is not None and mu > 0:
margin_used = round(float(mu), 2)
elif available is not None and equity > 0:
margin_used = round(max(0.0, equity - available), 2)
except Exception:
pass
else:
from modules.core.trading_context import _cached_ctp_account
cached = _cached_ctp_account(mode)
balance = float(cached.get("balance") or 0)
if balance > 0:
equity = balance
avail = cached.get("available")
if avail is not None:
available = round(float(avail), 2)
if equity > 0:
margin_used = round(max(0.0, equity - available), 2)
key_rows = conn.execute(
"""
SELECT id, symbol, symbol_name, market_code, sina_code,
monitor_type, direction, upper, lower, trade_mode,
bar_period, trailing_be
FROM key_monitors
WHERE status='active' OR status IS NULL
ORDER BY id DESC
"""
).fetchall()
keys: list[dict[str, Any]] = []
for r in key_rows:
sym = r["symbol"]
market = r["market_code"] or ""
sina = r["sina_code"] or ""
upper = float(r["upper"] or 0)
lower = float(r["lower"] or 0)
price = _cached_fetch_price(fetch_price, sym, market, sina)
dist_upper = dist_lower = None
if price is not None:
dist_upper = round(upper - float(price), 2)
dist_lower = round(float(price) - lower, 2)
mtype = r["monitor_type"] or ""
sf = _symbol_fields(sym)
keys.append({
"id": r["id"],
"symbol": sym,
**sf,
"symbol_name": r["symbol_name"] or sf.get("symbol_name") or sym,
"monitor_type": mtype,
"direction": r["direction"] or "",
"direction_label": _direction_label(r["direction"] or "long")
if r["direction"] else "",
"upper": upper,
"lower": lower,
"trade_mode": r["trade_mode"] or "",
"bar_period": r["bar_period"] or "5m",
"trailing_be": bool(r["trailing_be"]),
"price": price,
"dist_upper": dist_upper,
"dist_lower": dist_lower,
})
close_rows = conn.execute(
"""
SELECT id, symbol, symbol_name, direction, lots,
entry_price, close_price, pnl, pnl_net, fee,
close_time, result, source
FROM trade_logs
ORDER BY id DESC
LIMIT ?
""",
(max(1, min(200, closes_limit)),),
).fetchall()
closes: list[dict[str, Any]] = []
for r in close_rows:
sym_code = r["symbol"] or ""
sf = _symbol_fields(sym_code)
closes.append({
"id": r["id"],
"symbol": r["symbol_name"] or sf.get("symbol_name") or sym_code,
"symbol_code": sym_code,
**sf,
"symbol_name": r["symbol_name"] or sf.get("symbol_name") or sym_code,
"direction": r["direction"] or "long",
"direction_label": _direction_label(r["direction"] or "long"),
"lots": float(r["lots"] or 0),
"entry_price": float(r["entry_price"] or 0),
"close_price": float(r["close_price"] or 0),
"pnl": float(r["pnl"] or 0) if r["pnl"] is not None else None,
"pnl_net": float(r["pnl_net"] or 0) if r["pnl_net"] is not None else None,
"fee": float(r["fee"] or 0) if r["fee"] is not None else None,
"close_time": (r["close_time"] or "")[:16].replace("T", " "),
"result": r["result"] or "",
"source": r["source"] or "",
})
now_iso = datetime.now(_TZ).strftime("%Y-%m-%d %H:%M:%S")
risk = build_risk_overview(
conn, get_setting, equity=equity, margin_used=margin_used,
)
return {
"ok": True,
"updated_at": now_iso,
"trading_mode_label": trading_mode_label(get_setting),
"ctp_status": ctp_st,
"account": {
"equity": round(equity, 2),
"margin_used": margin_used,
"available": available,
"capital_fallback": round(capital, 2),
},
"risk": risk,
"keys": keys,
"closes": closes,
}
finally:
conn.close()
+174
View File
@@ -0,0 +1,174 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
"""HTTP routes for stats module."""
from __future__ import annotations
from datetime import date, datetime
from flask import (
Response,
flash,
jsonify,
redirect,
render_template,
request,
send_file,
session,
stream_with_context,
url_for,
)
def register(deps) -> None:
app = deps.app
login_required = deps.login_required
require_nav = deps.require_nav
get_db = deps.get_db
get_setting = deps.get_setting
set_setting = deps.set_setting
fetch_price = deps.fetch_price
send_wechat_msg = deps.send_wechat_msg
touch_stats_cache = deps.touch_stats_cache
get_stats_data = deps.get_stats_data
build_market_quote_payload = deps.build_market_quote_payload
today_str = deps.today_str
expire_old_plans = deps.expire_old_plans
TZ = deps.tz
DB_PATH = deps.db_path
UPLOAD_DIR = deps.upload_dir
OPEN_TYPES = deps.open_types
EXIT_TRIGGERS = deps.exit_triggers
BEHAVIOR_TAGS = deps.behavior_tags
KLINE_PERIODS = deps.kline_periods
KLINE_CUTOFFS = deps.kline_cutoffs
calc_holding_duration = deps.calc_holding_duration
holding_to_minutes = deps.holding_to_minutes
classify_close_result = deps.classify_close_result
calc_rr_ratio = deps.calc_rr_ratio
calc_theoretical_pnl = deps.calc_theoretical_pnl
parse_review_date_filter = deps.parse_review_date_filter
_trading_mode = deps.trading_mode
_ua_is_phone = deps.ua_is_phone
_static_asset_v = deps.static_asset_v
from modules.stats.stats_engine import (
STATS_VIEWS,
get_calendar_day,
get_calendar_month,
refresh_stats_cache,
)
from modules.settings.nav_settings import nav_enabled
from modules.stats.dashboard_lib import build_dashboard_payload
from modules.core.doc_render import read_doc, render_markdown
_dashboard_sync_tick = {"n": 0}
@app.route("/stats")
@login_required
def stats():
return render_template("stats.html")
@app.route("/calendar")
@login_required
def trade_calendar():
return render_template("calendar.html")
@app.route("/api/stats")
@login_required
def api_stats():
return jsonify(get_stats_data())
@app.route("/api/stats/views")
@login_required
def api_stats_views():
return jsonify({"views": STATS_VIEWS})
@app.route("/api/stats/refresh", methods=["POST"])
@login_required
def api_stats_refresh():
conn = get_db()
capital = float(get_setting("live_capital", "0") or 0)
data = refresh_stats_cache(conn, capital)
conn.close()
return jsonify(data)
@app.route("/api/stats/calendar")
@login_required
def api_stats_calendar():
now = datetime.now(TZ)
year = request.args.get("year", type=int) or now.year
month = request.args.get("month", type=int) or now.month
if month < 1 or month > 12:
return jsonify({"error": "invalid month"}), 400
conn = get_db()
try:
data = get_calendar_month(conn, year, month)
finally:
conn.close()
return jsonify(data)
@app.route("/api/stats/calendar/day")
@login_required
def api_stats_calendar_day():
day = (request.args.get("date") or "").strip()
if not day:
return jsonify({"error": "date required"}), 400
try:
date.fromisoformat(day)
except ValueError:
return jsonify({"error": "invalid date"}), 400
conn = get_db()
try:
data = get_calendar_day(conn, day)
finally:
conn.close()
return jsonify(data)
@app.route("/dashboard")
@login_required
@require_nav("dashboard")
def dashboard():
return render_template("dashboard.html")
@app.route("/risk-guide")
@login_required
@require_nav("risk_guide")
def risk_guide():
from modules.core.doc_render import read_doc, render_markdown
try:
_title, raw = read_doc("risk-guide")
except FileNotFoundError:
flash("文档不存在")
return redirect(url_for("positions"))
return render_template("risk_guide.html", doc_html=render_markdown(raw))
@app.route("/api/dashboard/live")
@login_required
def api_dashboard_live():
if not nav_enabled(get_setting, "dashboard"):
return jsonify({"ok": False, "error": "数据看板已在系统设置中关闭"}), 403
from modules.stats.dashboard_lib import build_dashboard_payload
_dashboard_sync_tick["n"] += 1
sync_trades = _dashboard_sync_tick["n"] % 15 == 0
try:
payload = build_dashboard_payload(
get_db=get_db,
get_setting=get_setting,
fetch_price=fetch_price,
sync_ctp_trades=sync_trades,
)
return jsonify(payload)
except Exception as exc:
app.logger.exception("dashboard live: %s", exc)
return jsonify({"ok": False, "error": "看板数据暂时不可用"}), 503
+568
View File
@@ -0,0 +1,568 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""交易统计计算与缓存结构。"""
from __future__ import annotations
import calendar
import json
import threading
from datetime import date, datetime
from typing import Any, Optional
from zoneinfo import ZoneInfo
from modules.core.db_conn import commit_retry, execute_retry
_stats_refresh_lock = threading.Lock()
TZ = ZoneInfo("Asia/Shanghai")
STATS_VIEWS = [
{"key": "by_time", "label": "按时间统计"},
{"key": "by_week", "label": "周统计"},
{"key": "by_month", "label": "月统计"},
{"key": "by_symbol", "label": "按品种统计"},
{"key": "by_fee", "label": "按手续费统计"},
{"key": "by_direction", "label": "按方向统计"},
{"key": "by_trade_type", "label": "按交易类型统计"},
{"key": "by_emotion", "label": "情绪单统计"},
]
BREAKDOWN_COLUMNS = [
{"key": "label", "label": "维度"},
{"key": "count", "label": "交易次数"},
{"key": "wins", "label": "盈利笔数"},
{"key": "losses", "label": "亏损笔数"},
{"key": "win_rate", "label": "胜率(%)"},
{"key": "avg_profit", "label": "平均盈利"},
{"key": "avg_loss", "label": "平均亏损"},
{"key": "profit_loss_ratio", "label": "盈亏比"},
{"key": "total_fee", "label": "累计手续费"},
{"key": "total_net", "label": "净盈亏合计"},
{"key": "max_loss", "label": "最大亏损"},
{"key": "max_profit", "label": "最大盈利"},
]
def _parse_dt(value: str) -> Optional[datetime]:
if not value:
return None
text = value.strip().replace(" ", "T")
try:
return datetime.fromisoformat(text)
except ValueError:
return None
def _row_dict(row) -> dict:
return dict(row) if row is not None else {}
def _net_pnl(row: dict) -> float:
if row.get("pnl_net") is not None:
return float(row["pnl_net"])
pnl = float(row.get("pnl") or 0)
fee = float(row.get("fee") or 0)
return round(pnl - fee, 2)
def _fee(row: dict) -> float:
return float(row.get("fee") or 0)
def _margin_pct(pnl_net: float, margin: Optional[float]) -> Optional[float]:
if margin and margin > 0:
return round(pnl_net / margin * 100, 2)
return None
def _agg_group(rows: list[dict], key_fn) -> list[dict]:
groups: dict[str, list[dict]] = {}
for row in rows:
key = key_fn(row) or "未知"
groups.setdefault(key, []).append(row)
result = []
for label, items in sorted(groups.items(), key=lambda x: x[0]):
result.append(_agg_metrics(label, items))
return result
def _agg_metrics(label: str, items: list[dict]) -> dict:
nets = [_net_pnl(r) for r in items]
wins = [n for n in nets if n > 0]
losses = [n for n in nets if n < 0]
count = len(items)
win_cnt = len(wins)
loss_cnt = len(losses)
avg_profit = round(sum(wins) / len(wins), 2) if wins else 0.0
avg_loss = round(sum(losses) / len(losses), 2) if losses else 0.0
pl_ratio = round(avg_profit / abs(avg_loss), 2) if wins and losses and avg_loss != 0 else 0.0
total_fee = round(sum(_fee(r) for r in items), 2)
total_net = round(sum(nets), 2)
max_loss = round(min(losses), 2) if losses else 0.0
max_profit = round(max(wins), 2) if wins else 0.0
win_rate = round(win_cnt / count * 100, 2) if count else 0.0
return {
"label": label,
"count": count,
"wins": win_cnt,
"losses": loss_cnt,
"win_rate": win_rate,
"avg_profit": avg_profit,
"avg_loss": avg_loss,
"profit_loss_ratio": pl_ratio,
"total_fee": total_fee,
"total_net": total_net,
"max_loss": max_loss,
"max_profit": max_profit,
}
def _max_consecutive_losses(nets: list[float]) -> int:
streak = 0
best = 0
for n in nets:
if n < 0:
streak += 1
best = max(best, streak)
else:
streak = 0
return best
def _max_drawdown(nets: list[float], initial_capital: float) -> tuple[float, float]:
equity = initial_capital
peak = initial_capital
max_dd = 0.0
max_dd_pct = 0.0
for n in nets:
equity += n
if equity > peak:
peak = equity
dd = peak - equity
if dd > max_dd:
max_dd = dd
if peak > 0:
pct = dd / peak * 100
if pct > max_dd_pct:
max_dd_pct = pct
return round(max_dd, 2), round(max_dd_pct, 2)
def fetch_trade_rows(conn) -> list[dict]:
rows = conn.execute(
"SELECT * FROM trade_logs ORDER BY close_time ASC, id ASC"
).fetchall()
return [_row_dict(r) for r in rows]
def fetch_review_rows(conn) -> list[dict]:
rows = conn.execute(
"SELECT * FROM review_records ORDER BY close_time ASC, id ASC"
).fetchall()
return [_row_dict(r) for r in rows]
def compute_summary(trades: list[dict], reviews: list[dict], live_capital: float) -> dict:
nets = [_net_pnl(t) for t in trades]
count = len(trades)
wins = [n for n in nets if n > 0]
losses = [n for n in nets if n < 0]
win_cnt = len(wins)
loss_cnt = len(losses)
avg_profit = round(sum(wins) / len(wins), 2) if wins else 0.0
avg_loss = round(sum(losses) / len(losses), 2) if losses else 0.0
pl_ratio = round(avg_profit / abs(avg_loss), 2) if wins and losses and avg_loss != 0 else 0.0
total_fee = round(sum(_fee(t) for t in trades) + sum(_fee(r) for r in reviews), 2)
max_loss_amt = round(min(losses), 2) if losses else 0.0
max_profit_amt = round(max(wins), 2) if wins else 0.0
margins_loss = [
_margin_pct(_net_pnl(t), t.get("margin"))
for t in trades
if _net_pnl(t) < 0 and t.get("margin")
]
margins_profit = [
_margin_pct(_net_pnl(t), t.get("margin"))
for t in trades
if _net_pnl(t) > 0 and t.get("margin")
]
max_loss_pct = round(min(margins_loss), 2) if margins_loss else 0.0
max_profit_pct = round(max(margins_profit), 2) if margins_profit else 0.0
consec_loss = _max_consecutive_losses(nets)
max_dd, max_dd_pct = _max_drawdown(nets, live_capital)
emotion_cnt = sum(1 for r in reviews if r.get("is_emotion"))
review_cnt = len(reviews)
denom = count if count else review_cnt
emotion_ratio = round(emotion_cnt / denom * 100, 2) if denom else 0.0
return {
"total_trades": count,
"win_rate": round(win_cnt / count * 100, 2) if count else 0.0,
"avg_profit": avg_profit,
"avg_loss": avg_loss,
"profit_loss_ratio": pl_ratio,
"consecutive_losses": consec_loss,
"max_drawdown": max_dd,
"max_drawdown_pct": max_dd_pct,
"max_loss_amount": max_loss_amt,
"max_loss_pct": max_loss_pct,
"max_profit_amount": max_profit_amt,
"max_profit_pct": max_profit_pct,
"total_fee": total_fee,
"emotion_count": emotion_cnt,
"emotion_ratio": emotion_ratio,
"review_count": review_cnt,
"win_count": win_cnt,
"loss_count": loss_cnt,
}
def compute_breakdowns(trades: list[dict], reviews: list[dict]) -> dict[str, dict]:
def day_key(row: dict) -> str:
dt = _parse_dt(row.get("close_time") or row.get("created_at") or "")
return dt.date().isoformat() if dt else "未知"
def week_key(row: dict) -> str:
dt = _parse_dt(row.get("close_time") or row.get("created_at") or "")
if not dt:
return "未知"
iso = dt.isocalendar()
return f"{iso.year}-W{iso.week:02d}"
def month_key(row: dict) -> str:
dt = _parse_dt(row.get("close_time") or row.get("created_at") or "")
return dt.strftime("%Y-%m") if dt else "未知"
def symbol_key(row: dict) -> str:
return row.get("symbol_name") or row.get("symbol") or "未知"
def direction_key(row: dict) -> str:
d = row.get("direction") or ""
return "做多" if d == "long" else ("做空" if d == "short" else d or "未知")
def type_key(row: dict) -> str:
return row.get("monitor_type") or "未知"
by_fee_rows = []
fee_groups = {}
for t in trades:
key = symbol_key(t)
fee_groups.setdefault(key, []).append(t)
for label, items in sorted(fee_groups.items()):
row = _agg_metrics(label, items)
row["avg_fee"] = round(row["total_fee"] / row["count"], 2) if row["count"] else 0.0
by_fee_rows.append(row)
emotion_trades = [r for r in reviews if r.get("is_emotion")]
non_emotion = [r for r in reviews if not r.get("is_emotion")]
emotion_rows = [
_agg_metrics("情绪单", emotion_trades),
_agg_metrics("非情绪单", non_emotion),
]
fee_columns = BREAKDOWN_COLUMNS + [{"key": "avg_fee", "label": "平均手续费"}]
return {
"by_time": {"columns": BREAKDOWN_COLUMNS, "rows": _agg_group(trades, day_key)},
"by_week": {"columns": BREAKDOWN_COLUMNS, "rows": _agg_group(trades, week_key)},
"by_month": {"columns": BREAKDOWN_COLUMNS, "rows": _agg_group(trades, month_key)},
"by_symbol": {"columns": BREAKDOWN_COLUMNS, "rows": _agg_group(trades, symbol_key)},
"by_fee": {"columns": fee_columns, "rows": by_fee_rows},
"by_direction": {"columns": BREAKDOWN_COLUMNS, "rows": _agg_group(trades, direction_key)},
"by_trade_type": {"columns": BREAKDOWN_COLUMNS, "rows": _agg_group(trades, type_key)},
"by_emotion": {"columns": BREAKDOWN_COLUMNS, "rows": emotion_rows},
}
def build_all_stats(conn, live_capital: float = 0.0) -> dict:
trades = fetch_trade_rows(conn)
reviews = fetch_review_rows(conn)
summary = compute_summary(trades, reviews, live_capital)
breakdowns = compute_breakdowns(trades, reviews)
return {
"updated_at": datetime.now(TZ).isoformat(timespec="seconds"),
"summary": summary,
"views": STATS_VIEWS,
"breakdowns": breakdowns,
}
def save_stats_cache(conn, data: dict) -> None:
execute_retry(
conn,
"""INSERT INTO stats_cache (key, data_json, updated_at)
VALUES ('all', ?, ?)
ON CONFLICT(key) DO UPDATE SET data_json=excluded.data_json, updated_at=excluded.updated_at""",
(json.dumps(data, ensure_ascii=False), data["updated_at"]),
)
commit_retry(conn)
def load_stats_cache(conn) -> Optional[dict]:
row = conn.execute(
"SELECT data_json FROM stats_cache WHERE key='all'"
).fetchone()
if not row:
return None
try:
return json.loads(row["data_json"])
except json.JSONDecodeError:
return None
def refresh_stats_cache(conn, live_capital: float = 0.0) -> dict:
with _stats_refresh_lock:
data = build_all_stats(conn, live_capital)
save_stats_cache(conn, data)
return data
def _norm_symbol(symbol: str) -> str:
s = (symbol or "").strip().lower()
if "." in s:
s = s.split(".")[0]
return s
def _close_day_key(row: dict) -> str:
dt = _parse_dt(row.get("close_time") or row.get("created_at") or "")
return dt.date().isoformat() if dt else ""
def _close_ts(row: dict) -> float:
dt = _parse_dt(row.get("close_time") or row.get("created_at") or "")
return dt.timestamp() if dt else 0.0
def _direction_label(direction: str) -> str:
if direction == "long":
return "做多"
if direction == "short":
return "做空"
return direction or ""
def _index_reviews_by_day_sym(reviews: list[dict]) -> dict[tuple[str, str], list[dict]]:
index: dict[tuple[str, str], list[dict]] = {}
for review in reviews:
day = _close_day_key(review)
if not day:
continue
sym = _norm_symbol(review.get("symbol") or "")
index.setdefault((day, sym), []).append(review)
return index
def _review_match_score(trade: dict, review: dict) -> float:
score = abs(_close_ts(trade) - _close_ts(review))
lots_t = trade.get("lots")
lots_r = review.get("lots")
if lots_t is not None and lots_r is not None and float(lots_t) != float(lots_r):
score += 86400.0
entry_t = trade.get("entry_price")
entry_r = review.get("entry_price")
if entry_t is not None and entry_r is not None and abs(float(entry_t) - float(entry_r)) > 0.01:
score += 3600.0
return score
def _find_review_for_trade(
trade: dict,
review_index: dict[tuple[str, str], list[dict]],
used_review_ids: set[int],
) -> Optional[dict]:
day = _close_day_key(trade)
sym = _norm_symbol(trade.get("symbol") or "")
candidates = [
r for r in review_index.get((day, sym), [])
if r.get("id") not in used_review_ids
]
if not candidates:
return None
return min(candidates, key=lambda r: _review_match_score(trade, r))
def _format_day_entry(
*,
trade: Optional[dict] = None,
review: Optional[dict] = None,
source: str,
) -> dict:
row = review if source == "review" and review else trade or review or {}
symbol = row.get("symbol") or ""
pnl_net = _net_pnl(row)
tags = (row.get("behavior_tags") or "").strip()
is_emotion = bool(row.get("is_emotion"))
return {
"source": source,
"trade_id": trade.get("id") if trade else None,
"review_id": review.get("id") if review else None,
"symbol": row.get("symbol_name") or symbol,
"symbol_code": symbol,
"direction": _direction_label(row.get("direction") or ""),
"lots": row.get("lots"),
"entry_price": row.get("entry_price"),
"close_price": row.get("close_price"),
"stop_loss": row.get("stop_loss"),
"take_profit": row.get("take_profit"),
"open_time": row.get("open_time") or "",
"close_time": row.get("close_time") or "",
"pnl": row.get("pnl"),
"fee": row.get("fee"),
"pnl_net": pnl_net,
"result": row.get("result") if trade else None,
"monitor_type": row.get("monitor_type") if trade else None,
"is_emotion": is_emotion,
"behavior_tags": tags,
"open_type": row.get("open_type") if review else None,
"exit_trigger": row.get("exit_trigger") if review else None,
"exit_supplement": row.get("exit_supplement") if review else None,
"holding_duration": row.get("holding_duration") if review else None,
"initial_pnl": row.get("initial_pnl") if review else None,
"actual_pnl": row.get("actual_pnl") if review else None,
"timeframe": row.get("timeframe") if review else None,
"notes": row.get("notes") if review else None,
"screenshot": row.get("screenshot") if review else None,
}
def build_day_detail(trades: list[dict], reviews: list[dict], day: str) -> list[dict]:
day_trades = [t for t in trades if _close_day_key(t) == day]
day_reviews = [r for r in reviews if _close_day_key(r) == day]
review_index = _index_reviews_by_day_sym(day_reviews)
used_review_ids: set[int] = set()
items: list[dict] = []
for trade in day_trades:
review = _find_review_for_trade(trade, review_index, used_review_ids)
if review:
used_review_ids.add(int(review["id"]))
items.append(_format_day_entry(trade=trade, review=review, source="review"))
else:
items.append(_format_day_entry(trade=trade, source="trade"))
for review in day_reviews:
if int(review.get("id") or 0) in used_review_ids:
continue
items.append(_format_day_entry(review=review, source="review"))
items.sort(key=lambda x: _close_ts(x), reverse=True)
return items
def build_calendar_month(trades: list[dict], reviews: list[dict], year: int, month: int) -> dict:
review_index = _index_reviews_by_day_sym(reviews)
day_map: dict[str, dict] = {}
matched_review_ids: dict[str, set[int]] = {}
for trade in trades:
dt = _parse_dt(trade.get("close_time") or "")
if not dt or dt.year != year or dt.month != month:
continue
day = dt.date().isoformat()
bucket = day_map.setdefault(
day,
{
"date": day,
"count": 0,
"total_net": 0.0,
"review_count": 0,
"emotion_count": 0,
"has_emotion": False,
},
)
bucket["count"] += 1
used = matched_review_ids.setdefault(day, set())
review = _find_review_for_trade(trade, review_index, used)
if review:
rid = int(review["id"])
used.add(rid)
bucket["total_net"] = round(bucket["total_net"] + _net_pnl(review), 2)
bucket["review_count"] += 1
if review.get("is_emotion"):
bucket["emotion_count"] += 1
bucket["has_emotion"] = True
else:
bucket["total_net"] = round(bucket["total_net"] + _net_pnl(trade), 2)
for review in reviews:
if not review.get("is_emotion"):
continue
day = _close_day_key(review)
if not day:
continue
try:
dt = date.fromisoformat(day)
except ValueError:
continue
if dt.year != year or dt.month != month:
continue
bucket = day_map.setdefault(
day,
{
"date": day,
"count": 0,
"total_net": 0.0,
"review_count": 0,
"emotion_count": 0,
"has_emotion": False,
},
)
bucket["has_emotion"] = True
rid = int(review.get("id") or 0)
if rid and rid not in matched_review_ids.get(day, set()):
bucket["emotion_count"] += 1
_, last_day = calendar.monthrange(year, month)
days = []
for d in range(1, last_day + 1):
iso = date(year, month, d).isoformat()
if iso in day_map:
row = day_map[iso]
row["total_net"] = round(row["total_net"], 2)
days.append(row)
else:
days.append(
{
"date": iso,
"count": 0,
"total_net": 0.0,
"review_count": 0,
"emotion_count": 0,
"has_emotion": False,
}
)
return {
"year": year,
"month": month,
"days": days,
"weekday_start": date(year, month, 1).weekday(),
}
def get_calendar_month(conn, year: int, month: int) -> dict:
trades = fetch_trade_rows(conn)
reviews = fetch_review_rows(conn)
return build_calendar_month(trades, reviews, year, month)
def get_calendar_day(conn, day: str) -> dict:
trades = fetch_trade_rows(conn)
reviews = fetch_review_rows(conn)
items = build_day_detail(trades, reviews, day)
total_net = round(sum(float(i.get("pnl_net") or 0) for i in items), 2)
emotion_count = sum(1 for i in items if i.get("is_emotion"))
return {
"date": day,
"count": len(items),
"total_net": total_net,
"emotion_count": emotion_count,
"items": items,
}
+10
View File
@@ -0,0 +1,10 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
"""Strategy routes are registered via modules.trading (install_trading)."""
def register(deps) -> None:
del deps
__all__ = ["register"]
+23
View File
@@ -0,0 +1,23 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""斐波计算(自 crypto_monitor 复制,期货共用)。"""
def calc_fib_plan(direction, upper, lower, ratio):
try:
h = float(upper)
l = float(lower)
r = float(ratio)
except (TypeError, ValueError):
return None
if h <= l or r <= 0 or r >= 1:
return None
span = h - l
direction = (direction or "long").strip().lower()
if direction == "short":
entry = l + r * span
return entry, h, l
entry = h - r * span
return entry, l, h
+169
View File
@@ -0,0 +1,169 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""策略相关表结构。"""
from __future__ import annotations
from modules.core.db_conn import rollback_if_postgres
ROLL_GROUPS_SQL = """
CREATE TABLE IF NOT EXISTS roll_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_monitor_id INTEGER,
symbol TEXT NOT NULL,
direction TEXT NOT NULL,
initial_take_profit REAL,
initial_stop_loss REAL,
current_stop_loss REAL,
risk_percent REAL DEFAULT 2,
leg_count INTEGER DEFAULT 0,
status TEXT DEFAULT 'active',
created_at TEXT,
updated_at TEXT
)
"""
ROLL_LEGS_SQL = """
CREATE TABLE IF NOT EXISTS roll_legs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
roll_group_id INTEGER NOT NULL,
leg_index INTEGER NOT NULL,
add_mode TEXT NOT NULL,
fill_price REAL,
lots INTEGER,
new_stop_loss REAL,
status TEXT DEFAULT 'filled',
created_at TEXT
)
"""
TREND_PLANS_SQL = """
CREATE TABLE IF NOT EXISTS trend_pullback_plans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
status TEXT DEFAULT 'active',
symbol TEXT NOT NULL,
symbol_name TEXT,
direction TEXT NOT NULL DEFAULT 'long',
stop_loss REAL NOT NULL,
add_upper REAL NOT NULL,
take_profit REAL NOT NULL,
risk_percent REAL DEFAULT 5,
capital_snapshot REAL,
plan_margin REAL,
target_lots INTEGER,
first_lots INTEGER,
remainder_lots INTEGER,
dca_legs INTEGER DEFAULT 5,
leg_amounts_json TEXT,
grid_prices_json TEXT,
legs_done INTEGER DEFAULT 0,
first_order_done INTEGER DEFAULT 0,
avg_entry_price REAL,
lots_open INTEGER DEFAULT 0,
opened_at TEXT,
message TEXT,
period TEXT DEFAULT '15m'
)
"""
STRATEGY_SNAPSHOTS_SQL = """
CREATE TABLE IF NOT EXISTS strategy_trade_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
strategy_type TEXT NOT NULL,
source_id INTEGER,
symbol TEXT,
direction TEXT,
result_label TEXT,
opened_at TEXT,
closed_at TEXT,
pnl_amount REAL,
snapshot_json TEXT NOT NULL,
created_at TEXT
)
"""
TRADE_ORDER_MONITORS_SQL = """
CREATE TABLE IF NOT EXISTS trade_order_monitors (
id INTEGER PRIMARY KEY AUTOINCREMENT,
symbol TEXT NOT NULL,
symbol_name TEXT,
market_code TEXT,
direction TEXT NOT NULL,
lots INTEGER NOT NULL,
entry_price REAL,
stop_loss REAL,
take_profit REAL,
open_time TEXT,
monitor_type TEXT DEFAULT 'manual',
status TEXT DEFAULT 'active',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
CTP_SIM_ACCOUNT_SQL = """
CREATE TABLE IF NOT EXISTS ctp_sim_account (
id INTEGER PRIMARY KEY CHECK (id = 1),
balance REAL DEFAULT 100000,
available REAL DEFAULT 100000,
updated_at TEXT
)
"""
CTP_SIM_POSITIONS_SQL = """
CREATE TABLE IF NOT EXISTS ctp_sim_positions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
symbol TEXT NOT NULL,
direction TEXT NOT NULL,
lots INTEGER NOT NULL,
avg_price REAL NOT NULL,
updated_at TEXT,
UNIQUE(symbol, direction)
)
"""
ROLL_LEG_EXTRA_COLUMNS = (
"ALTER TABLE roll_legs ADD COLUMN limit_price REAL",
"ALTER TABLE roll_legs ADD COLUMN breakthrough_price REAL",
"ALTER TABLE roll_legs ADD COLUMN last_mark_price REAL",
"ALTER TABLE roll_legs ADD COLUMN invalidated_reason TEXT",
"ALTER TABLE roll_legs ADD COLUMN capital_snapshot REAL",
"ALTER TABLE trade_order_monitors ADD COLUMN risk_percent REAL",
)
_TABLES_READY = False
def init_strategy_tables(conn) -> None:
global _TABLES_READY
if _TABLES_READY:
return
for sql in (
ROLL_GROUPS_SQL,
ROLL_LEGS_SQL,
TREND_PLANS_SQL,
STRATEGY_SNAPSHOTS_SQL,
TRADE_ORDER_MONITORS_SQL,
CTP_SIM_ACCOUNT_SQL,
CTP_SIM_POSITIONS_SQL,
):
conn.execute(sql)
conn.commit()
try:
conn.execute("ALTER TABLE trend_pullback_plans ADD COLUMN period TEXT DEFAULT '15m'")
except Exception:
pass
for sql in ROLL_LEG_EXTRA_COLUMNS:
try:
conn.execute(sql)
conn.commit()
except Exception:
rollback_if_postgres(conn)
pass
if not conn.execute("SELECT id FROM ctp_sim_account WHERE id=1").fetchone():
conn.execute("INSERT INTO ctp_sim_account (id, balance, available) VALUES (1, 100000, 100000)")
conn.commit()
_TABLES_READY = True
+370
View File
@@ -0,0 +1,370 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""顺势加仓(滚仓):纯计算与校验,期货版(手数整数、乘数计入盈亏)。"""
from __future__ import annotations
import math
from typing import Any, Optional, Tuple
from modules.trading.position_sizing import MODE_AMOUNT
from strategy.fib_lib import calc_fib_plan
ROLL_MAX_LEGS_LONG = 3
ROLL_MAX_LEGS_SHORT = 3
ROLL_STOP_OFFSET_PCT_DEFAULT = 1.0
ADD_MODE_MARKET = "market"
ADD_MODE_FIB_618 = "fib_618"
ADD_MODE_FIB_786 = "fib_786"
ADD_MODE_BREAKOUT = "breakout"
FIB_MODES = frozenset({ADD_MODE_FIB_618, ADD_MODE_FIB_786})
PENDING_MODES = frozenset({ADD_MODE_FIB_618, ADD_MODE_FIB_786, ADD_MODE_BREAKOUT})
ADD_MODE_LABELS = {
ADD_MODE_MARKET: "市价加仓",
ADD_MODE_FIB_618: "斐波0.618",
ADD_MODE_FIB_786: "斐波0.786",
ADD_MODE_BREAKOUT: "突破加仓",
}
LEG_STATUS_PENDING = "pending"
LEG_STATUS_FILLED = "filled"
LEG_STATUS_CANCELLED = "cancelled"
LEG_STATUS_INVALIDATED = "invalidated"
def add_mode_label(mode: str) -> str:
return ADD_MODE_LABELS.get((mode or "").strip().lower(), mode or "")
def fib_ratio_from_mode(mode: str) -> Optional[float]:
m = (mode or "").strip().lower()
if m in (ADD_MODE_FIB_618, "618", "0.618"):
return 0.618
if m in (ADD_MODE_FIB_786, "786", "0.786"):
return 0.786
return None
def fib_limit_entry(direction: str, upper: float, lower: float, mode: str) -> Tuple[Optional[float], Optional[str]]:
ratio = fib_ratio_from_mode(mode)
if ratio is None:
return None, "斐波档位无效"
h, l = float(upper), float(lower)
if h <= l:
return None, "上沿须大于下沿"
direction = (direction or "long").strip().lower()
plan = calc_fib_plan(direction, h, l, ratio)
if not plan:
return None, "无法计算斐波限价"
entry, _sl, _tp = plan
return float(entry), None
def max_roll_legs(direction: str) -> int:
return ROLL_MAX_LEGS_LONG if (direction or "long").strip().lower() == "long" else ROLL_MAX_LEGS_SHORT
def lots_precise(raw: float) -> int:
if raw is None or raw < 1:
return 0
return max(1, int(math.floor(float(raw))))
def unified_stop_from_avg(direction: str, avg: float, offset_pct: float) -> float:
avg_f = float(avg)
pct = float(offset_pct) / 100.0
direction = (direction or "long").strip().lower()
if direction == "short":
return avg_f * (1.0 + pct)
return avg_f * (1.0 - pct)
def avg_entry_after_add(qty_existing: float, entry_existing: float, add_qty: float, add_price: float) -> float:
q1, e1, q2, e2 = float(qty_existing), float(entry_existing), float(add_qty), float(add_price)
total = q1 + q2
return (q1 * e1 + q2 * e2) / total if total > 0 else 0.0
def solve_add_lots_for_total_risk(
direction: str,
qty_existing: float,
entry_existing: float,
add_price: float,
new_stop: float,
risk_budget: float,
mult: int,
) -> Tuple[Optional[int], Optional[str]]:
"""方案 C:合并持仓打到新止损 S 时总亏损 ≤ B。"""
q1, e1, e2, sl, b = float(qty_existing), float(entry_existing), float(add_price), float(new_stop), float(risk_budget)
m = float(mult)
direction = (direction or "long").strip().lower()
if direction == "short":
denom = (sl - e2) * m
numer = b - q1 * (sl - e1) * m
else:
denom = (e2 - sl) * m
numer = b - q1 * (e1 - sl) * m
if denom <= 0:
return None, "止损与加仓价关系无效"
q2 = numer / denom
lots = lots_precise(q2)
if lots < 1:
return None, "已满足风险上限或无法再加"
return lots, None
def roll_eligibility_error(
*,
sizing_mode: str,
monitor: dict,
has_active_trend: bool,
legs_done: int = 0,
has_pending_leg: bool = False,
) -> Optional[str]:
if normalize_sizing_mode(sizing_mode) != MODE_AMOUNT:
return "仅固定金额(以损定仓)模式可滚仓"
if has_active_trend:
return "趋势回调运行中,不可滚仓"
if not monitor or (monitor.get("status") or "").strip().lower() != "active":
return "无有效持仓监控"
if int(monitor.get("trailing_be") or 0):
return "移动保本持仓不可滚仓"
direction = (monitor.get("direction") or "long").strip().lower()
if legs_done >= max_roll_legs(direction):
return f"滚仓已达 {max_roll_legs(direction)} 次上限"
if has_pending_leg:
return "已有监控中的加仓腿,请等待成交或删除后再提交"
if int(monitor.get("lots") or 0) < 1:
return "持仓手数为 0"
if not float(monitor.get("take_profit") or 0):
return "首仓须设置止盈(移动保本不可滚仓)"
return None
def normalize_sizing_mode(raw: str) -> str:
from modules.trading.position_sizing import normalize_sizing_mode as _norm
return _norm(raw)
def resolve_risk_percent(monitor: dict, *, default: float) -> float:
try:
rp = float(monitor.get("risk_percent") or 0)
if rp > 0:
return rp
except (TypeError, ValueError):
pass
return float(default)
def validate_roll_geometry(
direction: str,
add_mode: str,
new_stop: float,
*,
mark_price: float,
limit_price: Optional[float] = None,
breakthrough_price: Optional[float] = None,
at_trigger: bool = False,
off_session_pending: bool = False,
) -> Optional[str]:
"""几何校验。
做多斐波回调止损 < 触发价 < 当前价
做多突破向上止损 < 突破价 < 当前价
做空斐波反弹当前价 < 触发价 < 止损
做空突破向下突破价 < 当前价 < 止损提交时触发后当前价可 突破价
"""
direction = (direction or "long").strip().lower()
mode = (add_mode or ADD_MODE_MARKET).strip().lower()
sl = float(new_stop)
mark = float(mark_price)
if sl <= 0 or mark <= 0:
return "止损或参考价无效"
if mode == ADD_MODE_MARKET:
if direction == "long" and sl >= mark:
return "做多:新止损须低于当前价"
if direction == "short" and sl <= mark:
return "做空:新止损须高于当前价"
return None
trigger = None
if mode in FIB_MODES:
trigger = float(limit_price or 0)
if trigger <= 0:
return "须填写斐波触发价"
if direction == "long":
if not (sl < trigger < mark):
return "做多斐波:须满足 止损 < 触发价 < 当前价"
else:
if not (mark < trigger < sl):
return "做空斐波:须满足 当前价 < 触发价 < 止损"
return None
if mode == ADD_MODE_BREAKOUT:
trigger = float(breakthrough_price or 0)
if trigger <= 0:
return "须填写突破价"
if off_session_pending:
if direction == "long" and not (sl < trigger):
return "做多突破:休盘提交须满足 止损 < 突破价"
if direction == "short" and not (trigger < sl):
return "做空突破:休盘提交须满足 突破价 < 止损"
return None
if at_trigger:
if direction == "long":
if not (sl < trigger <= mark):
return "做多突破:触发时须满足 止损 < 突破价 ≤ 当前价"
else:
if not (trigger < sl and mark < sl):
return "做空突破:触发时须满足 突破价 < 止损且当前价 < 止损"
return None
if direction == "long":
if not (sl < trigger < mark):
return "做多突破:须满足 止损 < 突破价 < 当前价"
else:
if not (trigger < mark < sl):
return "做空突破:须满足 突破价 < 当前价 < 止损"
return None
return "加仓方式无效"
def detect_mark_cross(
direction: str,
add_mode: str,
prev_mark: float,
mark: float,
trigger_price: float,
) -> bool:
"""标记价穿越触发价(上一 tick 与当前 tick 比较)。"""
direction = (direction or "long").strip().lower()
mode = (add_mode or "").strip().lower()
p = float(trigger_price)
prev_m = float(prev_mark)
cur_m = float(mark)
if p <= 0 or prev_m <= 0 or cur_m <= 0:
return False
if mode in FIB_MODES:
if direction == "long":
return prev_m > p and cur_m <= p
return prev_m < p and cur_m >= p
if mode == ADD_MODE_BREAKOUT:
if direction == "long":
return prev_m < p and cur_m >= p
return prev_m > p and cur_m <= p
return False
def preview_roll(
*,
direction: str,
symbol: str,
qty_existing: float,
entry_existing: float,
initial_take_profit: float,
add_mode: str,
new_stop_loss: float,
risk_budget: float,
mult: int,
mark_price: Optional[float] = None,
add_price: Optional[float] = None,
limit_price: Optional[float] = None,
breakthrough_price: Optional[float] = None,
fib_upper: Optional[float] = None,
fib_lower: Optional[float] = None,
legs_done: int = 0,
at_trigger: bool = False,
off_session_pending: bool = False,
) -> Tuple[Optional[dict[str, Any]], Optional[str]]:
direction = (direction or "long").strip().lower()
if legs_done >= max_roll_legs(direction):
return None, f"滚仓已达 {max_roll_legs(direction)} 次上限"
mode = (add_mode or ADD_MODE_MARKET).strip().lower()
mark = float(mark_price or add_price or 0)
if mark <= 0 and mode == ADD_MODE_BREAKOUT and off_session_pending:
mark = float(breakthrough_price or 0)
if mark <= 0:
return None, "需要有效参考价"
sl = float(new_stop_loss)
tp = float(initial_take_profit)
if sl <= 0 or tp <= 0:
return None, "止损/止盈无效"
entry_add = mark
mode_label = add_mode_label(mode)
trigger_price = mark
is_pending = mode in PENDING_MODES
if mode == ADD_MODE_MARKET:
entry_add = mark
elif mode in FIB_MODES:
if limit_price and float(limit_price) > 0:
entry_add = float(limit_price)
trigger_price = entry_add
elif fib_upper is not None and fib_lower is not None:
entry_add, err = fib_limit_entry(direction, float(fib_upper), float(fib_lower), mode)
if err:
return None, err
trigger_price = entry_add
else:
return None, "斐波须填触发价或上沿/下沿"
elif mode == ADD_MODE_BREAKOUT:
if not breakthrough_price or float(breakthrough_price) <= 0:
return None, "须填写突破价"
entry_add = float(breakthrough_price)
trigger_price = entry_add
else:
return None, "加仓方式无效"
geom_err = validate_roll_geometry(
direction, mode, sl,
mark_price=mark,
limit_price=trigger_price if mode in FIB_MODES else None,
breakthrough_price=trigger_price if mode == ADD_MODE_BREAKOUT else None,
at_trigger=at_trigger,
off_session_pending=off_session_pending and is_pending,
)
if geom_err:
return None, geom_err
budget = float(risk_budget)
if budget <= 0:
return None, "固定金额无效"
q2, err = solve_add_lots_for_total_risk(
direction, qty_existing, entry_existing, entry_add, sl, budget, mult,
)
if err:
return None, err
new_qty = qty_existing + q2
new_avg = avg_entry_after_add(qty_existing, entry_existing, q2, entry_add)
m = float(mult)
if direction == "long":
loss_at_sl = (new_avg - sl) * new_qty * m
reward_at_tp = (tp - new_avg) * new_qty * m
else:
loss_at_sl = (sl - new_avg) * new_qty * m
reward_at_tp = (new_avg - tp) * new_qty * m
return {
"symbol": symbol,
"direction": direction,
"add_mode": mode,
"add_mode_label": mode_label,
"is_pending": is_pending,
"add_price": round(entry_add, 4),
"trigger_price": round(trigger_price, 4),
"limit_price": round(trigger_price, 4) if mode in FIB_MODES else None,
"breakthrough_price": round(trigger_price, 4) if mode == ADD_MODE_BREAKOUT else None,
"new_stop_loss": round(sl, 4),
"initial_take_profit": tp,
"risk_budget": round(budget, 2),
"fixed_amount": round(budget, 2),
"add_lots": q2,
"qty_after": int(new_qty),
"avg_entry_after": round(new_avg, 4),
"loss_at_sl": round(loss_at_sl, 2),
"reward_at_tp": round(reward_at_tp, 2),
"legs_done": legs_done,
"mark_price": round(mark, 4),
}, None
@@ -0,0 +1,158 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""顺势滚仓程序监控:突破 pending 腿触价成交、外部平仓同步。"""
from __future__ import annotations
import logging
from datetime import datetime
from typing import Any, Callable, Optional
from zoneinfo import ZoneInfo
from modules.core.contract_specs import get_contract_spec
from strategy.strategy_roll_lib import (
ADD_MODE_BREAKOUT,
FIB_MODES,
LEG_STATUS_CANCELLED,
LEG_STATUS_FILLED,
LEG_STATUS_INVALIDATED,
LEG_STATUS_PENDING,
detect_mark_cross,
preview_roll,
)
logger = logging.getLogger(__name__)
TZ = ZoneInfo("Asia/Shanghai")
def _now() -> str:
return datetime.now(TZ).strftime("%Y-%m-%d %H:%M:%S")
def roll_sync_after_external_close(conn, *, monitor_id: int) -> None:
"""手动平仓或监控结案后关闭滚仓组并清除 pending 腿。"""
grp = conn.execute(
"SELECT id FROM roll_groups WHERE order_monitor_id=? AND status='active'",
(int(monitor_id),),
).fetchone()
if not grp:
return
gid = int(grp["id"])
conn.execute(
"UPDATE roll_legs SET status=? WHERE roll_group_id=? AND status=?",
(LEG_STATUS_CANCELLED, gid, LEG_STATUS_PENDING),
)
conn.execute(
"UPDATE roll_groups SET status='closed', updated_at=? WHERE id=?",
(_now(), gid),
)
def cancel_roll_leg(conn, leg_id: int) -> tuple[bool, str]:
row = conn.execute(
"SELECT * FROM roll_legs WHERE id=? AND status=?",
(int(leg_id), LEG_STATUS_PENDING),
).fetchone()
if not row:
return False, "仅可删除监控中的腿"
conn.execute(
"UPDATE roll_legs SET status=? WHERE id=?",
(LEG_STATUS_CANCELLED, int(leg_id)),
)
return True, "已删除"
def check_roll_monitors(
conn,
*,
get_mark_price_fn: Callable[[str], Optional[float]],
fill_roll_leg_fn: Callable[[dict, dict, dict, dict], tuple[bool, str]],
is_trading_session_fn: Callable[[], bool],
get_risk_budget_fn: Callable[[], float],
get_entry_price_fn: Optional[Callable[[str, str, float], float]] = None,
) -> None:
"""扫描 pending 滚仓腿,标记价穿越则重算手数并市价成交。"""
if not is_trading_session_fn():
return
rows = conn.execute(
"""SELECT l.*, g.order_monitor_id, g.symbol, g.direction, g.initial_take_profit,
g.risk_percent, g.leg_count AS group_leg_count,
m.lots AS mon_lots, m.entry_price AS mon_entry, m.take_profit AS mon_tp,
m.status AS mon_status
FROM roll_legs l
JOIN roll_groups g ON g.id = l.roll_group_id
JOIN trade_order_monitors m ON m.id = g.order_monitor_id
WHERE l.status=? AND g.status='active' AND m.status='active'""",
(LEG_STATUS_PENDING,),
).fetchall()
for raw in rows:
leg = dict(raw)
if (leg.get("mon_status") or "").strip().lower() != "active":
_invalidate_leg(conn, leg, "监控已结束")
continue
sym = (leg.get("symbol") or "").strip()
mark = get_mark_price_fn(sym)
if not mark or mark <= 0:
continue
prev_mark = float(leg.get("last_mark_price") or mark)
mode = (leg.get("add_mode") or "").strip().lower()
trigger = float(leg.get("limit_price") or leg.get("breakthrough_price") or 0)
direction = (leg.get("direction") or "long").strip().lower()
if mode in FIB_MODES or mode == ADD_MODE_BREAKOUT:
if not detect_mark_cross(direction, mode, prev_mark, mark, trigger):
conn.execute(
"UPDATE roll_legs SET last_mark_price=? WHERE id=?",
(float(mark), int(leg["id"])),
)
continue
mon = {
"id": leg["order_monitor_id"],
"symbol": sym,
"direction": direction,
"lots": leg["mon_lots"],
"entry_price": leg["mon_entry"],
"take_profit": leg["mon_tp"] or leg["initial_take_profit"],
}
entry_fb = float(leg["mon_entry"] or 0)
entry_existing = (
get_entry_price_fn(sym, direction, entry_fb)
if get_entry_price_fn
else entry_fb
)
grp = {
"id": leg["roll_group_id"],
"order_monitor_id": leg["order_monitor_id"],
"leg_count": leg.get("group_leg_count") or 0,
"risk_percent": leg.get("risk_percent"),
}
preview, err = preview_roll(
direction=direction,
symbol=sym,
qty_existing=float(leg["mon_lots"] or 0),
entry_existing=entry_existing,
initial_take_profit=float(leg["mon_tp"] or leg["initial_take_profit"] or 0),
add_mode=mode,
new_stop_loss=float(leg["new_stop_loss"] or 0),
risk_budget=float(leg.get("risk_percent") or 0) or get_risk_budget_fn(),
mult=int(get_contract_spec(sym).get("mult") or 1),
mark_price=mark,
limit_price=trigger if mode in FIB_MODES else None,
breakthrough_price=trigger if mode == ADD_MODE_BREAKOUT else None,
legs_done=int(leg.get("group_leg_count") or 0),
at_trigger=True,
)
if err or not preview:
_invalidate_leg(conn, leg, err or "触发时无法加仓")
continue
ok, msg = fill_roll_leg_fn(mon, grp, leg, preview)
if not ok:
logger.warning("roll leg fill failed #%s: %s", leg.get("id"), msg)
def _invalidate_leg(conn, leg: dict, reason: str) -> None:
conn.execute(
"UPDATE roll_legs SET status=?, invalidated_reason=? WHERE id=?",
(LEG_STATUS_INVALIDATED, (reason or "")[:200], int(leg["id"])),
)
+75
View File
@@ -0,0 +1,75 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""策略结束快照。"""
from __future__ import annotations
import json
from datetime import datetime
from typing import Any
STRATEGY_TREND = "trend_pullback"
STRATEGY_ROLL = "roll"
MAX_ROWS = 100
def save_snapshot(
conn,
*,
strategy_type: str,
source_id: int,
symbol: str,
direction: str,
result_label: str,
payload: dict,
pnl: float | None = None,
opened_at: str = "",
) -> None:
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
conn.execute(
"""INSERT INTO strategy_trade_snapshots (
strategy_type, source_id, symbol, direction, result_label,
opened_at, closed_at, pnl_amount, snapshot_json, created_at
) VALUES (?,?,?,?,?,?,?,?,?,?)""",
(
strategy_type,
source_id,
symbol,
direction,
result_label,
opened_at,
now,
pnl,
json.dumps(payload, ensure_ascii=False),
now,
),
)
conn.execute(
"""DELETE FROM strategy_trade_snapshots WHERE id NOT IN (
SELECT id FROM strategy_trade_snapshots ORDER BY id DESC LIMIT ?
)""",
(MAX_ROWS,),
)
def list_snapshots(conn, limit: int = 100) -> tuple[list[dict], list[dict]]:
rows = conn.execute(
"SELECT * FROM strategy_trade_snapshots ORDER BY id DESC LIMIT ?",
(max(1, min(limit, 200)),),
).fetchall()
trend, roll = [], []
for r in rows:
d = dict(r)
try:
d["snapshot"] = json.loads(d.get("snapshot_json") or "{}")
except Exception:
d["snapshot"] = {}
st = d.get("strategy_type")
d["strategy_label"] = "趋势回调" if st == STRATEGY_TREND else "顺势加仓"
if st == STRATEGY_TREND:
trend.append(d)
else:
roll.append(d)
return trend, roll
+233
View File
@@ -0,0 +1,233 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""趋势回调:纯计算(期货整数手)。"""
from __future__ import annotations
import json
import math
from typing import Any, Optional, Tuple
from modules.core.contract_specs import get_contract_spec
def validate_trend_bounds(direction: str, stop_loss: float, add_upper: float) -> Optional[str]:
direction = (direction or "long").strip().lower()
if direction == "long":
if not (float(stop_loss) < float(add_upper)):
return "做多:止损须低于补仓上沿"
else:
if not (float(stop_loss) > float(add_upper)):
return "做空:止损须高于补仓下沿"
return None
def build_grid_prices(direction: str, sl: float, upper: float, n_legs: int) -> list[float]:
sl, upper = float(sl), float(upper)
out: list[float] = []
if n_legs <= 0:
return out
direction = (direction or "long").strip().lower()
if direction == "long":
if upper <= sl:
return out
span = upper - sl
for i in range(1, n_legs + 1):
out.append(sl + (i / float(n_legs + 1)) * span)
out.sort(reverse=True)
else:
if sl <= upper:
return out
span = sl - upper
for i in range(1, n_legs + 1):
out.append(upper + (i / float(n_legs + 1)) * span)
out.sort()
return [round(p, 4) for p in out]
def compute_trend_plan_futures(
*,
direction: str,
stop_loss: float,
add_upper: float,
take_profit: float,
risk_percent: float,
capital: float,
live_price: float,
ths_code: str,
dca_legs: int = 5,
) -> Tuple[Optional[dict[str, Any]], Optional[str]]:
err = validate_trend_bounds(direction, stop_loss, add_upper)
if err:
return None, err
spec = get_contract_spec(ths_code)
mult = spec["mult"]
d = (direction or "long").strip().lower()
if d == "short":
worst_per_lot = (float(stop_loss) - float(add_upper)) * mult
else:
worst_per_lot = (float(add_upper) - float(stop_loss)) * mult
if worst_per_lot <= 0:
return None, "止损与补仓边界无法计算风险"
budget = float(capital) * float(risk_percent) / 100.0
total_lots = int(math.floor(budget / worst_per_lot))
if total_lots < 3:
return None, f"{risk_percent}% 风险,总手数至少需 3 手才能拆分首仓+补仓(当前 {total_lots} 手)"
first_lots = total_lots // 2
remainder = total_lots - first_lots
legs = max(1, min(int(dca_legs), remainder))
per_leg = remainder // legs
leg_amounts = [per_leg] * (legs - 1) + [remainder - per_leg * (legs - 1)]
if any(x < 1 for x in leg_amounts):
legs = 1
leg_amounts = [remainder]
grid = build_grid_prices(d, stop_loss, add_upper, len(leg_amounts))
margin_rate = spec["margin_rate"]
plan_margin = float(live_price) * mult * total_lots * margin_rate
return {
"direction": d,
"stop_loss": float(stop_loss),
"add_upper": float(add_upper),
"take_profit": float(take_profit),
"risk_percent": float(risk_percent),
"capital_snapshot": float(capital),
"live_price_ref": float(live_price),
"target_lots": total_lots,
"first_lots": first_lots,
"remainder_lots": remainder,
"dca_legs": len(leg_amounts),
"leg_amounts": leg_amounts,
"leg_amounts_json": json.dumps(leg_amounts),
"grid_prices_json": json.dumps(grid),
"grid": grid,
"plan_margin": round(plan_margin, 2),
"mult": mult,
}, None
def trend_dca_level_reached(direction: str, mark_price: float, level: float) -> bool:
d = (direction or "long").strip().lower()
pf, lv = float(mark_price), float(level)
return pf <= lv if d == "long" else pf >= lv
def trend_strategy_periods() -> list[dict[str, str]]:
"""策略页可选 K 线周期。"""
from modules.market.kline_chart import MARKET_PERIODS
skip = frozenset({"timeshare", "w"})
return [p for p in MARKET_PERIODS if p["key"] not in skip]
def trend_period_label(key: str) -> str:
k = (key or "").strip()
for p in trend_strategy_periods():
if p["key"] == k:
return p["label"]
return k or "15分"
def normalize_trend_period(key: str) -> str:
valid = {p["key"] for p in trend_strategy_periods()}
k = (key or "15m").strip()
return k if k in valid else "15m"
def _avg_after_entries(entries: list[tuple[float, int]]) -> float:
total = sum(q for _, q in entries)
if total <= 0:
return 0.0
return sum(p * q for p, q in entries) / total
def enrich_trend_plan_preview(
plan: dict,
*,
symbol: str,
symbol_name: str = "",
period: str = "15m",
) -> dict[str, Any]:
"""补全预览:周期、风险金额、分档表格(对齐币圈预览样式)。"""
out = dict(plan)
d = (out.get("direction") or "long").strip().lower()
sl = float(out["stop_loss"])
tp = float(out["take_profit"])
mult = float(out.get("mult") or 1)
entry0 = float(out["live_price_ref"])
first_lots = int(out["first_lots"])
leg_amounts = [int(x) for x in (out.get("leg_amounts") or [])]
grid = [float(x) for x in (out.get("grid") or [])]
capital = float(out.get("capital_snapshot") or 0)
risk_pct = float(out.get("risk_percent") or 0)
budget = capital * risk_pct / 100.0
remainder = int(out.get("remainder_lots") or sum(leg_amounts))
out["symbol"] = symbol
out["symbol_name"] = symbol_name or symbol
out["period"] = normalize_trend_period(period)
out["period_label"] = trend_period_label(out["period"])
out["stop_loss_budget"] = round(budget, 2)
out["direction_label"] = "做多" if d == "long" else "做空"
entries: list[tuple[float, int]] = [(entry0, first_lots)]
rows: list[dict[str, Any]] = []
def leg_metrics() -> tuple[float, float, float, Optional[float]]:
total = sum(q for _, q in entries)
avg = _avg_after_entries(entries)
if d == "long":
profit = (tp - avg) * total * mult
loss = (avg - sl) * total * mult
else:
profit = (avg - tp) * total * mult
loss = (sl - avg) * total * mult
rr = profit / loss if loss > 0 else None
return (
round(avg, 4),
round(profit, 2),
round(loss, 2),
round(rr, 2) if rr is not None else None,
)
avg, profit, loss, rr = leg_metrics()
rows.append({
"level": "首仓",
"price": round(entry0, 4),
"lots": first_lots,
"avg_after": avg,
"profit_at_tp": profit,
"loss_at_sl": loss,
"rr_ratio": rr,
})
out["first_rr_ratio"] = rr
for i, lots in enumerate(leg_amounts):
price = grid[i] if i < len(grid) else sl
entries.append((float(price), int(lots)))
avg, profit, loss, rr = leg_metrics()
rows.append({
"level": f"补仓{i + 1}",
"price": round(float(price), 4),
"lots": int(lots),
"avg_after": avg,
"profit_at_tp": profit,
"loss_at_sl": loss,
"rr_ratio": rr,
})
out["preview_rows"] = rows
out["summary_line"] = (
f"{out['symbol_name']} {out['symbol']} {out['direction_label']} {out['period_label']}"
f" | 权益 {capital:.2f}"
f" | 参考价 {entry0}"
f" | 计划保证金 ≈ {out.get('plan_margin')}"
f" | 总手 {out.get('target_lots')}(首仓 {first_lots} + 补仓 {remainder}"
)
out["detail_line"] = (
f"止损价 {sl} | 止损金额 {out['stop_loss_budget']} 元(权益 × 风险 {risk_pct}%"
f" | 补仓边界 {float(out['add_upper'])} | 止盈价 {tp}"
f" | 首仓盈亏比 {out['first_rr_ratio'] if out['first_rr_ratio'] is not None else ''}"
)
return out
+19
View File
@@ -0,0 +1,19 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
def register(deps) -> None:
from modules.trading.install import install_trading
install_trading(
deps.app,
login_required=deps.login_required,
require_nav=deps.require_nav,
get_db=deps.get_db,
get_setting=deps.get_setting,
set_setting=deps.set_setting,
fetch_price=deps.fetch_price,
send_wechat_msg=deps.send_wechat_msg,
)
__all__ = ["register"]
File diff suppressed because it is too large Load Diff
+284
View File
@@ -0,0 +1,284 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""开仓委托:pending 状态跟踪、成交转正、超时撤单。"""
from __future__ import annotations
import logging
import time
from datetime import datetime
from typing import Any, Callable, Optional
from zoneinfo import ZoneInfo
from modules.market.market_sessions import is_trading_session
from modules.ctp.vnpy_bridge import ctp_cancel_order, ctp_list_active_orders, ctp_status
logger = logging.getLogger(__name__)
TZ = ZoneInfo("Asia/Shanghai")
DEFAULT_PENDING_ORDER_TIMEOUT_SEC = 300
# 报单刚提交后短暂等待 CTP 回报,避免误判为拒单
PENDING_ORDER_SETTLE_GRACE_SEC = 8
def pending_monitor_has_live_order(
mon: dict,
*,
active_orders: dict[str, dict],
active_order_list: list[dict],
match_fn: Callable[[str, str], bool] | None = None,
) -> bool:
"""本地 pending 是否仍对应 CTP 柜台上的有效开仓委托。"""
match = match_fn or _match_symbol
sym = mon.get("symbol") or ""
direction = mon.get("direction") or "long"
vt_oid = (mon.get("vt_order_id") or "").strip()
age = pending_age_sec(mon)
if vt_oid and _vt_order_in_active(vt_oid, active_orders):
return True
if _symbol_open_order_active(active_order_list, sym, direction, match):
return True
if not vt_oid and age < PENDING_ORDER_SETTLE_GRACE_SEC:
return True
if vt_oid and age < PENDING_ORDER_SETTLE_GRACE_SEC:
return True
return False
def parse_monitor_ts(raw: str) -> Optional[float]:
s = (raw or "").strip()
if not s:
return None
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M"):
try:
return datetime.strptime(s[:19], fmt).replace(tzinfo=TZ).timestamp()
except ValueError:
continue
return None
def pending_age_sec(mon: dict) -> float:
ts = parse_monitor_ts(mon.get("open_time") or "") or parse_monitor_ts(
str(mon.get("created_at") or "")
)
if ts is None:
return 0.0
return max(0.0, time.time() - ts)
def pending_auto_cancel_remaining(
mon: dict,
*,
timeout_sec: int = DEFAULT_PENDING_ORDER_TIMEOUT_SEC,
) -> int:
limit = max(60, int(timeout_sec or DEFAULT_PENDING_ORDER_TIMEOUT_SEC))
return max(0, int(limit - pending_age_sec(mon)))
def _match_symbol(ctp_sym: str, ths: str) -> bool:
a = (ctp_sym or "").lower()
b = (ths or "").lower()
if a == b:
return True
if a and b and a.split(".")[0] == b.split(".")[0]:
return True
try:
from modules.ctp.ctp_symbol import ths_to_vnpy_symbol
vnpy_sym, _ = ths_to_vnpy_symbol(ths)
if a == vnpy_sym.lower():
return True
except Exception:
pass
return False
def _find_ctp_position(positions: list[dict], sym: str, direction: str) -> Optional[dict]:
direction = (direction or "long").strip().lower()
for p in positions or []:
if int(p.get("lots") or 0) <= 0:
continue
if (p.get("direction") or "long") != direction:
continue
if _match_symbol(p.get("symbol") or "", sym):
return p
return None
def _vt_order_in_active(vt_oid: str, active_orders: dict[str, dict]) -> bool:
oid = (vt_oid or "").strip()
if not oid:
return False
if oid in active_orders:
return True
tail = oid.rsplit("_", 1)[-1]
for key in active_orders:
if key == oid or key.endswith(tail) or oid.endswith(key):
return True
return False
def _symbol_open_order_active(
orders: list[dict],
sym: str,
direction: str,
match_fn: Callable[[str, str], bool],
) -> Optional[dict]:
direction = (direction or "long").strip().lower()
for o in orders or []:
offset_u = (o.get("offset") or "").upper()
if offset_u and "OPEN" not in offset_u:
continue
if (o.get("direction") or "long") != direction:
continue
if match_fn(o.get("symbol") or "", sym):
return o
return None
def reconcile_pending_orders(
conn,
mode: str,
*,
match_symbol_fn: Callable[[str, str], bool] | None = None,
sync_monitor_fn: Callable[..., None] | None = None,
capital: float = 0.0,
list_positions_fn: Callable[..., list] | None = None,
timeout_sec: int = DEFAULT_PENDING_ORDER_TIMEOUT_SEC,
) -> dict[str, int]:
"""同步 pending 委托:成交→active;超时/已撤→closed。"""
limit_sec = max(60, int(timeout_sec or DEFAULT_PENDING_ORDER_TIMEOUT_SEC))
stats = {"promoted": 0, "cancelled": 0, "closed": 0}
if not ctp_status(mode).get("connected"):
return stats
match = match_symbol_fn or _match_symbol
positions = (
list_positions_fn(mode, refresh_if_empty=True, refresh_margin=False)
if list_positions_fn
else []
)
try:
active_order_list = ctp_list_active_orders(mode)
active_orders = {}
for o in active_order_list:
for key in (o.get("order_id"), o.get("vt_order_id")):
if key:
active_orders[str(key)] = o
except Exception as exc:
logger.debug("list active orders: %s", exc)
active_order_list = []
active_orders = {}
rows = conn.execute(
"SELECT * FROM trade_order_monitors WHERE status='pending' ORDER BY id ASC"
).fetchall()
for r in rows:
mon = dict(r)
mid = int(mon["id"])
sym = mon.get("symbol") or ""
direction = mon.get("direction") or "long"
vt_oid = (mon.get("vt_order_id") or "").strip()
age = pending_age_sec(mon)
pos = _find_ctp_position(positions, sym, direction)
if pos:
conn.execute(
"UPDATE trade_order_monitors SET status='active' WHERE id=?",
(mid,),
)
if sync_monitor_fn:
sync_monitor_fn(
conn, mid, sym, direction, mode, ctp=pos, capital=capital,
)
stats["promoted"] += 1
continue
if vt_oid and _vt_order_in_active(vt_oid, active_orders):
if age >= limit_sec and is_trading_session():
if ctp_cancel_order(mode, vt_oid):
conn.execute(
"UPDATE trade_order_monitors SET status='closed' WHERE id=?",
(mid,),
)
stats["cancelled"] += 1
else:
logger.warning("pending auto-cancel failed monitor=%s order=%s", mid, vt_oid)
continue
live_open = _symbol_open_order_active(active_order_list, sym, direction, match)
if live_open:
if age >= limit_sec and is_trading_session():
cancel_oid = (
vt_oid
or live_open.get("vt_order_id")
or live_open.get("order_id")
or ""
)
if cancel_oid and ctp_cancel_order(mode, cancel_oid):
conn.execute(
"UPDATE trade_order_monitors SET status='closed' WHERE id=?",
(mid,),
)
stats["cancelled"] += 1
continue
# 有委托号但已不在 CTP 活跃列表且无持仓 → 拒单/已撤/终态
if vt_oid:
if age < PENDING_ORDER_SETTLE_GRACE_SEC:
continue
conn.execute(
"UPDATE trade_order_monitors SET status='closed' WHERE id=?",
(mid,),
)
stats["closed"] += 1
logger.info(
"pending monitor=%s order=%s closed (no longer active on CTP)",
mid, vt_oid,
)
continue
if age >= limit_sec:
if vt_oid and is_trading_session():
if ctp_cancel_order(mode, vt_oid):
conn.execute(
"UPDATE trade_order_monitors SET status='closed' WHERE id=?",
(mid,),
)
stats["cancelled"] += 1
else:
logger.info(
"pending monitor=%s order=%s kept (cancel not confirmed)",
mid, vt_oid,
)
elif not vt_oid:
conn.execute(
"UPDATE trade_order_monitors SET status='closed' WHERE id=?",
(mid,),
)
stats["closed"] += 1
if any(stats.values()):
conn.commit()
return stats
def cancel_pending_monitor(
conn,
mon: dict,
mode: str,
) -> tuple[bool, str]:
"""手动撤销 pending 开仓委托。"""
mid = int(mon.get("id") or 0)
vt_oid = (mon.get("vt_order_id") or "").strip()
if vt_oid and ctp_status(mode).get("connected"):
try:
ctp_cancel_order(mode, vt_oid)
except Exception as exc:
logger.warning("cancel pending order monitor=%s: %s", mid, exc)
conn.execute("UPDATE trade_order_monitors SET status='closed' WHERE id=?", (mid,))
conn.commit()
return True, "开仓委托已撤销"
+82
View File
@@ -0,0 +1,82 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""开仓挂单超时:后台定期 reconcile,不依赖 SSE 完整刷新。"""
from __future__ import annotations
import logging
import threading
import time
from typing import Callable, Optional
from modules.ctp.vnpy_bridge import ctp_status
logger = logging.getLogger(__name__)
CHECK_INTERVAL_SEC = 10
IDLE_INTERVAL_SEC = 45
DISCONNECTED_SLEEP_SEC = 30
STARTUP_DELAY_SEC = 15
def start_pending_order_worker(
*,
db_path: str,
get_mode_fn: Callable[[], str],
init_tables_fn: Callable | None = None,
get_capital_fn: Callable | None = None,
reconcile_fn: Callable[..., dict],
on_changed_fn: Callable[[], None] | None = None,
interval: int = CHECK_INTERVAL_SEC,
idle_interval: int = IDLE_INTERVAL_SEC,
) -> None:
"""后台线程:存在 pending 开仓监控时定期同步成交/超时撤单。"""
from modules.core.db_conn import connect_db
def _loop() -> None:
time.sleep(STARTUP_DELAY_SEC)
while True:
sleep_sec = max(5, idle_interval)
try:
mode = get_mode_fn()
if not ctp_status(mode).get("connected"):
time.sleep(DISCONNECTED_SLEEP_SEC)
continue
conn = connect_db(db_path)
try:
if init_tables_fn:
init_tables_fn(conn)
pending_n = conn.execute(
"SELECT COUNT(*) AS n FROM trade_order_monitors WHERE status='pending'"
).fetchone()["n"]
if pending_n <= 0:
time.sleep(sleep_sec)
continue
sleep_sec = max(1, interval)
capital = 0.0
if get_capital_fn:
try:
capital = float(get_capital_fn(conn) or 0)
except Exception:
capital = 0.0
stats = reconcile_fn(conn, mode, capital=capital) or {}
if any(int(stats.get(k) or 0) for k in ("promoted", "cancelled", "closed")):
logger.info(
"pending worker reconcile: promoted=%s cancelled=%s closed=%s",
stats.get("promoted", 0),
stats.get("cancelled", 0),
stats.get("closed", 0),
)
if on_changed_fn:
on_changed_fn()
finally:
conn.close()
except Exception as exc:
logger.warning("pending order worker: %s", exc)
time.sleep(sleep_sec)
threading.Thread(target=_loop, daemon=True, name="pending-order-worker").start()
+270
View File
@@ -0,0 +1,270 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""期货计仓:固定手数 / 固定金额。"""
from __future__ import annotations
import math
from typing import Optional
from modules.core.contract_specs import get_contract_spec, margin_one_lot
MODE_FIXED = "fixed"
MODE_AMOUNT = "amount"
MODE_RISK = "amount" # 兼容旧配置「以损定仓」
DEFAULT_MAX_ORDER_LOTS = 50
def normalize_sizing_mode(raw: str) -> str:
m = (raw or MODE_FIXED).strip().lower()
if m == "risk":
m = MODE_AMOUNT
return m if m in (MODE_FIXED, MODE_AMOUNT) else MODE_FIXED
def price_precision_from_tick(tick_size: float) -> int:
if tick_size <= 0:
return 0
s = f"{tick_size:.10f}".rstrip("0").rstrip(".")
if "." not in s:
return 0
return len(s.split(".")[1])
def _per_lot_risk(entry: float, stop_loss: float, direction: str, ths_code: str) -> tuple[float, Optional[str]]:
spec = get_contract_spec(ths_code)
mult = spec["mult"]
d = (direction or "long").strip().lower()
if d == "short":
per_lot = (stop_loss - entry) * mult
else:
per_lot = (entry - stop_loss) * mult
if per_lot <= 0:
return 0.0, "止损方向与入场价不匹配"
return per_lot, None
def calc_lots_by_amount(
entry: float,
stop_loss: float,
direction: str,
amount: float,
ths_code: str,
*,
capital: float = 0.0,
max_lots: Optional[int] = None,
max_margin_pct: float = 30.0,
trading_mode: str | None = None,
) -> tuple[Optional[int], Optional[str], dict]:
"""固定金额:先按止损距离算手数,再按保证金上限收紧。返回 (手数, 错误, 详情)。"""
info: dict = {
"lots_by_risk": 0,
"lots_by_margin": None,
"capped_by": None,
}
try:
entry_f = float(entry)
sl_f = float(stop_loss)
budget = float(amount)
cap = float(capital or 0)
except (TypeError, ValueError):
return None, "参数格式错误", info
if entry_f <= 0 or budget <= 0:
return None, "入场价或固定金额无效", info
per_lot_risk, err = _per_lot_risk(entry_f, sl_f, direction, ths_code)
if err:
return None, err, info
lots = int(math.floor(budget / per_lot_risk))
info["lots_by_risk"] = lots
if lots < 1:
return None, f"按固定金额 {budget:.0f} 元,当前止损距离下不足 1 手", info
if cap > 0:
margin_per_lot, _src, _spec = margin_one_lot(
ths_code, entry_f, direction=direction, trading_mode=trading_mode,
)
if margin_per_lot <= 0:
spec = get_contract_spec(ths_code)
margin_per_lot = entry_f * spec["mult"] * spec["margin_rate"]
margin_cap = max(1.0, min(100.0, float(max_margin_pct or 30.0)))
max_by_margin = (
int(math.floor(cap * margin_cap / 100.0 / margin_per_lot))
if margin_per_lot > 0 else lots
)
info["lots_by_margin"] = max_by_margin
info["margin_per_lot"] = round(margin_per_lot, 2)
info["max_margin_pct"] = margin_cap
if max_by_margin < 1:
return None, f"按保证金上限 {margin_cap:g}%,当前不足 1 手", info
if max_by_margin < lots:
info["capped_by"] = "margin"
lots = min(lots, max_by_margin)
cap_lots = max_lots if max_lots is not None else DEFAULT_MAX_ORDER_LOTS
if lots > cap_lots:
lots = cap_lots
info["capped_by"] = info.get("capped_by") or "max_lots"
info["lots"] = lots
return lots, None, info
def calc_lots_by_risk(
entry: float,
stop_loss: float,
direction: str,
capital: float,
risk_percent: float,
ths_code: str,
*,
max_lots: Optional[int] = None,
max_margin_pct: float = 30.0,
trading_mode: str | None = None,
) -> tuple[Optional[int], Optional[str]]:
"""策略等场景:按权益百分比风险预算换算手数。"""
try:
cap = float(capital)
rp = float(risk_percent)
except (TypeError, ValueError):
return None, "参数格式错误"
if cap <= 0 or rp <= 0:
return None, "资金或风险比例无效"
budget = cap * rp / 100.0
lots, err, info = calc_lots_by_amount(
entry, stop_loss, direction, budget, ths_code,
capital=cap, max_lots=max_lots, max_margin_pct=max_margin_pct,
trading_mode=trading_mode,
)
return lots, err
def calc_order_tick_metrics(
ths_code: str,
lots: float,
price: Optional[float] = None,
*,
direction: str = "long",
trading_mode: str | None = None,
) -> dict:
"""下单区展示:最小变动价位、每跳盈亏、保证金等。"""
spec = get_contract_spec(ths_code)
mult = int(spec["mult"])
tick = float(spec.get("tick_size") or 1.0)
margin_rate = float(spec["margin_rate"])
lots_i = max(1, int(lots or 1))
tick_value_per_lot = round(tick * mult, 4)
tick_value_total = round(tick_value_per_lot * lots_i, 2)
prec = price_precision_from_tick(tick)
mark = float(price) if price else 0.0
margin_per_lot = None
margin_source = "estimate"
if mark > 0:
margin_per_lot, margin_source, spec_used = margin_one_lot(
ths_code, mark, direction=direction, trading_mode=trading_mode,
)
if spec_used.get("mult"):
mult = int(spec_used["mult"])
if spec_used.get("tick_size"):
tick = float(spec_used["tick_size"])
tick_value_per_lot = round(tick * mult, 4)
tick_value_total = round(tick_value_per_lot * lots_i, 2)
prec = price_precision_from_tick(tick)
if margin_per_lot <= 0:
margin_per_lot = round(mark * mult * margin_rate, 2)
margin_source = "estimate"
margin_total = round(margin_per_lot * lots_i, 2) if margin_per_lot else None
return {
"mult": mult,
"tick_size": tick,
"price_precision": prec,
"tick_value_per_lot": tick_value_per_lot,
"tick_value_total": tick_value_total,
"lots": lots_i,
"margin_per_lot": margin_per_lot,
"margin_total": margin_total,
"margin_rate": margin_rate,
"margin_source": margin_source,
}
def calc_margin_usage_pct(
positions: list[dict],
capital: float,
*,
extra_symbol: str = "",
extra_lots: int = 0,
extra_price: float = 0,
extra_direction: str = "long",
trading_mode: str | None = None,
) -> float:
"""当前持仓 + 拟开仓占权益的保证金比例(%)。"""
cap = float(capital or 0)
if cap <= 0:
return 999.0
total = 0.0
for p in positions:
lots = int(p.get("lots") or 0)
if lots <= 0:
continue
ctp_margin = float(p.get("margin") or 0)
if ctp_margin > 0:
total += ctp_margin
continue
sym = (p.get("symbol") or p.get("symbol_code") or "").strip()
entry = float(p.get("avg_price") or p.get("entry_price") or 0)
direction = (p.get("direction") or "long").strip().lower()
if entry <= 0 or not sym:
continue
per_lot, _, _ = margin_one_lot(
sym, entry, direction=direction, trading_mode=trading_mode,
)
if per_lot <= 0:
spec = get_contract_spec(sym)
per_lot = entry * spec["mult"] * spec["margin_rate"]
total += per_lot * lots
if extra_symbol and extra_lots > 0 and extra_price > 0:
per_lot, _, _ = margin_one_lot(
extra_symbol, extra_price, direction=extra_direction, trading_mode=trading_mode,
)
if per_lot <= 0:
spec = get_contract_spec(extra_symbol)
per_lot = extra_price * spec["mult"] * spec["margin_rate"]
total += per_lot * extra_lots
return round(total / cap * 100.0, 2)
def cap_lots_for_margin_budget(
positions: list[dict],
capital: float,
symbol: str,
direction: str,
price: float,
desired_lots: int,
max_margin_pct: float,
trading_mode: str | None = None,
) -> tuple[int, float]:
"""在保证金上限内,返回可加仓手数及占用比例。"""
desired = max(0, int(desired_lots or 0))
if desired <= 0:
return 0, calc_margin_usage_pct(positions, capital, trading_mode=trading_mode)
for lots in range(desired, 0, -1):
usage = calc_margin_usage_pct(
positions,
capital,
extra_symbol=symbol,
extra_lots=lots,
extra_price=price,
extra_direction=direction,
trading_mode=trading_mode,
)
if usage <= max_margin_pct:
return lots, usage
return 0, calc_margin_usage_pct(
positions,
capital,
extra_symbol=symbol,
extra_lots=desired,
extra_price=price,
extra_direction=direction,
trading_mode=trading_mode,
)
+113
View File
@@ -0,0 +1,113 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""持仓监控:后台拉取 CTP 并 SSE 推送给前端(避免每次刷新阻塞读柜台)。"""
from __future__ import annotations
import logging
import queue
import threading
import time
from typing import Callable, Optional
from modules.market.kline_stream import sse_format
from modules.market.market_sessions import is_trading_session
logger = logging.getLogger(__name__)
PUSH_INTERVAL_SEC = 1
IDLE_INTERVAL_SEC = 5
class PositionStreamHub:
def __init__(self) -> None:
self._lock = threading.Lock()
self._subs: list[queue.Queue] = []
self._snapshot: Optional[dict] = None
self._snapshot_ts: float = 0.0
def subscribe(self) -> queue.Queue:
q: queue.Queue = queue.Queue(maxsize=16)
with self._lock:
self._subs.append(q)
return q
def unsubscribe(self, q: queue.Queue) -> None:
with self._lock:
try:
self._subs.remove(q)
except ValueError:
pass
def get_snapshot(self) -> Optional[dict]:
with self._lock:
return dict(self._snapshot) if self._snapshot else None
def set_snapshot(self, data: dict) -> None:
with self._lock:
self._snapshot = dict(data)
self._snapshot_ts = time.time()
def _fanout(self, event: str, data: dict) -> None:
msg = {"event": event, "data": data}
with self._lock:
subs = list(self._subs)
for q in subs:
try:
q.put_nowait(msg)
except queue.Full:
try:
q.get_nowait()
except queue.Empty:
pass
try:
q.put_nowait(msg)
except queue.Full:
pass
def broadcast(self, event: str, data: dict) -> None:
self.set_snapshot(data)
self._fanout(event, data)
def push_event(self, event: str, data: dict) -> None:
"""SSE 推送,不覆盖 positions 全量快照。"""
self._fanout(event, data)
position_hub = PositionStreamHub()
def start_position_worker(
*,
refresh_fn: Callable[[], dict],
interval: int = PUSH_INTERVAL_SEC,
idle_interval: int = IDLE_INTERVAL_SEC,
) -> None:
"""后台定时刷新持仓快照并 SSE 广播。"""
def _loop() -> None:
while True:
sleep_sec = idle_interval
try:
payload = refresh_fn()
if payload:
position_hub.broadcast("positions", payload)
ctp_st = (payload or {}).get("ctp_status") or {}
connected = bool(ctp_st.get("connected"))
in_session = bool((payload or {}).get("trading_session"))
rows = (payload or {}).get("rows") or []
has_sl_tp = any(
r.get("stop_loss") is not None or r.get("take_profit") is not None
for r in rows
)
if connected and in_session:
sleep_sec = max(1, interval)
elif connected:
sleep_sec = max(2, min(idle_interval, 3))
except Exception as exc:
logger.warning("position worker failed: %s", exc)
time.sleep(sleep_sec)
threading.Thread(target=_loop, daemon=True, name="position-stream").start()
+335
View File
@@ -0,0 +1,335 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""按账户资金筛选可开仓品种(保证金与仓位纪律)。"""
from __future__ import annotations
import logging
import math
from concurrent.futures import ThreadPoolExecutor
from typing import Callable, Optional
from modules.core.contract_specs import get_contract_spec, margin_one_lot
from modules.fees.fee_specs import calc_fee_breakdown
from modules.trading.recommend_trend import analyze_product_daily, sort_recommend_by_trend
from modules.core.symbols import PRODUCTS, product_category, product_has_night_session
logger = logging.getLogger(__name__)
# 权益不超过该值时,仅允许下列品种(可开仓列表、品种下拉、开仓报单)
SMALL_ACCOUNT_CAPITAL_MAX = 200_000.0
# 未连接 CTP 时,可开仓品种表按该权益估算最大手数(与参考资金设置无关)
DISCONNECTED_RECOMMEND_CAPITAL = 100_000.0
SMALL_ACCOUNT_PRODUCT_THS = frozenset({"c", "m", "MA", "rb"})
SMALL_ACCOUNT_SCOPE_LABEL = "玉米、豆粕、甲醇、螺纹钢"
SMALL_ACCOUNT_RECOMMENDED_OPEN_MARGIN_PCT = 30.0
SMALL_ACCOUNT_RECOMMENDED_ROLL_MARGIN_PCT = 40.0
def small_account_margin_recommendations() -> dict:
"""20 万以下账户建议的保证金比例(供系统设置参考)。"""
wan = int(SMALL_ACCOUNT_CAPITAL_MAX // 10_000)
return {
"open_margin_pct": SMALL_ACCOUNT_RECOMMENDED_OPEN_MARGIN_PCT,
"roll_margin_pct": SMALL_ACCOUNT_RECOMMENDED_ROLL_MARGIN_PCT,
"label": (
f"权益 {wan} 万以下建议:开仓保证金上限 "
f"{int(SMALL_ACCOUNT_RECOMMENDED_OPEN_MARGIN_PCT)}%"
f"滚仓总保证金不超过 {int(SMALL_ACCOUNT_RECOMMENDED_ROLL_MARGIN_PCT)}%"
),
}
def small_account_scope_hint(*, ctp_connected: bool = True) -> str:
wan = int(SMALL_ACCOUNT_CAPITAL_MAX // 10_000)
if not ctp_connected:
rec_wan = int(DISCONNECTED_RECOMMEND_CAPITAL // 10_000)
return (
f"未连接 CTP,按 {rec_wan} 万权益估算最大手数,"
f"仅显示并可交易 {SMALL_ACCOUNT_SCOPE_LABEL}"
)
return f"权益 {wan} 万以下仅显示并可交易:{SMALL_ACCOUNT_SCOPE_LABEL}"
def small_account_scope_status_label() -> str:
wan = int(SMALL_ACCOUNT_CAPITAL_MAX // 10_000)
return f"权益{wan}万以下限{SMALL_ACCOUNT_SCOPE_LABEL}"
def should_apply_small_account_scope(
capital: float,
*,
ctp_connected: bool,
) -> bool:
"""SimNow/实盘一致:未连接 CTP 时默认按 20 万以下四品种范围。"""
if not ctp_connected:
return True
return is_small_account(capital)
def filter_rows_for_account_scope(
rows: list[dict],
capital: float,
*,
ctp_connected: bool,
) -> list[dict]:
if not should_apply_small_account_scope(capital, ctp_connected=ctp_connected):
return rows
return [r for r in rows if product_in_small_account_whitelist(r.get("ths") or "")]
def normalize_product_ths(ths: str) -> str:
import re
s = (ths or "").strip()
m = re.match(r"^([A-Za-z]+)", s)
return m.group(1) if m else s
def is_small_account(capital: float) -> bool:
cap = float(capital or 0)
return 0 < cap <= SMALL_ACCOUNT_CAPITAL_MAX
def product_in_small_account_whitelist(ths_or_product) -> bool:
if isinstance(ths_or_product, dict):
key = (ths_or_product.get("ths") or "").strip()
else:
key = normalize_product_ths(str(ths_or_product or ""))
if not key:
return False
root = normalize_product_ths(key)
if root in SMALL_ACCOUNT_PRODUCT_THS:
return True
upper = root.upper()
return upper in {x.upper() for x in SMALL_ACCOUNT_PRODUCT_THS}
def assert_product_allowed_for_capital(
ths: str,
capital: float,
*,
ctp_connected: bool = True,
) -> Optional[str]:
"""小账户品种白名单校验;通过返回 None。"""
if not should_apply_small_account_scope(capital, ctp_connected=ctp_connected):
return None
if product_in_small_account_whitelist(ths):
return None
wan = int(SMALL_ACCOUNT_CAPITAL_MAX // 10_000)
if not ctp_connected:
return f"未连接 CTP,仅可交易:{SMALL_ACCOUNT_SCOPE_LABEL}"
return f"权益 {wan} 万以下仅可交易:{SMALL_ACCOUNT_SCOPE_LABEL}"
def filter_products_for_capital(
products: list[dict],
capital: float,
*,
ctp_connected: bool = True,
) -> list[dict]:
if not should_apply_small_account_scope(capital, ctp_connected=ctp_connected):
return list(products)
return [p for p in products if product_in_small_account_whitelist(p)]
def _attach_turnover(row: dict) -> None:
"""成交额 = 昨日成交量(手) × 昨收 × 合约乘数。"""
try:
vol = float(row.get("volume") or 0)
price = float(row.get("prev_close") or row.get("price") or 0)
mult = float(row.get("mult") or 0)
except (TypeError, ValueError):
return
if vol > 0 and price > 0 and mult > 0:
row["turnover"] = round(vol * price * mult, 2)
def _letters_from_ths(ths_code: str) -> str:
import re
m = re.match(r"^([A-Za-z]+)", (ths_code or "").strip())
return m.group(1) if m else ""
def assess_product_for_capital(
product: dict,
capital: float,
price: Optional[float],
*,
max_margin_pct: float = 30.0,
default_stop_ticks: int = 20,
reward_risk_ratio: float = 2.0,
trading_mode: str = "simulation",
ctp_connected: bool = True,
main_code: str = "",
margin_used: float = 0.0,
) -> dict:
"""评估单品种在当前资金下是否可交易。"""
ths = product.get("ths") or ""
name = product.get("name") or ths
exchange = product.get("exchange") or ""
category = product.get("category") or product_category(ths)
spec = get_contract_spec(ths + "8888")
mult = spec["mult"]
margin_rate = spec["margin_rate"]
tick = float(spec.get("tick_size") or 1.0)
p = float(price) if price and price > 0 else 0.0
cap = float(capital or 0)
margin_pct = max(1.0, min(100.0, float(max_margin_pct or 30.0)))
if should_apply_small_account_scope(cap, ctp_connected=ctp_connected) and not product_in_small_account_whitelist(product):
return {
"ths": ths,
"name": name,
"exchange": exchange,
"category": category,
"mult": spec["mult"],
"tick_size": tick,
"status": "blocked",
"status_label": small_account_scope_status_label(),
"min_capital_one_lot": None,
"margin_one_lot": None,
"max_lots": 0,
"risk_one_lot_1pct": None,
"has_night_session": product_has_night_session(product),
}
if p <= 0:
return {
"ths": ths,
"name": name,
"exchange": exchange,
"category": category,
"mult": mult,
"tick_size": tick,
"status": "no_price",
"status_label": "暂无行情",
"min_capital_one_lot": None,
"margin_one_lot": None,
"max_lots": 0,
"risk_one_lot_1pct": None,
"has_night_session": product_has_night_session(product),
}
margin_source = None
code_for_margin = (main_code or "").strip() or (ths + "8888")
if p > 0 and ctp_connected:
margin_one, margin_source, spec_used = margin_one_lot(
code_for_margin, p, direction="max", trading_mode=trading_mode,
)
if spec_used.get("mult"):
mult = spec_used["mult"]
if spec_used.get("tick_size"):
tick = float(spec_used["tick_size"])
else:
margin_one = p * mult * margin_rate
min_capital = margin_one / (margin_pct / 100.0) if margin_pct > 0 else margin_one
margin_budget = cap * margin_pct / 100.0 if cap > 0 else 0.0
margin_budget = max(0.0, margin_budget - max(0.0, float(margin_used or 0)))
max_lots = int(math.floor(margin_budget / margin_one)) if margin_one > 0 and margin_budget > 0 else 0
stop_dist = tick * default_stop_ticks
risk_one_lot = stop_dist * mult
risk_pct_1lot = (risk_one_lot / cap * 100) if cap > 0 else 999.0
ref_sl = round(p - stop_dist, 4)
ref_tp = round(p + stop_dist * reward_risk_ratio, 4)
fee_ths = ths + "8888"
try:
fee_info = calc_fee_breakdown(
fee_ths, p, p, 1.0, open_time="", close_time="", trading_mode=trading_mode,
)
except Exception as exc:
logger.debug("recommend fee calc failed %s: %s", ths, exc)
fee_info = {"open_fee": 0.0, "total_fee": 0.0}
can_margin = max_lots >= 1
can_risk = cap > 0 and risk_one_lot <= cap * 0.01
if can_margin and can_risk:
status, label = "ok", f"最大 {max_lots}"
elif can_margin:
status, label = "margin_ok", f"最大 {max_lots} 手·止损偏宽"
else:
status, label = "blocked", "资金不足"
if margin_source == "ctp" and can_margin:
label += "(柜台保证金)"
row_out = {
"ths": ths,
"name": name,
"exchange": exchange,
"category": category,
"price": round(p, 4),
"mult": mult,
"tick_size": tick,
"margin_one_lot": round(margin_one, 2),
"min_capital_one_lot": round(min_capital, 2),
"max_lots": max_lots,
"margin_budget": round(margin_budget, 2),
"max_margin_pct": margin_pct,
"risk_one_lot_1pct": round(risk_one_lot, 2),
"risk_pct_1lot_at_1pct_rule": round(risk_pct_1lot, 2),
"ref_stop_loss": ref_sl,
"ref_take_profit": ref_tp,
"open_fee_one_lot": fee_info["open_fee"],
"roundtrip_fee_one_lot": fee_info["total_fee"],
"status": status,
"status_label": label,
"has_night_session": product_has_night_session(product),
}
if margin_source:
row_out["margin_source"] = margin_source
return row_out
def list_product_recommendations(
capital: float,
quote_fn: Callable[[str], Optional[dict]],
*,
max_margin_pct: float = 30.0,
trading_mode: str = "simulation",
ctp_connected: bool = True,
margin_used: float = 0.0,
) -> list[dict]:
"""扫描全部品种并排序:可开且纪律友好 > 可开 > 不足。quote_fn(品种代码) -> {price, ths_code, ...}"""
def _one(product: dict) -> dict:
ths = product["ths"]
try:
quote = quote_fn(ths) or {}
price = quote.get("price")
main_code = (quote.get("ths_code") or "").strip()
row = assess_product_for_capital(
product, capital, price,
max_margin_pct=max_margin_pct,
trading_mode=trading_mode,
ctp_connected=ctp_connected,
main_code=main_code,
margin_used=margin_used,
)
row["main_code"] = main_code
if main_code:
row.update(analyze_product_daily(main_code))
_attach_turnover(row)
return row
except Exception as exc:
logger.warning("recommend product failed %s: %s", ths, exc)
spec = get_contract_spec(ths + "8888")
return {
"ths": ths,
"name": product.get("name") or ths,
"exchange": product.get("exchange") or "",
"category": product.get("category") or product_category(ths),
"mult": spec["mult"],
"tick_size": float(spec.get("tick_size") or 1.0),
"status": "no_price",
"status_label": "计算失败",
"main_code": "",
"max_lots": 0,
"has_night_session": product_has_night_session(product),
}
with ThreadPoolExecutor(max_workers=10) as pool:
products = filter_products_for_capital(PRODUCTS, capital)
rows = list(pool.map(_one, products))
return sort_recommend_by_trend(rows)
+399
View File
@@ -0,0 +1,399 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""可开仓品种:计算、按资金过滤、SQLite 缓存。"""
from __future__ import annotations
import json
import logging
import math
from datetime import datetime
from typing import Callable, Optional
from modules.core.contract_specs import get_contract_spec, margin_one_lot
from modules.fees.fee_specs import ensure_fee_rates_schema
from modules.trading.product_recommend import (
_attach_turnover,
filter_rows_for_account_scope,
list_product_recommendations,
)
from modules.trading.recommend_trend import sort_recommend_by_trend
from modules.core.symbols import product_category
logger = logging.getLogger(__name__)
RECOMMEND_CACHE_SQL = """
CREATE TABLE IF NOT EXISTS product_recommend_cache (
id INTEGER PRIMARY KEY CHECK (id = 1),
capital REAL NOT NULL DEFAULT 0,
rows_json TEXT NOT NULL DEFAULT '[]',
updated_at TEXT
)
"""
def ensure_recommend_tables(conn) -> None:
conn.execute(RECOMMEND_CACHE_SQL)
def filter_affordable_recommendations(rows: list[dict]) -> list[dict]:
"""仅保留当前资金可开 1 手的品种(不含资金不足、无行情)。"""
return [r for r in rows if r.get("status") in ("ok", "margin_ok")]
def rows_missing_max_lots(rows: list[dict]) -> bool:
"""缓存是否为旧版(缺少最大手数字段)。"""
if not rows:
return False
return any("max_lots" not in r for r in rows)
def rows_missing_trend(rows: list[dict]) -> bool:
"""缓存是否为旧版(缺少走势字段)。"""
if not rows:
return False
return any("trend" not in r for r in rows)
def rows_missing_daily_stats(rows: list[dict]) -> bool:
"""缓存是否为旧版(缺少跳空/量价字段)。"""
if not rows:
return False
return any("gap" not in r for r in rows)
def rows_missing_category(rows: list[dict]) -> bool:
if not rows:
return False
return any("category" not in r for r in rows)
def rows_missing_turnover(rows: list[dict]) -> bool:
if not rows:
return False
return any("turnover" not in r for r in rows)
def rows_missing_contract_spec(rows: list[dict]) -> bool:
if not rows:
return False
return any("mult" not in r or "tick_size" not in r for r in rows)
def recommend_cache_needs_refresh(
cached: dict,
*,
capital: float = 0.0,
) -> bool:
"""是否需要重新拉行情计算可开仓列表。"""
if recommend_cache_stale(cached.get("updated_at")):
return True
rows = cached.get("rows") or []
if rows_missing_max_lots(rows):
return True
if rows_missing_trend(rows):
return True
if rows_missing_daily_stats(rows):
return True
if rows_missing_category(rows):
return True
if rows_missing_turnover(rows):
return True
if rows_missing_contract_spec(rows):
return True
if float(capital or 0) > 0 and not rows:
return True
return False
def _ctp_connected_for_mode(trading_mode: str) -> bool:
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 bool(st.get("connected"))
except Exception:
pass
del trading_mode
return False
def recommend_margin_used(trading_mode: str) -> float:
"""当前持仓已占用保证金(各持仓 CTP 回报之和,与柜台持仓保证金一致)。"""
try:
from modules.trading.position_stream import position_hub
snap = position_hub.get_snapshot() or {}
raw = snap.get("margin_used")
if raw is not None:
return max(0.0, float(raw or 0))
except Exception:
pass
if not _ctp_connected_for_mode(trading_mode):
return 0.0
try:
from modules.ctp.vnpy_bridge import ctp_account_margin_used, ctp_sum_position_margins
total = ctp_sum_position_margins(
trading_mode, refresh_if_empty=False, refresh_margin=True,
)
if total > 0:
return total
used = ctp_account_margin_used(trading_mode)
return float(used) if used and used > 0 else 0.0
except Exception as exc:
logger.debug("recommend_margin_used: %s", exc)
return 0.0
def margin_budget_info(
capital: float,
max_margin_pct: float,
margin_used: float = 0.0,
) -> dict[str, float]:
"""保证金上限总额、已占用、剩余可开额度。"""
cap = float(capital or 0)
pct = max(1.0, min(100.0, float(max_margin_pct or 30.0)))
total = cap * pct / 100.0 if cap > 0 else 0.0
used = max(0.0, float(margin_used or 0))
remaining = max(0.0, total - used)
return {
"margin_budget_total": round(total, 2),
"margin_used": round(used, 2),
"margin_budget_remaining": round(remaining, 2),
"max_margin_pct": pct,
}
def enrich_recommend_rows(
rows: list[dict],
capital: float,
*,
max_margin_pct: float = 30.0,
trading_mode: str = "simulation",
margin_used: float = 0.0,
use_ctp_margin: bool = True,
) -> list[dict]:
"""用当前权益与保证金比例补算最大可开手数(兼容旧缓存)。"""
cap = float(capital or 0)
budget_info = margin_budget_info(cap, max_margin_pct, margin_used)
pct = budget_info["max_margin_pct"]
budget = budget_info["margin_budget_remaining"]
ctp_connected = _ctp_connected_for_mode(trading_mode)
enriched: list[dict] = []
for raw in rows:
row = dict(raw)
ths = (row.get("ths") or "").strip()
main_code = (row.get("main_code") or "").strip()
spec_code = main_code or (ths + "8888" if ths else "")
if spec_code:
spec = get_contract_spec(spec_code)
if row.get("mult") in (None, ""):
row["mult"] = spec["mult"]
if row.get("tick_size") in (None, ""):
row["tick_size"] = float(spec.get("tick_size") or 1.0)
margin_one = 0.0
try:
margin_one = float(row.get("margin_one_lot") or 0)
except (TypeError, ValueError):
margin_one = 0.0
price = float(row.get("price") or 0)
code_for_margin = main_code or spec_code
if price > 0 and code_for_margin:
margin_one, margin_source, spec_used = margin_one_lot(
code_for_margin,
price,
direction="max",
trading_mode=trading_mode if (ctp_connected and use_ctp_margin) else None,
)
if spec_used.get("mult"):
row["mult"] = spec_used["mult"]
if spec_used.get("tick_size"):
row["tick_size"] = spec_used["tick_size"]
row["margin_one_lot"] = margin_one
if margin_source == "ctp":
row["margin_source"] = "ctp"
row["spec_source"] = "ctp"
if margin_one > 0 and budget > 0:
lots = int(math.floor(budget / margin_one))
else:
try:
lots = int(row.get("max_lots") or row.get("recommended_lots") or 0)
except (TypeError, ValueError):
lots = 0
row["max_lots"] = lots
row.pop("recommended_lots", None)
row["margin_budget"] = round(budget, 2)
row["margin_budget_total"] = budget_info["margin_budget_total"]
row["margin_used"] = budget_info["margin_used"]
row["max_margin_pct"] = pct
status = row.get("status") or ""
if lots >= 1 and status in ("ok", "margin_ok"):
src = "柜台" if row.get("margin_source") == "ctp" else "估算"
row["status_label"] = (
f"最大 {lots}" if status == "ok" else f"最大 {lots} 手·止损偏宽"
)
if row.get("margin_source") == "ctp":
row["status_label"] += f"{src}保证金)"
if budget_info["margin_used"] > 0:
row["status_label"] += "·扣持仓"
elif lots < 1 and status in ("ok", "margin_ok"):
row["status"] = "blocked"
row["status_label"] = "资金不足"
if not row.get("category"):
row["category"] = product_category(row.get("ths") or "")
from modules.core.symbols import enrich_recommend_row
row = enrich_recommend_row(row)
_attach_turnover(row)
enriched.append(row)
from modules.core.symbols import filter_for_trading_session
return filter_for_trading_session(enriched)
def filter_recommend_by_sizing(
rows: list[dict],
*,
sizing_mode: str,
fixed_lots: int = 1,
) -> list[dict]:
"""固定手数模式下:最大手数低于设定值的品种不展示。"""
if (sizing_mode or "").strip().lower() != "fixed":
return rows
fl = max(1, int(fixed_lots or 1))
return [r for r in rows if int(r.get("max_lots") or 0) >= fl]
def refresh_recommend_cache(
conn,
capital: float,
quote_fn: Callable[[str], Optional[dict]],
*,
trading_mode: str = "simulation",
max_margin_pct: float = 30.0,
margin_used: float | None = None,
) -> list[dict]:
"""后台拉行情、筛选并写入数据库。"""
ensure_recommend_tables(conn)
ensure_fee_rates_schema(conn)
ctp_connected = _ctp_connected_for_mode(trading_mode)
used = (
float(margin_used)
if margin_used is not None
else recommend_margin_used(trading_mode)
)
all_rows = list_product_recommendations(
capital,
quote_fn,
max_margin_pct=max_margin_pct,
trading_mode=trading_mode,
ctp_connected=ctp_connected,
margin_used=used,
)
rows = filter_affordable_recommendations(all_rows)
if not rows and float(capital or 0) > 0:
logger.warning(
"recommend refresh: 0 affordable rows capital=%.2f total=%d no_price=%d blocked=%d",
float(capital or 0),
len(all_rows),
sum(1 for r in all_rows if r.get("status") == "no_price"),
sum(1 for r in all_rows if r.get("status") == "blocked"),
)
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
conn.execute(
"""INSERT INTO product_recommend_cache (id, capital, rows_json, updated_at)
VALUES (1, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
capital=excluded.capital,
rows_json=excluded.rows_json,
updated_at=excluded.updated_at""",
(float(capital or 0), json.dumps(rows, ensure_ascii=False), now),
)
conn.commit()
return rows
def recommend_cache_stale(updated_at: Optional[str], *, now: Optional[datetime] = None) -> bool:
"""缓存是否不是今日更新(需重新拉行情计算)。"""
if not updated_at:
return True
try:
cached_day = datetime.strptime(str(updated_at)[:10], "%Y-%m-%d").date()
except ValueError:
return True
today = (now or datetime.now()).date()
return cached_day != today
def load_recommend_cache(conn) -> dict:
"""优先从数据库读取可开仓品种列表。"""
ensure_recommend_tables(conn)
row = conn.execute("SELECT capital, rows_json, updated_at FROM product_recommend_cache WHERE id=1").fetchone()
if not row:
return {"capital": 0.0, "rows": [], "updated_at": None, "stale": True}
try:
rows = json.loads(row["rows_json"] or "[]")
except (TypeError, ValueError, json.JSONDecodeError):
rows = []
updated_at = row["updated_at"]
return {
"capital": float(row["capital"] or 0),
"rows": rows if isinstance(rows, list) else [],
"updated_at": updated_at,
"stale": recommend_cache_stale(updated_at),
}
def recommend_payload(
conn,
*,
live_capital: float,
max_margin_pct: float = 30.0,
trading_mode: str = "simulation",
sizing_mode: str = "fixed",
fixed_lots: int = 1,
use_ctp_margin: bool = True,
) -> dict:
"""读取缓存并附带当前权益(展示用,可能与缓存计算时不同)。"""
payload = load_recommend_cache(conn)
cap = float(live_capital or 0)
pct = max(1.0, min(100.0, float(max_margin_pct or 30.0)))
if use_ctp_margin:
used = recommend_margin_used(trading_mode)
else:
used = 0.0
try:
from modules.trading.position_stream import position_hub
snap = position_hub.get_snapshot() or {}
raw = snap.get("margin_used")
if raw is not None:
used = max(0.0, float(raw or 0))
except Exception:
pass
if used <= 0:
used = float(payload.get("margin_used") or 0)
budget_info = margin_budget_info(cap, pct, used)
payload["capital"] = cap
payload["max_margin_pct"] = pct
payload.update(budget_info)
rows = payload.get("rows") or []
rows = enrich_recommend_rows(
rows,
cap,
max_margin_pct=pct,
trading_mode=trading_mode,
margin_used=used,
use_ctp_margin=use_ctp_margin,
)
rows = filter_rows_for_account_scope(
rows, cap, ctp_connected=_ctp_connected_for_mode(trading_mode),
)
rows = filter_recommend_by_sizing(rows, sizing_mode=sizing_mode, fixed_lots=fixed_lots)
rows = sort_recommend_by_trend(rows)
payload["rows"] = rows
payload["needs_refresh"] = recommend_cache_needs_refresh(payload, capital=cap)
return payload
+163
View File
@@ -0,0 +1,163 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""可开仓品种 SSE 推送与后台刷新。"""
from __future__ import annotations
import json
import logging
import queue
import threading
import time
from typing import Callable, Optional
from modules.core.db_conn import connect_db
from modules.market.kline_stream import sse_format
from modules.trading.recommend_store import (
load_recommend_cache,
recommend_cache_needs_refresh,
recommend_payload,
refresh_recommend_cache,
)
logger = logging.getLogger(__name__)
CHECK_INTERVAL_SEC = 3600
_refresh_lock = threading.Lock()
_refresh_running = False
def schedule_recommend_refresh(
*,
db_path: str,
get_capital_fn: Callable,
quote_fn: Callable[[str], Optional[dict]],
init_tables_fn: Callable | None = None,
get_mode_fn: Callable[[], str] | None = None,
get_max_margin_pct_fn: Callable[[], float] | None = None,
get_sizing_mode_fn: Callable[[], str] | None = None,
get_fixed_lots_fn: Callable[[], int] | None = None,
) -> None:
"""后台刷新可开仓品种缓存(不阻塞页面请求)。"""
global _refresh_running
with _refresh_lock:
if _refresh_running:
return
_refresh_running = True
def _run() -> None:
global _refresh_running
try:
conn = connect_db(db_path)
try:
if init_tables_fn:
init_tables_fn(conn)
capital = float(get_capital_fn(conn) or 0)
mode = get_mode_fn() if get_mode_fn else "simulation"
max_pct = float(get_max_margin_pct_fn()) if get_max_margin_pct_fn else 30.0
cached = load_recommend_cache(conn)
if not recommend_cache_needs_refresh(cached, capital=capital):
payload = recommend_payload(
conn,
live_capital=capital,
max_margin_pct=max_pct,
trading_mode=mode,
sizing_mode=get_sizing_mode_fn() if get_sizing_mode_fn else "fixed",
fixed_lots=get_fixed_lots_fn() if get_fixed_lots_fn else 1,
)
recommend_hub.broadcast("recommend", {"ok": True, **payload})
return
refresh_recommend_cache(
conn, capital, quote_fn, trading_mode=mode, max_margin_pct=max_pct,
)
cached = load_recommend_cache(conn)
logger.info(
"可开仓品种后台刷新完成,capital=%.2f rows=%d",
capital, len(cached.get("rows") or []),
)
payload = recommend_payload(
conn,
live_capital=capital,
max_margin_pct=max_pct,
trading_mode=mode,
sizing_mode=get_sizing_mode_fn() if get_sizing_mode_fn else "fixed",
fixed_lots=get_fixed_lots_fn() if get_fixed_lots_fn else 1,
)
finally:
conn.close()
recommend_hub.broadcast("recommend", {"ok": True, **payload})
except Exception as exc:
logger.warning("recommend background refresh failed: %s", exc)
finally:
with _refresh_lock:
_refresh_running = False
threading.Thread(target=_run, daemon=True, name="recommend-refresh").start()
class RecommendStreamHub:
def __init__(self) -> None:
self._lock = threading.Lock()
self._subs: list[queue.Queue] = []
def subscribe(self) -> queue.Queue:
q: queue.Queue = queue.Queue(maxsize=8)
with self._lock:
self._subs.append(q)
return q
def unsubscribe(self, q: queue.Queue) -> None:
with self._lock:
try:
self._subs.remove(q)
except ValueError:
pass
def broadcast(self, event: str, data: dict) -> None:
msg = {"event": event, "data": data}
with self._lock:
subs = list(self._subs)
for q in subs:
try:
q.put_nowait(msg)
except queue.Full:
pass
recommend_hub = RecommendStreamHub()
def start_recommend_worker(
*,
db_path: str,
get_capital_fn: Callable,
quote_fn: Callable[[str], Optional[dict]],
init_tables_fn: Callable | None = None,
get_mode_fn: Callable[[], str] | None = None,
get_max_margin_pct_fn: Callable[[], float] | None = None,
get_sizing_mode_fn: Callable[[], str] | None = None,
get_fixed_lots_fn: Callable[[], int] | None = None,
interval: int = CHECK_INTERVAL_SEC,
) -> None:
"""后台每日刷新可开仓列表(每小时检查一次是否需更新),并推送给 SSE 订阅者。"""
def _loop() -> None:
while True:
try:
schedule_recommend_refresh(
db_path=db_path,
get_capital_fn=get_capital_fn,
quote_fn=quote_fn,
init_tables_fn=init_tables_fn,
get_mode_fn=get_mode_fn,
get_max_margin_pct_fn=get_max_margin_pct_fn,
get_sizing_mode_fn=get_sizing_mode_fn,
get_fixed_lots_fn=get_fixed_lots_fn,
)
except Exception as exc:
logger.warning("recommend worker failed: %s", exc)
time.sleep(max(300, interval))
threading.Thread(target=_loop, daemon=True, name="recommend-worker").start()
+339
View File
@@ -0,0 +1,339 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""可开仓品种:近一周日线走势(多头 / 空头 / 震荡 / 转多 / 转空)。"""
from __future__ import annotations
import logging
from typing import Callable, Optional
import requests
from modules.market.kline_chart import fetch_sina_klines, ths_to_sina_chart_symbol
logger = logging.getLogger(__name__)
DAILY_LOOKBACK = 7
OVERLAP_WINDOW = 3
OVERLAP_RANGE_THRESHOLD = 0.70
KLINE_FETCH_TIMEOUT = 5
TREND_LONG = "long"
TREND_SHORT = "short"
TREND_RANGE = "range"
TREND_BREAK_LONG = "break_long"
TREND_BREAK_SHORT = "break_short"
def _bar_ohlc(bar: dict) -> tuple[float, float, float, float]:
o = float(bar.get("o") or bar.get("open") or 0)
h = float(bar.get("h") or bar.get("high") or o)
l = float(bar.get("l") or bar.get("low") or o)
c = float(bar.get("c") or bar.get("close") or o)
return o, h, l, c
def kline_overlap_ratio(bars: list) -> float:
"""三根 K 线高低价区间的重叠度 = 交集 / 并集(0~1)。"""
if len(bars) < OVERLAP_WINDOW:
return 0.0
chunk = bars[-OVERLAP_WINDOW:]
lows, highs = [], []
for bar in chunk:
_, h, l, _ = _bar_ohlc(bar)
if h <= 0 and l <= 0:
continue
lows.append(l)
highs.append(h)
if len(lows) < OVERLAP_WINDOW:
return 0.0
overlap = max(0.0, min(highs) - max(lows))
union = max(highs) - min(lows)
if union <= 0:
return 1.0 if overlap > 0 else 0.0
return overlap / union
def _direction_from_closes(bars: list) -> str:
if len(bars) < 2:
return TREND_RANGE
closes = [_bar_ohlc(b)[3] for b in bars if _bar_ohlc(b)[3] > 0]
if len(closes) < 2:
return TREND_RANGE
if closes[-1] > closes[0]:
return TREND_LONG
if closes[-1] < closes[0]:
return TREND_SHORT
return TREND_RANGE
def _bar_ohlcv(bar: dict) -> tuple[float, float, float, float, float]:
o, h, l, c = _bar_ohlc(bar)
v = float(bar.get("v") or bar.get("volume") or 0)
return o, h, l, c, v
def compute_daily_quote_stats(bars: list) -> dict:
"""从日线提取:跳空、昨收、今开、昨涨跌、昨振幅、成交量。"""
empty = {
"gap": "",
"gap_label": "",
"gap_pct": None,
"prev_close": None,
"today_open": None,
"yesterday_change": None,
"yesterday_change_pct": None,
"yesterday_amplitude_pct": None,
"volume": None,
}
if len(bars) < 2:
return empty
t_o, _, _, _, t_v = _bar_ohlcv(bars[-1])
y_o, y_h, y_l, y_c, y_v = _bar_ohlcv(bars[-2])
if y_c <= 0:
return empty
prev_close = round(y_c, 4)
today_open = round(t_o, 4) if t_o > 0 else None
gap, gap_label, gap_pct = "none", "", 0.0
if today_open is not None and today_open > y_c:
gap, gap_label = "up", "跳空高开"
gap_pct = (today_open - y_c) / y_c * 100
elif today_open is not None and today_open < y_c:
gap, gap_label = "down", "跳空低开"
gap_pct = (today_open - y_c) / y_c * 100
if len(bars) >= 3:
_, _, _, p_c, _ = _bar_ohlcv(bars[-3])
base = p_c if p_c > 0 else y_o
else:
base = y_o if y_o > 0 else y_c
y_change = y_c - base if base > 0 else None
y_change_pct = (y_change / base * 100) if y_change is not None and base > 0 else None
y_amp = ((y_h - y_l) / base * 100) if base > 0 and y_h >= y_l else None
vol = y_v if y_v > 0 else (t_v if t_v > 0 else None)
return {
"gap": gap,
"gap_label": gap_label,
"gap_pct": round(gap_pct, 2) if gap != "none" else 0.0,
"prev_close": prev_close,
"today_open": today_open,
"yesterday_change": round(y_change, 4) if y_change is not None else None,
"yesterday_change_pct": round(y_change_pct, 2) if y_change_pct is not None else None,
"yesterday_amplitude_pct": round(y_amp, 2) if y_amp is not None else None,
"volume": int(vol) if vol is not None else None,
"volume_unit": "lot",
}
def analyze_daily_trend(bars: list, *, overlap_threshold: float = OVERLAP_RANGE_THRESHOLD) -> dict:
"""根据近一周日线判断走势;最近三天重叠度≥阈值视为震荡。"""
empty = {
"trend": "",
"trend_label": "",
"trend_transition": False,
"trend_overlap_pct": None,
"trend_prev_overlap_pct": None,
}
if len(bars) < OVERLAP_WINDOW:
return empty
recent = bars[-DAILY_LOOKBACK:] if len(bars) > DAILY_LOOKBACK else bars
curr_overlap = kline_overlap_ratio(recent)
prev_overlap = kline_overlap_ratio(recent[:-OVERLAP_WINDOW]) if len(recent) >= OVERLAP_WINDOW * 2 else 0.0
curr_range = curr_overlap >= overlap_threshold
prev_range = prev_overlap >= overlap_threshold
if curr_range:
trend, label = TREND_RANGE, "震荡"
transition = False
else:
direction = _direction_from_closes(recent[-OVERLAP_WINDOW:])
if direction == TREND_LONG:
trend, label = TREND_LONG, "多头"
elif direction == TREND_SHORT:
trend, label = TREND_SHORT, "空头"
else:
trend, label = TREND_RANGE, "震荡"
transition = prev_range and trend in (TREND_LONG, TREND_SHORT)
if transition:
if trend == TREND_LONG:
trend, label = TREND_BREAK_LONG, "转多"
else:
trend, label = TREND_BREAK_SHORT, "转空"
return {
"trend": trend,
"trend_label": label,
"trend_transition": transition,
"trend_overlap_pct": round(curr_overlap * 100, 1),
"trend_prev_overlap_pct": round(prev_overlap * 100, 1) if prev_overlap else None,
}
def _normalize_daily_bars(raw: list) -> list:
out = []
for row in raw:
if isinstance(row, list) and len(row) >= 5:
out.append({
"d": str(row[0]),
"o": float(row[1]),
"h": float(row[2]),
"l": float(row[3]),
"c": float(row[4]),
"v": float(row[5]) if len(row) > 5 and row[5] else 0.0,
})
elif isinstance(row, dict) and row.get("d"):
out.append({
"d": str(row["d"]),
"o": float(row.get("o", 0) or 0),
"h": float(row.get("h", 0) or 0),
"l": float(row.get("l", 0) or 0),
"c": float(row.get("c", 0) or 0),
"v": float(row.get("v", 0) or 0),
})
return out
def _fetch_sina_daily_quick(chart_sym: str) -> list:
url = (
"https://stock2.finance.sina.com.cn/futures/api/json.php/"
f"IndexService.getInnerFuturesDailyKLine?symbol={chart_sym}"
)
try:
resp = requests.get(
url, timeout=KLINE_FETCH_TIMEOUT,
headers={"Referer": "https://finance.sina.com.cn"},
)
raw = resp.json()
if raw and isinstance(raw, list):
bars = _normalize_daily_bars(raw)
if bars:
return bars
except Exception as exc:
logger.debug("quick daily kline failed %s: %s", chart_sym, exc)
return []
def fetch_week_daily_bars(
symbol: str,
*,
fetch_fn: Callable[[str, str], list] | None = None,
) -> list:
sym = (symbol or "").strip()
if not sym:
return []
if fetch_fn:
try:
bars = fetch_fn(sym, "d") or []
except Exception as exc:
logger.debug("fetch week daily failed %s: %s", sym, exc)
return []
return bars[-DAILY_LOOKBACK:] if bars else []
chart_sym = ths_to_sina_chart_symbol(sym)
if not chart_sym:
return []
bars = _fetch_sina_daily_quick(chart_sym)
if not bars:
try:
bars = fetch_sina_klines(sym, "d") or []
except Exception as exc:
logger.debug("fetch week daily fallback failed %s: %s", sym, exc)
return []
return bars[-DAILY_LOOKBACK:] if bars else []
def analyze_product_daily(
symbol: str,
*,
fetch_fn: Callable[[str, str], list] | None = None,
) -> dict:
"""拉取主力合约一周日线:走势 + 跳空/量价统计。"""
sym = (symbol or "").strip()
if not sym:
out = analyze_daily_trend([])
out.update(compute_daily_quote_stats([]))
return out
bars = fetch_week_daily_bars(sym, fetch_fn=fetch_fn)
out = analyze_daily_trend(bars)
out.update(compute_daily_quote_stats(bars))
return out
def analyze_product_trend(
symbol: str,
*,
fetch_fn: Callable[[str, str], list] | None = None,
) -> dict:
return analyze_product_daily(symbol, fetch_fn=fetch_fn)
GAP_SORT_RANK = {"up": 2, "down": 1, "none": 0, "": -1}
TREND_SORT_RANK = {
TREND_BREAK_LONG: 0,
TREND_BREAK_SHORT: 0,
TREND_LONG: 1,
TREND_SHORT: 2,
TREND_RANGE: 3,
"": 9,
}
def recommend_sort_key(row: dict, sort_by: str = "trend", *, desc: bool = True) -> tuple:
"""可排序字段:trend / gap / volume / amplitude。"""
key = (sort_by or "trend").strip().lower()
if key == "gap":
primary = GAP_SORT_RANK.get(row.get("gap") or "", -1)
secondary = abs(float(row.get("gap_pct") or 0))
elif key == "volume":
primary = float(row.get("volume") or 0)
secondary = 0.0
elif key == "amplitude":
primary = float(row.get("yesterday_amplitude_pct") or 0)
secondary = 0.0
else:
primary = TREND_SORT_RANK.get(row.get("trend") or "", 9)
secondary = -(int(row.get("max_lots") or 0))
if desc:
return (-primary, -secondary, row.get("name") or "")
return (primary, secondary, row.get("name") or "")
def sort_recommend_rows(
rows: list[dict],
*,
sort_by: str = "trend",
desc: bool = True,
) -> list[dict]:
return sorted(rows, key=lambda r: recommend_sort_key(r, sort_by, desc=desc))
def trend_sort_key(row: dict) -> tuple:
"""转多/转空优先,其次多头/空头,震荡靠后。"""
trend = (row.get("trend") or "").strip()
priority = {
TREND_BREAK_LONG: 0,
TREND_BREAK_SHORT: 0,
TREND_LONG: 1,
TREND_SHORT: 1,
TREND_RANGE: 2,
}
status_order = {"ok": 0, "margin_ok": 1, "blocked": 2, "no_price": 3}
return (
priority.get(trend, 3),
status_order.get(row.get("status") or "", 9),
-(int(row.get("max_lots") or 0)),
)
def sort_recommend_by_trend(rows: list[dict]) -> list[dict]:
return sort_recommend_rows(rows, sort_by="trend", desc=True)
File diff suppressed because it is too large Load Diff
+218
View File
@@ -0,0 +1,218 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""交易记录:字段补全、资金曲线数据。"""
from __future__ import annotations
from typing import Any
TRADE_LOG_EXTRA_COLUMNS = (
"ALTER TABLE trade_logs ADD COLUMN margin_pct REAL",
"ALTER TABLE trade_logs ADD COLUMN equity_after REAL",
"ALTER TABLE trade_logs ADD COLUMN source TEXT DEFAULT 'local'",
"ALTER TABLE trade_logs ADD COLUMN ctp_trade_key TEXT",
)
def ensure_trade_log_columns(conn) -> None:
for sql in TRADE_LOG_EXTRA_COLUMNS:
try:
conn.execute(sql)
except Exception:
pass
def calc_equity_after(capital: float, pnl_net: float) -> float | None:
cap = float(capital or 0)
if cap <= 0:
return None
return round(cap + float(pnl_net or 0), 2)
def recalc_trade_log_pnl(
*,
symbol: str,
direction: str,
entry_price: float,
close_price: float,
lots: float,
stop_loss: float | None = None,
take_profit: float | None = None,
open_time: str = "",
close_time: str = "",
trading_mode: str = "simulation",
capital: float = 0.0,
) -> dict[str, float]:
"""按开/平仓价重算盈亏与手续费(跨日持仓可手动改价后核对)。"""
from modules.core.contract_specs import calc_position_metrics
from modules.fees.fee_specs import calc_round_trip_fee
sym = (symbol or "").strip()
direction = (direction or "long").strip().lower()
entry = float(entry_price or close_price or 0)
close_px = float(close_price or 0)
lots_f = float(lots or 0)
sl = float(stop_loss) if stop_loss is not None else entry
tp = float(take_profit) if take_profit is not None else entry
metrics = calc_position_metrics(
direction, entry, sl, tp, lots_f, close_px, capital, sym,
)
pnl = round(float(metrics.get("float_pnl") or 0), 2)
fee = calc_round_trip_fee(
sym, entry, close_px, lots_f, open_time, close_time, trading_mode=trading_mode,
)
pnl_net = round(pnl - fee, 2)
return {"pnl": pnl, "fee": round(fee, 2), "pnl_net": pnl_net}
def _read_initial_capital(conn, initial_capital: float | None = None) -> float:
if initial_capital is not None and initial_capital > 0:
return float(initial_capital)
try:
row = conn.execute("SELECT value FROM settings WHERE key='live_capital'").fetchone()
if row and row[0]:
val = float(row[0] or 0)
if val > 0:
return val
except (TypeError, ValueError):
pass
try:
from modules.trading.product_recommend import DISCONNECTED_RECOMMEND_CAPITAL
return float(DISCONNECTED_RECOMMEND_CAPITAL)
except Exception:
return 100_000.0
def refresh_trade_log_equity_chain(
conn,
initial_capital: float | None = None,
) -> int:
"""按平仓时间顺序重算 trade_logs.equity_after(起始=参考资金 live_capital)。"""
base = _read_initial_capital(conn, initial_capital)
rows = [
dict(r)
for r in conn.execute(
"SELECT id, close_time, pnl_net FROM trade_logs ORDER BY close_time ASC, id ASC"
).fetchall()
]
running = float(base or 0)
updated = 0
for row in rows:
if running <= 0:
break
running = round(running + float(row.get("pnl_net") or 0), 2)
conn.execute(
"UPDATE trade_logs SET equity_after=? WHERE id=?",
(running, int(row["id"])),
)
updated += 1
return updated
def _norm_symbol(symbol: str) -> str:
return (symbol or "").split(".")[0].strip().lower()
def _norm_close_minute(ts: str) -> str:
"""统一 close_time 到分钟粒度,兼容 ISO `T` 与空格分隔。"""
return (ts or "").strip().replace("T", " ")[:16]
def purge_duplicate_local_trade_logs(conn) -> int:
"""删除已被 CTP 柜台记录覆盖的本地重复成交。"""
removed = 0
ctp_rows = [
dict(r)
for r in conn.execute("SELECT * FROM trade_logs WHERE source='ctp'").fetchall()
]
local_rows = [
dict(r)
for r in conn.execute(
"""SELECT * FROM trade_logs
WHERE COALESCE(source, 'local') != 'ctp'
AND (ctp_trade_key IS NULL OR ctp_trade_key = '')"""
).fetchall()
]
for ctp in ctp_rows:
ct16 = _norm_close_minute(ctp.get("close_time") or "")
sym_n = _norm_symbol(ctp.get("symbol") or "")
lots = float(ctp.get("lots") or 0)
direction = (ctp.get("direction") or "long").strip().lower()
for loc in local_rows:
if loc.get("id") == ctp.get("id"):
continue
if _norm_symbol(loc.get("symbol") or "") != sym_n:
continue
if (loc.get("direction") or "long").strip().lower() != direction:
continue
if _norm_close_minute(loc.get("close_time") or "") != ct16:
continue
if abs(float(loc.get("lots") or 0) - lots) > 0.01:
continue
conn.execute("DELETE FROM trade_logs WHERE id=?", (loc["id"],))
removed += 1
return removed
def _attach_symbol_meta(t: dict[str, Any]) -> None:
try:
from modules.core.symbols import position_symbol_meta
sym = (t.get("symbol") or "").strip()
meta = position_symbol_meta(sym)
if not t.get("symbol_name"):
t["symbol_name"] = meta.get("name") or sym
t["symbol_exchange"] = meta.get("exchange") or ""
t["symbol_is_main"] = bool(meta.get("is_main"))
except Exception:
t.setdefault("symbol_exchange", "")
t.setdefault("symbol_is_main", False)
def enrich_trades_for_records(
trades: list[dict[str, Any]],
*,
initial_capital: float = 0.0,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""表格仍按 id 降序;资金曲线按平仓时间升序用最新资金绘制。"""
rows = [dict(t) for t in trades]
chrono = sorted(
rows,
key=lambda t: ((t.get("close_time") or ""), int(t.get("id") or 0)),
)
running = float(initial_capital or 0)
curve: list[dict[str, Any]] = []
equity_by_id: dict[int, float | None] = {}
for t in chrono:
_attach_symbol_meta(t)
pnl_net = float(t.get("pnl_net") or 0)
if running > 0:
running = round(running + pnl_net, 2)
eq: float | None = running
else:
eq = None
equity_by_id[int(t.get("id") or 0)] = eq
cap_before = float(eq or 0) - pnl_net if eq is not None else 0.0
if t.get("margin_pct") is None:
margin = float(t.get("margin") or 0)
if margin > 0 and cap_before > 0:
t["margin_pct"] = round(margin / cap_before * 100, 2)
if eq is not None:
curve.append({
"time": (t.get("close_time") or "")[:19],
"value": float(eq),
"id": int(t.get("id") or 0),
})
for t in rows:
tid = int(t.get("id") or 0)
if tid in equity_by_id:
t["equity_after"] = equity_by_id[tid]
return rows, curve
+225
View File
@@ -0,0 +1,225 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""交易事件推送:企业微信 + AI 分析。"""
from __future__ import annotations
from typing import Callable, Optional
from modules.core.contract_specs import calc_position_metrics, get_contract_spec
from modules.trading.sl_tp_guard import monitor_source_label
from modules.notify.wechat_notify import format_close_done, format_key_open_success, format_open_success
def _risk_amount(capital: float, risk_percent: float) -> Optional[float]:
try:
return round(float(capital) * float(risk_percent) / 100.0, 2)
except (TypeError, ValueError):
return None
def notify_manual_open_filled(
*,
send_wechat: Callable[[str], None],
get_setting: Callable[[str, str], str],
mode_label: str,
sym: str,
symbol_name: str,
direction: str,
entry: float,
sl: Optional[float],
tp: Optional[float],
lots: int,
capital: float,
order_id: str = "",
trailing_be: bool = False,
be_tick_buffer: int = 2,
schedule_ai_fn=None,
db_path: str = "",
) -> None:
if not sl:
return
spec = get_contract_spec(sym)
tick = float(spec.get("tick_size") or 1.0)
try:
rp = float(get_setting("risk_percent", "1") or 1)
except (TypeError, ValueError):
rp = 1.0
metrics = calc_position_metrics(direction, entry, sl, tp or entry, lots, entry, capital, sym)
msg = format_open_success(
symbol_name=symbol_name,
symbol=sym,
direction=direction,
mode_label=mode_label,
order_id=order_id,
entry=entry,
stop_loss=float(sl),
take_profit=float(tp) if tp else None,
lots=lots,
capital=capital,
margin=metrics.get("margin"),
margin_pct=metrics.get("position_pct"),
risk_percent=rp,
risk_amount=_risk_amount(capital, rp),
trailing_be=trailing_be,
be_tick_buffer=be_tick_buffer,
tick_size=tick,
source="期货下单",
)
send_wechat(msg)
if schedule_ai_fn and db_path:
schedule_ai_fn(
db_path=db_path,
get_setting_fn=get_setting,
kind="open",
title=f"{symbol_name or sym} 开仓",
payload={
"symbol": sym,
"direction": direction,
"entry": entry,
"stop_loss": sl,
"take_profit": tp,
"lots": lots,
"capital": capital,
},
send_wechat_fn=None,
)
def notify_key_breakout_open(
*,
send_wechat: Callable[[str], None],
get_setting: Callable[[str, str], str],
mode_label: str,
row: dict,
break_side: str,
bar_time: str,
direction: str,
entry: float,
sl: float,
tp: float,
lots: int,
capital: float,
order_id: str = "",
schedule_ai_fn=None,
db_path: str = "",
) -> None:
sym = row.get("symbol") or ""
name = row.get("symbol_name") or sym
trailing_be = bool(int(row.get("trailing_be") or 0))
try:
rp = float(get_setting("risk_percent", "1") or 1)
be_buf = int(float(get_setting("trailing_be_tick_buffer", "2") or 2))
except (TypeError, ValueError):
rp, be_buf = 1.0, 2
spec = get_contract_spec(sym)
tick = float(spec.get("tick_size") or 1.0)
metrics = calc_position_metrics(direction, entry, sl, tp, lots, entry, capital, sym)
msg = format_key_open_success(
symbol_name=name,
symbol=sym,
monitor_type=row.get("monitor_type") or "",
trade_mode=row.get("trade_mode") or "顺势",
bar_time=bar_time,
break_side=break_side,
direction=direction,
mode_label=mode_label,
order_id=order_id,
entry=entry,
stop_loss=sl,
take_profit=tp,
lots=lots,
capital=capital,
margin=metrics.get("margin"),
margin_pct=metrics.get("position_pct"),
risk_percent=rp,
risk_amount=_risk_amount(capital, rp),
trailing_be=trailing_be,
be_tick_buffer=be_buf,
tick_size=tick,
)
send_wechat(msg)
if schedule_ai_fn and db_path:
schedule_ai_fn(
db_path=db_path,
get_setting_fn=get_setting,
kind="key_open",
title=f"{name} 关键位开仓",
payload={
"monitor_type": row.get("monitor_type"),
"trade_mode": row.get("trade_mode"),
"break_side": break_side,
"entry": entry,
"stop_loss": sl,
"take_profit": tp,
"lots": lots,
},
)
def notify_trade_log_close(
*,
send_wechat: Callable[[str], None],
get_setting: Callable[[str, str], str],
mode_label: str,
capital: float,
sym: str,
symbol_name: str,
direction: str,
entry: float,
close_price: float,
sl: Optional[float],
tp: Optional[float],
lots: float,
pnl_net: float,
equity_after: Optional[float],
holding_minutes: int,
result: str,
monitor_type: str = "",
schedule_ai_fn=None,
db_path: str = "",
) -> None:
src = monitor_source_label(monitor_type) if monitor_type else "期货下单"
note = ""
if tp and sl:
if direction == "long":
if close_price > tp or close_price < sl:
note = "成交价不在计划止盈/止损带内(可能为手动或其他类型平仓)"
else:
if close_price < tp or close_price > sl:
note = "成交价不在计划止盈/止损带内(可能为手动或其他类型平仓)"
msg = format_close_done(
symbol_name=symbol_name,
symbol=sym,
mode_label=mode_label,
direction=direction,
result=result,
pnl_net=pnl_net,
equity_after=equity_after,
capital=capital,
entry=entry,
close_price=close_price,
stop_loss=sl,
take_profit=tp,
lots=lots,
holding_minutes=holding_minutes,
note=note,
)
send_wechat(msg)
if schedule_ai_fn and db_path:
schedule_ai_fn(
db_path=db_path,
get_setting_fn=get_setting,
kind="close",
title=f"{symbol_name or sym} 平仓",
payload={
"source": src,
"result": result,
"pnl_net": pnl_net,
"entry": entry,
"close_price": close_price,
"lots": lots,
},
)
+5
View File
@@ -0,0 +1,5 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
from modules.web.routes import register
__all__ = ["register"]
+108
View File
@@ -0,0 +1,108 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
"""HTTP routes for web module."""
from __future__ import annotations
from datetime import date, datetime
from flask import (
Response,
flash,
jsonify,
redirect,
render_template,
request,
send_file,
session,
stream_with_context,
url_for,
)
def register(deps) -> None:
app = deps.app
login_required = deps.login_required
require_nav = deps.require_nav
get_db = deps.get_db
get_setting = deps.get_setting
set_setting = deps.set_setting
fetch_price = deps.fetch_price
send_wechat_msg = deps.send_wechat_msg
touch_stats_cache = deps.touch_stats_cache
get_stats_data = deps.get_stats_data
build_market_quote_payload = deps.build_market_quote_payload
today_str = deps.today_str
expire_old_plans = deps.expire_old_plans
TZ = deps.tz
DB_PATH = deps.db_path
UPLOAD_DIR = deps.upload_dir
OPEN_TYPES = deps.open_types
EXIT_TRIGGERS = deps.exit_triggers
BEHAVIOR_TAGS = deps.behavior_tags
KLINE_PERIODS = deps.kline_periods
KLINE_CUTOFFS = deps.kline_cutoffs
calc_holding_duration = deps.calc_holding_duration
holding_to_minutes = deps.holding_to_minutes
classify_close_result = deps.classify_close_result
calc_rr_ratio = deps.calc_rr_ratio
calc_theoretical_pnl = deps.calc_theoretical_pnl
parse_review_date_filter = deps.parse_review_date_filter
_trading_mode = deps.trading_mode
_ua_is_phone = deps.ua_is_phone
_static_asset_v = deps.static_asset_v
from werkzeug.security import check_password_hash
import json
@app.route("/")
def index():
if session.get("logged_in"):
return redirect(url_for("positions"))
return redirect(url_for("login"))
@app.route("/manifest.webmanifest")
def web_manifest():
import json
manifest_path = os.path.join(app.static_folder, "manifest.json")
with open(manifest_path, encoding="utf-8") as fh:
data = json.load(fh)
if _ua_is_phone(request.headers.get("User-Agent", "")):
data["orientation"] = "portrait-primary"
else:
data["orientation"] = "any"
response = app.make_response(json.dumps(data, ensure_ascii=False))
response.mimetype = "application/manifest+json"
response.headers["Cache-Control"] = "no-cache"
return response
@app.route("/sw.js")
def service_worker():
response = app.send_static_file("sw.js")
response.headers["Cache-Control"] = "no-cache"
response.headers["Service-Worker-Allowed"] = "/"
return response
@app.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
u = request.form.get("username", "").strip()
p = request.form.get("password", "")
admin_u = get_setting("admin_username")
admin_hash = get_setting("admin_password_hash")
if u == admin_u and check_password_hash(admin_hash, p):
session["logged_in"] = True
session["username"] = u
return redirect(url_for("positions"))
flash("账号或密码错误")
return render_template("login.html")
@app.route("/logout")
def logout():
session.clear()
return redirect(url_for("login"))
+12
View File
@@ -0,0 +1,12 @@
.ai-page .card-body{display:flex;flex-direction:column;gap:0;min-height:0}
.ai-usage{margin-bottom:.85rem;font-size:.84rem;color:var(--text-muted)}
.ai-usage summary{cursor:pointer;color:var(--accent);font-weight:600;margin-bottom:.35rem}
.ai-usage-body ul{margin:.25rem 0 0 1.1rem;padding:0;line-height:1.55}
.ai-usage-body a{color:var(--accent)}
.ai-section-label{font-size:.9rem;margin:0 0 .65rem;color:var(--text-title);font-weight:600}
.ai-msg-list{max-height:min(70vh,720px);overflow:auto;padding-right:.25rem}
.ai-msg{border:1px solid var(--border);border-radius:10px;padding:.85rem 1rem;margin-bottom:.75rem;background:rgba(255,255,255,.02)}
.ai-msg-head{display:flex;justify-content:space-between;gap:.5rem;font-size:.75rem;color:var(--text-muted);margin-bottom:.35rem}
.ai-msg-kind{text-transform:uppercase;letter-spacing:.04em;color:var(--accent)}
.ai-msg-title{font-size:.95rem;margin:0 0 .5rem;color:var(--text-title)}
.ai-msg-body{margin:0;white-space:pre-wrap;font-family:inherit;font-size:.84rem;line-height:1.55;color:var(--text-main)}
+491
View File
@@ -0,0 +1,491 @@
/* Copyright (c) 2025-2026 马建军. All rights reserved. */
html{background:#050508;color-scheme:dark}
html[data-theme="light"]{background:#e8eef8;color-scheme:light}
html:not([data-theme="light"]){
--bg-page:#050508;
--bg-grid:rgba(76,194,255,.045);
--border-header:rgba(76,194,255,.12);
--header-bg:transparent;
--text-primary:#e8eaf6;
--text-title:#ffffff;
--text-muted:#7a82a0;
--text-label:#a8b4ff;
--accent:#4cc2ff;
--accent-2:#9d6bff;
--ambient-glow:rgba(76,194,255,.14);
--ambient-glow-2:rgba(123,66,255,.1);
--scanline:rgba(76,194,255,.03);
--title-glow:rgba(76,194,255,.35);
--card-bg:rgba(10,12,22,.82);
--card-border:rgba(76,194,255,.22);
--card-border-hover:rgba(76,194,255,.45);
--card-glow:rgba(76,194,255,.12);
--card-inner:rgba(16,20,36,.9);
--input-bg:rgba(12,14,26,.95);
--input-border:rgba(76,194,255,.18);
--nav-bg:rgba(14,16,28,.9);
--nav-border:rgba(76,194,255,.15);
--nav-hover:rgba(30,40,68,.85);
--nav-hover-glow:rgba(76,194,255,.15);
--nav-active:#2563eb;
--nav-active-border:transparent;
--nav-active-glow:rgba(76,194,255,.45);
--focus-ring:rgba(76,194,255,.25);
--focus-glow:rgba(76,194,255,.2);
--btn-glow:rgba(76,194,255,.35);
--btn-glow-strong:rgba(123,66,255,.4);
--row-hover:rgba(76,194,255,.06);
--list-item-bg:rgba(14,18,32,.8);
--table-border:rgba(76,194,255,.1);
--profit:#4cd97f;
--profit-bg:rgba(76,217,127,.12);
--loss:#ff6b7a;
--loss-bg:rgba(255,107,122,.12);
--dir-bg:rgba(30,45,80,.6);
--planned-bg:rgba(234,193,71,.12);
--planned-text:#eac147;
--expired-bg:rgba(40,44,60,.8);
--expired-text:#8a8a9e;
--flash-bg:rgba(30,45,80,.7);
--flash-text:#4cc2ff;
--modal-mask:rgba(2,4,12,.82);
--danger:#ff6b7a;
--shadow-card:0 8px 32px rgba(0,0,0,.45),0 0 1px rgba(76,194,255,.3),inset 0 1px 0 rgba(255,255,255,.04);
--shadow-card-hover:0 16px 48px rgba(0,0,0,.5),0 0 24px var(--card-glow);
--calc-bg:rgba(20,24,42,.9);
--toggle-bg:rgba(14,16,28,.9);
--toggle-border:rgba(76,194,255,.2);
}
[data-theme="light"]{
--bg-page:#e8eef8;
--bg-grid:rgba(37,99,235,.07);
--border-header:rgba(37,99,235,.12);
--header-bg:transparent;
--text-primary:#1a2233;
--text-title:#0a1628;
--text-muted:#5c6578;
--text-label:#1d4ed8;
--accent:#2563eb;
--accent-2:#7c3aed;
--ambient-glow:rgba(37,99,235,.12);
--ambient-glow-2:rgba(124,58,237,.08);
--scanline:rgba(37,99,235,.04);
--title-glow:rgba(37,99,235,.2);
--card-bg:rgba(255,255,255,.92);
--card-border:rgba(37,99,235,.2);
--card-border-hover:rgba(37,99,235,.4);
--card-glow:rgba(37,99,235,.1);
--card-inner:#f4f7fc;
--input-bg:#ffffff;
--input-border:#b8c5d6;
--nav-bg:rgba(255,255,255,.95);
--nav-border:rgba(37,99,235,.18);
--nav-hover:#eef4ff;
--nav-hover-glow:rgba(37,99,235,.12);
--nav-active:#2563eb;
--nav-active-border:transparent;
--nav-active-glow:rgba(37,99,235,.35);
--focus-ring:rgba(37,99,235,.2);
--focus-glow:rgba(37,99,235,.15);
--btn-glow:rgba(37,99,235,.25);
--btn-glow-strong:rgba(37,99,235,.35);
--row-hover:rgba(37,99,235,.05);
--list-item-bg:#f1f5f9;
--table-border:#e2e8f0;
--profit:#15803d;
--profit-bg:#dcfce7;
--loss:#dc2626;
--loss-bg:#fee2e2;
--dir-bg:#dbeafe;
--planned-bg:#fef9c3;
--planned-text:#a16207;
--expired-bg:#f1f5f9;
--expired-text:#64748b;
--flash-bg:#dbeafe;
--flash-text:#1d4ed8;
--modal-mask:rgba(15,23,42,.45);
--danger:#dc2626;
--shadow-card:0 8px 28px rgba(15,23,42,.08),0 0 1px rgba(37,99,235,.15),inset 0 1px 0 rgba(255,255,255,.95);
--shadow-card-hover:0 16px 40px rgba(15,23,42,.12),0 0 20px var(--card-glow);
--calc-bg:#eef2ff;
--toggle-bg:#ffffff;
--toggle-border:#c5d0dc;
}
*{margin:0;padding:0;box-sizing:border-box}
body{
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"PingFang SC","Microsoft YaHei",sans-serif;
background:var(--bg-page);
color:var(--text-primary);
min-height:100vh;
}
.page-wrap{max-width:1800px;margin:0 auto;min-height:100vh}
.site-header{text-align:center;padding:1.5rem 1rem 1.25rem;border-bottom:1px solid var(--border-header);position:relative}
.site-title{font-size:1.75rem;font-weight:700;color:var(--text-title);margin-bottom:1.65rem;line-height:1.3}
.header-tools{position:absolute;top:1rem;left:1.5rem;display:flex;gap:.5rem;align-items:center;z-index:20}
.theme-switch{
display:inline-flex;align-items:center;
border-radius:999px;border:1px solid var(--toggle-border);
background:var(--toggle-bg);padding:3px;gap:2px;
}
.theme-switch-btn{
padding:.38rem .75rem;border:none;border-radius:999px;
background:transparent;color:var(--text-muted);
font-size:.75rem;cursor:pointer;transition:.2s;
white-space:nowrap;width:auto;flex-shrink:0;
}
.theme-switch-btn:hover{color:var(--text-primary)}
.theme-switch-btn.active{
background:linear-gradient(135deg,var(--accent),var(--accent-2));
color:#fff;box-shadow:0 0 12px var(--btn-glow);
}
.site-nav{display:flex;justify-content:center;gap:.45rem;flex-wrap:wrap}
.site-nav a{
padding:.55rem 1.15rem;border-radius:8px;
border:1px solid transparent;
background:transparent;
color:var(--text-primary);
text-decoration:none;font-size:.88rem;
transition:.2s;white-space:nowrap;
}
.site-nav a:hover{background:var(--nav-hover);border-color:var(--nav-border);color:var(--text-title)}
.site-nav a.active{background:var(--nav-active);border-color:var(--nav-active-border);color:#fff}
.user-bar{position:absolute;top:1rem;right:1.5rem;font-size:.8rem;color:var(--text-muted);white-space:nowrap}
.user-bar a{color:var(--danger);text-decoration:none;margin-left:.5rem}
.main{padding:1.5rem}
.text-muted{color:var(--text-muted)}
.text-label{color:var(--text-label)}
.text-accent{color:var(--accent)}
.text-profit{color:var(--profit)}
.text-loss{color:var(--loss)}
.section-label{font-size:.9rem;color:var(--text-label);margin:.75rem 0 .5rem}
.empty-hint{color:var(--text-muted);padding:.75rem;font-size:.85rem}
.flash{padding:1rem;background:var(--flash-bg);color:var(--flash-text);border-radius:10px;margin-bottom:1.5rem;text-align:center;border:1px solid var(--card-border)}
.card{
background:var(--card-bg);
border-radius:16px;padding:1.5rem;
border:1px solid var(--card-border);
margin-bottom:1.5rem;
position:relative;overflow:hidden;
box-shadow:var(--shadow-card);
backdrop-filter:blur(10px);
transition:background .25s,border-color .25s,box-shadow .25s;
}
.card::before{
content:"";position:absolute;inset:0;
background:linear-gradient(135deg,var(--card-glow) 0%,transparent 55%);
pointer-events:none;
}
.card::after{
content:"";position:absolute;top:0;left:12%;right:12%;height:1px;
background:linear-gradient(90deg,transparent,var(--accent),var(--accent-2),transparent);
opacity:.55;pointer-events:none;
}
.card > *{position:relative;z-index:1}
.card h2{
font-size:1.15rem;margin-bottom:1rem;color:var(--text-label);
display:flex;align-items:center;gap:.5rem;
}
.card h2:before{
content:"";width:4px;height:16px;
background:linear-gradient(180deg,var(--accent),var(--accent-2));
border-radius:2px;box-shadow:0 0 8px var(--card-glow);
}
.form-row{display:flex;gap:.5rem;flex-wrap:wrap;margin-bottom:1rem;align-items:center}
.form-compact{display:flex;flex-direction:column;gap:.5rem;margin-bottom:1rem}
.form-compact .form-line{display:grid;gap:.5rem;align-items:center}
.form-compact .line-2{grid-template-columns:repeat(2,1fr)}
.form-compact .line-3{grid-template-columns:repeat(3,1fr)}
.form-compact .line-4{grid-template-columns:repeat(4,1fr)}
.form-compact .line-5{grid-template-columns:repeat(5,1fr)}
.form-compact.line-tight{gap:.35rem;margin-bottom:.5rem}
.form-compact .line-btn{display:flex;gap:.5rem;align-items:center}
.form-compact input,.form-compact select{padding:.55rem .7rem;font-size:.85rem;border-radius:8px}
.form-compact .symbol-wrap{display:flex;flex-direction:column;align-self:stretch}
.form-compact .symbol-wrap>label.text-label,.form-compact .symbol-wrap>label.symbol-field-label{margin-bottom:.28rem}
.form-compact .symbol-selected{font-size:.68rem;margin:0}
.module-rules{margin-bottom:.75rem;font-size:.82rem;color:var(--text-muted)}
.module-rules summary{cursor:pointer;color:var(--accent);font-weight:600;margin-bottom:0;list-style:none}
.module-rules summary::-webkit-details-marker{display:none}
.module-rules[open] summary{margin-bottom:.35rem}
.module-rules-body{padding:.35rem 0 .15rem}
.module-rules-body ul{margin:.25rem 0 .5rem 1.1rem;padding:0}
.module-rules-body li{margin:.15rem 0}
.module-rules-body p{margin:.35rem 0 .25rem}
.form-compact button.btn-primary{padding:.55rem 1.5rem;font-size:.85rem;white-space:nowrap}
.form-compact-review input.calc-readonly{color:var(--accent);background:var(--calc-bg);font-size:.82rem}
.form-compact-review textarea{min-height:44px;font-size:.85rem;padding:.45rem .65rem}
.form-compact-review .section-hint{font-size:.72rem;color:var(--text-muted);margin:.25rem 0 .15rem}
.form-compact-review .tag-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:.15rem .5rem;font-size:.75rem}
.form-compact-review .tag-grid label{display:flex;align-items:center;gap:.3rem;cursor:pointer;color:var(--text-muted);white-space:nowrap}
.form-compact-review .tag-grid input{width:auto;flex-shrink:0}
.form-compact-review input[type="file"]{font-size:.72rem;padding:.4rem .5rem}
.form-compact-review .mini-field span{font-size:.65rem;color:var(--text-muted);display:block;line-height:1;margin-bottom:2px}
.form-compact-review .kline-row label{display:flex;align-items:center;gap:.35rem;color:var(--text-muted);white-space:nowrap}
.form-compact-review .kline-row{display:grid;grid-template-columns:auto 1fr 1fr 1fr auto;gap:.5rem;align-items:end;font-size:.78rem}
.split-grid.records-split .card{min-height:auto}
.split-grid.records-split .card h2{font-size:1rem;margin-bottom:.5rem}
.split-grid.records-split .card{padding:1rem 1.25rem}
.page-title-sm{font-size:1.25rem;margin-bottom:.75rem}
.form-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:.75rem;margin-bottom:1rem}
.form-grid .full{grid-column:1/-1}
.field label{display:block;font-size:.8rem;color:var(--text-label);margin-bottom:.35rem}
input,select,textarea,button{
padding:.7rem 1rem;border-radius:10px;
border:1px solid var(--input-border);
background:var(--input-bg);color:var(--text-primary);
font-size:.9rem;outline:none;width:100%;
transition:border-color .2s,background .2s;
}
textarea{min-height:80px;resize:vertical}
input:focus,select:focus,textarea:focus{border-color:var(--accent)}
button.btn-primary{background:linear-gradient(90deg,var(--accent),var(--accent-2));border:none;cursor:pointer;color:#fff;width:auto}
button.btn-primary:hover{opacity:.9}
.list{display:flex;flex-direction:column;gap:.75rem}
.list-item{
display:flex;justify-content:space-between;align-items:center;
padding:1rem;background:var(--list-item-bg);
border-radius:10px;gap:1rem;flex-wrap:wrap;
border:1px solid var(--card-border);
}
.btn-del{padding:.4rem .8rem;background:var(--loss-bg);color:var(--loss);border-radius:8px;text-decoration:none;font-size:.85rem}
table{width:100%;border-collapse:collapse}
th,td{padding:.85rem;text-align:left;border-bottom:1px solid var(--table-border);font-size:.9rem;color:var(--text-primary)}
th{color:var(--text-label);white-space:nowrap}
.badge{padding:.25rem .5rem;border-radius:6px;font-size:.75rem}
.badge.profit{background:var(--profit-bg);color:var(--profit)}
.badge.loss{background:var(--loss-bg);color:var(--loss)}
.badge.dir{background:var(--dir-bg);color:var(--accent)}
.badge.planned{background:var(--planned-bg);color:var(--planned-text)}
.badge.active{background:var(--profit-bg);color:var(--profit)}
.badge.expired{background:var(--expired-bg);color:var(--expired-text)}
.stat-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:1rem;margin-bottom:1.5rem}
.stat-item{
background:var(--card-inner);padding:1rem;border-radius:12px;text-align:center;
border:1px solid var(--card-border);position:relative;overflow:hidden;
}
.stat-item::before{
content:"";position:absolute;top:0;left:0;right:0;height:2px;
background:linear-gradient(90deg,var(--accent),var(--accent-2));opacity:.5;
}
.stat-item .label{font-size:.8rem;color:var(--text-muted)}
.stat-item .value{font-size:1.4rem;font-weight:600;color:var(--text-title);margin-top:.25rem}
.symbol-wrap{position:relative;z-index:1}
.symbol-wrap:has(.symbol-dropdown.show){z-index:50}
.symbol-dropdown{
position:absolute;top:100%;left:0;right:0;
background:var(--input-bg);border:1px solid var(--input-border);
border-radius:10px;margin-top:4px;z-index:200;
max-height:240px;overflow-y:auto;display:none;
box-shadow:var(--shadow-card-hover);
}
.card:has(.symbol-dropdown.show){overflow:visible}
.symbol-dropdown.show{display:block}
.symbol-option{padding:.65rem 1rem;cursor:pointer;font-size:.85rem;border-bottom:1px solid var(--table-border)}
.symbol-option:hover{background:var(--list-item-bg)}
.symbol-option .sub{font-size:.75rem;color:var(--text-muted);margin-top:2px}
.symbol-option.near-expiry{color:#ff6b7a}
html[data-theme="light"] .symbol-option.near-expiry{color:#dc2626}
.symbol-option.near-expiry .sub{color:inherit;opacity:.85}
.near-expiry-tag{
font-size:.68rem;padding:.1rem .35rem;border-radius:4px;
background:rgba(255,107,122,.15);color:inherit;font-weight:600;
}
html[data-theme="light"] .near-expiry-tag{background:rgba(220,38,38,.12)}
.night-session-tag{
display:inline-block;margin-left:4px;padding:0 5px;border-radius:4px;font-size:.7rem;font-weight:600;
color:#7dd3fc;background:rgba(56,189,248,.15);vertical-align:middle;line-height:1.3
}
html[data-theme="light"] .night-session-tag{color:#0369a1;background:rgba(14,165,233,.12)}
.symbol-group-head{
padding:.4rem .85rem;font-size:.72rem;font-weight:600;
color:var(--text-muted);background:var(--card-inner);
border-bottom:1px solid var(--table-border);position:sticky;top:0;
}
.symbol-wrap{display:flex;flex-direction:column;align-self:stretch}
.symbol-wrap>label.text-label,.symbol-wrap>label.symbol-field-label{
display:flex;align-items:baseline;flex-wrap:wrap;gap:.35rem;
font-size:.72rem;color:var(--text-label);margin-bottom:.28rem;line-height:1.35
}
.symbol-wrap.trade-field>label.text-label,.symbol-wrap.key-field>label.text-label{margin-bottom:.28rem}
.symbol-selected,.symbol-wrap label .symbol-selected{
font-size:.68rem;color:var(--accent);font-weight:400;margin:0;line-height:1.35
}
.check-row{display:flex;flex-wrap:wrap;gap:1rem;margin:.75rem 0}
.check-row label{display:flex;align-items:center;gap:.4rem;font-size:.85rem;color:var(--text-muted);cursor:pointer}
.check-row input{width:auto}
.hint{font-size:.78rem;color:var(--text-muted);line-height:1.5;margin-top:.5rem}
.filter-row{display:flex;gap:.75rem;flex-wrap:wrap;align-items:flex-end;margin-bottom:1rem}
.filter-row .field{width:auto;min-width:140px}
.filter-row button{width:auto}
.split-grid{display:grid;grid-template-columns:1fr 1fr;gap:1.5rem;align-items:stretch;margin-bottom:1.5rem}
.split-grid .card{margin-bottom:0;height:100%;min-height:480px;display:flex;flex-direction:column}
.split-grid .card-body{flex:1;overflow:auto}
.card-scroll{max-height:420px;overflow-y:auto}
.preset-tabs{display:flex;gap:.5rem;flex-wrap:wrap;margin-bottom:1rem}
.preset-tabs a{
padding:.45rem .85rem;border-radius:8px;
border:1px solid var(--input-border);
color:var(--text-muted);text-decoration:none;font-size:.85rem;
}
.preset-tabs a.active,.preset-tabs a:hover{background:var(--dir-bg);color:var(--accent);border-color:var(--accent)}
.btn-link{color:var(--accent);cursor:pointer;font-size:.85rem;background:none;border:none;padding:0}
.btn-link:hover{text-decoration:underline}
.modal-mask{position:fixed;inset:0;background:var(--modal-mask);z-index:1000;display:none;align-items:center;justify-content:center;padding:1rem}
.modal-mask.show{display:flex}
.modal-box{
background:var(--card-bg);border:1px solid var(--card-border);
border-radius:16px;max-width:900px;width:100%;
max-height:90vh;overflow:auto;padding:1.5rem;
box-shadow:var(--shadow-card);
}
.modal-box h3{margin-bottom:1rem;color:var(--text-label)}
.modal-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:.75rem;font-size:.9rem}
.modal-grid .item label{color:var(--text-muted);font-size:.75rem;display:block}
.modal-grid .item div{margin-top:.2rem;color:var(--text-primary)}
.form-compact .line-plan-1{grid-template-columns:minmax(0,1fr) minmax(72px,0.45fr) minmax(0,1.6fr)}
.form-compact .line-plan-2{grid-template-columns:repeat(4,minmax(0,1fr)) auto;align-items:center}
.form-compact .field-short select{padding:.55rem .5rem}
.badge.emotion{background:var(--loss-bg);color:var(--loss);font-weight:600;border:1px solid var(--loss)}
tr.row-emotion td{color:var(--loss)}
tr.row-emotion td .badge.emotion{background:var(--loss);color:#fff;border-color:var(--loss)}
.modal-box.review-modal-wide{max-width:960px}
.modal-box.review-modal-fullscreen{
width:calc(100vw - 1.5rem);max-width:none;
height:calc(100vh - 1.5rem);max-height:none;
border-radius:12px;display:flex;flex-direction:column;
}
.modal-box.review-modal-fullscreen h3{flex-shrink:0}
#review-modal-body.review-modal-body{flex:1;overflow:auto;min-height:0}
.review-detail-table{margin-bottom:1rem;overflow-x:auto}
.review-detail-headers,.review-detail-values{
display:grid;
grid-template-columns:repeat(16,minmax(52px,1fr));
gap:.35rem .4rem;align-items:start;
}
.review-detail-headers span{
font-size:.68rem;color:var(--text-muted);
white-space:nowrap;line-height:1.3;
}
.review-detail-values span{
font-size:.82rem;color:var(--text-primary);
line-height:1.35;word-break:break-all;
}
.review-detail-values span.emotion-val{color:var(--loss);font-weight:600}
.review-detail-fields{padding:.25rem 0}
.review-detail-section{margin-bottom:.85rem}
.review-detail-section h4{font-size:.78rem;color:var(--text-label);margin-bottom:.45rem;font-weight:600}
.review-detail-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:.45rem .65rem}
.review-detail-item label{display:block;font-size:.7rem;color:var(--text-muted);margin-bottom:.15rem}
.review-detail-item div{font-size:.85rem;color:var(--text-primary);line-height:1.35}
.review-detail-item.wide{grid-column:span 2}
.review-detail-item.full{grid-column:1/-1}
.review-detail-item.emotion div{color:var(--loss);font-weight:600}
.review-detail-image{flex-shrink:0;padding-top:.75rem;border-top:1px solid var(--table-border)}
.review-detail-image img{width:100%;border-radius:10px;border:1px solid var(--card-border)}
.review-detail-image .no-img{color:var(--text-muted);font-size:.85rem;padding:2rem;text-align:center;background:var(--card-inner);border-radius:10px}
.key-live{display:flex;align-items:center;justify-content:space-between;gap:.75rem;flex:1;min-width:160px}
.key-live .live-price-line{font-size:.85rem;font-weight:600;color:var(--accent);white-space:nowrap}
.key-live .live-dist{font-size:.72rem;color:var(--text-muted);white-space:nowrap}
.key-live .live-dist span{color:var(--text-primary)}
.list-item.key-item{gap:.65rem}
.pos-card{background:var(--card-inner);border:1px solid var(--card-border);border-radius:12px;padding:1rem;margin-bottom:.75rem}
.pos-card-head{display:flex;justify-content:space-between;align-items:flex-start;gap:.5rem;margin-bottom:.65rem}
.pos-card-head .title{font-size:1rem;font-weight:600;color:var(--text-title)}
.pos-card-meta{font-size:.75rem;color:var(--text-muted);margin-bottom:.65rem}
.pos-card-meta strong{color:var(--text-primary)}
.pos-metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:.5rem .65rem;margin-bottom:.65rem}
.pos-metrics .cell label{display:block;font-size:.68rem;color:var(--text-muted);margin-bottom:.15rem}
.pos-metrics .cell div{font-size:.88rem;color:var(--text-primary)}
.pos-metrics .cell.pnl-pos div{color:var(--profit)}
.pos-metrics .cell.pnl-neg div{color:var(--loss)}
.pos-footer{font-size:.72rem;color:var(--text-muted);display:flex;flex-wrap:wrap;gap:.35rem 1rem;padding-top:.65rem;border-top:1px solid var(--table-border)}
.pos-footer span{color:var(--text-primary)}
.pos-del{font-size:.75rem;padding:.35rem .65rem}
.trade-toolbar{display:flex;align-items:center;gap:1rem;margin-bottom:1rem;flex-wrap:wrap}
.trade-switch-label{
display:flex;align-items:center;gap:.4rem;
font-size:.78rem;color:var(--text-muted);
white-space:normal;margin-bottom:.65rem;cursor:pointer;
line-height:1.45;max-width:100%;
}
.trade-switch-label span{line-height:1.45;color:var(--text-muted)}
.trade-switch-label input{flex-shrink:0;width:auto}
.trade-table-wrap{
overflow:auto;
max-height:420px;
width:100%;
-webkit-overflow-scrolling:touch;
border-radius:10px;
border:1px solid var(--table-border);
background:var(--card-inner);
}
.trade-table{font-size:.8rem;width:max-content;min-width:100%;table-layout:auto}
.trade-table th{font-size:.75rem;padding:.55rem .45rem;white-space:nowrap;background:var(--card-inner)}
.trade-table td{padding:.45rem .4rem;vertical-align:middle;white-space:nowrap;background:var(--card-inner)}
.trade-table th:last-child,
.trade-table td:last-child{
position:sticky;right:0;z-index:3;
box-shadow:-6px 0 10px rgba(0,0,0,.08);
}
.trade-table thead th:last-child{z-index:4}
.trade-table input,.trade-table select{
padding:.35rem .45rem;font-size:.78rem;border-radius:6px;width:100%;min-width:0;
}
.trade-table .cell-readonly{color:var(--text-primary)}
.records-equity-card .card-body{padding-top:0;background:transparent}
.records-equity-card #equity-curve-chart{background:transparent;border-radius:0}
.records-trade-card{overflow:visible}
.records-trade-card .card-body{overflow:visible;background:transparent}
.records-trade-card .trade-table-wrap{
--rec-row-h:2.35rem;
--rec-head-h:2.1rem;
overflow:auto;
height:calc(var(--rec-row-h) * 10 + var(--rec-head-h));
max-height:calc(var(--rec-row-h) * 10 + var(--rec-head-h));
width:100%;
-webkit-overflow-scrolling:touch;
border:none;
border-radius:0;
background:transparent;
}
.records-trade-card .trade-table th,
.records-trade-card .trade-table td{
background:transparent;
}
.records-trade-card .trade-table thead th{
position:sticky;
top:0;
z-index:2;
background:transparent;
backdrop-filter:blur(12px);
-webkit-backdrop-filter:blur(12px);
box-shadow:0 1px 0 var(--table-border);
}
.records-trade-card .trade-table th:last-child,
.records-trade-card .trade-table td:last-child{
box-shadow:none;
background:transparent;
}
.records-trade-card .trade-table thead th:last-child{
z-index:5;
}
.trade-actions{display:flex;gap:.35rem;flex-wrap:nowrap;align-items:center;min-width:230px;white-space:nowrap}
.trade-actions a,.trade-actions button{flex-shrink:0;white-space:nowrap;font-size:.72rem;padding:.3rem .55rem;border-radius:6px;text-decoration:none;border:none;cursor:pointer;width:auto;min-width:0}
.btn-fill{background:var(--dir-bg);color:var(--accent)}
.btn-verify{background:var(--nav-active);color:#fff}
.btn-verify:disabled{opacity:.45;cursor:not-allowed}
.badge.result-manual{background:var(--dir-bg);color:var(--accent)}
.badge.result-external{background:var(--expired-bg);color:var(--expired-text)}
.profile-page .profile-head{display:flex;align-items:center;gap:.65rem;flex-wrap:wrap;margin:1rem 0 .75rem;font-size:.9rem}
.profile-page .profile-source{font-size:.72rem;color:var(--text-muted)}
.profile-spec{max-width:820px;border:1px solid var(--card-border);border-radius:10px;background:var(--card-inner);padding:.25rem .85rem}
.profile-row{display:grid;grid-template-columns:minmax(120px,28%) 1fr;gap:.5rem 1rem;padding:.6rem 0;border-bottom:1px solid var(--table-border);align-items:start}
.profile-row:last-child{border-bottom:none}
.profile-label{color:var(--text-muted);font-size:.84rem;line-height:1.4}
.profile-value{color:var(--text-primary);font-size:.86rem;line-height:1.5;word-break:break-word}
.profile-hint{color:var(--planned-text);font-size:.74rem;margin-top:.25rem;line-height:1.35}
.calc-readonly{background:var(--calc-bg);color:var(--accent)}
@media(max-width:1100px){
.split-grid{grid-template-columns:1fr}
.split-grid .card{min-height:auto}
}
+471
View File
@@ -0,0 +1,471 @@
/* Copyright (c) 2025-2026 马建军. All rights reserved. 详见 LICENSE.zh-CN.txt */
.dashboard-page {
display: flex;
flex-direction: column;
gap: 1rem;
}
.dashboard-top {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.5rem;
}
.dashboard-top-left {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.45rem;
}
.dash-updated {
font-size: 0.78rem;
}
.dashboard-account-card {
margin-bottom: 0;
}
.dashboard-account-grid {
margin-bottom: 0;
}
.dashboard-account-grid .stat-item {
min-width: 6.5rem;
}
.dashboard-account-grid .stat-item .value {
font-size: 1rem;
font-weight: 700;
}
.dashboard-section h2 {
margin-bottom: 0.65rem;
}
.dashboard-risk-card h2 {
margin-bottom: 0.45rem;
}
.dashboard-risk-reason {
margin: 0 0 0.65rem;
font-size: 0.82rem;
line-height: 1.5;
color: var(--text-muted);
}
.dashboard-risk-reason.is-blocked {
color: var(--loss);
}
.dashboard-risk-reason.is-ok {
color: var(--profit);
}
.dashboard-risk-grid {
display: flex;
flex-wrap: wrap;
align-items: stretch;
gap: 0;
margin-bottom: 0;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.dashboard-risk-heading {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 0.35rem;
width: 100%;
}
.dash-risk-doc-ref {
font-size: 0.72rem;
font-weight: 400;
}
.dash-risk-doc-link {
font-size: 0.74rem;
font-weight: 500;
color: var(--accent);
text-decoration: none;
margin-left: 0.15rem;
}
.dash-risk-doc-link:hover {
text-decoration: underline;
}
.dash-risk-doc-ref code {
font-size: 0.68rem;
}
.dashboard-risk-item {
flex: 1 1 0;
min-width: 6.4rem;
padding: 0.5rem 0.6rem;
text-align: center;
border-right: 1px solid var(--table-border);
}
.dashboard-risk-item:last-child {
border-right: none;
}
.dashboard-risk-label {
font-size: 0.74rem;
line-height: 1.35;
color: var(--text-muted);
white-space: nowrap;
}
.dashboard-risk-value {
font-size: 0.92rem;
font-weight: 600;
color: var(--text-title);
margin-top: 0.22rem;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.dashboard-risk-value.risk-switch-on {
color: var(--profit);
}
.dashboard-risk-value.risk-switch-off {
color: var(--loss);
}
.dashboard-risk-value.risk-cap-single {
color: #5eb8ff;
}
[data-theme="light"] .dashboard-risk-value.risk-cap-single {
color: #1d4ed8;
}
.dashboard-risk-value.risk-cap-roll {
color: #c4a035;
}
[data-theme="light"] .dashboard-risk-value.risk-cap-roll {
color: #b45309;
}
.dashboard-risk-value .risk-margin-safe {
color: var(--profit);
}
.dashboard-risk-value .risk-margin-warn {
color: var(--planned-text);
}
.dashboard-risk-value .risk-margin-over {
color: var(--loss);
}
.dashboard-risk-value .risk-margin-sep {
color: var(--text-muted);
font-weight: 400;
}
.dashboard-risk-value .risk-margin-cap-inline {
color: #c4a035;
font-weight: 600;
}
[data-theme="light"] .dashboard-risk-value .risk-margin-cap-inline {
color: #b45309;
}
.dashboard-risk-grid .stat-item {
min-width: 5.5rem;
}
.dashboard-risk-grid .stat-item .value {
font-size: 0.82rem;
}
.dash-symbol-cell {
min-width: 7.5rem;
white-space: normal;
}
.dash-symbol-title {
font-weight: 600;
line-height: 1.45;
}
.dash-symbol-ex {
font-weight: 400;
font-size: 0.78rem;
}
.dash-main-badge {
font-size: 0.68rem;
vertical-align: middle;
}
.dashboard-table .badge.dir-long,
.dashboard-table .badge.dir-short {
font-size: 0.72rem;
}
.dashboard-table .badge.dir-long {
background: var(--profit-bg);
color: var(--profit);
}
.dashboard-table .badge.dir-short {
background: var(--loss-bg);
color: var(--loss);
}
.dashboard-table {
width: 100%;
border-collapse: collapse;
font-size: 0.82rem;
}
.dashboard-table th,
.dashboard-table td {
padding: 0.45rem 0.55rem;
border-bottom: 1px solid var(--table-border);
text-align: left;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.dashboard-table tbody tr:last-child td {
border-bottom: none;
}
.dashboard-table .pnl-pos {
color: var(--profit);
font-weight: 600;
}
.dashboard-table .pnl-neg {
color: var(--loss);
font-weight: 600;
}
@media (max-width: 767px) {
.dashboard-table {
font-size: 0.75rem;
}
.dashboard-table th,
.dashboard-table td {
padding: 0.35rem 0.4rem;
}
}
/* ---- 风控折叠 / 平板两行 ---- */
.dash-section-toggle {
cursor: pointer;
user-select: none;
}
.dash-section-toggle-label {
margin-right: 0.15rem;
}
.dash-toggle-icon {
margin-left: auto;
font-size: 0.68rem;
color: var(--text-muted);
transition: transform 0.2s ease;
}
.dashboard-risk-card.is-expanded .dash-toggle-icon {
transform: rotate(180deg);
}
html:is([data-layout="phone"], [data-layout="tablet"], .layout-phone, .layout-tablet)
.dashboard-risk-card:not(.is-expanded) .dash-risk-body {
display: none;
}
html[data-layout="tablet"] .dashboard-risk-grid,
html.layout-tablet .dashboard-risk-grid {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
overflow-x: visible;
}
html[data-layout="tablet"] .dashboard-risk-item,
html.layout-tablet .dashboard-risk-item {
flex: none;
min-width: 0;
border-right: 1px solid var(--table-border);
border-bottom: 1px solid var(--table-border);
}
html[data-layout="tablet"] .dashboard-risk-item:nth-child(7n),
html.layout-tablet .dashboard-risk-item:nth-child(7n) {
border-right: none;
}
html[data-layout="tablet"] .dashboard-risk-item:nth-last-child(-n+6),
html.layout-tablet .dashboard-risk-item:nth-last-child(-n+6) {
border-bottom: none;
}
html:is([data-layout="phone"], .layout-phone) .dashboard-risk-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
overflow-x: visible;
}
html:is([data-layout="phone"], .layout-phone) .dashboard-risk-item {
flex: none;
min-width: 0;
border-right: 1px solid var(--table-border);
border-bottom: 1px solid var(--table-border);
}
html:is([data-layout="phone"], .layout-phone) .dashboard-risk-item:nth-child(2n) {
border-right: none;
}
html:is([data-layout="phone"], .layout-phone) .dashboard-risk-item:nth-last-child(-n+1) {
border-bottom: none;
}
/* ---- 持仓 / 关键位:桌面平板最多 3 行后滚动 ---- */
html:not([data-layout="phone"]):not(.layout-phone) .dash-pos-table-wrap,
html:not([data-layout="phone"]):not(.layout-phone) .dash-keys-table-wrap {
max-height: 12rem;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
html:not([data-layout="phone"]):not(.layout-phone) .dash-pos-table-wrap thead th,
html:not([data-layout="phone"]):not(.layout-phone) .dash-keys-table-wrap thead th {
position: sticky;
top: 0;
z-index: 1;
background: var(--card-inner);
}
/* ---- 手机简要列表 ---- */
.dash-mobile-list {
display: none;
}
html:is([data-layout="phone"], .layout-phone) .dash-mobile-list {
display: block;
}
html:is([data-layout="phone"], .layout-phone) .dash-pos-table-wrap,
html:is([data-layout="phone"], .layout-phone) .dash-keys-table-wrap,
html:is([data-layout="phone"], .layout-phone) .dash-closes-table-wrap {
display: none;
}
.dash-mobile-item {
display: block;
width: 100%;
text-align: left;
border: 1px solid var(--table-border);
border-radius: 10px;
background: var(--card-inner);
padding: 0.65rem 0.75rem;
margin-bottom: 0.5rem;
cursor: pointer;
color: inherit;
font: inherit;
transition: border-color 0.2s, box-shadow 0.2s;
}
.dash-mobile-item:hover,
.dash-mobile-item:focus-visible {
border-color: var(--accent);
outline: none;
box-shadow: 0 0 0 1px rgba(76, 194, 255, 0.15);
}
.dash-mobile-item-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.45rem;
margin-bottom: 0.3rem;
}
.dash-mobile-item-title {
min-width: 0;
flex: 1;
}
.dash-mobile-item-meta {
font-size: 0.74rem;
color: var(--text-muted);
line-height: 1.45;
}
.dash-mobile-item-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-top: 0.35rem;
font-size: 0.78rem;
}
.dash-mobile-item-summary {
flex: 1;
min-width: 0;
line-height: 1.45;
color: var(--text-muted);
}
.dash-mobile-item-summary .pnl-pos {
color: var(--profit);
font-weight: 600;
}
.dash-mobile-item-summary .pnl-neg {
color: var(--loss);
font-weight: 600;
}
.dash-mobile-item-title .badge {
vertical-align: middle;
}
.dash-mobile-chevron {
font-size: 0.72rem;
color: var(--accent);
white-space: nowrap;
}
.dash-mobile-empty {
padding: 0.85rem 0.35rem;
text-align: center;
color: var(--text-muted);
font-size: 0.82rem;
}
.dash-be-badge {
font-size: 0.66rem;
vertical-align: middle;
}
.dash-detail-modal {
max-width: 520px;
width: 100%;
}
.dash-detail-modal .modal-grid .item.wide {
grid-column: 1 / -1;
}
.dash-detail-modal .modal-actions {
margin-top: 1rem;
text-align: right;
}
+108
View File
@@ -0,0 +1,108 @@
/* Copyright (c) 2025-2026 马建军. All rights reserved. 详见 LICENSE.zh-CN.txt */
.doc-page {
max-width: 52rem;
margin: 0 auto;
}
.doc-content {
font-size: 0.92rem;
line-height: 1.65;
color: var(--text-primary);
}
.doc-content h1 {
font-size: 1.35rem;
color: var(--text-title);
margin-bottom: 0.75rem;
padding-bottom: 0.45rem;
border-bottom: 1px solid var(--table-border);
}
.doc-content h2 {
font-size: 1.05rem;
color: var(--text-title);
margin: 1.35rem 0 0.55rem;
}
.doc-content h3 {
font-size: 0.95rem;
color: var(--text-title);
margin: 1rem 0 0.45rem;
}
.doc-content p {
margin: 0.45rem 0;
}
.doc-content hr {
border: none;
border-top: 1px solid var(--table-border);
margin: 1.1rem 0;
}
.doc-content ul,
.doc-content ol {
margin: 0.45rem 0 0.65rem 1.25rem;
}
.doc-content li {
margin: 0.25rem 0;
}
.doc-content code {
font-size: 0.84em;
padding: 0.08rem 0.32rem;
border-radius: 4px;
background: var(--card-inner);
color: var(--accent);
}
.doc-content a {
color: var(--accent);
text-decoration: none;
}
.doc-content a:hover {
text-decoration: underline;
}
.doc-content .doc-xref {
color: var(--text-muted);
}
.doc-content .doc-table {
width: 100%;
border-collapse: collapse;
margin: 0.65rem 0 0.85rem;
font-size: 0.86rem;
}
.doc-content .doc-table th,
.doc-content .doc-table td {
padding: 0.42rem 0.55rem;
border: 1px solid var(--table-border);
text-align: left;
vertical-align: top;
}
.doc-content .doc-table th {
background: var(--card-inner);
color: var(--text-title);
font-weight: 600;
}
.doc-content strong {
color: var(--text-title);
}
@media (max-width: 767px) {
.doc-content {
font-size: 0.86rem;
}
.doc-content .doc-table {
display: block;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
}
+18
View File
@@ -0,0 +1,18 @@
.key-form-rows{display:flex;flex-direction:column;gap:.75rem;margin-bottom:.85rem}
.key-form-line{display:grid;gap:.65rem;align-items:end}
.key-form-line.line-3{grid-template-columns:1.4fr .85fr .85fr}
.key-form-line.line-2{grid-template-columns:1fr 1fr}
.key-field label{display:block;font-size:.72rem;margin-bottom:.28rem;color:var(--text-label)}
.symbol-wrap.key-field>label.text-label{display:flex;align-items:baseline;flex-wrap:wrap;gap:.35rem}
.key-field select,.key-field input{width:100%;box-sizing:border-box}
.key-action-row{display:flex;flex-direction:column;gap:.35rem;margin-top:.15rem}
.key-action-row .trailing-be-toggle{display:flex;align-items:center;gap:.4rem;font-size:.78rem;color:var(--text-label);margin:0;cursor:pointer;user-select:none}
.key-action-row .trailing-be-toggle input{width:auto;margin:0;flex-shrink:0}
.key-trailing-hint{font-size:.72rem;margin:0;color:var(--text-muted);line-height:1.45}
.key-action-row .key-submit-btn{width:100%;padding:.65rem .75rem;font-size:.9rem;margin-top:.25rem}
#key-row-auto.is-hidden,#key-rr-wrap.is-hidden,#key-trade-mode-wrap.is-hidden,#key-direction-wrap.is-hidden,#key-trailing-wrap.is-hidden,#key-trailing-hint.is-hidden{display:none!important}
@media(max-width:720px){
.key-form-line.line-3{grid-template-columns:1fr 1fr}
.key-form-line.line-3 .key-field:first-child{grid-column:1/-1}
.key-form-line.line-2{grid-template-columns:1fr}
}
+553
View File
@@ -0,0 +1,553 @@
/* Copyright (c) 2025-2026 马建军. All rights reserved.
* 手机端竖屏 UI
*/
html:is([data-mobile="1"], .layout-phone) {
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
overflow-x: hidden;
width: 100%;
max-width: 100vw;
}
html:is([data-mobile="1"], .layout-phone) body {
font-size: 15px;
overflow-x: hidden;
overflow-y: auto;
width: 100%;
max-width: 100vw;
-webkit-overflow-scrolling: touch;
}
html:is([data-mobile="1"], .layout-phone) .page-wrap {
max-width: 100%;
width: 100%;
overflow-x: hidden;
}
html:is([data-mobile="1"], .layout-phone) .tech-bg .tech-scanline {
display: none;
}
/* ── 顶栏:菜单 | 标题 | 主题 ── */
html:is([data-mobile="1"], .layout-phone) .site-header {
display: grid;
grid-template-columns: 40px minmax(0, 1fr) auto;
grid-template-areas: "toggle title tools";
align-items: center;
column-gap: .45rem;
padding: calc(var(--safe-top) + .4rem) .6rem .5rem;
text-align: left;
border-bottom: 1px solid var(--border-header);
position: sticky;
top: 0;
z-index: 80;
background: var(--card-bg);
backdrop-filter: blur(10px);
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
html:is([data-mobile="1"], .layout-phone) .header-bar {
display: contents;
}
html:is([data-mobile="1"], .layout-phone) .nav-toggle {
display: inline-flex !important;
grid-area: toggle;
width: 40px;
height: 40px;
flex-shrink: 0;
}
html:is([data-mobile="1"], .layout-phone) .header-tools {
grid-area: tools;
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: .2rem;
flex-shrink: 0;
}
html:is([data-mobile="1"], .layout-phone) .header-tools .pwa-install-btn {
display: none;
}
html:is([data-mobile="1"], .layout-phone) .theme-switch {
padding: 2px;
border-radius: 999px;
}
html:is([data-mobile="1"], .layout-phone) .theme-switch-btn {
padding: .28rem .42rem;
font-size: .66rem;
min-height: 28px;
line-height: 1.1;
}
html:is([data-mobile="1"], .layout-phone) .user-bar {
display: none;
}
html:is([data-mobile="1"], .layout-phone) .site-title {
grid-area: title;
font-size: .9rem;
font-weight: 700;
margin: 0;
text-align: left;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
html:is([data-mobile="1"], .layout-phone) .site-title-mobile {
display: inline;
}
html:is([data-mobile="1"], .layout-phone) .site-title-desktop {
display: none;
}
.site-title-mobile {
display: none;
}
html:is([data-mobile="1"], .layout-phone) .site-title-sub {
display: none !important;
}
html:is([data-mobile="1"], .layout-phone) .pwa-ios-hint {
display: none !important;
}
html:is([data-mobile="1"], .layout-phone) .site-nav {
position: fixed !important;
top: 0;
left: 0;
width: min(88vw, 300px);
height: 100dvh;
margin: 0;
padding: calc(var(--safe-top) + 3.25rem) .85rem 1.25rem;
flex-direction: column !important;
flex-wrap: nowrap !important;
justify-content: flex-start !important;
align-items: stretch !important;
gap: .3rem;
background: var(--card-bg);
border-right: 1px solid var(--card-border);
box-shadow: var(--shadow-card-hover);
z-index: 100;
transform: translateX(-105%);
transition: transform .25s ease;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
html:is([data-mobile="1"], .layout-phone) .site-nav.open {
transform: translateX(0);
}
html:is([data-mobile="1"], .layout-phone) .site-nav a {
width: 100%;
text-align: left;
padding: .7rem .85rem;
font-size: .88rem;
min-height: 44px;
display: flex;
align-items: center;
border-radius: 10px;
white-space: nowrap;
}
html:is([data-mobile="1"], .layout-phone) .main {
padding: .65rem .6rem calc(1rem + var(--safe-bottom));
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
html:is([data-mobile="1"], .layout-phone) .card {
padding: .85rem;
border-radius: 12px;
margin-bottom: .75rem;
overflow: visible;
max-width: 100%;
box-sizing: border-box;
}
html:is([data-mobile="1"], .layout-phone) .card h2 {
font-size: .95rem;
margin-bottom: .55rem;
}
html:is([data-mobile="1"], .layout-phone) .split-grid,
html:is([data-mobile="1"], .layout-phone) .trade-split,
html:is([data-mobile="1"], .layout-phone) .strategy-page .split-grid {
display: flex !important;
flex-direction: column !important;
gap: .75rem !important;
grid-template-columns: 1fr !important;
width: 100%;
max-width: 100%;
}
html:is([data-mobile="1"], .layout-phone) .split-grid .card,
html:is([data-mobile="1"], .layout-phone) .trade-split .card,
html:is([data-mobile="1"], .layout-phone) .strategy-page .split-grid .card {
min-height: auto !important;
height: auto !important;
width: 100%;
max-width: 100%;
}
/* ── 下单监控页 ── */
html:is([data-mobile="1"], .layout-phone) .trade-page {
width: 100%;
max-width: 100%;
overflow-x: hidden;
}
html:is([data-mobile="1"], .layout-phone) .trade-top-bar {
flex-direction: column;
gap: .55rem;
padding: .65rem;
border-radius: 12px;
background: var(--card-inner);
border: 1px solid var(--card-border);
margin-bottom: .75rem;
max-width: 100%;
box-sizing: border-box;
}
html:is([data-mobile="1"], .layout-phone) .trade-top-bar-main {
flex-direction: column;
align-items: flex-start;
gap: .35rem;
width: 100%;
min-width: 0;
}
html:is([data-mobile="1"], .layout-phone) .trade-top-bar-main .badge {
font-size: .68rem;
}
html:is([data-mobile="1"], .layout-phone) .trade-session-clock {
display: block;
font-size: .7rem;
line-height: 1.45;
word-break: break-word;
}
html:is([data-mobile="1"], .layout-phone) .trade-top-bar-actions {
width: 100%;
}
html:is([data-mobile="1"], .layout-phone) .trade-top-bar-actions .btn-ctp-sm {
width: 100%;
min-height: 44px;
}
html:is([data-mobile="1"], .layout-phone) .trade-top-hint {
font-size: .65rem;
line-height: 1.4;
white-space: normal;
}
html:is([data-mobile="1"], .layout-phone) .trade-form-rows {
width: 100%;
max-width: 100%;
min-width: 0;
}
html:is([data-mobile="1"], .layout-phone) .trade-form-line {
width: 100%;
max-width: 100%;
min-width: 0;
box-sizing: border-box;
}
/* 品种独占一行;方向 + 手数并排 */
html:is([data-mobile="1"], .layout-phone) .trade-form-line.line-3 {
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
gap: .45rem !important;
align-items: end;
}
html:is([data-mobile="1"], .layout-phone) .trade-form-line.line-3 .trade-field:first-child {
grid-column: 1 / -1 !important;
}
html:is([data-mobile="1"], .layout-phone) .trade-form-line.line-2 {
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
gap: .45rem !important;
}
html:is([data-mobile="1"], .layout-phone) .trade-field,
html:is([data-mobile="1"], .layout-phone) .symbol-wrap {
min-width: 0;
max-width: 100%;
}
html:is([data-mobile="1"], .layout-phone) .trade-field label,
html:is([data-mobile="1"], .layout-phone) .text-label {
font-size: .68rem;
margin-bottom: .18rem;
}
html:is([data-mobile="1"], .layout-phone) .trade-field input,
html:is([data-mobile="1"], .layout-phone) .trade-field select {
padding: .42rem .5rem;
max-width: 100%;
}
html:is([data-mobile="1"], .layout-phone) .symbol-input {
font-size: 14px;
}
html:is([data-mobile="1"], .layout-phone) .price-type-tabs {
margin-bottom: .22rem;
gap: .25rem;
}
html:is([data-mobile="1"], .layout-phone) .price-tab {
padding: .2rem .3rem;
font-size: .66rem;
flex: 1;
}
html:is([data-mobile="1"], .layout-phone) .form-compact .line-trend-head {
grid-template-columns: minmax(0, 1.4fr) minmax(0, 0.7fr) minmax(0, 0.75fr) !important;
gap: .4rem .45rem !important;
}
html:is([data-mobile="1"], .layout-phone) .form-compact .line-2 {
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
gap: .4rem .45rem !important;
}
html:is([data-mobile="1"], .layout-phone) .form-compact .line-3 {
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
gap: .4rem .45rem !important;
}
html:is([data-mobile="1"], .layout-phone) #roll-form .form-line.line-2 {
grid-template-columns: minmax(0, 1.2fr) minmax(0, 0.8fr) !important;
gap: .4rem .45rem !important;
}
html:is([data-mobile="1"], .layout-phone) .form-compact .form-line {
min-width: 0;
}
html:is([data-mobile="1"], .layout-phone) .form-compact input,
html:is([data-mobile="1"], .layout-phone) .form-compact select {
padding: .42rem .5rem;
}
html:is([data-mobile="1"], .layout-phone) .trade-action-row {
flex-direction: column;
align-items: stretch;
}
html:is([data-mobile="1"], .layout-phone) .trade-action-row .btn-open {
width: 100%;
min-height: 44px;
}
html:is([data-mobile="1"], .layout-phone) .pos-metrics {
grid-template-columns: repeat(2, 1fr) !important;
gap: .45rem;
}
html:is([data-mobile="1"], .layout-phone) .pos-card {
padding: .75rem;
}
html:is([data-mobile="1"], .layout-phone) .pos-card-head {
flex-direction: column;
align-items: stretch;
gap: .45rem;
}
html:is([data-mobile="1"], .layout-phone) .pos-card-actions {
width: 100%;
justify-content: flex-end;
}
html:is([data-mobile="1"], .layout-phone) .rec-sort-bar {
flex-direction: column;
align-items: stretch;
gap: .45rem;
}
html:is([data-mobile="1"], .layout-phone) .rec-sort-bar select {
width: 100%;
min-width: 0;
}
html:is([data-mobile="1"], .layout-phone) #recommend .trade-table-wrap,
html:is([data-mobile="1"], .layout-phone) .trade-table-wrap {
overflow-x: auto !important;
overflow-y: auto;
max-height: 55vh;
-webkit-overflow-scrolling: touch;
width: 100%;
max-width: 100%;
}
html:is([data-mobile="1"], .layout-phone) .trade-table {
font-size: .72rem;
}
html:is([data-mobile="1"], .layout-phone) .strategy-page .split-grid .card {
min-height: auto !important;
}
html:is([data-mobile="1"], .layout-phone) .strategy-preview-table {
font-size: .66rem;
min-width: 0;
width: 100%;
}
html:is([data-mobile="1"], .layout-phone) .table-responsive {
margin-bottom: .5rem;
max-width: 100%;
overflow-x: auto;
}
html:is([data-mobile="1"], .layout-phone) .module-rules summary {
font-size: .78rem;
}
html:is([data-mobile="1"], .layout-phone) input,
html:is([data-mobile="1"], .layout-phone) select,
html:is([data-mobile="1"], .layout-phone) textarea,
html:is([data-mobile="1"], .layout-phone) button {
font-size: 16px;
}
html:is([data-mobile="1"], .layout-phone) .btn-primary {
min-height: 44px;
}
html:is([data-mobile="1"], .layout-phone) .site-nav::after {
content: attr(data-user-label);
display: block;
margin-top: auto;
padding: .85rem .5rem 0;
font-size: .72rem;
color: var(--text-muted);
border-top: 1px solid var(--table-border);
}
/* 桌面端:标题仍居中,工具栏绝对定位 */
html:not([data-mobile="1"]):not(.layout-phone) .header-bar {
display: block;
position: relative;
min-height: 2rem;
}
html:not([data-mobile="1"]):not(.layout-phone) .nav-toggle {
display: none;
}
html:not([data-mobile="1"]):not(.layout-phone) .site-title {
display: block;
text-align: center;
margin: 0 0 1.65rem;
font-size: 1.75rem;
width: 100%;
}
html:not([data-mobile="1"]):not(.layout-phone) .site-title-mobile {
display: none;
}
html:not([data-mobile="1"]):not(.layout-phone) .site-title-desktop {
display: inline;
}
html:not([data-mobile="1"]):not(.layout-phone) .header-tools {
position: absolute;
top: 0;
left: 0;
z-index: 20;
}
html:not([data-mobile="1"]):not(.layout-phone) .user-bar {
display: block;
position: absolute;
top: 0;
right: 0;
}
/* 触控小屏兜底(JS 未执行时) */
@media (pointer: coarse) and (max-width: 600px) {
body {
overflow-x: hidden;
overflow-y: auto;
max-width: 100vw;
}
.site-header {
display: grid !important;
grid-template-columns: 40px minmax(0, 1fr) auto !important;
grid-template-areas: "toggle title tools" !important;
align-items: center;
column-gap: .45rem;
padding: calc(var(--safe-top) + .4rem) .6rem .5rem;
text-align: left;
max-width: 100%;
}
.header-bar {
display: contents !important;
}
.nav-toggle {
display: inline-flex !important;
grid-area: toggle;
}
.header-tools {
grid-area: tools;
justify-content: flex-end;
}
.user-bar {
display: none !important;
}
.site-title {
grid-area: title;
font-size: .9rem;
margin: 0;
text-align: left;
min-width: 0;
}
.site-title-mobile {
display: inline !important;
}
.site-title-desktop {
display: none !important;
}
.trade-form-line.line-3 {
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
gap: .45rem !important;
}
.trade-form-line.line-3 .trade-field:first-child {
grid-column: 1 / -1 !important;
}
.card {
overflow: visible;
max-width: 100%;
}
}
+440
View File
@@ -0,0 +1,440 @@
/* Copyright (c) 2025-2026 马建军. All rights reserved.
* 交易记录与复盘 手机/平板简洁列表 + 详情弹窗
*/
.records-page .records-mobile-list {
display: none;
}
.records-page .records-desktop-only {
display: block;
}
.records-mobile-item {
display: block;
width: 100%;
text-align: left;
border: 1px solid var(--card-border);
border-radius: 12px;
background: var(--card-inner);
padding: .75rem .85rem;
margin-bottom: .55rem;
cursor: pointer;
color: inherit;
font: inherit;
transition: border-color .2s, box-shadow .2s;
}
.records-mobile-item:hover,
.records-mobile-item:focus-visible {
border-color: var(--accent);
box-shadow: 0 0 0 1px rgba(56, 189, 248, .2);
outline: none;
}
.records-mobile-item-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: .5rem;
margin-bottom: .35rem;
}
.records-mobile-symbol {
font-size: .92rem;
font-weight: 600;
color: var(--text-title);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.records-mobile-item-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: .35rem .5rem;
font-size: .72rem;
color: var(--text-muted);
margin-bottom: .3rem;
}
.records-mobile-item-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: .5rem;
}
.records-mobile-pnl {
font-size: .88rem;
font-weight: 600;
}
.records-mobile-pnl.is-profit { color: var(--profit); }
.records-mobile-pnl.is-loss { color: var(--loss); }
.records-mobile-pnl.is-flat { color: var(--text-muted); }
.records-mobile-chevron {
font-size: .72rem;
color: var(--accent);
white-space: nowrap;
}
.records-mobile-empty {
padding: 1.25rem .5rem;
text-align: center;
color: var(--text-muted);
font-size: .85rem;
}
.records-page .trade-switch-label.records-desktop-only {
display: flex;
}
.records-page .records-verify-toggle {
display: flex;
align-items: center;
gap: .45rem;
margin-bottom: .75rem;
font-size: .82rem;
color: var(--text-muted);
cursor: pointer;
}
.records-page .records-verify-toggle input {
flex-shrink: 0;
}
.records-trade-card .records-src-badge {
margin-left: .25rem;
font-size: .65rem;
}
.records-trade-card .records-verified-inline {
margin-left: .25rem;
}
.records-trade-card .btn-records-action,
.records-trade-table-wrap .btn-records-action {
background: #1f3a5a;
color: #8fc8ff;
border: none;
border-radius: 6px;
padding: .3rem .55rem;
font-size: .72rem;
text-decoration: none;
cursor: pointer;
white-space: nowrap;
}
.records-trade-card .btn-records-action:disabled,
.records-trade-table-wrap .btn-records-action:disabled {
opacity: .45;
cursor: not-allowed;
}
.records-trade-card .btn-records-del,
.records-trade-table-wrap .btn-records-del {
background: rgba(239, 68, 68, .15);
color: var(--loss);
border: none;
border-radius: 6px;
padding: .3rem .55rem;
font-size: .72rem;
text-decoration: none;
cursor: pointer;
white-space: nowrap;
}
.records-trade-card .records-trade-actions,
.records-trade-table-wrap .records-trade-actions {
min-width: 15.5rem;
}
#trade-detail-modal .records-detail-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: .55rem .75rem;
margin-bottom: 1rem;
}
#trade-detail-modal .records-detail-item label {
display: block;
font-size: .68rem;
color: var(--text-muted);
margin-bottom: .12rem;
}
#trade-detail-modal .records-detail-item div {
font-size: .84rem;
color: var(--text-primary);
line-height: 1.35;
word-break: break-word;
}
#trade-detail-modal .records-detail-item.wide {
grid-column: 1 / -1;
}
#trade-detail-modal .records-detail-actions {
display: flex;
flex-wrap: wrap;
gap: .45rem;
padding-top: .75rem;
border-top: 1px solid var(--table-border);
}
#trade-detail-modal .records-detail-actions a,
#trade-detail-modal .records-detail-actions button {
font-size: .78rem;
padding: .45rem .7rem;
border-radius: 8px;
text-decoration: none;
border: none;
cursor: pointer;
}
.records-review-mobile .records-mobile-item.is-emotion {
border-color: rgba(239, 68, 68, .35);
}
/* 交易记录简洁行(对齐数据看板平仓记录) */
.records-trade-row {
border: 1px solid var(--card-border);
border-radius: 12px;
background: var(--card-inner);
padding: .75rem .85rem;
margin-bottom: .55rem;
color: inherit;
transition: border-color .2s, box-shadow .2s;
}
.records-trade-main {
min-width: 0;
}
.records-trade-head {
margin-bottom: .3rem;
}
.records-trade-title {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: .3rem .4rem;
min-width: 0;
}
.records-trade-code {
font-size: .84rem;
}
.records-verified-badge {
font-size: .62rem;
}
.records-trade-summary {
font-size: .78rem;
color: var(--text-muted);
line-height: 1.45;
}
.records-trade-verify-form {
display: inline-flex;
margin: 0;
}
.records-trade-row-actions {
min-width: 0;
}
.records-trade-row-actions .btn-link {
background: transparent;
color: var(--accent);
}
.records-trade-phone-foot {
display: flex;
justify-content: flex-end;
margin-top: .35rem;
}
.records-phone-only,
.records-tablet-only {
display: none;
}
/* 平板交易记录表格(对齐数据看板平仓记录) */
.records-trade-table-wrap {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.records-trade-table-wrap .dashboard-table {
width: 100%;
border-collapse: collapse;
font-size: .82rem;
}
.records-trade-table-wrap .dashboard-table th,
.records-trade-table-wrap .dashboard-table td {
padding: .45rem .55rem;
border-bottom: 1px solid var(--table-border);
text-align: left;
white-space: nowrap;
font-variant-numeric: tabular-nums;
vertical-align: middle;
}
.records-trade-table-wrap .dashboard-table tbody tr:last-child td {
border-bottom: none;
}
.records-trade-table-wrap .dash-symbol-ex {
font-weight: 400;
font-size: .78rem;
}
.records-trade-table-wrap .dash-main-badge {
font-size: .68rem;
vertical-align: middle;
}
.records-trade-table-wrap .dashboard-table .badge.dir-long,
.records-trade-table-wrap .dashboard-table .badge.dir-short {
font-size: .72rem;
}
.records-trade-table-wrap .dashboard-table .badge.dir-long {
background: var(--profit-bg);
color: var(--profit);
}
.records-trade-table-wrap .dashboard-table .badge.dir-short {
background: var(--loss-bg);
color: var(--loss);
}
.records-trade-table-wrap .pnl-pos {
color: var(--profit);
font-weight: 600;
}
.records-trade-table-wrap .pnl-neg {
color: var(--loss);
font-weight: 600;
}
.records-trade-table-wrap .trade-actions {
min-width: 17rem;
}
html:is([data-mobile="1"], .layout-phone) .records-trade-row,
html:is([data-layout="phone"], .layout-phone) .records-trade-row {
cursor: pointer;
}
html:is([data-mobile="1"], .layout-phone) .records-trade-row:hover,
html:is([data-layout="phone"], .layout-phone) .records-trade-row:hover {
border-color: var(--accent);
box-shadow: 0 0 0 1px rgba(56, 189, 248, .2);
}
html:is([data-mobile="1"], .layout-phone) .records-page .records-phone-only,
html:is([data-layout="phone"], .layout-phone) .records-page .records-phone-only,
html:is([data-mobile="1"], .layout-phone) .records-page .records-review-mobile,
html:is([data-layout="phone"], .layout-phone) .records-page .records-review-mobile {
display: block;
}
html:is([data-layout="tablet"], .layout-tablet) .records-page .records-tablet-only {
display: block;
}
html:is([data-layout="tablet"], .layout-tablet) .records-page .records-review-mobile {
display: block;
}
html:is([data-mobile="1"], .layout-phone) .records-page .records-desktop-only,
html:is([data-layout="phone"], .layout-phone) .records-page .records-desktop-only,
html:is([data-layout="tablet"], .layout-tablet) .records-page .records-desktop-only {
display: none !important;
}
html:is([data-mobile="1"], .layout-phone) .records-page .records-verify-toggle,
html:is([data-layout="phone"], .layout-phone) .records-page .records-verify-toggle {
display: flex;
}
html:is([data-mobile="1"], .layout-phone) .records-page .records-trade-card .card-body,
html:is([data-layout="phone"], .layout-phone) .records-page .records-trade-card .card-body {
padding: 0;
}
html:is([data-layout="tablet"], .layout-tablet) .records-page .records-trade-card .card-body {
padding: 0 .75rem .35rem;
}
html:is([data-layout="tablet"], .layout-tablet) .records-trade-table-wrap .dashboard-table {
font-size: .78rem;
}
html:is([data-layout="tablet"], .layout-tablet) .records-trade-table-wrap .dashboard-table th,
html:is([data-layout="tablet"], .layout-tablet) .records-trade-table-wrap .dashboard-table td {
padding: .4rem .45rem;
}
html:is([data-layout="tablet"], .layout-tablet) .records-trade-table-wrap .trade-actions {
min-width: 15.5rem;
}
html:is([data-layout="tablet"], .layout-tablet) .records-trade-table-wrap .trade-actions a,
html:is([data-layout="tablet"], .layout-tablet) .records-trade-table-wrap .trade-actions button {
font-size: .68rem;
padding: .28rem .45rem;
}
html:is([data-mobile="1"], .layout-phone) .records-page .records-equity-card #equity-curve-chart,
html:is([data-layout="phone"], .layout-phone) .records-page .records-equity-card #equity-curve-chart {
min-height: 180px;
}
html:is([data-mobile="1"], .layout-phone) .records-page .preset-tabs,
html:is([data-layout="phone"], .layout-phone) .records-page .preset-tabs {
flex-wrap: wrap;
gap: .35rem;
}
html:is([data-mobile="1"], .layout-phone) .records-page .preset-tabs a,
html:is([data-layout="phone"], .layout-phone) .records-page .preset-tabs a {
flex: 1;
text-align: center;
min-width: 0;
padding: .45rem .35rem;
font-size: .75rem;
}
html:is([data-layout="tablet"], .layout-tablet) .records-page .preset-tabs a {
flex: 1;
text-align: center;
min-width: 0;
padding: .45rem .35rem;
font-size: .75rem;
}
html:is([data-layout="tablet"], .layout-tablet) #review-modal .review-detail-headers,
html:is([data-layout="tablet"], .layout-tablet) #review-modal .review-detail-values,
html:is([data-mobile="1"], .layout-phone) #review-modal .review-detail-headers,
html:is([data-mobile="1"], .layout-phone) #review-modal .review-detail-values,
html:is([data-layout="phone"], .layout-phone) #review-modal .review-detail-headers,
html:is([data-layout="phone"], .layout-phone) #review-modal .review-detail-values {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@media (pointer: coarse) and (max-width: 600px) {
.records-page .records-phone-only,
.records-page .records-review-mobile { display: block; }
.records-page .records-desktop-only { display: none !important; }
}
+811
View File
@@ -0,0 +1,811 @@
/* Copyright (c) 2025-2026 马建军. All rights reserved. 专有软件,详见 LICENSE.zh-CN.txt */
/* 响应式布局 — 电脑 / 平板 / 手机 + PWA 独立窗口 */
:root {
--safe-top: env(safe-area-inset-top, 0px);
--safe-right: env(safe-area-inset-right, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px);
--safe-left: env(safe-area-inset-left, 0px);
--touch-min: 44px;
}
html {
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
}
body {
padding-left: var(--safe-left);
padding-right: var(--safe-right);
padding-bottom: var(--safe-bottom);
}
.page-wrap {
padding-top: var(--safe-top);
}
.header-bar {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: .5rem .75rem;
margin-bottom: .85rem;
min-height: var(--touch-min);
}
.nav-toggle {
display: none;
width: var(--touch-min);
height: var(--touch-min);
border: 1px solid var(--toggle-border);
border-radius: 10px;
background: var(--toggle-bg);
cursor: pointer;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 5px;
padding: 0;
flex-shrink: 0;
}
.nav-toggle span {
display: block;
width: 18px;
height: 2px;
background: var(--text-primary);
border-radius: 2px;
transition: transform .2s, opacity .2s;
}
.nav-toggle[aria-expanded="true"] span:nth-child(1) {
transform: translateY(7px) rotate(45deg);
}
.nav-toggle[aria-expanded="true"] span:nth-child(2) {
opacity: 0;
}
.nav-toggle[aria-expanded="true"] span:nth-child(3) {
transform: translateY(-7px) rotate(-45deg);
}
.header-tools {
position: static;
justify-content: flex-start;
flex-wrap: wrap;
}
.user-bar {
position: static;
text-align: right;
justify-self: end;
}
.pwa-install-btn {
padding: .38rem .7rem;
border-radius: 999px;
border: 1px solid var(--accent);
background: transparent;
color: var(--accent);
font-size: .72rem;
cursor: pointer;
white-space: nowrap;
width: auto;
flex-shrink: 0;
min-height: 32px;
}
.pwa-install-btn:hover {
background: var(--dir-bg);
}
.pwa-ios-hint {
display: none;
font-size: .72rem;
color: var(--text-muted);
padding: .5rem .75rem;
margin: 0 0 .75rem;
border-radius: 10px;
border: 1px dashed var(--card-border);
background: var(--card-inner);
line-height: 1.5;
}
.pwa-ios-hint.show {
display: block;
}
.nav-backdrop {
display: none;
position: fixed;
inset: 0;
background: var(--modal-mask);
z-index: 90;
border: none;
padding: 0;
cursor: pointer;
}
.nav-backdrop.show {
display: block;
}
@media (min-width: 1025px) {
.site-header {
padding: 1.5rem 1.5rem 1.25rem;
}
.site-nav {
justify-content: center;
}
.main {
padding: 1.5rem 1.75rem;
}
}
@media (min-width: 768px) and (max-width: 1024px) {
.site-header {
padding: 1.25rem 1rem 1rem;
}
.site-title {
font-size: 1.5rem;
margin-bottom: 1rem;
}
.site-nav {
gap: .4rem;
justify-content: center;
}
.site-nav a {
padding: .5rem .85rem;
font-size: .82rem;
}
.main {
padding: 1.25rem 1rem;
}
.card {
padding: 1.25rem;
}
.split-grid {
grid-template-columns: 1fr;
}
.trade-split .card {
min-height: auto;
}
.trade-form-line.line-3 {
grid-template-columns: 1fr 1fr;
}
.trade-form-line.line-3 .trade-field:first-child {
grid-column: 1 / -1;
}
.form-compact .line-4,
.form-compact .line-5 {
grid-template-columns: repeat(2, 1fr);
}
.form-compact .line-plan-2 {
grid-template-columns: repeat(2, 1fr);
}
.pos-metrics {
grid-template-columns: repeat(2, 1fr);
}
.review-detail-grid {
grid-template-columns: repeat(2, 1fr);
}
.stat-grid-summary {
display: flex;
flex-wrap: nowrap;
overflow-x: auto;
}
}
@media (max-width: 767px) {
html:not([data-mobile="1"]):not(.layout-phone) .nav-toggle {
display: inline-flex;
}
html:not([data-mobile="1"]):not(.layout-phone) .header-bar {
grid-template-columns: auto 1fr;
grid-template-areas:
"toggle tools"
"user user";
}
html:not([data-mobile="1"]):not(.layout-phone) .nav-toggle { grid-area: toggle; }
html:not([data-mobile="1"]):not(.layout-phone) .header-tools { grid-area: tools; justify-content: flex-end; }
html:not([data-mobile="1"]):not(.layout-phone) .user-bar {
grid-area: user;
text-align: center;
width: 100%;
}
html:not([data-mobile="1"]):not(.layout-phone) .site-header {
padding: .85rem .75rem .75rem;
text-align: left;
}
html:not([data-mobile="1"]):not(.layout-phone) .site-title {
font-size: 1.15rem;
margin-bottom: .65rem;
text-align: center;
}
html:not([data-mobile="1"]):not(.layout-phone) .site-title-sub {
font-size: .58rem;
letter-spacing: .1em;
}
html:not([data-mobile="1"]):not(.layout-phone) .site-nav {
position: fixed;
top: 0;
left: 0;
width: min(86vw, 320px);
height: 100dvh;
flex-direction: column;
align-items: stretch;
justify-content: flex-start;
gap: .35rem;
padding: calc(var(--safe-top) + 3.5rem) 1rem 1.5rem;
background: var(--card-bg);
border-right: 1px solid var(--card-border);
box-shadow: var(--shadow-card-hover);
z-index: 100;
transform: translateX(-105%);
transition: transform .28s ease;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.site-nav.open {
transform: translateX(0);
}
.site-nav a {
width: 100%;
text-align: left;
padding: .75rem 1rem;
font-size: .9rem;
min-height: var(--touch-min);
display: flex;
align-items: center;
border-radius: 10px;
}
.main {
padding: .85rem .75rem 1.25rem;
}
.card {
padding: 1rem;
border-radius: 12px;
margin-bottom: 1rem;
}
.card h2 {
font-size: 1rem;
}
.form-compact .line-2,
.form-compact .line-3,
.form-compact .line-4,
.form-compact .line-5,
.form-compact .line-plan-1,
.form-compact .line-plan-2 {
grid-template-columns: 1fr;
}
.form-compact-review .tag-grid {
grid-template-columns: repeat(2, 1fr);
}
.form-compact-review .kline-row {
grid-template-columns: 1fr 1fr;
}
.form-grid {
grid-template-columns: 1fr;
}
.split-grid {
grid-template-columns: 1fr;
gap: 1rem;
}
.split-grid .card {
min-height: auto;
}
.trade-split .card {
min-height: auto;
}
.trade-top-bar {
flex-direction: column;
align-items: stretch;
}
.trade-top-bar-actions {
width: 100%;
}
.trade-top-bar-actions .btn-ctp-sm {
width: 100%;
min-height: var(--touch-min);
}
.pos-metrics {
grid-template-columns: repeat(2, 1fr);
}
.review-detail-grid {
grid-template-columns: 1fr;
}
.review-detail-item.wide {
grid-column: span 1;
}
.stat-grid,
.stat-grid:not(.stat-grid-summary) {
grid-template-columns: repeat(2, 1fr);
gap: .65rem;
}
.stat-grid-summary {
display: flex;
flex-wrap: nowrap;
overflow-x: auto;
}
.stat-grid-summary .stat-item {
flex: 0 0 auto;
min-width: 4.25rem;
padding: .35rem .15rem;
}
.stat-grid-summary .stat-item .label {
font-size: .58rem;
}
.stat-grid-summary .stat-item .value {
font-size: .72rem;
}
.stat-item {
padding: .75rem .5rem;
}
.stat-grid-summary .stat-item {
padding: .35rem .15rem;
}
.stat-item .value {
font-size: 1.1rem;
}
.filter-row .field {
width: 100%;
min-width: 0;
}
.trade-toolbar {
flex-direction: column;
align-items: stretch;
}
.profile-row {
grid-template-columns: 1fr;
gap: .25rem;
}
.modal-box {
padding: 1rem;
border-radius: 12px;
max-height: calc(100dvh - 1rem);
}
.modal-box.review-modal-fullscreen {
width: 100%;
height: 100dvh;
border-radius: 0;
}
th, td {
padding: .55rem .45rem;
font-size: .8rem;
}
.card-scroll {
max-height: none;
}
.stats-card-head {
flex-direction: column;
align-items: stretch;
}
.stats-view-field {
width: 100%;
}
input, select, textarea, button {
font-size: 16px;
}
.form-compact input,
.form-compact select {
font-size: 16px;
}
}
@media (max-width: 479px) {
.stat-grid:not(.stat-grid-summary) {
grid-template-columns: 1fr 1fr;
}
.stat-grid-summary .stat-item {
min-width: 3.75rem;
}
.theme-switch-btn {
padding: .35rem .55rem;
font-size: .7rem;
}
.pos-metrics {
grid-template-columns: 1fr;
}
}
@media (display-mode: standalone) {
.site-header {
padding-top: max(.75rem, var(--safe-top));
}
.pwa-install-btn,
.pwa-ios-hint {
display: none !important;
}
}
@media (max-width: 767px) and (orientation: landscape) {
.site-nav {
width: min(50vw, 280px);
padding-top: calc(var(--safe-top) + 2.5rem);
}
}
@media (hover: none) and (pointer: coarse) {
.site-nav a,
.btn-del,
.trade-actions a,
.trade-actions button,
.preset-tabs a {
min-height: var(--touch-min);
}
.card:hover {
transform: none;
}
.list-item:hover {
box-shadow: none;
}
.stat-item:hover {
transform: none;
}
}
.table-responsive {
width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.table-responsive table {
min-width: 560px;
}
body.login-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
min-height: 100dvh;
padding: 1rem;
padding-top: max(1rem, var(--safe-top));
padding-bottom: max(1rem, var(--safe-bottom));
}
@media (max-width: 767px) {
body.login-page {
align-items: flex-start;
padding: .75rem;
padding-top: max(.75rem, var(--safe-top));
}
body.login-page .login-wrap {
max-width: 100%;
}
body.login-page .login-box {
padding: 1.75rem 1.25rem 1.5rem;
}
}
/* ── 设备布局:手机竖屏 / 平板横屏 ── */
.orientation-lock {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
background: var(--modal-mask);
backdrop-filter: blur(6px);
}
.orientation-lock[hidden] {
display: none !important;
}
.orientation-lock-box {
max-width: 18rem;
padding: 1.75rem 1.25rem;
border-radius: 14px;
border: 1px solid var(--card-border);
background: var(--card-bg);
text-align: center;
box-shadow: var(--shadow-card-hover);
}
.orientation-lock-icon {
font-size: 2.5rem;
line-height: 1;
margin-bottom: .85rem;
color: var(--accent);
animation: orientation-spin 2.4s ease-in-out infinite;
}
.orientation-lock-box p {
margin: 0;
font-size: .95rem;
line-height: 1.55;
color: var(--text-title);
}
@keyframes orientation-spin {
0%, 100% { transform: rotate(0deg); }
50% { transform: rotate(90deg); }
}
html[data-layout="phone"]:not([data-mobile="1"]):not(.layout-phone) .nav-toggle {
display: inline-flex;
}
html[data-layout="phone"]:not([data-mobile="1"]):not(.layout-phone) .header-bar {
grid-template-columns: auto 1fr;
grid-template-areas:
"toggle tools"
"user user";
}
html[data-layout="phone"]:not([data-mobile="1"]):not(.layout-phone) .nav-toggle { grid-area: toggle; }
html[data-layout="phone"]:not([data-mobile="1"]):not(.layout-phone) .header-tools { grid-area: tools; justify-content: flex-end; }
html[data-layout="phone"]:not([data-mobile="1"]):not(.layout-phone) .user-bar {
grid-area: user;
text-align: center;
width: 100%;
}
html[data-layout="phone"]:not([data-mobile="1"]):not(.layout-phone) .site-header {
padding: .75rem .75rem .65rem;
text-align: left;
}
html[data-layout="phone"]:not([data-mobile="1"]):not(.layout-phone) .site-title {
font-size: 1.05rem;
margin-bottom: .5rem;
text-align: center;
}
html[data-layout="phone"]:not([data-mobile="1"]):not(.layout-phone) .site-title-sub {
display: none;
}
html[data-layout="phone"]:not([data-mobile="1"]):not(.layout-phone) .site-nav {
position: fixed;
top: 0;
left: 0;
width: min(86vw, 320px);
height: 100dvh;
flex-direction: column;
align-items: stretch;
justify-content: flex-start;
gap: .35rem;
padding: calc(var(--safe-top) + 3.5rem) 1rem 1.5rem;
background: var(--card-bg);
border-right: 1px solid var(--card-border);
box-shadow: var(--shadow-card-hover);
z-index: 100;
transform: translateX(-105%);
transition: transform .28s ease;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
html[data-layout="phone"]:not([data-mobile="1"]):not(.layout-phone) .site-nav.open {
transform: translateX(0);
}
html[data-layout="phone"]:not([data-mobile="1"]):not(.layout-phone) .site-nav a {
width: 100%;
text-align: left;
padding: .75rem 1rem;
font-size: .9rem;
min-height: var(--touch-min);
display: flex;
align-items: center;
border-radius: 10px;
}
html[data-layout="phone"] .main {
padding: .75rem .65rem 1.1rem;
}
html[data-layout="phone"] .split-grid {
grid-template-columns: 1fr;
gap: .85rem;
}
html[data-layout="phone"] .split-grid .card,
html[data-layout="phone"] .trade-split .card {
min-height: auto;
}
html[data-layout="phone"] .trade-top-bar-main {
flex-direction: column;
align-items: flex-start;
gap: .35rem;
}
html[data-layout="phone"] .trade-session-clock {
display: block;
font-size: .72rem;
line-height: 1.45;
}
html[data-layout="phone"] .session-clock-detail {
display: block;
margin-top: .15rem;
}
html[data-layout="phone"] .trade-top-hint {
font-size: .68rem;
line-height: 1.4;
}
html[data-layout="phone"] .pos-metrics {
grid-template-columns: repeat(2, 1fr);
}
html[data-layout="phone"] .pos-card-head {
flex-direction: column;
align-items: stretch;
}
html[data-layout="phone"] .pos-card-actions {
width: 100%;
justify-content: flex-end;
}
html[data-layout="phone"] .rec-sort-bar {
flex-direction: column;
align-items: stretch;
}
html[data-layout="phone"] .rec-sort-bar select {
width: 100%;
min-width: 0;
}
html[data-layout="phone"] #recommend .trade-table-wrap {
overflow-x: auto;
overflow-y: auto;
max-height: 52vh;
}
html[data-layout="phone"] .strategy-preview-table {
font-size: .68rem;
}
html[data-layout="tablet"][data-orientation="landscape"] .site-header {
padding: .85rem 1rem .75rem;
}
html[data-layout="tablet"][data-orientation="landscape"] .site-title {
font-size: 1.35rem;
margin-bottom: .85rem;
}
html[data-layout="tablet"][data-orientation="landscape"] .site-nav {
flex-wrap: nowrap;
overflow-x: auto;
justify-content: flex-start;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
html[data-layout="tablet"][data-orientation="landscape"] .site-nav::-webkit-scrollbar {
display: none;
}
html[data-layout="tablet"][data-orientation="landscape"] .site-nav a {
flex-shrink: 0;
padding: .48rem .75rem;
font-size: .8rem;
}
html[data-layout="tablet"][data-orientation="landscape"] .main {
padding: 1rem .85rem;
}
html[data-layout="tablet"][data-orientation="landscape"] .split-grid,
html[data-layout="tablet"][data-orientation="landscape"] .trade-split {
grid-template-columns: 1fr 1fr;
gap: 1rem;
align-items: stretch;
}
html[data-layout="tablet"][data-orientation="landscape"] .split-grid .card,
html[data-layout="tablet"][data-orientation="landscape"] .trade-split .card {
min-height: 380px;
height: 100%;
}
html[data-layout="tablet"][data-orientation="landscape"] .trade-form-line.line-3 {
grid-template-columns: 1fr 1fr;
}
html[data-layout="tablet"][data-orientation="landscape"] .trade-form-line.line-3 .trade-field:first-child {
grid-column: 1 / -1;
}
html[data-layout="tablet"][data-orientation="landscape"] .pos-metrics {
grid-template-columns: repeat(4, 1fr);
}
html[data-layout="tablet"][data-orientation="landscape"] .trade-top-bar {
flex-direction: row;
align-items: flex-start;
}
html[data-layout="tablet"][data-orientation="landscape"] .trade-top-bar-actions {
width: auto;
flex-shrink: 0;
}
html[data-layout="tablet"][data-orientation="landscape"] .strategy-page .split-grid {
grid-template-columns: 1fr 1fr;
}
html[data-layout="tablet"][data-orientation="landscape"] .strategy-page .split-grid .card {
min-height: 420px;
}
+208
View File
@@ -0,0 +1,208 @@
/* Copyright (c) 2025-2026 马建军. All rights reserved. 专有软件,详见 LICENSE.zh-CN.txt */
/* 科技感增强层 — 与 base.html 变量配合 */
.tech-bg{
position:fixed;inset:0;z-index:0;pointer-events:none;overflow:hidden;
}
.tech-grid{
position:absolute;inset:0;
background-image:
linear-gradient(var(--bg-grid) 1px,transparent 1px),
linear-gradient(90deg,var(--bg-grid) 1px,transparent 1px);
background-size:32px 32px;
mask-image:radial-gradient(ellipse 85% 75% at 50% 35%,#000 20%,transparent 75%);
}
.tech-glow{
position:absolute;width:70vmax;height:70vmax;
top:-25%;left:50%;transform:translateX(-50%);
background:radial-gradient(circle,var(--ambient-glow) 0%,transparent 65%);
animation:tech-pulse 8s ease-in-out infinite;
}
.tech-glow-2{
position:absolute;width:50vmax;height:50vmax;
bottom:-20%;right:-10%;
background:radial-gradient(circle,var(--ambient-glow-2) 0%,transparent 70%);
animation:tech-pulse 10s ease-in-out infinite reverse;
}
.tech-scanline{
position:absolute;inset:0;
background:repeating-linear-gradient(
0deg,
transparent,
transparent 2px,
var(--scanline) 2px,
var(--scanline) 3px
);
opacity:.35;
animation:tech-scan 12s linear infinite;
}
@keyframes tech-pulse{
0%,100%{opacity:.55;transform:translateX(-50%) scale(1)}
50%{opacity:.85;transform:translateX(-50%) scale(1.05)}
}
@keyframes tech-scan{
0%{transform:translateY(0)}
100%{transform:translateY(32px)}
}
@keyframes tech-shine{
0%,100%{opacity:.45}
50%{opacity:.9}
}
@media (prefers-reduced-motion: reduce){
.tech-glow,.tech-glow-2,.tech-scanline,.card::after,.site-header::after{animation:none}
}
.main.nav-loading{opacity:.75;pointer-events:none;transition:opacity .12s}
.page-wrap{position:relative;z-index:1}
.site-header{
border-bottom:1px solid var(--border-header);
background:transparent;
backdrop-filter:none;
}
.site-header::after{
content:"";display:block;height:1px;margin-top:-1px;
background:linear-gradient(90deg,transparent,var(--accent),var(--accent-2),transparent);
opacity:.7;animation:tech-shine 4s ease-in-out infinite;
}
.site-title{
letter-spacing:.04em;
background:linear-gradient(135deg,var(--text-title) 0%,var(--accent) 45%,var(--accent-2) 100%);
-webkit-background-clip:text;-webkit-text-fill-color:transparent;
background-clip:text;
filter:drop-shadow(0 0 24px var(--title-glow));
}
.site-title-sub{
display:block;font-size:.72rem;font-weight:500;
letter-spacing:.12em;text-transform:uppercase;
color:var(--text-muted);margin-top:.4rem;
-webkit-text-fill-color:var(--text-muted);
filter:none;
}
.site-nav a{
border-radius:999px;
letter-spacing:.02em;
position:relative;overflow:hidden;
transition:transform .2s,box-shadow .2s,border-color .2s,background .2s;
}
.site-nav a::before{
content:"";position:absolute;inset:0;
background:linear-gradient(120deg,transparent,rgba(255,255,255,.06),transparent);
opacity:0;transition:opacity .25s;
}
.site-nav a:hover::before{opacity:1}
.site-nav a:hover{
transform:translateY(-1px);
box-shadow:0 4px 20px var(--nav-hover-glow);
}
.site-nav a.active{
background:linear-gradient(135deg,var(--nav-active),var(--accent-2));
border-color:transparent;
box-shadow:0 0 20px var(--nav-active-glow),inset 0 1px 0 rgba(255,255,255,.15);
}
.theme-switch-btn:hover{
color:var(--text-primary);
}
.theme-switch-btn.active{
box-shadow:0 0 12px var(--btn-glow);
}
.card{
border-radius:14px;
transition:transform .25s,box-shadow .25s,border-color .25s;
}
.card:hover{
transform:translateY(-2px);
border-color:var(--card-border-hover);
box-shadow:var(--shadow-card-hover);
}
.card::after{
animation:tech-shine 5s ease-in-out infinite;
}
.card h2{letter-spacing:.03em}
.card h2:before{
box-shadow:0 0 12px var(--accent),0 0 4px var(--accent-2);
}
input:focus,select:focus,textarea:focus{
box-shadow:0 0 0 3px var(--focus-ring),0 0 16px var(--focus-glow);
}
button.btn-primary{
font-weight:600;letter-spacing:.04em;
box-shadow:0 4px 20px var(--btn-glow);
transition:transform .15s,box-shadow .2s,opacity .2s;
}
button.btn-primary:hover{
transform:translateY(-1px);
box-shadow:0 6px 28px var(--btn-glow-strong);
opacity:1;
}
.list-item{
transition:border-color .2s,box-shadow .2s,transform .2s;
}
.list-item:hover{
border-color:var(--card-border-hover);
box-shadow:0 4px 16px var(--card-glow);
}
table tbody tr{transition:background .15s}
table tbody tr:hover{background:var(--row-hover)}
.stat-item{
backdrop-filter:blur(8px);
transition:transform .2s,box-shadow .2s;
}
.stat-item:hover{
transform:translateY(-2px);
box-shadow:0 8px 24px var(--card-glow);
}
.stat-item .value{
font-variant-numeric:tabular-nums;
letter-spacing:.02em;
}
.pos-card{
position:relative;overflow:visible;
transition:border-color .2s,box-shadow .2s;
}
.pos-card::before{
content:"";position:absolute;top:0;left:0;right:0;height:2px;
background:linear-gradient(90deg,var(--accent),var(--accent-2));
opacity:.5;
}
.pos-card:hover{
border-color:var(--card-border-hover);
box-shadow:0 6px 24px var(--card-glow);
}
.badge{letter-spacing:.02em;border:1px solid transparent}
.badge.dir{border-color:rgba(76,194,255,.25)}
.badge.profit{border-color:rgba(76,217,127,.3)}
.badge.loss{border-color:rgba(255,102,102,.3)}
.modal-box{
border:1px solid var(--card-border-hover);
box-shadow:var(--shadow-card-hover),0 0 60px var(--card-glow);
}
.flash{
box-shadow:0 0 24px var(--focus-glow);
letter-spacing:.02em;
}
.profile-spec{
border:1px solid var(--card-border-hover);
box-shadow:inset 0 0 40px var(--card-glow);
}
.key-live .live-price-line,.live-price{
text-shadow:0 0 12px var(--focus-glow);
}
.preset-tabs a.active{
box-shadow:0 0 12px var(--focus-glow);
}
+220
View File
@@ -0,0 +1,220 @@
/* Copyright (c) 2025-2026 马建军. All rights reserved. 专有软件,详见 LICENSE.zh-CN.txt */
/* 持仓监控页 — 与 split-grid(关键位监控)同宽,全端自适应 */
.trade-page{width:100%}
.trade-split{margin-bottom:1.25rem}
.trade-split .card{min-height:360px}
.trade-split .trade-card#order{margin-bottom:.75rem}
.trading-live-body{gap:0}
.trading-live-section{padding-bottom:.5rem}
.trading-live-section.trading-live-positions{
margin-top:.65rem;padding-top:.75rem;border-top:1px solid var(--card-border);
}
.trading-live-subtitle{
font-size:.82rem;font-weight:600;color:var(--text-muted);
margin:0 0 .45rem .15rem;letter-spacing:.02em;
}
.sync-badge{font-size:.72rem;font-weight:400;margin-left:.35rem}
.trade-top-bar{
display:flex;flex-wrap:wrap;gap:.65rem 1rem;
align-items:center;justify-content:space-between;
margin-bottom:1.25rem;
}
.trade-top-bar-main{display:flex;flex-wrap:wrap;gap:.5rem .65rem;align-items:center;flex:1;min-width:0}
.trade-top-bar-actions{display:flex;flex-wrap:wrap;gap:.5rem;align-items:center}
.trade-top-hint{font-size:.72rem;white-space:nowrap}
.trade-session-clock{font-size:.78rem;line-height:1.45}
.session-clock-detail strong{color:var(--accent);font-weight:600}
.btn-ctp-sm{padding:.4rem .9rem;font-size:.8rem;width:auto;white-space:nowrap}
.trade-card{margin-bottom:0;height:100%;display:flex;flex-direction:column}
.trade-card h2{margin-bottom:.35rem;flex-shrink:0}
.trade-card .card-body{flex:1;min-height:0;display:flex;flex-direction:column}
.trade-card-full{margin-bottom:1.5rem}
.pos-hint{font-size:.75rem;margin:-.15rem 0 .5rem .25rem;color:var(--text-muted)}
.trade-order-status{display:grid;gap:.55rem;margin:.5rem 0 .75rem;padding:.65rem .85rem;background:var(--card-inner);border:1px solid var(--card-border);border-radius:8px;font-size:.82rem}
.trade-order-status-compact{margin-top:0}
.trade-order-status .status-row{display:flex;flex-wrap:wrap;align-items:center;gap:.35rem .65rem}
.trade-form-rows{display:flex;flex-direction:column;gap:.75rem;margin-bottom:.85rem}
.trade-form-line{display:grid;gap:.65rem;align-items:end}
.trade-form-line.line-3{grid-template-columns:1.4fr 0.8fr 0.8fr}
.trade-field label{display:block;font-size:.72rem;margin-bottom:.28rem;color:var(--text-label)}
.symbol-wrap.trade-field>label.text-label{display:flex;align-items:baseline;flex-wrap:wrap;gap:.35rem}
.trade-field select,.trade-field input{width:100%;box-sizing:border-box}
.trade-field .lots-auto{color:var(--accent);font-weight:600;background:var(--card-inner);cursor:default}
.lots-warn{font-size:.7rem;margin-top:.25rem;margin-bottom:0}
.price-type-tabs{display:flex;gap:.35rem;margin-bottom:.35rem}
.price-tab{border:1px solid var(--card-border);background:var(--card-inner);color:var(--text-muted);padding:.28rem .7rem;border-radius:6px;font-size:.75rem;cursor:pointer;flex:1;text-align:center;width:auto}
.price-tab.active{border-color:var(--accent);color:var(--accent);font-weight:600;background:rgba(56,189,248,.08)}
.market-hint{font-size:.7rem;margin-top:.25rem}
.trade-action-row{display:flex;flex-direction:column;gap:.45rem;margin:.85rem 0 .55rem}
.trade-action-row .btn-open{padding:.65rem .75rem;font-size:.9rem;width:100%}
.trade-action-row .btn-open:disabled{opacity:.45;cursor:not-allowed;filter:grayscale(.25)}
.trade-action-row .btn-open.btn-session-off{background:var(--text-muted);border-color:var(--text-muted)}
.trailing-be-toggle{display:flex;align-items:center;gap:.4rem;font-size:.78rem;color:var(--text-label);margin-bottom:.45rem;cursor:pointer;user-select:none}
.trailing-be-toggle input{width:auto;margin:0}
.trailing-be-hint{font-size:.72rem;margin:0;color:var(--text-muted)}
.trade-form-line.line-3 #field-tp.is-hidden{display:none}
.trade-rr-hint{font-size:.78rem;color:var(--text-accent);margin:0}
.session-hint{font-size:.72rem;margin:.35rem 0 0;text-align:center}
.trade-order-msg{font-size:.82rem;text-align:center;margin:0;padding:.35rem}
.trade-order-msg.ok{color:var(--profit)}
.trade-order-msg.err{color:var(--loss)}
.trade-footer{background:var(--card-inner);border-radius:8px;padding:.65rem .85rem;font-size:.78rem;line-height:1.5;border:1px solid var(--card-border);margin-top:.5rem}
.trade-footer strong{color:var(--accent)}
.rec-blocked td{opacity:.55}
.rec-ok td:first-child{font-weight:600}
.rec-trend-break td:first-child .trend-name{font-weight:700}
.trend-badge{font-size:.72rem;white-space:nowrap}
.trend-badge.break{color:var(--accent);font-weight:700;border:1px solid var(--accent);background:rgba(56,189,248,.12)}
.trend-hint{font-size:.72rem;color:var(--text-muted);margin:.35rem 0 .65rem;line-height:1.5}
.rec-sort-bar{display:flex;flex-wrap:wrap;align-items:center;gap:.45rem .65rem;margin-bottom:.55rem;font-size:.78rem}
.rec-sort-bar label{color:var(--text-muted);white-space:nowrap}
.rec-sort-bar select{padding:.35rem .5rem;font-size:.78rem;min-width:7rem}
.rec-stats{
font-size:.78rem;color:var(--text-muted);margin-bottom:.45rem;line-height:1.5;
}
.rec-stats strong{color:var(--accent);font-weight:600}
.rec-sort-dir-btn{
border:1px solid var(--card-border);background:var(--card-inner);color:var(--text-muted);
padding:.3rem .55rem;border-radius:6px;cursor:pointer;font-size:.78rem;min-width:2rem;
}
.rec-sort-dir-btn:hover{border-color:var(--accent);color:var(--accent)}
.gap-badge{font-size:.72rem}
.rec-market-link{color:inherit;text-decoration:none;display:inline-flex;flex-wrap:wrap;align-items:baseline;gap:.2rem .35rem}
.rec-market-link:hover strong,.rec-market-link:hover .text-accent{color:var(--accent);text-decoration:underline}
.pos-market-link{color:inherit;text-decoration:none}
.pos-market-link:hover{color:var(--accent)}
.pos-market-link:hover .text-accent{text-decoration:underline}
.pos-symbol-sub{font-size:.72rem;line-height:1.35}
.pos-main-badge{font-size:.68rem;vertical-align:middle}
.pos-change-up{color:var(--profit)}
.rec-change-down{color:var(--loss)}
#recommend .trade-table-wrap{max-height:none;overflow:visible}
#recommend.card{height:auto}
#recommend .card-body{display:flex;flex-direction:column}
#recommend .trade-table-wrap{flex:0 0 auto}
#trading-live.card,
.trade-split .trade-card#trading-live {
overflow: visible;
}
#position-live-list {
overflow: visible;
}
#trading-live .trading-live-body.card-scroll {
flex: 1;
max-height: none;
overflow-y: visible;
}
/* 电脑端:右侧委托面板随内容增高;平板见下方等高规则 */
@media (min-width: 768px) {
html:not([data-layout="phone"]):not(.layout-phone):not([data-layout="tablet"]):not(.layout-tablet) .trade-split .card-body {
overflow: visible;
}
html:not([data-layout="phone"]):not(.layout-phone):not([data-layout="tablet"]):not(.layout-tablet) .trade-split .trade-card#trading-live {
height: auto;
min-height: auto;
align-self: start;
}
html:not([data-layout="phone"]):not(.layout-phone):not([data-layout="tablet"]):not(.layout-tablet) .trade-split .trade-card#trading-live .trading-live-body {
overflow: visible;
flex: none;
}
html:not([data-layout="phone"]):not(.layout-phone):not([data-layout="tablet"]):not(.layout-tablet) #position-live-list.pos-list-many {
--pos-card-unit-h: 17.5rem;
max-height: calc(var(--pos-card-unit-h) * 3 + 1.5rem);
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding-right: .15rem;
}
}
/* 平板:期货下单与委托持仓两列卡片等高 */
html[data-layout="tablet"] .trade-split,
html.layout-tablet .trade-split {
align-items: stretch;
}
html[data-layout="tablet"] .trade-split .trade-card#order,
html[data-layout="tablet"] .trade-split .trade-card#trading-live,
html.layout-tablet .trade-split .trade-card#order,
html.layout-tablet .trade-split .trade-card#trading-live {
height: 100%;
min-height: 380px;
align-self: stretch;
}
html[data-layout="tablet"] .trade-split .trade-card#order .card-body,
html.layout-tablet .trade-split .trade-card#order .card-body {
flex: 1;
min-height: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
html[data-layout="tablet"] .trade-split .trade-card#trading-live .trading-live-body,
html.layout-tablet .trade-split .trade-card#trading-live .trading-live-body {
flex: 1;
min-height: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
html[data-layout="tablet"] #position-live-list,
html.layout-tablet #position-live-list {
overflow: visible;
}
html[data-layout="tablet"] #position-live-list.pos-list-many,
html.layout-tablet #position-live-list.pos-list-many {
--pos-card-unit-h: 17.5rem;
max-height: calc(var(--pos-card-unit-h) * 3 + 1.5rem);
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding-right: .15rem;
}
.dash-be-badge,
.pos-be-badge {
font-size: .66rem;
vertical-align: middle;
}
.pos-pending-orders{margin-top:.55rem;padding-top:.55rem;border-top:1px dashed var(--table-border)}
.pos-pending-orders .pending-title{font-size:.68rem;color:var(--text-muted);margin-bottom:.35rem}
.pos-pending-item{display:flex;justify-content:space-between;align-items:center;gap:.5rem;font-size:.75rem;padding:.35rem .5rem;border-radius:6px;margin-bottom:.25rem;background:var(--list-item-bg)}
.pos-pending-right{display:flex;align-items:center;gap:.45rem;flex-shrink:0}
.pos-dismiss-btn{padding:.2rem .55rem;font-size:.68rem;border-radius:6px;border:1px solid var(--table-border);background:var(--card-inner);color:var(--text-muted);cursor:pointer;width:auto;min-height:auto;line-height:1.3}
.pos-dismiss-btn:disabled{opacity:.55;cursor:wait}
.pos-sl-btn{border-color:var(--accent);color:var(--accent)}
.pos-pending-item.sl{border-left:3px solid var(--loss)}
.pos-pending-item.tp{border-left:3px solid var(--profit)}
.pos-pending-item.ctp{border-left:3px solid var(--accent)}
.pos-card.is-pending{border:1px dashed var(--accent);opacity:.95}
.pos-card.is-pending .badge.pending{background:rgba(56,189,248,.15);color:var(--accent)}
.pos-card.is-pending .pos-metrics .cell.pnl-pending label{color:var(--accent)}
.pos-close-btn{padding:.4rem .85rem;font-size:.78rem;border-radius:8px;border:1px solid var(--loss);background:var(--loss-bg);color:var(--loss);cursor:pointer;white-space:nowrap;width:auto;flex-shrink:0;min-height:36px}
.pos-close-btn:disabled,.pos-close-btn.is-session-off{opacity:.45;cursor:not-allowed;border-color:var(--text-muted);background:var(--card-inner);color:var(--text-muted)}
.pos-dismiss-btn:disabled,.pos-dismiss-btn.is-session-off{opacity:.45;cursor:not-allowed;color:var(--text-muted)}
.pos-card-meta-line{font-size:.78rem;line-height:1.65;color:var(--text-muted);margin-bottom:.55rem}
.pos-card-meta-line strong{color:var(--text)}
.pos-card-actions{display:flex;gap:.35rem;flex-shrink:0;align-items:center}
.pos-order-btn{padding:.4rem .85rem;font-size:.78rem;border-radius:8px;border:1px solid var(--accent);background:rgba(56,189,248,.1);color:var(--accent);cursor:pointer;white-space:nowrap;width:auto;flex-shrink:0;min-height:36px}
.pos-order-btn:disabled,.pos-order-btn.pos-order-done{opacity:.55;cursor:default;border-color:var(--table-border);background:var(--card-inner);color:var(--text-muted)}
.pos-order-btn:disabled:not(.pos-order-done){cursor:wait}
.sl-tp-modal{max-width:420px;width:100%}
.sl-tp-modal-fields{display:flex;flex-direction:column;gap:.75rem;margin-bottom:1rem}
.sl-tp-modal-fields .trade-field{margin:0}
.sl-tp-modal-trailing{margin-top:.15rem}
.sl-tp-modal-actions{display:flex;gap:.5rem;justify-content:flex-end}
.sl-tp-modal-actions .btn-secondary,.sl-tp-modal-actions .btn-primary{width:auto;min-width:5rem;padding:.45rem 1rem;font-size:.85rem}
@media (min-width:768px) and (max-width:1100px){
.trade-split .card{min-height:420px}
.trade-form-line.line-3{grid-template-columns:1fr 1fr}
.trade-form-line.line-3 .trade-field:first-child{grid-column:1/-1}
}
@media (max-width:767px){
.trade-top-bar{flex-direction:column;align-items:stretch}
.trade-top-bar-actions{width:100%}
.btn-ctp-sm{width:100%;min-height:44px}
.trade-split .card{min-height:auto}
html:not([data-mobile="1"]) .trade-form-line.line-3{grid-template-columns:1fr}
.trade-card-full{margin-bottom:1rem}
.trade-table-wrap{max-height:320px}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="'ѧ">
<defs>
<linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#4cc2ff"/>
<stop offset="100%" stop-color="#9d6bff"/>
</linearGradient>
</defs>
<rect width="512" height="512" rx="96" fill="#0a0c16"/>
<rect x="48" y="48" width="416" height="416" rx="72" fill="#141a2e" stroke="url(#g)" stroke-width="8"/>
<polygon points="148,320 256,168 364,320" fill="url(#g)"/>
<rect x="196" y="320" width="120" height="56" rx="8" fill="#9d6bff"/>
</svg>
+318
View File
@@ -0,0 +1,318 @@
/* Copyright (c) 2025-2026 . All rights reserved.
* 交易日历 按日汇总平仓与复盘
*/
(function () {
var calYear = 0;
var calMonth = 0;
var selectedDate = '';
function pad2(n) {
return n < 10 ? '0' + n : String(n);
}
function todayIso() {
var d = new Date();
return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate());
}
function fmtNum(v) {
if (v === null || v === undefined || v === '') return '-';
var n = Number(v);
if (isNaN(n)) return String(v);
return Number.isInteger(n) ? String(n) : n.toFixed(2);
}
function fmtMoney(v) {
if (v === null || v === undefined) return '-';
return fmtNum(v) + ' 元';
}
function pnlClass(v) {
if (v > 0) return 'is-profit';
if (v < 0) return 'is-loss';
return 'is-flat';
}
function fmtPnlShort(v) {
if (v === null || v === undefined) return '-';
var n = Number(v);
if (isNaN(n)) return '-';
var s = Number.isInteger(n) ? String(n) : n.toFixed(0);
return (n > 0 ? '+' : '') + s;
}
function fmtTime(v) {
if (!v) return '-';
return String(v).replace('T', ' ').slice(0, 16);
}
function fmtTags(item) {
var tags = item.behavior_tags || '';
if (item.is_emotion) {
return tags ? '情绪单 · ' + tags : '情绪单';
}
return tags || '';
}
function setCalendarTitle() {
var title = document.getElementById('trade-cal-title');
if (!title) return;
if (window.qihuoLunar && qihuoLunar.monthTitle) {
title.textContent = qihuoLunar.monthTitle(calYear, calMonth);
} else {
title.textContent = '公历 ' + calYear + '年' + calMonth + '月';
}
}
function lunarCellHtml(iso) {
if (!window.qihuoLunar) return '';
var text = qihuoLunar.cellLunarText(iso);
var info = qihuoLunar.fromIso(iso);
var cls = 'trade-cal-day-lunar';
if (info.lunarDay === 1) cls += ' is-month-start';
return '<span class="' + cls + '">' + text + '</span>';
}
function renderCalendar(data) {
var grid = document.getElementById('trade-cal-grid');
if (!grid) return;
setCalendarTitle();
var html = '';
var pad = data.weekday_start || 0;
var i;
for (i = 0; i < pad; i++) {
html += '<div class="trade-cal-cell is-empty"></div>';
}
(data.days || []).forEach(function (day) {
var dayNum = day.date.slice(8, 10).replace(/^0/, '');
var classes = ['trade-cal-cell'];
if (day.count > 0) classes.push('is-clickable');
if (day.date === todayIso()) classes.push('is-today');
if (day.date === selectedDate) classes.push('is-selected');
if (day.has_emotion) classes.push('is-emotion');
html += '<div class="' + classes.join(' ') + '" data-date="' + day.date + '" role="button" tabindex="' + (day.count > 0 ? '0' : '-1') + '">';
html += '<div class="trade-cal-day-head">';
html += '<span class="trade-cal-day-solar">' + dayNum + '</span>';
html += lunarCellHtml(day.date);
html += '</div>';
if (day.count > 0) {
html += '<div class="trade-cal-meta"><div class="trade-cal-count">' + day.count + ' 笔</div>';
html += '<div class="trade-cal-pnl ' + pnlClass(day.total_net) + '">' + fmtPnlShort(day.total_net) + '</div>';
if (day.has_emotion) {
html += '<span class="trade-cal-emotion">情绪' + (day.emotion_count > 1 ? '×' + day.emotion_count : '') + '</span>';
}
html += '</div>';
}
html += '</div>';
});
grid.innerHTML = html;
}
function loadCalendar() {
fetch('/api/stats/calendar?year=' + calYear + '&month=' + calMonth, { credentials: 'same-origin' })
.then(function (r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
})
.then(function (data) {
renderCalendar(data);
if (selectedDate && selectedDate.slice(0, 7) === calYear + '-' + pad2(calMonth)) {
loadDayDetail(selectedDate, false);
} else {
hideDayDetail();
}
})
.catch(function () {
var grid = document.getElementById('trade-cal-grid');
if (grid) grid.innerHTML = '<div class="text-muted" style="grid-column:1/-1">日历加载失败</div>';
});
}
function hideDayDetail() {
var panel = document.getElementById('trade-cal-day-detail');
if (panel) panel.hidden = true;
}
function renderDayDetail(data) {
var panel = document.getElementById('trade-cal-day-detail');
var title = document.getElementById('trade-cal-day-detail-title');
var summary = document.getElementById('trade-cal-day-summary');
var list = document.getElementById('trade-cal-day-list');
if (!panel || !list) return;
var label = data.date.replace(/-/g, '/');
var lunarPart = window.qihuoLunar ? '' + qihuoLunar.daySubtitle(data.date) + '' : '';
if (title) title.textContent = label + lunarPart + ' 交易记录';
if (summary) {
var parts = [data.count + ' 笔', '净盈亏 ' + fmtMoney(data.total_net)];
if (data.emotion_count) parts.push('情绪单 ' + data.emotion_count);
summary.textContent = parts.join(' · ');
}
list.innerHTML = '';
if (!data.items || !data.items.length) {
list.innerHTML = '<div class="text-muted">当日无平仓记录</div>';
panel.hidden = false;
return;
}
data.items.forEach(function (item) {
var card = document.createElement('div');
card.className = 'trade-cal-day-item' + (item.is_emotion ? ' is-emotion' : '');
var head = document.createElement('div');
head.className = 'trade-cal-day-item-head';
var sym = document.createElement('div');
sym.className = 'trade-cal-day-item-symbol';
sym.textContent = (item.symbol || item.symbol_code || '-') + ' · ' + (item.direction || '-');
var pnl = document.createElement('div');
pnl.className = 'trade-cal-day-item-pnl ' + pnlClass(item.pnl_net);
pnl.textContent = fmtMoney(item.pnl_net);
head.appendChild(sym);
head.appendChild(pnl);
var meta = document.createElement('div');
meta.className = 'trade-cal-day-item-meta';
var badges = '';
if (item.source === 'review') {
badges += '<span class="trade-cal-badge review">复盘</span>';
}
if (item.is_emotion) {
badges += '<span class="trade-cal-badge emotion">情绪单</span>';
}
var metaParts = [
badges,
'平仓 ' + fmtTime(item.close_time),
item.lots != null ? item.lots + ' 手' : '',
item.entry_price != null ? '开 ' + item.entry_price : '',
item.close_price != null ? '平 ' + item.close_price : '',
];
if (item.source === 'review' && item.open_type) metaParts.push(item.open_type);
if (item.source === 'review' && item.exit_trigger) metaParts.push('出场: ' + item.exit_trigger);
if (item.result) metaParts.push(item.result);
meta.innerHTML = metaParts.filter(Boolean).join(' · ');
card.appendChild(head);
card.appendChild(meta);
var tags = fmtTags(item);
if (tags) {
var tagEl = document.createElement('div');
tagEl.className = 'trade-cal-day-item-notes';
tagEl.textContent = tags;
card.appendChild(tagEl);
}
if (item.notes) {
var notes = document.createElement('div');
notes.className = 'trade-cal-day-item-notes';
notes.textContent = item.notes;
card.appendChild(notes);
}
if (item.screenshot) {
var shot = document.createElement('div');
shot.className = 'trade-cal-day-item-shot';
shot.innerHTML = '<img src="/uploads/' + item.screenshot + '" alt="复盘截图">';
card.appendChild(shot);
}
list.appendChild(card);
});
panel.hidden = false;
}
function loadDayDetail(dateStr, scroll) {
if (scroll === undefined) scroll = true;
selectedDate = dateStr;
document.querySelectorAll('.trade-cal-cell.is-selected').forEach(function (el) {
el.classList.remove('is-selected');
});
var cell = document.querySelector('.trade-cal-cell[data-date="' + dateStr + '"]');
if (cell) cell.classList.add('is-selected');
fetch('/api/stats/calendar/day?date=' + encodeURIComponent(dateStr), { credentials: 'same-origin' })
.then(function (r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
})
.then(function (data) {
renderDayDetail(data);
if (scroll) {
var panel = document.getElementById('trade-cal-day-detail');
if (panel) panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
})
.catch(function () {
var panel = document.getElementById('trade-cal-day-detail');
var list = document.getElementById('trade-cal-day-list');
if (panel && list) {
list.innerHTML = '<div class="text-muted">加载失败</div>';
panel.hidden = false;
}
});
}
function shiftCalendarMonth(delta) {
calMonth += delta;
if (calMonth > 12) {
calMonth = 1;
calYear += 1;
} else if (calMonth < 1) {
calMonth = 12;
calYear -= 1;
}
loadCalendar();
}
function bindCalendar() {
var grid = document.getElementById('trade-cal-grid');
if (!grid || grid.dataset.tradeCalBound) return;
grid.dataset.tradeCalBound = '1';
grid.addEventListener('click', function (e) {
var cell = e.target.closest('.trade-cal-cell.is-clickable');
if (!cell) return;
loadDayDetail(cell.getAttribute('data-date'));
});
grid.addEventListener('keydown', function (e) {
if (e.key !== 'Enter' && e.key !== ' ') return;
var cell = e.target.closest('.trade-cal-cell.is-clickable');
if (!cell) return;
e.preventDefault();
loadDayDetail(cell.getAttribute('data-date'));
});
var prev = document.getElementById('trade-cal-prev');
var next = document.getElementById('trade-cal-next');
var todayBtn = document.getElementById('trade-cal-today');
if (prev) prev.addEventListener('click', function () { shiftCalendarMonth(-1); });
if (next) next.addEventListener('click', function () { shiftCalendarMonth(1); });
if (todayBtn) todayBtn.addEventListener('click', function () {
var d = new Date();
calYear = d.getFullYear();
calMonth = d.getMonth() + 1;
loadCalendar();
});
}
function bootCalendarPage() {
if (!document.getElementById('trade-cal-grid')) return;
var d = new Date();
calYear = d.getFullYear();
calMonth = d.getMonth() + 1;
bindCalendar();
loadCalendar();
var params = new URLSearchParams(window.location.search);
var dateParam = params.get('date');
if (dateParam && /^\d{4}-\d{2}-\d{2}$/.test(dateParam)) {
var parts = dateParam.split('-');
calYear = parseInt(parts[0], 10);
calMonth = parseInt(parts[1], 10);
loadCalendar();
setTimeout(function () { loadDayDetail(dateParam); }, 300);
}
}
if (window.qihuoPageBoot) window.qihuoPageBoot(bootCalendarPage, '#trade-cal-grid');
else if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', bootCalendarPage);
else bootCalendarPage();
})();

Some files were not shown because too many files have changed in this diff Show More