Restructure into modules/ with single-process CTP and config/ layout.
Move business code under modules/, env template to config/, PM2 single qihuo process, and _legacy shims for old imports. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
|
||||
"""Qihuo feature modules package."""
|
||||
@@ -0,0 +1,5 @@
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
|
||||
from modules.backup.routes import register
|
||||
|
||||
__all__ = ["register"]
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -0,0 +1,8 @@
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
|
||||
"""Core bootstrap and shared types."""
|
||||
|
||||
from modules.core.bootstrap import register_all_modules, start_module_workers
|
||||
from modules.core.deps import AppDeps
|
||||
|
||||
__all__ = ["AppDeps", "register_all_modules", "start_module_workers"]
|
||||
@@ -0,0 +1,55 @@
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
|
||||
"""Application module registry and startup wiring."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from modules.core.deps import AppDeps
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Registration order: core services first, trading last among features.
|
||||
_MODULE_NAMES = (
|
||||
"modules.web",
|
||||
"modules.market",
|
||||
"modules.keys",
|
||||
"modules.plans",
|
||||
"modules.notify",
|
||||
"modules.records",
|
||||
"modules.stats",
|
||||
"modules.fees",
|
||||
"modules.backup",
|
||||
"modules.settings",
|
||||
"modules.risk",
|
||||
"modules.strategy",
|
||||
"modules.ctp",
|
||||
"modules.trading",
|
||||
)
|
||||
|
||||
|
||||
def register_all_modules(deps: "AppDeps") -> None:
|
||||
for name in _MODULE_NAMES:
|
||||
mod = importlib.import_module(name)
|
||||
register = getattr(mod, "register", None)
|
||||
if not callable(register):
|
||||
logger.warning("module %s has no register()", name)
|
||||
continue
|
||||
register(deps)
|
||||
logger.debug("registered %s", name)
|
||||
|
||||
|
||||
def start_module_workers(deps: "AppDeps") -> None:
|
||||
"""Background threads owned by feature modules."""
|
||||
from modules.ctp.vnpy_bridge import try_init_vnpy
|
||||
|
||||
try_init_vnpy({})
|
||||
for name in ("modules.market",):
|
||||
mod = importlib.import_module(name)
|
||||
start = getattr(mod, "start_workers", None)
|
||||
if callable(start):
|
||||
start(deps)
|
||||
@@ -0,0 +1,280 @@
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
# 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
|
||||
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
||||
|
||||
"""期货合约简介:东方财富 / 新浪 / AKShare。"""
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Optional
|
||||
|
||||
import requests
|
||||
|
||||
from modules.core.contract_specs import get_contract_spec
|
||||
from modules.core.symbols import ths_to_codes, search_symbols
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
EM_LABEL_MAP = {
|
||||
"vname": "交易品种",
|
||||
"vcode": "交易代码",
|
||||
"jydw": "交易单位",
|
||||
"bjdw": "报价单位",
|
||||
"market": "交易所",
|
||||
"zxbddw": "最小变动价位",
|
||||
"zdtbfd": "涨跌停幅度",
|
||||
"hyjgyf": "合约月份",
|
||||
"jysj": "交易时间",
|
||||
"zhjyr": "最后交易日",
|
||||
"zhjgr": "交割日期",
|
||||
"jgpj": "交割品级",
|
||||
"zcjybzj": "最低交易保证金",
|
||||
"jgfs": "交割方式",
|
||||
"jgdd": "交割地点",
|
||||
"ssrq": "上市日期",
|
||||
}
|
||||
|
||||
DISPLAY_ORDER = [
|
||||
"交易品种",
|
||||
"交易代码",
|
||||
"交易单位",
|
||||
"报价单位",
|
||||
"最小变动价位",
|
||||
"最低交易保证金",
|
||||
"涨跌停幅度",
|
||||
"合约月份",
|
||||
"交易时间",
|
||||
"最后交易日",
|
||||
"交割日期",
|
||||
"交割方式",
|
||||
"交割地点",
|
||||
"交割品级",
|
||||
"上市日期",
|
||||
"交易所",
|
||||
]
|
||||
|
||||
SKIP_ITEMS = {"", "-", "None", "nan", "null"}
|
||||
|
||||
|
||||
def _normalize_ths_code(raw: str) -> Optional[str]:
|
||||
code = (raw or "").strip()
|
||||
if not code:
|
||||
return None
|
||||
# 已是完整合约
|
||||
if re.match(r"^[A-Za-z]+\d{3,4}$", code):
|
||||
return code
|
||||
# 仅品种字母时尝试匹配主力
|
||||
results = search_symbols(code)
|
||||
if results:
|
||||
return results[0].get("ths_code") or code
|
||||
codes = ths_to_codes(code)
|
||||
if codes:
|
||||
return codes["ths_code"]
|
||||
return code
|
||||
|
||||
|
||||
def _to_sina_quote_symbol(ths_code: str) -> str:
|
||||
m = re.match(r"^([A-Za-z]+)(\d+)$", ths_code.strip())
|
||||
if not m:
|
||||
return ths_code.upper()
|
||||
return m.group(1).upper() + m.group(2)
|
||||
|
||||
|
||||
def _to_em_page_symbol(ths_code: str) -> str:
|
||||
return ths_code.strip().lower() + "F"
|
||||
|
||||
|
||||
def _clean_value(val: Any) -> str:
|
||||
if val is None:
|
||||
return ""
|
||||
s = str(val).strip()
|
||||
if s in SKIP_ITEMS:
|
||||
return ""
|
||||
return s
|
||||
|
||||
|
||||
def _rows_from_dict(data: dict[str, str]) -> list[dict]:
|
||||
rows: list[dict] = []
|
||||
seen: set[str] = set()
|
||||
for label in DISPLAY_ORDER:
|
||||
val = _clean_value(data.get(label))
|
||||
if not val:
|
||||
continue
|
||||
hint = _clean_value(data.get(f"{label}_hint"))
|
||||
rows.append({"label": label, "value": val, "hint": hint})
|
||||
seen.add(label)
|
||||
for label, val in data.items():
|
||||
if label.endswith("_hint") or label in seen:
|
||||
continue
|
||||
val = _clean_value(val)
|
||||
if val:
|
||||
rows.append({"label": label, "value": val, "hint": ""})
|
||||
return rows
|
||||
|
||||
|
||||
def _add_computed_hints(ths_code: str, data: dict[str, str]) -> None:
|
||||
spec = get_contract_spec(ths_code)
|
||||
mult = spec.get("mult") or 0
|
||||
tick_raw = data.get("最小变动价位", "")
|
||||
m = re.search(r"([\d.]+)", tick_raw)
|
||||
if m and mult:
|
||||
tick = float(m.group(1))
|
||||
data["最小变动价位_hint"] = f"一手合约最小波动{round(tick * mult, 2)}元"
|
||||
|
||||
|
||||
def _fetch_em_direct(em_symbol: str) -> dict[str, str]:
|
||||
page_url = f"https://quote.eastmoney.com/qihuo/{em_symbol}.html"
|
||||
r = requests.get(page_url, timeout=12)
|
||||
r.encoding = r.apparent_encoding or "utf-8"
|
||||
inner = None
|
||||
for pat in [
|
||||
r"futures_([A-Za-z0-9_]+)",
|
||||
r"#(futures_[A-Za-z0-9_]+)",
|
||||
r"/(futures_[A-Za-z0-9_]+)",
|
||||
]:
|
||||
m = re.search(pat, r.text)
|
||||
if m:
|
||||
inner = m.group(1).replace("futures_", "")
|
||||
break
|
||||
if not inner:
|
||||
raise ValueError("无法解析东方财富合约标识")
|
||||
|
||||
info_url = f"https://futsse-static.eastmoney.com/redis?msgid={inner}_info"
|
||||
r2 = requests.get(info_url, timeout=12)
|
||||
payload = r2.json()
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("东方财富返回数据无效")
|
||||
|
||||
out: dict[str, str] = {}
|
||||
for key, label in EM_LABEL_MAP.items():
|
||||
val = _clean_value(payload.get(key))
|
||||
if val:
|
||||
out[label] = val
|
||||
if not out:
|
||||
raise ValueError("东方财富合约字段为空")
|
||||
return out
|
||||
|
||||
|
||||
def _fetch_em_akshare(em_symbol: str) -> dict[str, str]:
|
||||
import akshare as ak
|
||||
|
||||
df = ak.futures_contract_detail_em(symbol=em_symbol)
|
||||
out: dict[str, str] = {}
|
||||
for _, row in df.iterrows():
|
||||
label = _clean_value(row.get("item"))
|
||||
val = _clean_value(row.get("value"))
|
||||
if label and val:
|
||||
if label == "跌涨停板幅度":
|
||||
label = "涨跌停幅度"
|
||||
if label == "最后交割日":
|
||||
label = "交割日期"
|
||||
if label == "上市交易所":
|
||||
label = "交易所"
|
||||
if label == "合约交割月份":
|
||||
label = "合约月份"
|
||||
if label == "最初交易保证金":
|
||||
label = "最低交易保证金"
|
||||
if label == "最小变动价格":
|
||||
label = "最小变动价位"
|
||||
out[label] = val
|
||||
return out
|
||||
|
||||
|
||||
def _fetch_sina_direct(sina_symbol: str) -> dict[str, str]:
|
||||
from io import StringIO
|
||||
|
||||
import pandas as pd
|
||||
|
||||
url = f"https://finance.sina.com.cn/futures/quotes/{sina_symbol}.shtml"
|
||||
r = requests.get(url, timeout=12, headers={"Referer": "https://finance.sina.com.cn/"})
|
||||
r.encoding = "gb2312"
|
||||
tables = pd.read_html(StringIO(r.text))
|
||||
if len(tables) < 7:
|
||||
raise ValueError("新浪页面结构变化")
|
||||
temp_df = tables[6]
|
||||
parts = []
|
||||
for ncol in [slice(0, 2), slice(2, 4), slice(4, None)]:
|
||||
part = temp_df.iloc[:, ncol]
|
||||
part.columns = ["item", "value"]
|
||||
parts.append(part)
|
||||
merged = pd.concat(parts, axis=0, ignore_index=True)
|
||||
out: dict[str, str] = {}
|
||||
for _, row in merged.iterrows():
|
||||
label = _clean_value(row["item"])
|
||||
val = _clean_value(row["value"])
|
||||
if not label or not val or len(label) > 80 or "发帖" in val:
|
||||
continue
|
||||
out[label] = val
|
||||
return out
|
||||
|
||||
|
||||
def _fetch_sina_akshare(sina_symbol: str) -> dict[str, str]:
|
||||
import akshare as ak
|
||||
|
||||
df = ak.futures_contract_detail(symbol=sina_symbol)
|
||||
out: dict[str, str] = {}
|
||||
for _, row in df.iterrows():
|
||||
label = _clean_value(row.get("item"))
|
||||
val = _clean_value(row.get("value"))
|
||||
if label and val and "发帖" not in val:
|
||||
out[label] = val
|
||||
return out
|
||||
|
||||
|
||||
def _merge_profile(primary: dict[str, str], secondary: dict[str, str]) -> dict[str, str]:
|
||||
merged = dict(secondary)
|
||||
merged.update(primary)
|
||||
return merged
|
||||
|
||||
|
||||
def get_contract_profile(raw_symbol: str) -> Optional[dict]:
|
||||
ths_code = _normalize_ths_code(raw_symbol)
|
||||
if not ths_code:
|
||||
return None
|
||||
|
||||
em_symbol = _to_em_page_symbol(ths_code)
|
||||
sina_symbol = _to_sina_quote_symbol(ths_code)
|
||||
data: dict[str, str] = {}
|
||||
source_parts: list[str] = []
|
||||
|
||||
# 东方财富(字段与看盘软件简介接近)
|
||||
try:
|
||||
try:
|
||||
data = _fetch_em_akshare(em_symbol)
|
||||
source_parts.append("东方财富")
|
||||
except ImportError:
|
||||
data = _fetch_em_direct(em_symbol)
|
||||
source_parts.append("东方财富")
|
||||
except Exception as exc:
|
||||
logger.warning("eastmoney profile failed %s: %s", em_symbol, exc)
|
||||
|
||||
# 新浪补充交割地点、上市日期等
|
||||
sina_data: dict[str, str] = {}
|
||||
try:
|
||||
try:
|
||||
sina_data = _fetch_sina_akshare(sina_symbol)
|
||||
except ImportError:
|
||||
sina_data = _fetch_sina_direct(sina_symbol)
|
||||
if sina_data:
|
||||
source_parts.append("新浪")
|
||||
except Exception as exc:
|
||||
logger.warning("sina profile failed %s: %s", sina_symbol, exc)
|
||||
|
||||
if sina_data:
|
||||
data = _merge_profile(data, sina_data)
|
||||
|
||||
if not data:
|
||||
return None
|
||||
|
||||
_add_computed_hints(ths_code, data)
|
||||
rows = _rows_from_dict(data)
|
||||
if not rows:
|
||||
return None
|
||||
|
||||
return {
|
||||
"ths_code": ths_code,
|
||||
"symbol_name": data.get("交易品种", ""),
|
||||
"exchange": data.get("交易所", ""),
|
||||
"rows": rows,
|
||||
"source": " + ".join(source_parts) if source_parts else "未知",
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
# 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
|
||||
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
||||
|
||||
"""国内期货合约乘数与参考保证金比例(用于估算保证金与风险)。"""
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
DEFAULT_SPEC = {"mult": 10, "margin_rate": 0.10, "tick_size": 1.0}
|
||||
|
||||
# 参考交易所常见规格(乘数 + 保证金比例 + 最小变动价位)
|
||||
_SPEC_BY_THS: dict[str, dict] = {
|
||||
"ag": {"mult": 15, "margin_rate": 0.14, "tick_size": 1.0},
|
||||
"au": {"mult": 1000, "margin_rate": 0.10, "tick_size": 0.02},
|
||||
"cu": {"mult": 5, "margin_rate": 0.10, "tick_size": 10.0},
|
||||
"al": {"mult": 5, "margin_rate": 0.10},
|
||||
"zn": {"mult": 5, "margin_rate": 0.10},
|
||||
"pb": {"mult": 5, "margin_rate": 0.10},
|
||||
"ni": {"mult": 1, "margin_rate": 0.12},
|
||||
"sn": {"mult": 1, "margin_rate": 0.12},
|
||||
"rb": {"mult": 10, "margin_rate": 0.09},
|
||||
"hc": {"mult": 10, "margin_rate": 0.09},
|
||||
"ss": {"mult": 5, "margin_rate": 0.11},
|
||||
"sc": {"mult": 1000, "margin_rate": 0.11},
|
||||
"fu": {"mult": 10, "margin_rate": 0.11},
|
||||
"bu": {"mult": 10, "margin_rate": 0.11},
|
||||
"ru": {"mult": 10, "margin_rate": 0.11},
|
||||
"sp": {"mult": 10, "margin_rate": 0.10},
|
||||
"i": {"mult": 100, "margin_rate": 0.11},
|
||||
"j": {"mult": 100, "margin_rate": 0.12},
|
||||
"jm": {"mult": 60, "margin_rate": 0.12},
|
||||
"m": {"mult": 10, "margin_rate": 0.08},
|
||||
"y": {"mult": 10, "margin_rate": 0.08},
|
||||
"p": {"mult": 10, "margin_rate": 0.09},
|
||||
"c": {"mult": 10, "margin_rate": 0.08},
|
||||
"cs": {"mult": 10, "margin_rate": 0.08},
|
||||
"jd": {"mult": 10, "margin_rate": 0.09},
|
||||
"lh": {"mult": 16, "margin_rate": 0.12},
|
||||
"l": {"mult": 5, "margin_rate": 0.09},
|
||||
"pp": {"mult": 5, "margin_rate": 0.09},
|
||||
"v": {"mult": 5, "margin_rate": 0.09},
|
||||
"eg": {"mult": 10, "margin_rate": 0.09},
|
||||
"eb": {"mult": 5, "margin_rate": 0.10},
|
||||
"pg": {"mult": 20, "margin_rate": 0.10},
|
||||
"RM": {"mult": 10, "margin_rate": 0.08},
|
||||
"OI": {"mult": 10, "margin_rate": 0.08},
|
||||
"SR": {"mult": 10, "margin_rate": 0.08},
|
||||
"CF": {"mult": 5, "margin_rate": 0.08},
|
||||
"MA": {"mult": 10, "margin_rate": 0.09},
|
||||
"TA": {"mult": 5, "margin_rate": 0.09},
|
||||
"FG": {"mult": 20, "margin_rate": 0.10},
|
||||
"SA": {"mult": 20, "margin_rate": 0.10},
|
||||
"UR": {"mult": 20, "margin_rate": 0.10},
|
||||
"SF": {"mult": 5, "margin_rate": 0.10},
|
||||
"SM": {"mult": 5, "margin_rate": 0.10},
|
||||
"AP": {"mult": 10, "margin_rate": 0.10},
|
||||
"CJ": {"mult": 5, "margin_rate": 0.10},
|
||||
"PK": {"mult": 5, "margin_rate": 0.10},
|
||||
"IF": {"mult": 300, "margin_rate": 0.12, "tick_size": 0.2},
|
||||
"IH": {"mult": 300, "margin_rate": 0.12, "tick_size": 0.2},
|
||||
"IC": {"mult": 200, "margin_rate": 0.12, "tick_size": 0.2},
|
||||
"IM": {"mult": 200, "margin_rate": 0.12, "tick_size": 0.2},
|
||||
}
|
||||
|
||||
_TICK_OVERRIDES: dict[str, float] = {
|
||||
"sc": 0.1, "TA": 2.0, "CF": 5.0, "SF": 2.0, "SM": 2.0,
|
||||
}
|
||||
|
||||
|
||||
def get_contract_spec(ths_code: str) -> dict:
|
||||
code = (ths_code or "").strip()
|
||||
m = re.match(r"^([A-Za-z]+)", code)
|
||||
if not m:
|
||||
return dict(DEFAULT_SPEC)
|
||||
letters = m.group(1)
|
||||
spec = _SPEC_BY_THS.get(letters) or _SPEC_BY_THS.get(letters.upper()) or _SPEC_BY_THS.get(letters.lower())
|
||||
if spec:
|
||||
tick = spec.get("tick_size")
|
||||
if tick is None:
|
||||
tick = _TICK_OVERRIDES.get(letters) or _TICK_OVERRIDES.get(letters.upper()) or 1.0
|
||||
return {"mult": spec["mult"], "margin_rate": spec["margin_rate"], "tick_size": float(tick)}
|
||||
return dict(DEFAULT_SPEC)
|
||||
|
||||
|
||||
def margin_one_lot(
|
||||
ths_code: str,
|
||||
price: float,
|
||||
*,
|
||||
direction: str = "long",
|
||||
trading_mode: str | None = None,
|
||||
) -> tuple[float, str, dict]:
|
||||
"""1 手保证金。CTP 已连接时优先读柜台合约保证金率,否则用本地参考规格估算。
|
||||
|
||||
direction 可为 long / short / max(多空费率取较大值,用于可开仓品种表)。
|
||||
返回 (保证金, 来源 estimate|ctp, 合约规格片段)。
|
||||
"""
|
||||
spec = get_contract_spec(ths_code)
|
||||
est = 0.0
|
||||
if price and price > 0:
|
||||
est = round(float(price) * spec["mult"] * spec["margin_rate"], 2)
|
||||
if trading_mode:
|
||||
try:
|
||||
from modules.ctp.vnpy_bridge import ctp_estimate_margin_one_lot, ctp_lookup_contract_spec, ctp_status
|
||||
|
||||
if ctp_status(trading_mode).get("connected"):
|
||||
ctp_margin = ctp_estimate_margin_one_lot(
|
||||
trading_mode, ths_code, float(price), direction=direction,
|
||||
)
|
||||
if ctp_margin and ctp_margin > 0:
|
||||
merged = dict(spec)
|
||||
ctp_spec = ctp_lookup_contract_spec(trading_mode, ths_code) or {}
|
||||
if ctp_spec.get("mult"):
|
||||
merged["mult"] = ctp_spec["mult"]
|
||||
if ctp_spec.get("tick_size"):
|
||||
merged["tick_size"] = ctp_spec["tick_size"]
|
||||
if ctp_spec.get("margin_rate"):
|
||||
merged["margin_rate"] = ctp_spec["margin_rate"]
|
||||
return float(ctp_margin), "ctp", merged
|
||||
except Exception:
|
||||
pass
|
||||
return est, "estimate", spec
|
||||
|
||||
|
||||
def calc_position_metrics(
|
||||
direction: str,
|
||||
entry: float,
|
||||
stop_loss: float,
|
||||
take_profit: float,
|
||||
lots: float,
|
||||
mark_price: Optional[float],
|
||||
capital: float,
|
||||
ths_code: str,
|
||||
) -> dict:
|
||||
spec = get_contract_spec(ths_code)
|
||||
mult = spec["mult"]
|
||||
margin_rate = spec["margin_rate"]
|
||||
lots = lots or 1.0
|
||||
margin = entry * mult * lots * margin_rate
|
||||
|
||||
if direction == "long":
|
||||
risk_amt = max(0.0, (entry - stop_loss) * mult * lots)
|
||||
reward = max(0.0, (take_profit - entry) * mult * lots)
|
||||
float_pnl = (mark_price - entry) * mult * lots if mark_price is not None else None
|
||||
else:
|
||||
risk_amt = max(0.0, (stop_loss - entry) * mult * lots)
|
||||
reward = max(0.0, (entry - take_profit) * mult * lots)
|
||||
float_pnl = (entry - mark_price) * mult * lots if mark_price is not None else None
|
||||
|
||||
risk_pct = (risk_amt / capital * 100) if capital > 0 else 0.0
|
||||
pos_pct = (margin / capital * 100) if capital > 0 else 0.0
|
||||
rr = (reward / risk_amt) if risk_amt > 0 else None
|
||||
float_pct = (float_pnl / margin * 100) if margin > 0 and float_pnl is not None else None
|
||||
|
||||
return {
|
||||
"mult": mult,
|
||||
"margin_rate": margin_rate,
|
||||
"margin": round(margin, 2),
|
||||
"risk_amount": round(risk_amt, 2),
|
||||
"risk_pct": round(risk_pct, 2),
|
||||
"position_pct": round(pos_pct, 2),
|
||||
"float_pnl": round(float_pnl, 2) if float_pnl is not None else None,
|
||||
"float_pct": round(float_pct, 2) if float_pct is not None else None,
|
||||
"reward_amount": round(reward, 2) if reward else None,
|
||||
"rr_ratio": round(rr, 2) if rr is not None else None,
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
# 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
|
||||
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
||||
|
||||
"""数据库连接:开发默认 SQLite,生产推荐 PostgreSQL(DATABASE_URL)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Iterable, Optional, Sequence
|
||||
|
||||
from modules.core.paths import DB_PATH as _ROOT_DB_PATH
|
||||
|
||||
DB_PATH = _ROOT_DB_PATH
|
||||
|
||||
_backend_lock = threading.Lock()
|
||||
_backend: Optional[str] = None
|
||||
|
||||
try:
|
||||
import psycopg
|
||||
from psycopg import OperationalError as PgOperationalError
|
||||
from psycopg import IntegrityError as PgIntegrityError
|
||||
from psycopg.rows import dict_row
|
||||
|
||||
_PSYCOPG_OK = True
|
||||
except ImportError:
|
||||
psycopg = None # type: ignore[assignment]
|
||||
PgOperationalError = Exception # type: ignore[misc,assignment]
|
||||
PgIntegrityError = Exception # type: ignore[misc,assignment]
|
||||
dict_row = None # type: ignore[assignment]
|
||||
_PSYCOPG_OK = False
|
||||
|
||||
OperationalError = sqlite3.OperationalError
|
||||
IntegrityError = sqlite3.IntegrityError
|
||||
|
||||
|
||||
def db_backend() -> str:
|
||||
"""``sqlite`` 或 ``postgres``。"""
|
||||
global _backend
|
||||
if _backend is not None:
|
||||
return _backend
|
||||
with _backend_lock:
|
||||
if _backend is not None:
|
||||
return _backend
|
||||
url = (os.getenv("DATABASE_URL") or "").strip()
|
||||
if url.startswith(("postgresql://", "postgres://")):
|
||||
if not _PSYCOPG_OK:
|
||||
raise RuntimeError(
|
||||
"已配置 DATABASE_URL 但未安装 psycopg,请执行: pip install 'psycopg[binary]'"
|
||||
)
|
||||
_backend = "postgres"
|
||||
else:
|
||||
_backend = "sqlite"
|
||||
return _backend
|
||||
|
||||
|
||||
def is_postgres() -> bool:
|
||||
return db_backend() == "postgres"
|
||||
|
||||
|
||||
def database_label() -> str:
|
||||
if is_postgres():
|
||||
url = (os.getenv("DATABASE_URL") or "").strip()
|
||||
host = url.split("@")[-1].split("/")[0] if "@" in url else "postgresql"
|
||||
return f"PostgreSQL ({host})"
|
||||
return f"SQLite ({DB_PATH})"
|
||||
|
||||
|
||||
def adapt_sql(sql: str) -> str:
|
||||
"""将 SQLite 风格 SQL 适配为当前后端。"""
|
||||
if not is_postgres():
|
||||
return sql
|
||||
out = sql
|
||||
out = re.sub(
|
||||
r"\bINTEGER PRIMARY KEY AUTOINCREMENT\b",
|
||||
"SERIAL PRIMARY KEY",
|
||||
out,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
out = re.sub(r"\bAUTOINCREMENT\b", "", out, flags=re.IGNORECASE)
|
||||
out = re.sub(r'DEFAULT\s+"([^"]*)"', r"DEFAULT '\1'", out, flags=re.IGNORECASE)
|
||||
if "?" in out:
|
||||
out = out.replace("?", "%s")
|
||||
return out
|
||||
|
||||
|
||||
def is_benign_migration_error(exc: BaseException) -> bool:
|
||||
"""ALTER TABLE 重复列等初始化迁移可忽略的错误。"""
|
||||
if is_schema_migration_error(exc):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def is_schema_migration_error(exc: BaseException) -> bool:
|
||||
"""init_db 增量迁移:缺表/缺列/重复列均可忽略。"""
|
||||
msg = str(exc).lower()
|
||||
if any(
|
||||
x in msg
|
||||
for x in (
|
||||
"duplicate column",
|
||||
"already exists",
|
||||
"duplicate key",
|
||||
"no such table",
|
||||
"does not exist",
|
||||
"undefined table",
|
||||
"undefined column",
|
||||
)
|
||||
):
|
||||
return True
|
||||
if isinstance(exc, sqlite3.OperationalError) and (
|
||||
"duplicate column" in msg or "no such table" in msg
|
||||
):
|
||||
return True
|
||||
if _PSYCOPG_OK and isinstance(exc, PgOperationalError):
|
||||
code = getattr(exc, "sqlstate", "") or ""
|
||||
if code in ("42701", "42P07", "42P01", "42703"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def is_missing_relation_error(exc: BaseException) -> bool:
|
||||
"""表/视图不存在。"""
|
||||
if is_schema_migration_error(exc):
|
||||
msg = str(exc).lower()
|
||||
return any(x in msg for x in ("no such table", "does not exist", "undefined table"))
|
||||
return False
|
||||
|
||||
|
||||
def rollback_if_postgres(conn: "DbConnection") -> None:
|
||||
if is_postgres():
|
||||
try:
|
||||
conn.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class DbCursor:
|
||||
"""统一 cursor:兼容 sqlite3 的 execute / fetchone / lastrowid。"""
|
||||
|
||||
def __init__(self, backend: str, raw_cursor: Any, raw_conn: Any) -> None:
|
||||
self._backend = backend
|
||||
self._cur = raw_cursor
|
||||
self._conn = raw_conn
|
||||
self.lastrowid: Optional[int] = None
|
||||
self.rowcount: int = 0
|
||||
|
||||
def execute(self, sql: str, params: Sequence[Any] | None = None) -> "DbCursor":
|
||||
sql = adapt_sql(sql)
|
||||
params = params or ()
|
||||
self._cur.execute(sql, params)
|
||||
self.rowcount = int(getattr(self._cur, "rowcount", 0) or 0)
|
||||
self.lastrowid = getattr(self._cur, "lastrowid", None)
|
||||
if self.lastrowid is None and is_postgres():
|
||||
if re.match(r"^\s*INSERT\b", sql, re.IGNORECASE):
|
||||
try:
|
||||
row = self._cur.fetchone()
|
||||
if row is not None:
|
||||
if isinstance(row, dict):
|
||||
self.lastrowid = int(row.get("id") or row.get("Id") or 0) or None
|
||||
else:
|
||||
self.lastrowid = int(row[0])
|
||||
except Exception:
|
||||
try:
|
||||
self._cur.execute("SELECT lastval()")
|
||||
lv = self._cur.fetchone()
|
||||
if lv:
|
||||
self.lastrowid = int(lv[0] if not isinstance(lv, dict) else lv["lastval"])
|
||||
except Exception:
|
||||
pass
|
||||
return self
|
||||
|
||||
def fetchone(self) -> Any:
|
||||
return self._cur.fetchone()
|
||||
|
||||
def fetchall(self) -> list[Any]:
|
||||
return self._cur.fetchall()
|
||||
|
||||
def close(self) -> None:
|
||||
try:
|
||||
self._cur.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class DbConnection:
|
||||
"""统一连接:execute / commit / close,接口对齐 sqlite3.Connection。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
backend: str,
|
||||
raw_conn: Any,
|
||||
*,
|
||||
from_pool: bool = False,
|
||||
) -> None:
|
||||
self._backend = backend
|
||||
self._conn = raw_conn
|
||||
self._from_pool = from_pool
|
||||
self.row_factory = None
|
||||
|
||||
def execute(self, sql: str, params: Sequence[Any] | None = None) -> DbCursor:
|
||||
cur = self.cursor()
|
||||
try:
|
||||
return cur.execute(sql, params)
|
||||
except Exception:
|
||||
rollback_if_postgres(self)
|
||||
raise
|
||||
|
||||
def cursor(self) -> DbCursor:
|
||||
if self._backend == "sqlite":
|
||||
return DbCursor(self._backend, self._conn.cursor(), self._conn)
|
||||
raw = self._conn.cursor(row_factory=dict_row)
|
||||
return DbCursor(self._backend, raw, self._conn)
|
||||
|
||||
def commit(self) -> None:
|
||||
self._conn.commit()
|
||||
|
||||
def rollback(self) -> None:
|
||||
self._conn.rollback()
|
||||
|
||||
def close(self) -> None:
|
||||
try:
|
||||
self._conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def __enter__(self) -> "DbConnection":
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> None:
|
||||
if exc:
|
||||
try:
|
||||
self.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
self.commit()
|
||||
except Exception:
|
||||
pass
|
||||
self.close()
|
||||
|
||||
|
||||
def connect_db(path: str | None = None) -> DbConnection:
|
||||
"""获取数据库连接。PostgreSQL / SQLite 均为每次新建连接(用毕 close)。"""
|
||||
if is_postgres():
|
||||
url = (os.getenv("DATABASE_URL") or "").strip()
|
||||
raw = psycopg.connect(url, row_factory=dict_row)
|
||||
try:
|
||||
with raw.cursor() as cur:
|
||||
cur.execute("SET TIME ZONE 'Asia/Shanghai'")
|
||||
raw.commit()
|
||||
except Exception:
|
||||
pass
|
||||
return DbConnection("postgres", raw, from_pool=False)
|
||||
|
||||
db_path = path or DB_PATH
|
||||
raw = sqlite3.connect(db_path, timeout=30, check_same_thread=False)
|
||||
raw.row_factory = sqlite3.Row
|
||||
raw.execute("PRAGMA busy_timeout=30000")
|
||||
try:
|
||||
raw.execute("PRAGMA journal_mode=WAL")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
return DbConnection("sqlite", raw)
|
||||
|
||||
|
||||
def close_pg_pool() -> None:
|
||||
"""兼容旧调用;当前 PostgreSQL 使用直连,无全局连接池。"""
|
||||
return
|
||||
|
||||
|
||||
def execute_retry(
|
||||
conn: DbConnection,
|
||||
sql: str,
|
||||
params: tuple = (),
|
||||
*,
|
||||
retries: int = 6,
|
||||
base_delay: float = 0.05,
|
||||
) -> DbCursor:
|
||||
"""遇锁冲突时短暂退避重试(SQLite locked / PG serialization)。"""
|
||||
last_exc: Exception | None = None
|
||||
for attempt in range(retries):
|
||||
try:
|
||||
return conn.execute(sql, params)
|
||||
except (OperationalError, PgOperationalError) as exc:
|
||||
msg = str(exc).lower()
|
||||
retryable = "locked" in msg or "serialize" in msg or "deadlock" in msg
|
||||
if not retryable:
|
||||
raise
|
||||
last_exc = exc
|
||||
if attempt < retries - 1:
|
||||
time.sleep(base_delay * (attempt + 1))
|
||||
if last_exc:
|
||||
raise last_exc
|
||||
raise OperationalError("database is locked")
|
||||
|
||||
|
||||
def commit_retry(
|
||||
conn: DbConnection,
|
||||
*,
|
||||
retries: int = 6,
|
||||
base_delay: float = 0.05,
|
||||
) -> None:
|
||||
"""遇锁冲突时短暂退避重试 commit。"""
|
||||
last_exc: Exception | None = None
|
||||
for attempt in range(retries):
|
||||
try:
|
||||
conn.commit()
|
||||
return
|
||||
except (OperationalError, PgOperationalError) as exc:
|
||||
msg = str(exc).lower()
|
||||
retryable = "locked" in msg or "serialize" in msg or "deadlock" in msg
|
||||
if not retryable:
|
||||
raise
|
||||
last_exc = exc
|
||||
if attempt < retries - 1:
|
||||
time.sleep(base_delay * (attempt + 1))
|
||||
if last_exc:
|
||||
raise last_exc
|
||||
raise OperationalError("database is locked")
|
||||
|
||||
|
||||
def is_db_contention_error(exc: BaseException) -> bool:
|
||||
"""SQLite locked / PostgreSQL serialization / deadlock。"""
|
||||
msg = str(exc).lower()
|
||||
if isinstance(exc, sqlite3.OperationalError):
|
||||
return "locked" in msg
|
||||
if _PSYCOPG_OK and isinstance(exc, PgOperationalError):
|
||||
code = getattr(exc, "sqlstate", "") or ""
|
||||
if code in ("40001", "40P01", "55P03"):
|
||||
return True
|
||||
return any(x in msg for x in ("deadlock", "serialize", "lock"))
|
||||
return False
|
||||
|
||||
|
||||
def reset_backend_for_tests(backend: str | None = None) -> None:
|
||||
"""测试用:重置后端检测。"""
|
||||
global _backend
|
||||
close_pg_pool()
|
||||
with _backend_lock:
|
||||
_backend = backend
|
||||
@@ -0,0 +1,46 @@
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
|
||||
"""Shared dependencies passed into each feature module at register() time."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class AppDeps:
|
||||
app: Any
|
||||
get_db: Callable
|
||||
get_setting: Callable
|
||||
set_setting: Callable
|
||||
login_required: Callable
|
||||
require_nav: Callable
|
||||
fetch_price: Callable
|
||||
send_wechat_msg: Callable
|
||||
touch_stats_cache: Callable
|
||||
get_stats_data: Callable
|
||||
build_market_quote_payload: Callable
|
||||
today_str: Callable
|
||||
expire_old_plans: Callable
|
||||
check_order_plans: Callable
|
||||
check_key_monitors: Callable
|
||||
background_task: Callable
|
||||
start_background_threads: Callable
|
||||
tz: Any
|
||||
db_path: str
|
||||
upload_dir: str
|
||||
open_types: list
|
||||
exit_triggers: list
|
||||
behavior_tags: list
|
||||
kline_periods: list
|
||||
kline_cutoffs: list
|
||||
calc_holding_duration: Callable
|
||||
holding_to_minutes: Callable
|
||||
classify_close_result: Callable
|
||||
calc_rr_ratio: Callable
|
||||
calc_theoretical_pnl: Callable
|
||||
parse_review_date_filter: Callable
|
||||
trading_mode: Callable
|
||||
static_asset_v: Callable
|
||||
ua_is_phone: Callable
|
||||
@@ -0,0 +1,170 @@
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
# 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
# 详见 LICENSE.zh-CN.txt
|
||||
|
||||
"""将项目 docs 下的 Markdown 转为安全 HTML(无第三方依赖)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
_DOCS_ROOT = Path(__file__).resolve().parent / "docs"
|
||||
|
||||
ALLOWED_DOCS: dict[str, str] = {
|
||||
"risk-guide": "风控说明.md",
|
||||
}
|
||||
|
||||
|
||||
def docs_root() -> Path:
|
||||
return _DOCS_ROOT
|
||||
|
||||
|
||||
def read_doc(slug: str) -> tuple[str, str]:
|
||||
"""返回 (title, raw_markdown)。"""
|
||||
name = ALLOWED_DOCS.get(slug)
|
||||
if not name:
|
||||
raise FileNotFoundError(slug)
|
||||
path = (_DOCS_ROOT / name).resolve()
|
||||
if not path.is_file() or _DOCS_ROOT.resolve() not in path.parents:
|
||||
raise FileNotFoundError(slug)
|
||||
text = path.read_text(encoding="utf-8")
|
||||
title = name
|
||||
for line in text.splitlines():
|
||||
s = line.strip()
|
||||
if s.startswith("# "):
|
||||
title = s[2:].strip()
|
||||
break
|
||||
return title, text
|
||||
|
||||
|
||||
def _inline(text: str) -> str:
|
||||
s = html.escape(text)
|
||||
s = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", s)
|
||||
s = re.sub(r"`([^`]+)`", r"<code>\1</code>", s)
|
||||
s = re.sub(
|
||||
r"\[([^\]]+)\]\(([^)]+)\)",
|
||||
lambda m: _link_html(m.group(1), m.group(2)),
|
||||
s,
|
||||
)
|
||||
return s
|
||||
|
||||
|
||||
def _link_html(label: str, href: str) -> str:
|
||||
h = html.escape(href)
|
||||
lbl = _inline(label)
|
||||
if href.startswith(("http://", "https://", "mailto:")):
|
||||
return f'<a href="{h}" target="_blank" rel="noopener noreferrer">{lbl}</a>'
|
||||
if href.endswith(".md") or href.startswith("./"):
|
||||
return f'<span class="doc-xref">{lbl}</span>'
|
||||
return f'<a href="{h}">{lbl}</a>'
|
||||
|
||||
|
||||
def render_markdown(text: str) -> str:
|
||||
lines = text.splitlines()
|
||||
out: list[str] = []
|
||||
i = 0
|
||||
in_ul = False
|
||||
in_ol = False
|
||||
|
||||
def close_lists() -> None:
|
||||
nonlocal in_ul, in_ol
|
||||
if in_ul:
|
||||
out.append("</ul>")
|
||||
in_ul = False
|
||||
if in_ol:
|
||||
out.append("</ol>")
|
||||
in_ol = False
|
||||
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
stripped = line.strip()
|
||||
|
||||
if not stripped:
|
||||
close_lists()
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if stripped == "---":
|
||||
close_lists()
|
||||
out.append("<hr>")
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if stripped.startswith("|") and stripped.endswith("|"):
|
||||
close_lists()
|
||||
table_lines: list[str] = []
|
||||
while i < len(lines) and lines[i].strip().startswith("|"):
|
||||
table_lines.append(lines[i].strip())
|
||||
i += 1
|
||||
out.append(_render_table(table_lines))
|
||||
continue
|
||||
|
||||
if stripped.startswith("### "):
|
||||
close_lists()
|
||||
out.append(f"<h3>{_inline(stripped[4:])}</h3>")
|
||||
i += 1
|
||||
continue
|
||||
if stripped.startswith("## "):
|
||||
close_lists()
|
||||
out.append(f"<h2>{_inline(stripped[3:])}</h2>")
|
||||
i += 1
|
||||
continue
|
||||
if stripped.startswith("# "):
|
||||
close_lists()
|
||||
out.append(f"<h1>{_inline(stripped[2:])}</h1>")
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if re.match(r"^[-*]\s+", stripped):
|
||||
if not in_ul:
|
||||
close_lists()
|
||||
out.append("<ul>")
|
||||
in_ul = True
|
||||
item_text = re.sub(r"^[-*]\s+", "", stripped)
|
||||
out.append(f"<li>{_inline(item_text)}</li>")
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if re.match(r"^\d+\.\s+", stripped):
|
||||
if not in_ol:
|
||||
close_lists()
|
||||
out.append("<ol>")
|
||||
in_ol = True
|
||||
item_text = re.sub(r"^\d+\.\s+", "", stripped)
|
||||
out.append(f"<li>{_inline(item_text)}</li>")
|
||||
i += 1
|
||||
continue
|
||||
|
||||
close_lists()
|
||||
para = stripped
|
||||
i += 1
|
||||
while i < len(lines):
|
||||
nxt = lines[i].strip()
|
||||
if not nxt or nxt == "---" or nxt.startswith("#") or nxt.startswith("|") or re.match(r"^[-*]\s+", nxt):
|
||||
break
|
||||
para += " " + nxt
|
||||
i += 1
|
||||
out.append(f"<p>{_inline(para)}</p>")
|
||||
|
||||
close_lists()
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def _render_table(rows: list[str]) -> str:
|
||||
if len(rows) < 2:
|
||||
return ""
|
||||
header = [c.strip() for c in rows[0].strip("|").split("|")]
|
||||
body_rows = rows[2:] if len(rows) > 2 and re.match(r"^[\|\s:-]+$", rows[1]) else rows[1:]
|
||||
parts = ["<table class=\"doc-table\">", "<thead><tr>"]
|
||||
for cell in header:
|
||||
parts.append(f"<th>{_inline(cell)}</th>")
|
||||
parts.append("</tr></thead><tbody>")
|
||||
for row in body_rows:
|
||||
cells = [c.strip() for c in row.strip("|").split("|")]
|
||||
parts.append("<tr>")
|
||||
for cell in cells:
|
||||
parts.append(f"<td>{_inline(cell)}</td>")
|
||||
parts.append("</tr>")
|
||||
parts.append("</tbody></table>")
|
||||
return "".join(parts)
|
||||
@@ -0,0 +1,74 @@
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
# 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
|
||||
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
||||
|
||||
"""读写项目根目录 .env 文件(更新指定键,保留其余行)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
from modules.core.paths import ENV_FILE, LEGACY_ENV_FILE
|
||||
|
||||
|
||||
def _default_env_path() -> str:
|
||||
if ENV_FILE.is_file():
|
||||
return str(ENV_FILE)
|
||||
if LEGACY_ENV_FILE.is_file():
|
||||
return str(LEGACY_ENV_FILE)
|
||||
return str(ENV_FILE)
|
||||
|
||||
|
||||
ENV_PATH = _default_env_path()
|
||||
_KEY_RE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)\s*=")
|
||||
|
||||
|
||||
def env_file_path(path: str | None = None) -> str:
|
||||
if path:
|
||||
return path
|
||||
from modules.core.paths import resolve_env_file
|
||||
return resolve_env_file()
|
||||
|
||||
|
||||
def _quote_env_value(value: str) -> str:
|
||||
if value == "":
|
||||
return '""'
|
||||
if re.search(r'[\s#"\'\\=]', value):
|
||||
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
|
||||
return f'"{escaped}"'
|
||||
return value
|
||||
|
||||
|
||||
def update_env_vars(updates: dict[str, str], path: str | None = None) -> None:
|
||||
"""更新或追加 KEY=value,不改动注释与其他配置项。"""
|
||||
if not updates:
|
||||
return
|
||||
env_path = env_file_path(path)
|
||||
lines: list[str] = []
|
||||
if os.path.isfile(env_path):
|
||||
with open(env_path, encoding="utf-8") as f:
|
||||
lines = f.read().splitlines()
|
||||
|
||||
seen: set[str] = set()
|
||||
out: list[str] = []
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#"):
|
||||
out.append(line)
|
||||
continue
|
||||
m = _KEY_RE.match(stripped)
|
||||
if m and m.group(1) in updates:
|
||||
key = m.group(1)
|
||||
out.append(f"{key}={_quote_env_value(updates[key])}")
|
||||
seen.add(key)
|
||||
else:
|
||||
out.append(line)
|
||||
|
||||
for key, val in updates.items():
|
||||
if key not in seen:
|
||||
out.append(f"{key}={_quote_env_value(val)}")
|
||||
|
||||
with open(env_path, "w", encoding="utf-8", newline="\n") as f:
|
||||
if out:
|
||||
f.write("\n".join(out) + "\n")
|
||||
@@ -0,0 +1,96 @@
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
# 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
|
||||
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
||||
|
||||
"""Linux 上 vnpy_ctp 连接 CTP 前须设置有效 locale(否则 C++ 层 abort)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import locale
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_LOCALE_DONE = False
|
||||
_LOCALE_NAME = ""
|
||||
|
||||
# CTP C++ API 登录回调依赖中文 locale(见 vnpy/vnpy_ctp#24)
|
||||
_CTP_REQUIRED_LOCALES = ("zh_CN.GB18030", "zh_CN.gb18030")
|
||||
|
||||
|
||||
def _available_locales() -> set[str]:
|
||||
try:
|
||||
out = subprocess.check_output(["locale", "-a"], text=True, stderr=subprocess.DEVNULL)
|
||||
return {line.strip() for line in out.splitlines() if line.strip()}
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return set()
|
||||
|
||||
|
||||
def missing_ctp_locales() -> list[str]:
|
||||
"""CTP 所需的 zh_CN.GB18030 是否已安装。"""
|
||||
avail = {x.lower() for x in _available_locales()}
|
||||
if any(x.lower() in avail for x in _CTP_REQUIRED_LOCALES):
|
||||
return []
|
||||
return ["zh_CN.GB18030"]
|
||||
|
||||
|
||||
def _list_locale_candidates() -> list[str]:
|
||||
avail = _available_locales()
|
||||
names: list[str] = []
|
||||
# CTP 回调优先尝试中文 locale
|
||||
for item in (
|
||||
"zh_CN.GB18030",
|
||||
"zh_CN.gb18030",
|
||||
"zh_CN.UTF-8",
|
||||
"zh_CN.utf8",
|
||||
"en_US.UTF-8",
|
||||
"en_US.utf8",
|
||||
"C.UTF-8",
|
||||
"C.utf8",
|
||||
"POSIX",
|
||||
"C",
|
||||
):
|
||||
if item in avail and item not in names:
|
||||
names.append(item)
|
||||
for loc in sorted(avail):
|
||||
low = loc.lower()
|
||||
if "utf" in low and loc not in names:
|
||||
names.append(loc)
|
||||
return names
|
||||
|
||||
|
||||
def ensure_process_locale() -> str:
|
||||
"""强制设置进程 locale,覆盖系统里无效的旧值。"""
|
||||
global _LOCALE_DONE, _LOCALE_NAME
|
||||
if _LOCALE_DONE:
|
||||
return _LOCALE_NAME
|
||||
|
||||
missing = missing_ctp_locales()
|
||||
if missing:
|
||||
raise RuntimeError(
|
||||
"CTP 需要中文 locale zh_CN.GB18030,当前系统未安装。"
|
||||
"请执行: sed -i '/^# zh_CN.GB18030/s/^# //' /etc/locale.gen && "
|
||||
"locale-gen zh_CN.GB18030"
|
||||
)
|
||||
|
||||
last_err: locale.Error | None = None
|
||||
for name in _list_locale_candidates():
|
||||
try:
|
||||
locale.setlocale(locale.LC_ALL, name)
|
||||
os.environ["LANG"] = name
|
||||
os.environ["LC_ALL"] = name
|
||||
os.environ["LC_CTYPE"] = name
|
||||
_LOCALE_DONE = True
|
||||
_LOCALE_NAME = name
|
||||
logger.info("进程 locale 已设置: %s", name)
|
||||
return name
|
||||
except locale.Error as exc:
|
||||
last_err = exc
|
||||
continue
|
||||
|
||||
raise RuntimeError(
|
||||
"未找到可用 locale,vnpy_ctp 会在 CTP 登录后崩溃。"
|
||||
"请执行: apt install -y locales && locale-gen zh_CN.GB18030 en_US.UTF-8"
|
||||
) from last_err
|
||||
@@ -0,0 +1,37 @@
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
|
||||
"""Repository layout paths — single source for config, data, uploads."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# .../qihuo/modules/core/paths.py -> repo root
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
CONFIG_DIR = ROOT / "config"
|
||||
ENV_FILE = CONFIG_DIR / ".env"
|
||||
LEGACY_ENV_FILE = ROOT / ".env"
|
||||
|
||||
DATA_DIR = ROOT / "data"
|
||||
UPLOADS_DIR = ROOT / "uploads"
|
||||
LOGS_DIR = ROOT / "logs"
|
||||
|
||||
DB_PATH = str(ROOT / "futures.db")
|
||||
|
||||
|
||||
def ensure_runtime_dirs() -> None:
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
LOGS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def resolve_env_file() -> str:
|
||||
"""Prefer config/.env, fall back to legacy root .env."""
|
||||
if ENV_FILE.is_file():
|
||||
return str(ENV_FILE)
|
||||
if LEGACY_ENV_FILE.is_file():
|
||||
return str(LEGACY_ENV_FILE)
|
||||
return str(ENV_FILE)
|
||||
@@ -0,0 +1,683 @@
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
# 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
|
||||
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
||||
|
||||
"""
|
||||
期货品种与同花顺代码映射。
|
||||
展示同花顺合约代码(ag2608);行情默认新浪,机构用户可通过环境变量启用同花顺 iFinD。
|
||||
"""
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from datetime import date
|
||||
from typing import Optional
|
||||
|
||||
from modules.market.market import fetch_raw_for_volume, get_price as market_get_price, THS_EX_SUFFIX
|
||||
|
||||
PRODUCTS = [
|
||||
{"name": "白银", "ths": "ag", "sina": "AG", "exchange": "上期所", "ex": "SHFE"},
|
||||
{"name": "黄金", "ths": "au", "sina": "AU", "exchange": "上期所", "ex": "SHFE"},
|
||||
{"name": "铜", "ths": "cu", "sina": "CU", "exchange": "上期所", "ex": "SHFE"},
|
||||
{"name": "铝", "ths": "al", "sina": "AL", "exchange": "上期所", "ex": "SHFE"},
|
||||
{"name": "锌", "ths": "zn", "sina": "ZN", "exchange": "上期所", "ex": "SHFE"},
|
||||
{"name": "铅", "ths": "pb", "sina": "PB", "exchange": "上期所", "ex": "SHFE"},
|
||||
{"name": "镍", "ths": "ni", "sina": "NI", "exchange": "上期所", "ex": "SHFE"},
|
||||
{"name": "锡", "ths": "sn", "sina": "SN", "exchange": "上期所", "ex": "SHFE"},
|
||||
{"name": "螺纹钢", "ths": "rb", "sina": "RB", "exchange": "上期所", "ex": "SHFE"},
|
||||
{"name": "热卷", "ths": "hc", "sina": "HC", "exchange": "上期所", "ex": "SHFE"},
|
||||
{"name": "不锈钢", "ths": "ss", "sina": "SS", "exchange": "上期所", "ex": "SHFE"},
|
||||
{"name": "原油", "ths": "sc", "sina": "SC", "exchange": "上期能源", "ex": "INE"},
|
||||
{"name": "燃油", "ths": "fu", "sina": "FU", "exchange": "上期所", "ex": "SHFE"},
|
||||
{"name": "沥青", "ths": "bu", "sina": "BU", "exchange": "上期所", "ex": "SHFE"},
|
||||
{"name": "橡胶", "ths": "ru", "sina": "RU", "exchange": "上期所", "ex": "SHFE"},
|
||||
{"name": "纸浆", "ths": "sp", "sina": "SP", "exchange": "上期所", "ex": "SHFE"},
|
||||
{"name": "铁矿石", "ths": "i", "sina": "I", "exchange": "大商所", "ex": "DCE"},
|
||||
{"name": "焦炭", "ths": "j", "sina": "J", "exchange": "大商所", "ex": "DCE"},
|
||||
{"name": "焦煤", "ths": "jm", "sina": "JM", "exchange": "大商所", "ex": "DCE"},
|
||||
{"name": "豆粕", "ths": "m", "sina": "M", "exchange": "大商所", "ex": "DCE"},
|
||||
{"name": "豆油", "ths": "y", "sina": "Y", "exchange": "大商所", "ex": "DCE"},
|
||||
{"name": "棕榈油", "ths": "p", "sina": "P", "exchange": "大商所", "ex": "DCE"},
|
||||
{"name": "玉米", "ths": "c", "sina": "C", "exchange": "大商所", "ex": "DCE"},
|
||||
{"name": "淀粉", "ths": "cs", "sina": "CS", "exchange": "大商所", "ex": "DCE"},
|
||||
{"name": "鸡蛋", "ths": "jd", "sina": "JD", "exchange": "大商所", "ex": "DCE"},
|
||||
{"name": "生猪", "ths": "lh", "sina": "LH", "exchange": "大商所", "ex": "DCE"},
|
||||
{"name": "聚乙烯", "ths": "l", "sina": "L", "exchange": "大商所", "ex": "DCE"},
|
||||
{"name": "聚丙烯", "ths": "pp", "sina": "PP", "exchange": "大商所", "ex": "DCE"},
|
||||
{"name": "PVC", "ths": "v", "sina": "V", "exchange": "大商所", "ex": "DCE"},
|
||||
{"name": "乙二醇", "ths": "eg", "sina": "EG", "exchange": "大商所", "ex": "DCE"},
|
||||
{"name": "苯乙烯", "ths": "eb", "sina": "EB", "exchange": "大商所", "ex": "DCE"},
|
||||
{"name": "液化气", "ths": "pg", "sina": "PG", "exchange": "大商所", "ex": "DCE"},
|
||||
{"name": "菜粕", "ths": "RM", "sina": "RM", "exchange": "郑商所", "ex": "CZCE"},
|
||||
{"name": "菜油", "ths": "OI", "sina": "OI", "exchange": "郑商所", "ex": "CZCE"},
|
||||
{"name": "白糖", "ths": "SR", "sina": "SR", "exchange": "郑商所", "ex": "CZCE"},
|
||||
{"name": "棉花", "ths": "CF", "sina": "CF", "exchange": "郑商所", "ex": "CZCE"},
|
||||
{"name": "甲醇", "ths": "MA", "sina": "MA", "exchange": "郑商所", "ex": "CZCE"},
|
||||
{"name": "PTA", "ths": "TA", "sina": "TA", "exchange": "郑商所", "ex": "CZCE"},
|
||||
{"name": "玻璃", "ths": "FG", "sina": "FG", "exchange": "郑商所", "ex": "CZCE"},
|
||||
{"name": "纯碱", "ths": "SA", "sina": "SA", "exchange": "郑商所", "ex": "CZCE"},
|
||||
{"name": "尿素", "ths": "UR", "sina": "UR", "exchange": "郑商所", "ex": "CZCE"},
|
||||
{"name": "硅铁", "ths": "SF", "sina": "SF", "exchange": "郑商所", "ex": "CZCE"},
|
||||
{"name": "锰硅", "ths": "SM", "sina": "SM", "exchange": "郑商所", "ex": "CZCE"},
|
||||
{"name": "苹果", "ths": "AP", "sina": "AP", "exchange": "郑商所", "ex": "CZCE"},
|
||||
{"name": "红枣", "ths": "CJ", "sina": "CJ", "exchange": "郑商所", "ex": "CZCE"},
|
||||
{"name": "花生", "ths": "PK", "sina": "PK", "exchange": "郑商所", "ex": "CZCE"},
|
||||
{"name": "沪深300", "ths": "IF", "sina": "IF", "exchange": "中金所", "ex": "CFFEX"},
|
||||
{"name": "上证50", "ths": "IH", "sina": "IH", "exchange": "中金所", "ex": "CFFEX"},
|
||||
{"name": "中证500", "ths": "IC", "sina": "IC", "exchange": "中金所", "ex": "CFFEX"},
|
||||
{"name": "中证1000", "ths": "IM", "sina": "IM", "exchange": "中金所", "ex": "CFFEX"},
|
||||
]
|
||||
|
||||
PRODUCT_CATEGORY_MAP = {
|
||||
"ag": "贵金属", "au": "贵金属",
|
||||
"cu": "有色金属", "al": "有色金属", "zn": "有色金属", "pb": "有色金属", "ni": "有色金属", "sn": "有色金属",
|
||||
"rb": "黑色金属", "hc": "黑色金属", "ss": "黑色金属", "i": "黑色金属", "j": "黑色金属", "jm": "黑色金属",
|
||||
"SF": "黑色金属", "SM": "黑色金属",
|
||||
"sc": "能源化工", "fu": "能源化工", "bu": "能源化工", "ru": "能源化工", "sp": "能源化工",
|
||||
"l": "能源化工", "pp": "能源化工", "v": "能源化工", "eg": "能源化工", "eb": "能源化工", "pg": "能源化工",
|
||||
"MA": "能源化工", "TA": "能源化工", "SA": "能源化工", "UR": "能源化工", "FG": "能源化工",
|
||||
"m": "农产品", "y": "农产品", "p": "农产品", "c": "农产品", "cs": "农产品", "jd": "农产品", "lh": "农产品",
|
||||
"RM": "农产品", "OI": "农产品", "SR": "农产品", "CF": "农产品", "AP": "农产品", "CJ": "农产品", "PK": "农产品",
|
||||
"IF": "金融期货", "IH": "金融期货", "IC": "金融期货", "IM": "金融期货",
|
||||
}
|
||||
PRODUCT_CATEGORIES = ["贵金属", "有色金属", "黑色金属", "能源化工", "农产品", "金融期货"]
|
||||
|
||||
for _p in PRODUCTS:
|
||||
_p["category"] = PRODUCT_CATEGORY_MAP.get(_p["ths"], "其他")
|
||||
|
||||
# 无夜盘品种(日盘-only):中金所股指、大商所鸡蛋/生猪等
|
||||
NO_NIGHT_SESSION_THS = frozenset({"IF", "IH", "IC", "IM", "jd", "lh"})
|
||||
|
||||
|
||||
def product_has_night_session(ths_or_product) -> bool:
|
||||
"""品种是否参与夜盘交易。"""
|
||||
if isinstance(ths_or_product, dict):
|
||||
ths = (ths_or_product.get("ths") or "").strip()
|
||||
else:
|
||||
ths = (ths_or_product or "").strip()
|
||||
if not ths:
|
||||
return True
|
||||
m = re.match(r"^([A-Za-z]+)", ths)
|
||||
letters = m.group(1) if m else ths
|
||||
return letters not in NO_NIGHT_SESSION_THS and letters.upper() not in NO_NIGHT_SESSION_THS
|
||||
|
||||
|
||||
def filter_for_trading_session(rows: list[dict]) -> list[dict]:
|
||||
"""夜盘时段隐藏无夜盘品种。"""
|
||||
from modules.market.market_sessions import is_night_trading_session
|
||||
|
||||
if not is_night_trading_session():
|
||||
return rows
|
||||
out: list[dict] = []
|
||||
for row in rows:
|
||||
if row.get("has_night_session") is False:
|
||||
continue
|
||||
ths = row.get("ths") or row.get("ths_code") or ""
|
||||
if row.get("has_night_session") is True or product_has_night_session(ths):
|
||||
out.append(row)
|
||||
return out
|
||||
|
||||
|
||||
def product_category(ths: str) -> str:
|
||||
return PRODUCT_CATEGORY_MAP.get((ths or "").strip(), "其他")
|
||||
|
||||
|
||||
EXCHANGE_ORDER = ["上期所", "上期能源", "大商所", "郑商所", "中金所"]
|
||||
_MAIN_CACHE: dict[str, tuple[float, dict]] = {}
|
||||
_CACHE_TTL = 300
|
||||
_main_index_lock = threading.Lock()
|
||||
_main_index: dict[str, dict] = {}
|
||||
_main_index_ts = 0.0
|
||||
_index_refresh_lock = threading.Lock()
|
||||
|
||||
|
||||
def build_ths_code(product: dict, year: int, month: int) -> str:
|
||||
"""同花顺软件内显示的合约代码。"""
|
||||
ex = product["ex"]
|
||||
letters = product["ths"]
|
||||
if ex == "CZCE":
|
||||
return f"{letters}{year % 10}{month:02d}"
|
||||
return f"{letters}{year % 100:02d}{month:02d}"
|
||||
|
||||
|
||||
def build_ths_full_code(product: dict, year: int, month: int) -> str:
|
||||
"""同花顺 iFinD HTTP API 代码,如 ag2608.SHFE"""
|
||||
ths = build_ths_code(product, year, month)
|
||||
suffix = THS_EX_SUFFIX.get(product["ex"], product["ex"])
|
||||
return f"{ths}.{suffix}"
|
||||
|
||||
|
||||
def build_sina_code(product: dict, year: int, month: int) -> str:
|
||||
letters = product["sina"]
|
||||
suffix = f"{year % 100:02d}{month:02d}"
|
||||
if product["ex"] == "CFFEX":
|
||||
return f"CFF_RE_{letters}{suffix}"
|
||||
return f"nf_{letters}{suffix}"
|
||||
|
||||
|
||||
def build_sina_main_code(product: dict) -> str:
|
||||
letters = product["sina"]
|
||||
if product["ex"] == "CFFEX":
|
||||
return f"CFF_RE_{letters}0"
|
||||
return f"nf_{letters}0"
|
||||
|
||||
|
||||
def _find_product_by_letters(letters: str) -> Optional[dict]:
|
||||
letters_up = letters.upper()
|
||||
for p in PRODUCTS:
|
||||
if p["ths"].upper() == letters_up or p["sina"] == letters_up:
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def _product_codes(product: dict, ths_code: str, market_code: str, sina_code: str) -> dict:
|
||||
return {
|
||||
"ths_code": ths_code,
|
||||
"market_code": market_code,
|
||||
"sina_code": sina_code,
|
||||
"ex": product["ex"],
|
||||
"name": product["name"],
|
||||
"exchange": product["exchange"],
|
||||
}
|
||||
|
||||
|
||||
def ths_to_codes(ths_code: str) -> Optional[dict]:
|
||||
"""同花顺合约代码 -> ths_full + sina 回退代码。"""
|
||||
code = ths_code.strip()
|
||||
if not code:
|
||||
return None
|
||||
|
||||
m4 = re.match(r"^([A-Za-z]+)(\d{4})$", code)
|
||||
if m4:
|
||||
letters, digits = m4.group(1), m4.group(2)
|
||||
year = 2000 + int(digits[:2])
|
||||
month = int(digits[2:])
|
||||
if not 1 <= month <= 12:
|
||||
return None
|
||||
product = _find_product_by_letters(letters)
|
||||
if product:
|
||||
ths = build_ths_code(product, year, month)
|
||||
return _product_codes(
|
||||
product,
|
||||
ths,
|
||||
build_ths_full_code(product, year, month),
|
||||
build_sina_code(product, year, month),
|
||||
)
|
||||
letters_up = letters.upper()
|
||||
if letters_up in ("IF", "IH", "IC", "IM", "T", "TF", "TS"):
|
||||
ths = f"{letters_up}{digits}"
|
||||
return {
|
||||
"ths_code": ths,
|
||||
"market_code": f"{ths}.CFFEX",
|
||||
"sina_code": f"CFF_RE_{letters_up}{digits}",
|
||||
"ex": "CFFEX",
|
||||
"name": letters_up,
|
||||
"exchange": "中金所",
|
||||
}
|
||||
|
||||
m3 = re.match(r"^([A-Za-z]+)(\d{3})$", code)
|
||||
if m3:
|
||||
letters, digits = m3.group(1), m3.group(2)
|
||||
y_digit = int(digits[0])
|
||||
month = int(digits[1:])
|
||||
if not 1 <= month <= 12:
|
||||
return None
|
||||
year = date.today().year
|
||||
decade = year // 10 * 10
|
||||
candidate = decade + y_digit
|
||||
if candidate < year - 1:
|
||||
candidate += 10
|
||||
product = _find_product_by_letters(letters)
|
||||
if product:
|
||||
ths = build_ths_code(product, candidate, month)
|
||||
return _product_codes(
|
||||
product,
|
||||
ths,
|
||||
build_ths_full_code(product, candidate, month),
|
||||
build_sina_code(product, candidate, month),
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def ths_to_sina_code(ths_code: str) -> Optional[str]:
|
||||
codes = ths_to_codes(ths_code)
|
||||
return codes["sina_code"] if codes else None
|
||||
|
||||
|
||||
def parse_contract_year_month(ths_code: str) -> Optional[tuple[int, int]]:
|
||||
"""从同花顺合约代码解析交割年月。"""
|
||||
code = (ths_code or "").strip()
|
||||
if not code or "888" in code:
|
||||
return None
|
||||
m4 = re.match(r"^([A-Za-z]+)(\d{4})$", code)
|
||||
if m4:
|
||||
digits = m4.group(2)
|
||||
year = 2000 + int(digits[:2])
|
||||
month = int(digits[2:])
|
||||
if 1 <= month <= 12:
|
||||
return year, month
|
||||
m3 = re.match(r"^([A-Za-z]+)(\d{3})$", code)
|
||||
if m3:
|
||||
letters, digits = m3.group(1), m3.group(2)
|
||||
month = int(digits[1:])
|
||||
if not 1 <= month <= 12:
|
||||
return None
|
||||
y_digit = int(digits[0])
|
||||
year = date.today().year
|
||||
decade = year // 10 * 10
|
||||
candidate = decade + y_digit
|
||||
if candidate < year - 1:
|
||||
candidate += 10
|
||||
product = _find_product_by_letters(letters)
|
||||
if product:
|
||||
return candidate, month
|
||||
return None
|
||||
|
||||
|
||||
def is_near_expiry_main(ths_code: str) -> bool:
|
||||
"""主力合约交割月为当月或下月时视为临期。"""
|
||||
ym = parse_contract_year_month(ths_code)
|
||||
if not ym:
|
||||
return False
|
||||
cy, cm = ym
|
||||
today = date.today()
|
||||
months_ahead = (cy - today.year) * 12 + (cm - today.month)
|
||||
return months_ahead <= 1
|
||||
|
||||
|
||||
def _main_contract_score(raw: dict) -> float:
|
||||
"""主力判定:优先持仓量,其次成交量。"""
|
||||
oi = float(raw.get("open_interest") or 0)
|
||||
vol = float(raw.get("volume") or 0)
|
||||
return oi if oi > 0 else vol
|
||||
|
||||
|
||||
def _make_symbol_item(
|
||||
product: dict,
|
||||
year: int,
|
||||
month: int,
|
||||
volume: float,
|
||||
open_interest: float = 0,
|
||||
) -> dict:
|
||||
ths = build_ths_code(product, year, month)
|
||||
name = product["name"]
|
||||
return {
|
||||
"name": name,
|
||||
"ths_code": ths,
|
||||
"market_code": build_ths_full_code(product, year, month),
|
||||
"sina_code": build_sina_code(product, year, month),
|
||||
"exchange": product["exchange"],
|
||||
"contract": f"主力 {ths}",
|
||||
"display": f"{name} 主力 {ths}",
|
||||
"input_label": f"{name} {ths}",
|
||||
"volume": volume,
|
||||
"open_interest": open_interest,
|
||||
}
|
||||
|
||||
|
||||
def resolve_main_contract(product: dict) -> Optional[dict]:
|
||||
cache_key = product["sina"]
|
||||
now = time.time()
|
||||
cached = _MAIN_CACHE.get(cache_key)
|
||||
if cached and now - cached[0] < _CACHE_TTL:
|
||||
return cached[1]
|
||||
|
||||
today = date.today()
|
||||
y, m = today.year, today.month
|
||||
best = None
|
||||
best_score = 0.0
|
||||
|
||||
for i in range(14):
|
||||
cy, cm = y, m + i
|
||||
while cm > 12:
|
||||
cm -= 12
|
||||
cy += 1
|
||||
sina = build_sina_code(product, cy, cm)
|
||||
raw = fetch_raw_for_volume(sina)
|
||||
if not raw:
|
||||
continue
|
||||
score = _main_contract_score(raw)
|
||||
if score <= 0:
|
||||
continue
|
||||
item = _make_symbol_item(
|
||||
product, cy, cm, raw["volume"], raw.get("open_interest", 0),
|
||||
)
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best = item
|
||||
|
||||
if best is None:
|
||||
sina_main = build_sina_main_code(product)
|
||||
raw = fetch_raw_for_volume(sina_main)
|
||||
if raw:
|
||||
ths_letters = product["ths"]
|
||||
ths_main = (
|
||||
f"{ths_letters}888"
|
||||
if product["ex"] != "CFFEX"
|
||||
else f"{ths_letters.upper()}888"
|
||||
)
|
||||
suffix = THS_EX_SUFFIX.get(product["ex"], product["ex"])
|
||||
best = {
|
||||
"name": product["name"],
|
||||
"ths_code": ths_main,
|
||||
"market_code": f"{ths_main}.{suffix}",
|
||||
"sina_code": sina_main,
|
||||
"exchange": product["exchange"],
|
||||
"contract": f"主力连续 {ths_main}",
|
||||
"display": f"{product['name']} 主力连续 {ths_main}",
|
||||
"input_label": f"{product['name']} {ths_main}",
|
||||
"volume": raw.get("volume", 0),
|
||||
}
|
||||
|
||||
if best:
|
||||
best = _enrich_item(best, product)
|
||||
_MAIN_CACHE[cache_key] = (now, best)
|
||||
return best
|
||||
|
||||
|
||||
def _enrich_item(item: dict, product: Optional[dict] = None) -> dict:
|
||||
out = dict(item)
|
||||
if not out.get("input_label"):
|
||||
out["input_label"] = f"{out.get('name', '')} {out.get('ths_code', '')}".strip()
|
||||
out["near_expiry"] = is_near_expiry_main(out.get("ths_code", ""))
|
||||
if product is None and out.get("ths_code"):
|
||||
product = _product_for_contract_code(out["ths_code"])
|
||||
if product is not None:
|
||||
out["has_night_session"] = product_has_night_session(product)
|
||||
elif "has_night_session" not in out:
|
||||
out["has_night_session"] = product_has_night_session(out.get("ths_code") or "")
|
||||
return out
|
||||
|
||||
|
||||
def refresh_main_index():
|
||||
"""后台预热全部品种主力合约,搜索时只读本地缓存。"""
|
||||
global _main_index, _main_index_ts
|
||||
with _index_refresh_lock:
|
||||
new_idx: dict[str, dict] = {}
|
||||
with ThreadPoolExecutor(max_workers=10) as pool:
|
||||
futures = {pool.submit(resolve_main_contract, p): p for p in PRODUCTS}
|
||||
for fut in as_completed(futures):
|
||||
product = futures[fut]
|
||||
try:
|
||||
main = fut.result()
|
||||
if main:
|
||||
new_idx[product["sina"]] = _enrich_item(main, product)
|
||||
except Exception:
|
||||
pass
|
||||
with _main_index_lock:
|
||||
_main_index = new_idx
|
||||
_main_index_ts = time.time()
|
||||
|
||||
|
||||
def _warm_loop():
|
||||
while True:
|
||||
try:
|
||||
refresh_main_index()
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(_CACHE_TTL)
|
||||
|
||||
|
||||
def _start_warm_thread():
|
||||
threading.Thread(target=_warm_loop, daemon=True).start()
|
||||
|
||||
|
||||
def _stub_main_contract(product: dict) -> dict:
|
||||
"""缓存未就绪时的快速占位(当月合约),避免首次打开搜索为空。"""
|
||||
today = date.today()
|
||||
return _enrich_item(_make_symbol_item(product, today.year, today.month, 0), product)
|
||||
|
||||
|
||||
def _product_matches(product: dict, q_lower: str) -> bool:
|
||||
name_lower = product["name"].lower()
|
||||
if q_lower in name_lower:
|
||||
return True
|
||||
if len(q_lower) >= 2:
|
||||
ths_lower = product["ths"].lower()
|
||||
sina_lower = product["sina"].lower()
|
||||
if q_lower in ths_lower or q_lower in sina_lower:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _match_score(product: dict, q_lower: str) -> int:
|
||||
name_lower = product["name"].lower()
|
||||
if name_lower == q_lower:
|
||||
return 200
|
||||
if name_lower.startswith(q_lower):
|
||||
return 150
|
||||
if q_lower in name_lower:
|
||||
return 100
|
||||
ths_lower = product["ths"].lower()
|
||||
if ths_lower == q_lower:
|
||||
return 90
|
||||
if ths_lower.startswith(q_lower):
|
||||
return 70
|
||||
if product["sina"].lower() == q_lower:
|
||||
return 80
|
||||
return 10
|
||||
|
||||
|
||||
def search_symbols(query: str, *, capital: float | None = None, ctp_connected: bool = True) -> list:
|
||||
q = query.strip()
|
||||
if not q:
|
||||
return []
|
||||
|
||||
q_lower = q.lower()
|
||||
from modules.market.market_sessions import is_night_trading_session
|
||||
from modules.trading.product_recommend import filter_products_for_capital, should_apply_small_account_scope
|
||||
|
||||
night_only = is_night_trading_session()
|
||||
product_pool = PRODUCTS
|
||||
if capital is not None and should_apply_small_account_scope(capital, ctp_connected=ctp_connected):
|
||||
product_pool = filter_products_for_capital(
|
||||
PRODUCTS, capital, ctp_connected=ctp_connected,
|
||||
)
|
||||
with _main_index_lock:
|
||||
index = dict(_main_index)
|
||||
index_ready = bool(index)
|
||||
|
||||
scored: list[tuple[int, dict]] = []
|
||||
for p in product_pool:
|
||||
if night_only and not product_has_night_session(p):
|
||||
continue
|
||||
if not _product_matches(p, q_lower):
|
||||
continue
|
||||
main = index.get(p["sina"])
|
||||
if not main and not index_ready:
|
||||
main = _stub_main_contract(p)
|
||||
if main:
|
||||
scored.append((_match_score(p, q_lower), main))
|
||||
|
||||
scored.sort(key=lambda x: -x[0])
|
||||
results = [item for _, item in scored[:12]]
|
||||
results = filter_for_trading_session(results)
|
||||
|
||||
if not results and len(q) >= 3:
|
||||
codes = ths_to_codes(q)
|
||||
if codes:
|
||||
product = _product_for_contract_code(codes["ths_code"])
|
||||
if capital is not None and should_apply_small_account_scope(
|
||||
capital, ctp_connected=ctp_connected,
|
||||
):
|
||||
from modules.trading.product_recommend import product_in_small_account_whitelist
|
||||
if not product or not product_in_small_account_whitelist(product):
|
||||
return results
|
||||
raw = fetch_raw_for_volume(codes["sina_code"])
|
||||
name = raw["name"] if raw else q
|
||||
results.append(_enrich_item({
|
||||
"name": name,
|
||||
"ths_code": codes["ths_code"],
|
||||
"market_code": codes["market_code"],
|
||||
"sina_code": codes["sina_code"],
|
||||
"exchange": "",
|
||||
"contract": codes["ths_code"],
|
||||
"display": f"{name} ({codes['ths_code']})",
|
||||
"volume": raw.get("volume", 0) if raw else 0,
|
||||
}))
|
||||
results = filter_for_trading_session(results)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def enrich_recommend_row(row: dict) -> dict:
|
||||
"""补全推荐行字段(含是否夜盘)。"""
|
||||
out = dict(row)
|
||||
ths = out.get("ths") or ""
|
||||
out["has_night_session"] = product_has_night_session(ths)
|
||||
return out
|
||||
|
||||
|
||||
_THS_TO_PRODUCT = {p["ths"]: p for p in PRODUCTS}
|
||||
for _p in PRODUCTS:
|
||||
_THS_TO_PRODUCT.setdefault(_p["ths"].lower(), _p)
|
||||
|
||||
|
||||
def _product_for_ths(ths: str) -> Optional[dict]:
|
||||
key = (ths or "").strip()
|
||||
if not key:
|
||||
return None
|
||||
return _THS_TO_PRODUCT.get(key) or _THS_TO_PRODUCT.get(key.lower())
|
||||
|
||||
|
||||
def _product_for_contract_code(ths_code: str) -> Optional[dict]:
|
||||
sym = (ths_code or "").strip()
|
||||
if not sym:
|
||||
return None
|
||||
m = re.match(r"^([A-Za-z]+)", sym)
|
||||
if not m:
|
||||
return None
|
||||
return _find_product_by_letters(m.group(1))
|
||||
|
||||
|
||||
def position_symbol_meta(ths_code: str) -> dict:
|
||||
"""持仓/委托展示:品种名、交易所、是否主力合约。"""
|
||||
sym = (ths_code or "").strip()
|
||||
if not sym:
|
||||
return {"name": "", "exchange": "", "is_main": False}
|
||||
product = _product_for_contract_code(sym)
|
||||
if not product:
|
||||
return {"name": sym, "exchange": "", "is_main": False}
|
||||
codes = ths_to_codes(sym)
|
||||
norm = (codes["ths_code"] if codes else sym).strip().lower()
|
||||
is_main = False
|
||||
with _main_index_lock:
|
||||
main_item = _main_index.get(product["sina"])
|
||||
if main_item:
|
||||
main_ths = (main_item.get("ths_code") or "").strip().lower()
|
||||
is_main = main_ths == norm or main_ths == sym.lower()
|
||||
return {
|
||||
"name": product["name"],
|
||||
"exchange": product.get("exchange") or "",
|
||||
"is_main": is_main,
|
||||
}
|
||||
|
||||
|
||||
def _item_from_recommend_row(row: dict, product: dict) -> Optional[dict]:
|
||||
"""由可开仓缓存行快速构造下拉项(不在 HTTP 请求中解析主力)。"""
|
||||
name = row.get("name") or product["name"]
|
||||
main_code = (row.get("main_code") or "").strip()
|
||||
max_lots = row.get("max_lots")
|
||||
|
||||
if main_code:
|
||||
codes = ths_to_codes(main_code)
|
||||
if codes:
|
||||
ths = codes["ths_code"]
|
||||
item = {
|
||||
"name": name,
|
||||
"ths_code": ths,
|
||||
"market_code": codes.get("market_code") or "",
|
||||
"sina_code": codes.get("sina_code") or "",
|
||||
"exchange": product["exchange"],
|
||||
"contract": f"主力 {ths}",
|
||||
"display": f"{name} 主力 {ths}",
|
||||
"input_label": f"{name} {ths}",
|
||||
}
|
||||
if max_lots is not None:
|
||||
item["max_lots"] = max_lots
|
||||
return _enrich_item(item, product)
|
||||
|
||||
with _main_index_lock:
|
||||
main = _main_index.get(product["sina"])
|
||||
if main:
|
||||
item = dict(main)
|
||||
if max_lots is not None:
|
||||
item["max_lots"] = max_lots
|
||||
return _enrich_item(item, product)
|
||||
|
||||
item = _stub_main_contract(product)
|
||||
if max_lots is not None:
|
||||
item["max_lots"] = max_lots
|
||||
return item
|
||||
|
||||
|
||||
def list_recommended_symbols_grouped(recommend_rows: list[dict]) -> list[dict]:
|
||||
"""按交易所分类返回可开仓品种对应的主力合约(品种选择下拉用)。"""
|
||||
if not recommend_rows:
|
||||
return []
|
||||
|
||||
buckets: dict[str, list] = defaultdict(list)
|
||||
seen: set[str] = set()
|
||||
for row in recommend_rows:
|
||||
if row.get("status") not in ("ok", "margin_ok"):
|
||||
continue
|
||||
ths_key = (row.get("ths") or "").strip()
|
||||
if not ths_key or ths_key in seen:
|
||||
continue
|
||||
product = _product_for_ths(ths_key)
|
||||
if not product:
|
||||
continue
|
||||
if not product_has_night_session(product):
|
||||
from modules.market.market_sessions import is_night_trading_session
|
||||
if is_night_trading_session():
|
||||
continue
|
||||
seen.add(ths_key)
|
||||
item = _item_from_recommend_row(row, product)
|
||||
if not item:
|
||||
continue
|
||||
buckets[product["exchange"]].append(item)
|
||||
|
||||
groups: list[dict] = []
|
||||
for cat in EXCHANGE_ORDER:
|
||||
items = buckets.get(cat)
|
||||
if items:
|
||||
groups.append({"category": cat, "items": items})
|
||||
return groups
|
||||
|
||||
|
||||
def list_main_contracts_grouped() -> list[dict]:
|
||||
"""按交易所分类返回全部品种主力合约(行情页下拉用)。"""
|
||||
with _main_index_lock:
|
||||
index = dict(_main_index)
|
||||
|
||||
if len(index) < len(PRODUCTS) // 2:
|
||||
refresh_main_index()
|
||||
with _main_index_lock:
|
||||
index = dict(_main_index)
|
||||
|
||||
buckets: dict[str, list] = defaultdict(list)
|
||||
for p in PRODUCTS:
|
||||
main = index.get(p["sina"])
|
||||
if not main:
|
||||
resolved = resolve_main_contract(p)
|
||||
if resolved:
|
||||
main = _enrich_item(resolved)
|
||||
if main:
|
||||
buckets[p["exchange"]].append(main)
|
||||
|
||||
groups: list[dict] = []
|
||||
for cat in EXCHANGE_ORDER:
|
||||
items = buckets.get(cat)
|
||||
if items:
|
||||
groups.append({"category": cat, "items": items})
|
||||
return groups
|
||||
|
||||
|
||||
_start_warm_thread()
|
||||
|
||||
|
||||
def get_price(market_code: str, sina_code: str = "") -> Optional[float]:
|
||||
return market_get_price(market_code, sina_code)
|
||||
@@ -0,0 +1,184 @@
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
# 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
|
||||
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
||||
|
||||
"""交易上下文:设置读取、资金、模式。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable, Optional
|
||||
|
||||
TRADING_MODE_SIM = "simulation" # SimNow CTP
|
||||
TRADING_MODE_LIVE = "live" # 期货公司 CTP
|
||||
|
||||
|
||||
def get_trading_mode(get_setting: Callable[[str, str], str]) -> str:
|
||||
m = (get_setting("trading_mode", TRADING_MODE_SIM) or TRADING_MODE_SIM).strip().lower()
|
||||
return m if m in (TRADING_MODE_SIM, TRADING_MODE_LIVE) else TRADING_MODE_SIM
|
||||
|
||||
|
||||
def get_sizing_mode(get_setting: Callable[[str, str], str]) -> str:
|
||||
from modules.trading.position_sizing import normalize_sizing_mode
|
||||
return normalize_sizing_mode(get_setting("position_sizing_mode", "fixed"))
|
||||
|
||||
|
||||
def get_fixed_lots(get_setting: Callable[[str, str], str]) -> int:
|
||||
try:
|
||||
return max(1, int(float(get_setting("fixed_lots", "1") or 1)))
|
||||
except (TypeError, ValueError):
|
||||
return 1
|
||||
|
||||
|
||||
def get_fixed_amount(get_setting: Callable[[str, str], str]) -> float:
|
||||
try:
|
||||
return max(1.0, float(get_setting("fixed_amount", "5000") or 5000))
|
||||
except (TypeError, ValueError):
|
||||
return 5000.0
|
||||
|
||||
|
||||
def get_risk_percent(get_setting: Callable[[str, str], str]) -> float:
|
||||
try:
|
||||
return max(0.1, float(get_setting("risk_percent", "1") or 1))
|
||||
except (TypeError, ValueError):
|
||||
return 1.0
|
||||
|
||||
|
||||
def get_max_margin_pct(get_setting: Callable[[str, str], str]) -> float:
|
||||
"""单笔/总仓位保证金占权益上限(%),默认 30。"""
|
||||
try:
|
||||
return max(1.0, min(100.0, float(get_setting("max_margin_pct", "30") or 30)))
|
||||
except (TypeError, ValueError):
|
||||
return 30.0
|
||||
|
||||
|
||||
def get_roll_max_margin_pct(get_setting: Callable[[str, str], str]) -> float:
|
||||
"""滚仓后总保证金占权益上限(%),默认 50。"""
|
||||
try:
|
||||
return max(1.0, min(100.0, float(get_setting("roll_max_margin_pct", "50") or 50)))
|
||||
except (TypeError, ValueError):
|
||||
return 50.0
|
||||
|
||||
|
||||
def get_trailing_be_tick_buffer(get_setting: Callable[[str, str], str]) -> int:
|
||||
"""移动保本:止损移至开仓价 ± N 个最小变动价位(默认 2)。"""
|
||||
try:
|
||||
return max(1, min(20, int(float(get_setting("trailing_be_tick_buffer", "2") or 2))))
|
||||
except (TypeError, ValueError):
|
||||
return 2
|
||||
|
||||
|
||||
def get_pending_order_timeout_min(get_setting: Callable[[str, str], str]) -> int:
|
||||
"""开仓限价委托未成交自动撤单时间(分钟),默认 5。"""
|
||||
try:
|
||||
return max(1, min(60, int(float(get_setting("pending_order_timeout_min", "5") or 5))))
|
||||
except (TypeError, ValueError):
|
||||
return 5
|
||||
|
||||
|
||||
def get_pending_order_timeout_sec(get_setting: Callable[[str, str], str]) -> int:
|
||||
return get_pending_order_timeout_min(get_setting) * 60
|
||||
|
||||
|
||||
def _cached_ctp_account(mode: str) -> dict[str, float]:
|
||||
"""CTP 未连接时,用最近一次 worker/持仓快照里的账户权益。"""
|
||||
import json
|
||||
|
||||
try:
|
||||
from modules.trading.position_stream import position_hub
|
||||
|
||||
snap = position_hub.get_snapshot() or {}
|
||||
cap = float(snap.get("capital") or 0)
|
||||
if cap > 0:
|
||||
return {"balance": cap}
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from modules.core.db_conn import connect_db
|
||||
|
||||
conn = connect_db()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT value FROM ctp_worker_snapshots WHERE key='account' LIMIT 1"
|
||||
).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
if row and row["value"]:
|
||||
acc = json.loads(row["value"])
|
||||
balance = float(acc.get("balance") or 0)
|
||||
available = acc.get("available")
|
||||
out: dict[str, float] = {}
|
||||
if balance > 0:
|
||||
out["balance"] = balance
|
||||
if available is not None:
|
||||
out["available"] = float(available)
|
||||
return out
|
||||
except Exception:
|
||||
pass
|
||||
del mode
|
||||
return {}
|
||||
|
||||
|
||||
def _ctp_status_from_snapshot(mode: str) -> Optional[dict]:
|
||||
"""读持仓快照中的 CTP 状态,避免页面渲染同步 IPC。"""
|
||||
try:
|
||||
from modules.trading.position_stream import position_hub
|
||||
|
||||
snap = position_hub.get_snapshot() or {}
|
||||
st = snap.get("ctp_status")
|
||||
if isinstance(st, dict) and st:
|
||||
return st
|
||||
except Exception:
|
||||
pass
|
||||
del mode
|
||||
return None
|
||||
|
||||
|
||||
def get_account_capital(conn, get_setting: Callable[[str, str], str]) -> float:
|
||||
"""优先读持仓/Worker 快照权益;无快照时才同步问 CTP。"""
|
||||
del conn
|
||||
mode = get_trading_mode(get_setting)
|
||||
cached = _cached_ctp_account(mode)
|
||||
balance = float(cached.get("balance") or 0)
|
||||
if balance > 0:
|
||||
return balance
|
||||
try:
|
||||
from modules.ctp.vnpy_bridge import ctp_status, get_ctp_balance
|
||||
|
||||
st = ctp_status(mode)
|
||||
if st.get("connected"):
|
||||
bal = get_ctp_balance(mode)
|
||||
if bal and bal > 0:
|
||||
return float(bal)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
return float(get_setting("live_capital", "0") or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def get_recommend_capital(conn, get_setting: Callable[[str, str], str]) -> float:
|
||||
"""可开仓品种表用权益:已连接 CTP 用柜台权益,未连接固定 10 万。"""
|
||||
from modules.trading.product_recommend import DISCONNECTED_RECOMMEND_CAPITAL
|
||||
|
||||
if is_ctp_connected(get_setting):
|
||||
return get_account_capital(conn, get_setting)
|
||||
return float(DISCONNECTED_RECOMMEND_CAPITAL)
|
||||
|
||||
|
||||
def is_ctp_connected(get_setting: Callable[[str, str], str]) -> bool:
|
||||
"""当前交易模式(SimNow / 实盘)是否已连接 CTP。"""
|
||||
mode = get_trading_mode(get_setting)
|
||||
st = _ctp_status_from_snapshot(mode)
|
||||
if st is not None:
|
||||
return bool(st.get("connected"))
|
||||
try:
|
||||
from modules.ctp.vnpy_bridge import ctp_status
|
||||
|
||||
return bool(ctp_status(mode).get("connected"))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def trading_mode_label(get_setting: Callable[[str, str], str]) -> str:
|
||||
return "SimNow" if get_trading_mode(get_setting) == TRADING_MODE_SIM else "期货公司实盘"
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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, SHFE;SR609 → 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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
@@ -0,0 +1,5 @@
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
|
||||
from modules.fees.routes import register
|
||||
|
||||
__all__ = ["register"]
|
||||
@@ -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()
|
||||
@@ -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})"
|
||||
@@ -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")),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
|
||||
from modules.keys.routes import register
|
||||
|
||||
__all__ = ["register"]
|
||||
@@ -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)
|
||||
@@ -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"))
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
|
||||
from modules.market.routes import register
|
||||
|
||||
|
||||
def start_workers(deps) -> None:
|
||||
deps.start_background_threads()
|
||||
|
||||
|
||||
__all__ = ["register", "start_workers"]
|
||||
@@ -0,0 +1,558 @@
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
# 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
|
||||
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
||||
|
||||
"""复盘 K 线:新浪拉取 + matplotlib 生成截图。"""
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import requests
|
||||
|
||||
from modules.core.symbols import ths_to_codes
|
||||
from modules.core.db_conn import connect_db
|
||||
from modules.market.kline_store import ensure_kline_tables, get_cached_entry, save_bars
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
TZ = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
# CTP tick 聚合 bar 少于此数时,用新浪历史补齐走势
|
||||
MIN_CTP_KLINE_BARS = 15
|
||||
|
||||
PERIOD_MINUTES = {
|
||||
"1m": "1",
|
||||
"3m": "3",
|
||||
"5m": "5",
|
||||
"15m": "15",
|
||||
"30m": "30",
|
||||
"1h": "60",
|
||||
"4h": "240",
|
||||
}
|
||||
|
||||
MARKET_PERIODS = [
|
||||
{"key": "timeshare", "label": "分时"},
|
||||
{"key": "1m", "label": "1分"},
|
||||
{"key": "2m", "label": "2分"},
|
||||
{"key": "5m", "label": "5分"},
|
||||
{"key": "15m", "label": "15分"},
|
||||
{"key": "1h", "label": "1小时"},
|
||||
{"key": "2h", "label": "2小时"},
|
||||
{"key": "4h", "label": "4小时"},
|
||||
{"key": "d", "label": "日线"},
|
||||
{"key": "w", "label": "周线"},
|
||||
]
|
||||
|
||||
|
||||
def ths_to_sina_chart_symbol(symbol: str) -> Optional[str]:
|
||||
"""ag2608 -> AG2608(新浪 K 线接口合约代码)。"""
|
||||
code = (symbol or "").strip()
|
||||
if not code:
|
||||
return None
|
||||
codes = ths_to_codes(code)
|
||||
if codes:
|
||||
sina = codes.get("sina_code", "")
|
||||
if sina.startswith("nf_"):
|
||||
return sina[3:]
|
||||
if sina.startswith("CFF_RE_"):
|
||||
return sina[7:]
|
||||
ths = codes.get("ths_code", "")
|
||||
return ths.upper() if ths else None
|
||||
m = re.match(r"^([A-Za-z]+)(\d+)$", code)
|
||||
if m:
|
||||
return m.group(1).upper() + m.group(2)
|
||||
return None
|
||||
|
||||
|
||||
def _parse_jsonp(text: str) -> Optional[list]:
|
||||
m = re.search(r"\((.*)\)\s*;?\s*$", text.strip(), re.DOTALL)
|
||||
if not m:
|
||||
return None
|
||||
try:
|
||||
data = json.loads(m.group(1))
|
||||
return data if isinstance(data, list) else None
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
|
||||
def fetch_sina_klines(symbol: str, period: str) -> list:
|
||||
"""拉取新浪期货 K 线(原始 bar 列表)。"""
|
||||
chart_sym = ths_to_sina_chart_symbol(symbol)
|
||||
if not chart_sym:
|
||||
return []
|
||||
p = (period or "").lower()
|
||||
if p in ("1d", "d"):
|
||||
return _fetch_sina_daily(chart_sym)
|
||||
if p == "w":
|
||||
return _weekly_from_daily(_fetch_sina_daily(chart_sym))
|
||||
if p == "timeshare":
|
||||
bars = _fetch_few_min_line(chart_sym, "1")
|
||||
return _timeshare_session(bars)
|
||||
if p == "2m":
|
||||
return _aggregate_bars(_fetch_few_min_line(chart_sym, "1"), 2)
|
||||
if p == "2h":
|
||||
return _aggregate_bars(_fetch_few_min_line(chart_sym, "60"), 2)
|
||||
typ = PERIOD_MINUTES.get(p)
|
||||
if typ:
|
||||
return _fetch_few_min_line(chart_sym, typ)
|
||||
return []
|
||||
|
||||
|
||||
def _fetch_few_min_line(chart_sym: str, typ: str) -> list:
|
||||
ts = datetime.now(TZ).strftime("%Y%m%d%H%M%S")
|
||||
url = (
|
||||
"https://stock2.finance.sina.com.cn/futures/api/jsonp.php/"
|
||||
f"var_{chart_sym}_{typ}_{ts}=/InnerFuturesNewService.getFewMinLine"
|
||||
f"?symbol={chart_sym}&type={typ}"
|
||||
)
|
||||
try:
|
||||
resp = requests.get(
|
||||
url,
|
||||
timeout=20,
|
||||
headers={"Referer": "https://finance.sina.com.cn"},
|
||||
)
|
||||
bars = _parse_jsonp(resp.text)
|
||||
return _normalize_bars(bars or [])
|
||||
except Exception as exc:
|
||||
logger.warning("fetch kline failed %s %s: %s", chart_sym, typ, exc)
|
||||
return []
|
||||
|
||||
|
||||
def _normalize_bars(raw: list) -> list:
|
||||
out = []
|
||||
for row in raw:
|
||||
if isinstance(row, list) and len(row) >= 5:
|
||||
out.append({
|
||||
"d": str(row[0]),
|
||||
"o": float(row[1]),
|
||||
"h": float(row[2]),
|
||||
"l": float(row[3]),
|
||||
"c": float(row[4]),
|
||||
"v": float(row[5]) if len(row) > 5 and row[5] else 0.0,
|
||||
})
|
||||
elif isinstance(row, dict) and row.get("d"):
|
||||
out.append({
|
||||
"d": str(row["d"]),
|
||||
"o": float(row.get("o", 0) or 0),
|
||||
"h": float(row.get("h", 0) or 0),
|
||||
"l": float(row.get("l", 0) or 0),
|
||||
"c": float(row.get("c", 0) or 0),
|
||||
"v": float(row.get("v", 0) or 0),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _aggregate_bars(bars: list, n: int) -> list:
|
||||
if n <= 1 or not bars:
|
||||
return bars
|
||||
out = []
|
||||
chunk: list = []
|
||||
for bar in bars:
|
||||
chunk.append(bar)
|
||||
if len(chunk) >= n:
|
||||
out.append(_merge_bars(chunk))
|
||||
chunk = []
|
||||
if chunk:
|
||||
out.append(_merge_bars(chunk))
|
||||
return out
|
||||
|
||||
|
||||
def _merge_bars(chunk: list) -> dict:
|
||||
return {
|
||||
"d": chunk[0]["d"],
|
||||
"o": chunk[0]["o"],
|
||||
"h": max(b["h"] for b in chunk),
|
||||
"l": min(b["l"] for b in chunk),
|
||||
"c": chunk[-1]["c"],
|
||||
"v": sum(b.get("v", 0) for b in chunk),
|
||||
}
|
||||
|
||||
|
||||
def _merge_kline_bars(history: list, live: list) -> list:
|
||||
"""新浪历史 + CTP 实时尾部(去重叠)。"""
|
||||
if not history:
|
||||
return list(live or [])
|
||||
if not live:
|
||||
return list(history)
|
||||
first_live = _bar_datetime(live[0])
|
||||
if not first_live:
|
||||
return history + live
|
||||
trimmed = []
|
||||
for bar in history:
|
||||
dt = _bar_datetime(bar)
|
||||
if dt and dt < first_live:
|
||||
trimmed.append(bar)
|
||||
merged = trimmed + list(live)
|
||||
return merged if merged else list(history)
|
||||
|
||||
|
||||
def _weekly_from_daily(daily: list) -> list:
|
||||
if not daily:
|
||||
return []
|
||||
buckets: dict[tuple, list] = {}
|
||||
for bar in daily:
|
||||
dt = _bar_datetime(bar)
|
||||
if not dt:
|
||||
continue
|
||||
iso = dt.isocalendar()
|
||||
key = (iso[0], iso[1])
|
||||
buckets.setdefault(key, []).append(bar)
|
||||
out = []
|
||||
for key in sorted(buckets.keys()):
|
||||
chunk = buckets[key]
|
||||
out.append(_merge_bars(chunk))
|
||||
out[-1]["d"] = chunk[-1]["d"]
|
||||
return out
|
||||
|
||||
|
||||
def _timeshare_session(bars: list) -> list:
|
||||
if not bars:
|
||||
return []
|
||||
today = datetime.now(TZ).date()
|
||||
session = []
|
||||
for bar in bars:
|
||||
dt = _bar_datetime(bar)
|
||||
if dt and dt.date() == today:
|
||||
session.append(bar)
|
||||
if session:
|
||||
return session[-480:]
|
||||
return bars[-480:]
|
||||
|
||||
|
||||
def bars_to_api(bars: list) -> list[dict]:
|
||||
"""转为前端图表 JSON(去重、排序、数值规范化)。"""
|
||||
result: list[dict] = []
|
||||
seen: dict[int, dict] = {}
|
||||
for bar in bars:
|
||||
dt = _bar_datetime(bar)
|
||||
ts = int(dt.timestamp() * 1000) if dt else None
|
||||
try:
|
||||
o = float(bar.get("o") or 0)
|
||||
h = float(bar.get("h") or o)
|
||||
l = float(bar.get("l") or o)
|
||||
c = float(bar.get("c") or o)
|
||||
v = float(bar.get("v") or 0)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if h < l:
|
||||
h, l = l, h
|
||||
h = max(h, o, c)
|
||||
l = min(l, o, c)
|
||||
row = {
|
||||
"time": bar["d"],
|
||||
"timestamp": ts,
|
||||
"open": o,
|
||||
"high": h,
|
||||
"low": l,
|
||||
"close": c,
|
||||
"volume": v,
|
||||
}
|
||||
if ts is not None:
|
||||
seen[ts] = row
|
||||
else:
|
||||
result.append(row)
|
||||
if seen:
|
||||
result = [seen[k] for k in sorted(seen.keys())]
|
||||
return result
|
||||
|
||||
|
||||
def fetch_market_klines(
|
||||
symbol: str,
|
||||
period: str,
|
||||
db_path: Optional[str] = None,
|
||||
force_remote: bool = False,
|
||||
*,
|
||||
trading_mode: Optional[str] = None,
|
||||
prefer_ctp: bool = False,
|
||||
) -> dict:
|
||||
chart_sym = ths_to_sina_chart_symbol(symbol)
|
||||
p = (period or "15m").lower()
|
||||
if p == "timeshare":
|
||||
chart_type = "line"
|
||||
else:
|
||||
chart_type = "candle"
|
||||
|
||||
bars: list = []
|
||||
source = "remote"
|
||||
cached_at = None
|
||||
ctp_connected = False
|
||||
ctp_bars: list = []
|
||||
|
||||
if prefer_ctp:
|
||||
try:
|
||||
from modules.ctp.ctp_kline import fetch_ctp_klines
|
||||
from modules.ctp.vnpy_bridge import ctp_status
|
||||
|
||||
mode = trading_mode
|
||||
if not mode:
|
||||
try:
|
||||
from app import get_setting
|
||||
from modules.core.trading_context import get_trading_mode
|
||||
|
||||
mode = get_trading_mode(get_setting)
|
||||
except Exception:
|
||||
mode = "simulation"
|
||||
ctp_connected = bool(ctp_status(mode).get("connected"))
|
||||
if ctp_connected:
|
||||
ctp_bars = fetch_ctp_klines(symbol, p, mode) or []
|
||||
except Exception as exc:
|
||||
logger.debug("ctp kline fetch failed %s %s: %s", symbol, p, exc)
|
||||
|
||||
need_sina = force_remote or not prefer_ctp or not ctp_bars or len(ctp_bars) < MIN_CTP_KLINE_BARS
|
||||
|
||||
if ctp_bars and len(ctp_bars) >= MIN_CTP_KLINE_BARS:
|
||||
bars = ctp_bars
|
||||
source = "ctp"
|
||||
|
||||
if not bars and db_path and chart_sym and not force_remote and need_sina:
|
||||
try:
|
||||
conn = connect_db(db_path)
|
||||
cached = get_cached_entry(conn, chart_sym, p)
|
||||
conn.close()
|
||||
if cached and cached.get("fresh"):
|
||||
bars = cached["bars"]
|
||||
source = "local"
|
||||
cached_at = cached.get("updated_at")
|
||||
except Exception as exc:
|
||||
logger.warning("kline cache read failed %s %s: %s", chart_sym, p, exc)
|
||||
|
||||
if not bars or len(ctp_bars) < MIN_CTP_KLINE_BARS or not prefer_ctp:
|
||||
remote_bars = fetch_sina_klines(symbol, p)
|
||||
if remote_bars:
|
||||
if prefer_ctp and ctp_bars and ctp_connected:
|
||||
bars = _merge_kline_bars(remote_bars, ctp_bars)
|
||||
source = "ctp+remote"
|
||||
else:
|
||||
bars = remote_bars
|
||||
source = "remote"
|
||||
if db_path and chart_sym and not ctp_connected:
|
||||
try:
|
||||
conn = connect_db(db_path)
|
||||
ensure_kline_tables(conn)
|
||||
save_bars(conn, chart_sym, p, remote_bars)
|
||||
meta = conn.execute(
|
||||
"SELECT updated_at FROM kline_meta WHERE chart_symbol=? AND period=?",
|
||||
(chart_sym, p),
|
||||
).fetchone()
|
||||
conn.close()
|
||||
cached_at = meta[0] if meta else None
|
||||
except Exception as exc:
|
||||
logger.warning("kline cache write failed %s %s: %s", chart_sym, p, exc)
|
||||
elif not bars and db_path and chart_sym:
|
||||
try:
|
||||
conn = connect_db(db_path)
|
||||
cached = get_cached_entry(conn, chart_sym, p)
|
||||
conn.close()
|
||||
if cached and cached.get("bars"):
|
||||
bars = cached["bars"]
|
||||
source = "local"
|
||||
cached_at = cached.get("updated_at")
|
||||
except Exception as exc:
|
||||
logger.warning("kline cache fallback failed %s %s: %s", chart_sym, p, exc)
|
||||
|
||||
api_bars = bars_to_api(bars)
|
||||
prev_close = None
|
||||
if len(api_bars) >= 2:
|
||||
prev_close = api_bars[-2]["close"]
|
||||
|
||||
return {
|
||||
"symbol": symbol,
|
||||
"chart_symbol": chart_sym,
|
||||
"period": p,
|
||||
"chart_type": chart_type,
|
||||
"count": len(bars),
|
||||
"bars": api_bars,
|
||||
"prev_close": prev_close,
|
||||
"source": source,
|
||||
"cached_at": cached_at,
|
||||
"ctp_connected": ctp_connected,
|
||||
}
|
||||
|
||||
|
||||
def _fetch_sina_daily(chart_sym: str) -> list:
|
||||
url = (
|
||||
"https://stock2.finance.sina.com.cn/futures/api/json.php/"
|
||||
f"IndexService.getInnerFuturesDailyKLine?symbol={chart_sym}"
|
||||
)
|
||||
try:
|
||||
resp = requests.get(url, timeout=20, headers={"Referer": "https://finance.sina.com.cn"})
|
||||
raw = resp.json()
|
||||
if raw and isinstance(raw, list):
|
||||
bars = _normalize_bars(raw)
|
||||
if bars:
|
||||
return bars
|
||||
except Exception as exc:
|
||||
logger.warning("fetch daily kline failed %s: %s", chart_sym, exc)
|
||||
return _daily_from_minutes(chart_sym)
|
||||
|
||||
|
||||
def _daily_from_minutes(chart_sym: str) -> list:
|
||||
"""合约日线接口无数据时,由 60 分钟 K 线按日合成。"""
|
||||
bars_60 = _fetch_few_min_line(chart_sym, "60")
|
||||
if not bars_60:
|
||||
bars_60 = _fetch_few_min_line(chart_sym, "240")
|
||||
buckets: dict[str, list] = {}
|
||||
for bar in bars_60:
|
||||
dt = _bar_datetime(bar)
|
||||
if not dt:
|
||||
continue
|
||||
key = dt.strftime("%Y-%m-%d")
|
||||
buckets.setdefault(key, []).append(bar)
|
||||
out = []
|
||||
for day in sorted(buckets.keys()):
|
||||
chunk = buckets[day]
|
||||
merged = _merge_bars(chunk)
|
||||
merged["d"] = day + " 15:00:00"
|
||||
out.append(merged)
|
||||
return out
|
||||
|
||||
|
||||
def _parse_dt(value: str) -> Optional[datetime]:
|
||||
if not value:
|
||||
return None
|
||||
v = value.strip().replace("T", " ")
|
||||
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M"):
|
||||
try:
|
||||
return datetime.strptime(v, fmt).replace(tzinfo=TZ)
|
||||
except ValueError:
|
||||
continue
|
||||
try:
|
||||
return datetime.fromisoformat(value.strip()).replace(tzinfo=TZ)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _bar_datetime(bar: dict) -> Optional[datetime]:
|
||||
d = bar.get("d")
|
||||
if not d:
|
||||
return None
|
||||
try:
|
||||
return datetime.strptime(d, "%Y-%m-%d %H:%M:%S").replace(tzinfo=TZ)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _select_bars(
|
||||
bars: list,
|
||||
cutoff: datetime,
|
||||
count: int,
|
||||
) -> list:
|
||||
filtered = []
|
||||
for bar in bars:
|
||||
dt = _bar_datetime(bar)
|
||||
if dt and dt <= cutoff:
|
||||
filtered.append(bar)
|
||||
if not filtered:
|
||||
filtered = bars
|
||||
if count > 0 and len(filtered) > count:
|
||||
filtered = filtered[-count:]
|
||||
return filtered
|
||||
|
||||
|
||||
def generate_review_kline_chart(
|
||||
symbol: str,
|
||||
periods: list[str],
|
||||
count: int,
|
||||
cutoff_label: str,
|
||||
open_time: str,
|
||||
close_time: str,
|
||||
entry_price: Optional[float],
|
||||
stop_loss: Optional[float],
|
||||
take_profit: Optional[float],
|
||||
close_price: Optional[float],
|
||||
upload_dir: str,
|
||||
) -> Optional[str]:
|
||||
"""生成双周期 K 线复盘图,返回 uploads 目录下的文件名。"""
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
import matplotlib.dates as mdates
|
||||
|
||||
now = datetime.now(TZ)
|
||||
if cutoff_label == "开仓时间":
|
||||
cutoff = _parse_dt(open_time) or now
|
||||
elif cutoff_label == "当前时间":
|
||||
cutoff = now
|
||||
else:
|
||||
cutoff = _parse_dt(close_time) or now
|
||||
|
||||
open_dt = _parse_dt(open_time)
|
||||
close_dt = _parse_dt(close_time)
|
||||
|
||||
valid_periods = [p for p in periods if p]
|
||||
if not valid_periods:
|
||||
valid_periods = ["15m", "1h"]
|
||||
|
||||
fig, axes = plt.subplots(
|
||||
len(valid_periods), 1,
|
||||
figsize=(14, 4.5 * len(valid_periods)),
|
||||
facecolor="#0a0a10",
|
||||
squeeze=False,
|
||||
)
|
||||
|
||||
plotted = False
|
||||
for idx, period in enumerate(valid_periods):
|
||||
ax = axes[idx, 0]
|
||||
bars = fetch_sina_klines(symbol, period)
|
||||
bars = _select_bars(bars, cutoff, count)
|
||||
if not bars:
|
||||
ax.set_facecolor("#12121a")
|
||||
ax.text(0.5, 0.5, f"No {period} data", ha="center", va="center", color="#888")
|
||||
ax.set_xticks([])
|
||||
ax.set_yticks([])
|
||||
continue
|
||||
|
||||
times = [_bar_datetime(b) for b in bars]
|
||||
closes = [float(b["c"]) for b in bars]
|
||||
highs = [float(b["h"]) for b in bars]
|
||||
lows = [float(b["l"]) for b in bars]
|
||||
|
||||
ax.set_facecolor("#12121a")
|
||||
ax.plot(times, closes, color="#4cc2ff", linewidth=1.2)
|
||||
ax.fill_between(
|
||||
times, lows, highs,
|
||||
color="#4cc2ff", alpha=0.12,
|
||||
)
|
||||
|
||||
levels = [
|
||||
(entry_price, "#eac147", "Entry"),
|
||||
(stop_loss, "#ff6666", "SL"),
|
||||
(take_profit, "#4cd97f", "TP"),
|
||||
(close_price, "#c4c4ff", "Close"),
|
||||
]
|
||||
for price, color, label in levels:
|
||||
if price is not None:
|
||||
ax.axhline(price, color=color, linewidth=0.9, linestyle="--", alpha=0.85)
|
||||
ax.text(times[-1], price, label, color=color, fontsize=8, va="bottom")
|
||||
|
||||
if open_dt:
|
||||
ax.axvline(open_dt, color="#888", linewidth=0.8, linestyle=":", alpha=0.7)
|
||||
if close_dt:
|
||||
ax.axvline(close_dt, color="#aaa", linewidth=0.8, linestyle=":", alpha=0.7)
|
||||
|
||||
chart_sym = ths_to_sina_chart_symbol(symbol) or symbol
|
||||
ax.set_title(f"{chart_sym} {period}", color="#eaeaea", fontsize=11, pad=8)
|
||||
ax.tick_params(colors="#888", labelsize=8)
|
||||
for spine in ax.spines.values():
|
||||
spine.set_color("#2e2e45")
|
||||
ax.xaxis.set_major_formatter(mdates.DateFormatter("%m-%d %H:%M"))
|
||||
ax.grid(True, color="#1e1e30", linewidth=0.5)
|
||||
plotted = True
|
||||
|
||||
if not plotted:
|
||||
plt.close(fig)
|
||||
return None
|
||||
|
||||
fig.tight_layout()
|
||||
ts = datetime.now(TZ).strftime("%Y%m%d%H%M%S")
|
||||
chart_sym = ths_to_sina_chart_symbol(symbol) or "chart"
|
||||
filename = f"{ts}_kline_{chart_sym}.png"
|
||||
path = os.path.join(upload_dir, filename)
|
||||
fig.savefig(path, dpi=120, facecolor=fig.get_facecolor())
|
||||
plt.close(fig)
|
||||
return filename
|
||||
@@ -0,0 +1,175 @@
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
# 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
|
||||
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
||||
|
||||
"""K 线本地 SQLite 缓存。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
TZ = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
REFRESH_SECONDS = {
|
||||
"timeshare": 30,
|
||||
"1m": 30,
|
||||
"2m": 30,
|
||||
"5m": 60,
|
||||
"15m": 60,
|
||||
"1h": 120,
|
||||
"2h": 120,
|
||||
"4h": 180,
|
||||
"d": 300,
|
||||
"w": 600,
|
||||
}
|
||||
|
||||
|
||||
def ensure_kline_tables(conn: sqlite3.Connection) -> None:
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS kline_bars (
|
||||
chart_symbol TEXT NOT NULL,
|
||||
period TEXT NOT NULL,
|
||||
bar_time TEXT NOT NULL,
|
||||
open REAL NOT NULL,
|
||||
high REAL NOT NULL,
|
||||
low REAL NOT NULL,
|
||||
close REAL NOT NULL,
|
||||
volume REAL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (chart_symbol, period, bar_time)
|
||||
)"""
|
||||
)
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS kline_meta (
|
||||
chart_symbol TEXT NOT NULL,
|
||||
period TEXT NOT NULL,
|
||||
bar_count INTEGER DEFAULT 0,
|
||||
last_bar_time TEXT,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (chart_symbol, period)
|
||||
)"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_kline_bars_sym_period "
|
||||
"ON kline_bars(chart_symbol, period, bar_time)"
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _parse_updated_at(value: str) -> Optional[datetime]:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(value.strip()).replace(tzinfo=TZ)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def is_cache_fresh(period: str, updated_at: str) -> bool:
|
||||
dt = _parse_updated_at(updated_at)
|
||||
if not dt:
|
||||
return False
|
||||
ttl = REFRESH_SECONDS.get((period or "").lower(), 60)
|
||||
return datetime.now(TZ) - dt < timedelta(seconds=ttl)
|
||||
|
||||
|
||||
def load_bars(conn: sqlite3.Connection, chart_symbol: str, period: str) -> list[dict]:
|
||||
rows = conn.execute(
|
||||
"""SELECT bar_time, open, high, low, close, volume
|
||||
FROM kline_bars
|
||||
WHERE chart_symbol=? AND period=?
|
||||
ORDER BY bar_time ASC""",
|
||||
(chart_symbol, period),
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"d": row[0],
|
||||
"o": float(row[1]),
|
||||
"h": float(row[2]),
|
||||
"l": float(row[3]),
|
||||
"c": float(row[4]),
|
||||
"v": float(row[5] or 0),
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def load_meta(conn: sqlite3.Connection, chart_symbol: str, period: str) -> Optional[dict]:
|
||||
row = conn.execute(
|
||||
"SELECT bar_count, last_bar_time, updated_at FROM kline_meta "
|
||||
"WHERE chart_symbol=? AND period=?",
|
||||
(chart_symbol, period),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
"bar_count": row[0],
|
||||
"last_bar_time": row[1],
|
||||
"updated_at": row[2],
|
||||
}
|
||||
|
||||
|
||||
def save_bars(conn: sqlite3.Connection, chart_symbol: str, period: str, bars: list[dict]) -> int:
|
||||
if not bars:
|
||||
return 0
|
||||
ensure_kline_tables(conn)
|
||||
now = datetime.now(TZ).isoformat(timespec="seconds")
|
||||
for bar in bars:
|
||||
conn.execute(
|
||||
"""INSERT INTO kline_bars
|
||||
(chart_symbol, period, bar_time, open, high, low, close, volume, updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(chart_symbol, period, bar_time) DO UPDATE SET
|
||||
open=excluded.open,
|
||||
high=excluded.high,
|
||||
low=excluded.low,
|
||||
close=excluded.close,
|
||||
volume=excluded.volume,
|
||||
updated_at=excluded.updated_at""",
|
||||
(
|
||||
chart_symbol,
|
||||
period,
|
||||
str(bar["d"]),
|
||||
float(bar["o"]),
|
||||
float(bar["h"]),
|
||||
float(bar["l"]),
|
||||
float(bar["c"]),
|
||||
float(bar.get("v") or 0),
|
||||
now,
|
||||
),
|
||||
)
|
||||
last_time = str(bars[-1]["d"])
|
||||
conn.execute(
|
||||
"""INSERT INTO kline_meta (chart_symbol, period, bar_count, last_bar_time, updated_at)
|
||||
VALUES (?,?,?,?,?)
|
||||
ON CONFLICT(chart_symbol, period) DO UPDATE SET
|
||||
bar_count=excluded.bar_count,
|
||||
last_bar_time=excluded.last_bar_time,
|
||||
updated_at=excluded.updated_at""",
|
||||
(chart_symbol, period, len(bars), last_time, now),
|
||||
)
|
||||
conn.commit()
|
||||
return len(bars)
|
||||
|
||||
|
||||
def get_cached_entry(
|
||||
conn: sqlite3.Connection,
|
||||
chart_symbol: str,
|
||||
period: str,
|
||||
) -> Optional[dict]:
|
||||
if not chart_symbol:
|
||||
return None
|
||||
ensure_kline_tables(conn)
|
||||
meta = load_meta(conn, chart_symbol, period)
|
||||
bars = load_bars(conn, chart_symbol, period)
|
||||
if not bars:
|
||||
return None
|
||||
updated_at = meta["updated_at"] if meta else ""
|
||||
return {
|
||||
"bars": bars,
|
||||
"updated_at": updated_at,
|
||||
"fresh": is_cache_fresh(period, updated_at),
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
# 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
|
||||
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
||||
|
||||
"""K 线 SSE 推送与后台刷新。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Callable, Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from modules.market.kline_chart import fetch_market_klines, ths_to_sina_chart_symbol
|
||||
from modules.market.kline_store import is_cache_fresh, load_meta, ensure_kline_tables
|
||||
from modules.market.market_sessions import is_trading_session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
TZ = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
FAST_PERIODS = frozenset({
|
||||
"timeshare", "1m", "2m", "5m", "15m", "1h", "2h", "4h",
|
||||
})
|
||||
|
||||
|
||||
def sse_format(event: str, data: dict) -> str:
|
||||
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False, default=str)}\n\n"
|
||||
|
||||
|
||||
@dataclass
|
||||
class KlineSubscription:
|
||||
symbol: str
|
||||
period: str
|
||||
market_code: str = ""
|
||||
sina_code: str = ""
|
||||
queue: queue.Queue = field(default_factory=queue.Queue)
|
||||
|
||||
|
||||
class KlineStreamHub:
|
||||
def __init__(self):
|
||||
self._lock = threading.Lock()
|
||||
self._subs: list[KlineSubscription] = []
|
||||
|
||||
def subscribe(
|
||||
self,
|
||||
symbol: str,
|
||||
period: str,
|
||||
market_code: str = "",
|
||||
sina_code: str = "",
|
||||
) -> KlineSubscription:
|
||||
sub = KlineSubscription(
|
||||
symbol=symbol.strip(),
|
||||
period=(period or "15m").strip().lower(),
|
||||
market_code=market_code.strip(),
|
||||
sina_code=sina_code.strip(),
|
||||
)
|
||||
with self._lock:
|
||||
self._subs.append(sub)
|
||||
return sub
|
||||
|
||||
def unsubscribe(self, sub: KlineSubscription) -> None:
|
||||
with self._lock:
|
||||
try:
|
||||
self._subs.remove(sub)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def _snapshot_subs(self) -> list[KlineSubscription]:
|
||||
with self._lock:
|
||||
return list(self._subs)
|
||||
|
||||
def publish(self, sub: KlineSubscription, event: str, data: dict) -> None:
|
||||
try:
|
||||
sub.queue.put_nowait({"event": event, "data": data})
|
||||
except queue.Full:
|
||||
pass
|
||||
|
||||
def _should_refresh(self, sub: KlineSubscription, db_path: str) -> bool:
|
||||
chart_sym = ths_to_sina_chart_symbol(sub.symbol)
|
||||
if not chart_sym:
|
||||
return False
|
||||
if is_trading_session() and sub.period in FAST_PERIODS:
|
||||
return True
|
||||
try:
|
||||
from modules.core.db_conn import connect_db
|
||||
conn = connect_db(db_path)
|
||||
ensure_kline_tables(conn)
|
||||
meta = load_meta(conn, chart_sym, sub.period)
|
||||
conn.close()
|
||||
if not meta:
|
||||
return True
|
||||
return not is_cache_fresh(sub.period, meta.get("updated_at", ""))
|
||||
except Exception as exc:
|
||||
logger.warning("kline refresh check failed: %s", exc)
|
||||
return True
|
||||
|
||||
def worker_loop(
|
||||
self,
|
||||
db_path: str,
|
||||
quote_fn: Callable[..., dict],
|
||||
get_mode_fn: Optional[Callable[[], str]] = None,
|
||||
) -> None:
|
||||
while True:
|
||||
try:
|
||||
subs = self._snapshot_subs()
|
||||
for sub in subs:
|
||||
if not self._should_refresh(sub, db_path):
|
||||
continue
|
||||
try:
|
||||
kline_data = fetch_market_klines(
|
||||
sub.symbol,
|
||||
sub.period,
|
||||
db_path,
|
||||
force_remote=True,
|
||||
prefer_ctp=False,
|
||||
)
|
||||
if kline_data.get("bars"):
|
||||
self.publish(sub, "kline", kline_data)
|
||||
quote_data = quote_fn(
|
||||
sub.symbol, sub.market_code, sub.sina_code,
|
||||
)
|
||||
if quote_data:
|
||||
self.publish(sub, "quote", quote_data)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"kline stream refresh %s %s: %s",
|
||||
sub.symbol, sub.period, exc,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("kline stream worker: %s", exc)
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
kline_hub = KlineStreamHub()
|
||||
@@ -0,0 +1,248 @@
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
# 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
|
||||
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
||||
|
||||
"""
|
||||
行情拉取:默认新浪(免费,普通用户可用)。
|
||||
同花顺 iFinD HTTP 仅面向机构用户,需单独申请 token,可选开启。
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
THS_TOKEN_URL = "https://quantapi.51ifind.com/api/v1/get_access_token"
|
||||
THS_QUOTE_URL = "https://quantapi.51ifind.com/api/v1/real_time_quotation"
|
||||
|
||||
# iFinD HTTP 期货交易所后缀
|
||||
THS_EX_SUFFIX = {
|
||||
"SHFE": "SHFE",
|
||||
"DCE": "DCE",
|
||||
"CZCE": "CZCE",
|
||||
"CFFEX": "CFFEX",
|
||||
"INE": "INE",
|
||||
}
|
||||
|
||||
_token_cache: dict = {"token": "", "expires": 0.0, "refresh": ""}
|
||||
|
||||
|
||||
def _quote_source() -> str:
|
||||
return os.getenv("QUOTE_SOURCE", "sina").strip().lower()
|
||||
|
||||
|
||||
def _has_ths_token() -> bool:
|
||||
return bool(_get_refresh_token())
|
||||
|
||||
|
||||
def get_quote_source_label(*, ctp_connected: bool = False) -> str:
|
||||
"""界面展示用行情源说明。"""
|
||||
if ctp_connected:
|
||||
return "CTP 柜台(已连接)"
|
||||
source = _quote_source()
|
||||
if source == "sina":
|
||||
return "新浪(CTP 未连接时备用)"
|
||||
if source == "ths":
|
||||
return "同花顺 iFinD" if _has_ths_token() else "同花顺(未配置 token)"
|
||||
if _has_ths_token():
|
||||
return "同花顺优先,失败回退新浪"
|
||||
return "新浪(CTP 未连接时备用)"
|
||||
|
||||
|
||||
def _sina_headers() -> dict:
|
||||
return {"Referer": "https://finance.sina.com.cn"}
|
||||
|
||||
|
||||
def _parse_sina_futures_quote(parts: list) -> Optional[dict]:
|
||||
"""解析新浪 nf_/CFF_RE_ 期货行情字段。"""
|
||||
if len(parts) < 9:
|
||||
return None
|
||||
price = None
|
||||
for idx in (8, 7, 6, 5):
|
||||
if len(parts) > idx and parts[idx]:
|
||||
try:
|
||||
val = float(parts[idx])
|
||||
if val > 0:
|
||||
price = val
|
||||
break
|
||||
except ValueError:
|
||||
pass
|
||||
if price is None:
|
||||
price = 0.0
|
||||
|
||||
open_interest = 0.0
|
||||
volume = 0.0
|
||||
if len(parts) > 13 and parts[13]:
|
||||
try:
|
||||
open_interest = float(parts[13])
|
||||
except ValueError:
|
||||
pass
|
||||
if len(parts) > 14 and parts[14]:
|
||||
try:
|
||||
volume = float(parts[14])
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
prev_close = None
|
||||
if len(parts) > 9 and parts[9]:
|
||||
try:
|
||||
prev_close = float(parts[9])
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return {
|
||||
"name": parts[0],
|
||||
"price": price,
|
||||
"volume": volume,
|
||||
"open_interest": open_interest,
|
||||
"prev_close": prev_close,
|
||||
}
|
||||
|
||||
|
||||
def _fetch_sina_raw(sina_code: str) -> Optional[dict]:
|
||||
try:
|
||||
url = f"https://hq.sinajs.cn/list={sina_code}"
|
||||
resp = requests.get(url, headers=_sina_headers(), timeout=5)
|
||||
resp.encoding = "gbk"
|
||||
if '"' not in resp.text:
|
||||
return None
|
||||
body = resp.text.split('"')[1]
|
||||
if not body:
|
||||
return None
|
||||
parts = body.split(",")
|
||||
return _parse_sina_futures_quote(parts)
|
||||
except Exception as exc:
|
||||
logger.debug("sina fetch failed %s: %s", sina_code, exc)
|
||||
return None
|
||||
|
||||
|
||||
def get_sina_price(sina_code: str) -> Optional[float]:
|
||||
raw = _fetch_sina_raw(sina_code)
|
||||
return raw["price"] if raw else None
|
||||
|
||||
|
||||
_runtime_refresh_token: str = ""
|
||||
|
||||
|
||||
def set_ths_refresh_token(token: str):
|
||||
global _runtime_refresh_token
|
||||
_runtime_refresh_token = (token or "").strip()
|
||||
|
||||
|
||||
def _get_refresh_token() -> str:
|
||||
if _runtime_refresh_token:
|
||||
return _runtime_refresh_token
|
||||
return os.getenv("THS_REFRESH_TOKEN", "").strip()
|
||||
|
||||
|
||||
def _get_ths_access_token(refresh_token: str) -> Optional[str]:
|
||||
if not refresh_token:
|
||||
return None
|
||||
now = time.time()
|
||||
if (
|
||||
_token_cache["token"]
|
||||
and _token_cache["refresh"] == refresh_token
|
||||
and now < _token_cache["expires"]
|
||||
):
|
||||
return _token_cache["token"]
|
||||
try:
|
||||
resp = requests.post(
|
||||
THS_TOKEN_URL,
|
||||
headers={"Content-Type": "application/json", "refresh_token": refresh_token},
|
||||
timeout=10,
|
||||
)
|
||||
data = resp.json()
|
||||
if data.get("errorcode") != 0:
|
||||
logger.warning("THS token error: %s", data.get("errmsg"))
|
||||
return None
|
||||
access = data["data"]["access_token"]
|
||||
_token_cache.update({
|
||||
"token": access,
|
||||
"refresh": refresh_token,
|
||||
"expires": now + 3600 * 6,
|
||||
})
|
||||
return access
|
||||
except Exception as exc:
|
||||
logger.warning("THS token request failed: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def _parse_ths_quote(data: dict) -> Optional[float]:
|
||||
"""从同花顺实时行情响应解析最新价。"""
|
||||
try:
|
||||
tables = data.get("tables") or []
|
||||
for table in tables:
|
||||
t = table.get("table") or {}
|
||||
for key in ("latest", "new", "close", "trade", "last"):
|
||||
val = t.get(key)
|
||||
if val is None:
|
||||
continue
|
||||
if isinstance(val, list) and val:
|
||||
return float(val[0])
|
||||
if isinstance(val, (int, float, str)) and str(val):
|
||||
return float(val)
|
||||
# 部分响应嵌套在 data 字段
|
||||
if "data" in data and isinstance(data["data"], dict):
|
||||
return _parse_ths_quote(data["data"])
|
||||
except Exception as exc:
|
||||
logger.debug("parse ths quote failed: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def get_ths_price(ths_full_code: str, refresh_token: str = "") -> Optional[float]:
|
||||
"""ths_full_code 如 ag2608.SHFE、IF2606.CFFEX"""
|
||||
token = refresh_token or _get_refresh_token()
|
||||
access = _get_ths_access_token(token)
|
||||
if not access:
|
||||
return None
|
||||
try:
|
||||
resp = requests.post(
|
||||
THS_QUOTE_URL,
|
||||
headers={"Content-Type": "application/json", "access_token": access},
|
||||
json={"codes": ths_full_code, "indicators": "latest"},
|
||||
timeout=10,
|
||||
)
|
||||
data = resp.json()
|
||||
if data.get("errorcode") != 0:
|
||||
logger.warning("THS quote error %s: %s", ths_full_code, data.get("errmsg"))
|
||||
return None
|
||||
return _parse_ths_quote(data)
|
||||
except Exception as exc:
|
||||
logger.warning("THS quote failed %s: %s", ths_full_code, exc)
|
||||
return None
|
||||
|
||||
|
||||
def get_price(market_code: str, sina_fallback: str = "") -> Optional[float]:
|
||||
"""
|
||||
统一取价入口。
|
||||
sina_fallback: 新浪代码 nf_AG2608(普通用户默认使用)
|
||||
market_code: 同花顺完整代码 ag2608.SHFE(仅机构 token 可用时)
|
||||
"""
|
||||
source = _quote_source()
|
||||
|
||||
# 仅在有 token 且配置为 ths/auto 时才尝试同花顺
|
||||
use_ths = source == "ths" or (source == "auto" and _has_ths_token())
|
||||
if use_ths and market_code and "." in market_code:
|
||||
price = get_ths_price(market_code)
|
||||
if price is not None:
|
||||
return price
|
||||
if source == "ths":
|
||||
return None
|
||||
|
||||
if sina_fallback:
|
||||
return get_sina_price(sina_fallback)
|
||||
|
||||
if market_code.startswith("nf_") or market_code.startswith("CFF_RE_"):
|
||||
return get_sina_price(market_code)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def fetch_raw_for_volume(sina_code: str) -> Optional[dict]:
|
||||
"""主力合约扫描用(成交量),走新浪。"""
|
||||
return _fetch_sina_raw(sina_code)
|
||||
@@ -0,0 +1,287 @@
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
# 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
|
||||
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
||||
|
||||
"""国内期货交易时段与盘前连接窗口。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
TZ = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
# 各交易段开盘时刻 (时, 分)
|
||||
SESSION_OPENS = (
|
||||
(9, 0),
|
||||
(10, 30), # 上午小节休息后续盘
|
||||
(13, 30),
|
||||
(21, 0),
|
||||
)
|
||||
|
||||
# 日盘各连续交易段 (start_h, start_m, end_h, end_m),左闭右开
|
||||
_DAY_SEGMENTS = (
|
||||
(9, 0, 10, 15),
|
||||
(10, 30, 11, 30),
|
||||
(13, 30, 15, 0),
|
||||
)
|
||||
|
||||
|
||||
def _normalize_dt(now: Optional[datetime] = None) -> datetime:
|
||||
d = now or datetime.now(TZ)
|
||||
if d.tzinfo is None:
|
||||
return d.replace(tzinfo=TZ)
|
||||
return d.astimezone(TZ)
|
||||
|
||||
|
||||
def _minutes_of_day(d: datetime) -> int:
|
||||
return d.hour * 60 + d.minute
|
||||
|
||||
|
||||
def _in_time_range(t: int, sh: int, sm: int, eh: int, em: int) -> bool:
|
||||
return t >= sh * 60 + sm and t < eh * 60 + em
|
||||
|
||||
|
||||
def is_trading_session(now: Optional[datetime] = None) -> bool:
|
||||
d = _normalize_dt(now)
|
||||
wd = d.weekday()
|
||||
if wd == 6:
|
||||
return False
|
||||
if wd == 5 and d.hour < 21:
|
||||
return False
|
||||
t = _minutes_of_day(d)
|
||||
for sh, sm, eh, em in _DAY_SEGMENTS:
|
||||
if _in_time_range(t, sh, sm, eh, em):
|
||||
return True
|
||||
if _in_time_range(t, 21, 0, 24, 0):
|
||||
return True
|
||||
if _in_time_range(t, 0, 0, 2, 30):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def is_morning_break(now: Optional[datetime] = None) -> bool:
|
||||
"""10:15–10:30 上午小节休息。"""
|
||||
d = _normalize_dt(now)
|
||||
if d.weekday() >= 5:
|
||||
return False
|
||||
t = _minutes_of_day(d)
|
||||
return _in_time_range(t, 10, 15, 10, 30)
|
||||
|
||||
|
||||
def is_lunch_break(now: Optional[datetime] = None) -> bool:
|
||||
"""11:30–13:30 午间休盘。"""
|
||||
d = _normalize_dt(now)
|
||||
if d.weekday() >= 5:
|
||||
return False
|
||||
t = _minutes_of_day(d)
|
||||
return _in_time_range(t, 11, 30, 13, 30)
|
||||
|
||||
|
||||
def is_night_trading_session(now: Optional[datetime] = None) -> bool:
|
||||
"""当前是否处于夜盘时段(21:00–02:30,且整体仍在交易时段内)。"""
|
||||
if not is_trading_session(now):
|
||||
return False
|
||||
d = _normalize_dt(now)
|
||||
t = _minutes_of_day(d)
|
||||
return t >= 21 * 60 or t < 2 * 60 + 30
|
||||
|
||||
|
||||
def _session_open_allowed(day: datetime, hour: int, minute: int) -> bool:
|
||||
wd = day.weekday()
|
||||
if (hour, minute) in ((9, 0), (10, 30), (13, 30)):
|
||||
return wd < 5
|
||||
if (hour, minute) == (21, 0):
|
||||
if wd < 5:
|
||||
return True
|
||||
return wd == 5
|
||||
return False
|
||||
|
||||
|
||||
def iter_session_starts(
|
||||
start: datetime,
|
||||
*,
|
||||
hours_ahead: int = 36,
|
||||
) -> list[datetime]:
|
||||
"""列出 start 之后若干小时内的各段开盘时刻。"""
|
||||
if start.tzinfo is None:
|
||||
start = start.replace(tzinfo=TZ)
|
||||
else:
|
||||
start = start.astimezone(TZ)
|
||||
end = start + timedelta(hours=hours_ahead)
|
||||
out: list[datetime] = []
|
||||
day = start.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
while day <= end:
|
||||
for h, m in SESSION_OPENS:
|
||||
if not _session_open_allowed(day, h, m):
|
||||
continue
|
||||
dt = day.replace(hour=h, minute=m)
|
||||
if dt > start and dt <= end:
|
||||
out.append(dt)
|
||||
day += timedelta(days=1)
|
||||
out.sort()
|
||||
return out
|
||||
|
||||
|
||||
def minutes_until_next_session(now: Optional[datetime] = None) -> Optional[float]:
|
||||
d = _normalize_dt(now)
|
||||
starts = iter_session_starts(d, hours_ahead=48)
|
||||
if not starts:
|
||||
return None
|
||||
return (starts[0] - d).total_seconds() / 60.0
|
||||
|
||||
|
||||
def _session_open_label(dt: datetime) -> str:
|
||||
h, m = dt.hour, dt.minute
|
||||
if (h, m) == (9, 0):
|
||||
return "日盘开盘"
|
||||
if (h, m) == (10, 30):
|
||||
return "上午续盘"
|
||||
if (h, m) == (13, 30):
|
||||
return "午盘开盘"
|
||||
if (h, m) == (21, 0):
|
||||
return "夜盘开盘"
|
||||
return "开盘"
|
||||
|
||||
|
||||
def _session_status_label(d: datetime, in_sess: bool) -> str:
|
||||
if in_sess:
|
||||
return "交易时间段"
|
||||
if is_morning_break(d):
|
||||
return "上午休盘"
|
||||
if is_lunch_break(d):
|
||||
return "午间休盘"
|
||||
return "非交易时间段"
|
||||
|
||||
|
||||
def _fmt_countdown(seconds: int) -> str:
|
||||
s = max(0, int(seconds))
|
||||
h, rem = divmod(s, 3600)
|
||||
m, sec = divmod(rem, 60)
|
||||
if h > 0:
|
||||
return f"{h}小时{m:02d}分{sec:02d}秒"
|
||||
if m > 0:
|
||||
return f"{m}分{sec:02d}秒"
|
||||
return f"{sec}秒"
|
||||
|
||||
|
||||
def _day_close_dt(d: datetime) -> datetime:
|
||||
return d.replace(hour=15, minute=0, second=0, microsecond=0)
|
||||
|
||||
|
||||
def _night_close_dt(d: datetime) -> datetime:
|
||||
t = d.hour * 60 + d.minute
|
||||
if t >= 21 * 60:
|
||||
nxt = (d + timedelta(days=1)).replace(hour=2, minute=30, second=0, microsecond=0)
|
||||
return nxt
|
||||
return d.replace(hour=2, minute=30, second=0, microsecond=0)
|
||||
|
||||
|
||||
def _current_break_close(d: datetime) -> tuple[Optional[datetime], Optional[datetime], Optional[str], Optional[str]]:
|
||||
"""当前交易段内的休盘/收盘时刻与标签。"""
|
||||
t = _minutes_of_day(d)
|
||||
if _in_time_range(t, 9, 0, 10, 15):
|
||||
br = d.replace(hour=10, minute=15, second=0, microsecond=0)
|
||||
cl = _day_close_dt(d)
|
||||
return br, cl, "上午休盘", "日盘收盘"
|
||||
if _in_time_range(t, 10, 30, 11, 30):
|
||||
br = d.replace(hour=11, minute=30, second=0, microsecond=0)
|
||||
cl = _day_close_dt(d)
|
||||
return br, cl, "午间休盘", "日盘收盘"
|
||||
if _in_time_range(t, 13, 30, 15, 0):
|
||||
cl = _day_close_dt(d)
|
||||
return None, cl, None, "日盘收盘"
|
||||
if t >= 21 * 60 or t < 2 * 60 + 30:
|
||||
cl = _night_close_dt(d)
|
||||
return None, cl, None, "夜盘收盘"
|
||||
return None, None, None, None
|
||||
|
||||
|
||||
def trading_session_clock(now: Optional[datetime] = None) -> dict:
|
||||
"""顶栏展示:当前时间、交易状态、距开盘/休盘/收盘倒计时。"""
|
||||
d = _normalize_dt(now)
|
||||
in_sess = is_trading_session(d)
|
||||
out = {
|
||||
"now": d.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"now_time": d.strftime("%m-%d %H:%M:%S"),
|
||||
"in_session": in_sess,
|
||||
"status_label": _session_status_label(d, in_sess),
|
||||
}
|
||||
if not in_sess:
|
||||
starts = iter_session_starts(d, hours_ahead=72)
|
||||
if starts:
|
||||
nxt = starts[0]
|
||||
secs = int(max(0, (nxt - d).total_seconds()))
|
||||
out["next_open_at"] = nxt.strftime("%m-%d %H:%M")
|
||||
out["next_open_label"] = _session_open_label(nxt)
|
||||
out["secs_to_open"] = secs
|
||||
out["countdown_open"] = _fmt_countdown(secs)
|
||||
return out
|
||||
br, cl, br_label, cl_label = _current_break_close(d)
|
||||
if br and br > d:
|
||||
secs = int((br - d).total_seconds())
|
||||
out["break_at"] = br.strftime("%H:%M")
|
||||
out["break_label"] = br_label or "休盘"
|
||||
out["secs_to_break"] = secs
|
||||
out["countdown_break"] = _fmt_countdown(secs)
|
||||
if cl and cl > d:
|
||||
secs = int((cl - d).total_seconds())
|
||||
out["close_at"] = cl.strftime("%H:%M")
|
||||
out["close_label"] = cl_label or "收盘"
|
||||
out["secs_to_close"] = secs
|
||||
out["countdown_close"] = _fmt_countdown(secs)
|
||||
return out
|
||||
|
||||
|
||||
def in_premarket_connect_window(
|
||||
now: Optional[datetime] = None,
|
||||
*,
|
||||
minutes_before: int = 30,
|
||||
) -> bool:
|
||||
"""距下一段开盘 <= minutes_before 分钟,且当前尚未进入交易时段。"""
|
||||
if is_trading_session(now):
|
||||
return False
|
||||
mins = minutes_until_next_session(now)
|
||||
if mins is None:
|
||||
return False
|
||||
return 0 < mins <= float(minutes_before)
|
||||
|
||||
|
||||
def in_postmarket_grace_window(
|
||||
now: Optional[datetime] = None,
|
||||
*,
|
||||
minutes_after: int = 30,
|
||||
) -> bool:
|
||||
"""日盘 15:00 或夜盘 02:30 收盘后 minutes_after 分钟内(仍保持连接,便于收尾)。"""
|
||||
if is_trading_session(now):
|
||||
return False
|
||||
d = _normalize_dt(now)
|
||||
t = _minutes_of_day(d)
|
||||
wd = d.weekday()
|
||||
ma = max(1, int(minutes_after))
|
||||
day_close = 15 * 60
|
||||
night_close = 2 * 60 + 30
|
||||
# 日盘收盘 15:00 后宽限(周一至周五)
|
||||
if wd < 5 and day_close <= t < day_close + ma:
|
||||
return True
|
||||
# 夜盘收盘 02:30 后宽限(含周六凌晨结束周五夜盘)
|
||||
if night_close <= t < night_close + ma:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def should_keep_ctp_connected(
|
||||
now: Optional[datetime] = None,
|
||||
*,
|
||||
minutes_before: int = 30,
|
||||
minutes_after: int = 30,
|
||||
) -> bool:
|
||||
"""是否处于应连接 CTP 的窗口:交易时段 + 小节/午间休盘 + 盘前 + 盘后宽限。"""
|
||||
if is_trading_session(now):
|
||||
return True
|
||||
if is_morning_break(now) or is_lunch_break(now):
|
||||
return True
|
||||
if in_postmarket_grace_window(now, minutes_after=minutes_after):
|
||||
return True
|
||||
return in_premarket_connect_window(now, minutes_before=minutes_before)
|
||||
@@ -0,0 +1,230 @@
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
"""HTTP routes for market module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
from flask import (
|
||||
Response,
|
||||
flash,
|
||||
jsonify,
|
||||
redirect,
|
||||
render_template,
|
||||
request,
|
||||
send_file,
|
||||
session,
|
||||
stream_with_context,
|
||||
url_for,
|
||||
)
|
||||
|
||||
|
||||
def register(deps) -> None:
|
||||
app = deps.app
|
||||
login_required = deps.login_required
|
||||
require_nav = deps.require_nav
|
||||
get_db = deps.get_db
|
||||
get_setting = deps.get_setting
|
||||
set_setting = deps.set_setting
|
||||
fetch_price = deps.fetch_price
|
||||
send_wechat_msg = deps.send_wechat_msg
|
||||
touch_stats_cache = deps.touch_stats_cache
|
||||
get_stats_data = deps.get_stats_data
|
||||
build_market_quote_payload = deps.build_market_quote_payload
|
||||
today_str = deps.today_str
|
||||
expire_old_plans = deps.expire_old_plans
|
||||
TZ = deps.tz
|
||||
DB_PATH = deps.db_path
|
||||
UPLOAD_DIR = deps.upload_dir
|
||||
OPEN_TYPES = deps.open_types
|
||||
EXIT_TRIGGERS = deps.exit_triggers
|
||||
BEHAVIOR_TAGS = deps.behavior_tags
|
||||
KLINE_PERIODS = deps.kline_periods
|
||||
KLINE_CUTOFFS = deps.kline_cutoffs
|
||||
calc_holding_duration = deps.calc_holding_duration
|
||||
holding_to_minutes = deps.holding_to_minutes
|
||||
classify_close_result = deps.classify_close_result
|
||||
calc_rr_ratio = deps.calc_rr_ratio
|
||||
calc_theoretical_pnl = deps.calc_theoretical_pnl
|
||||
parse_review_date_filter = deps.parse_review_date_filter
|
||||
_trading_mode = deps.trading_mode
|
||||
_ua_is_phone = deps.ua_is_phone
|
||||
_static_asset_v = deps.static_asset_v
|
||||
|
||||
from modules.core.symbols import (
|
||||
list_main_contracts_grouped,
|
||||
list_recommended_symbols_grouped,
|
||||
search_symbols,
|
||||
)
|
||||
from modules.market.kline_chart import MARKET_PERIODS, fetch_market_klines
|
||||
from modules.market.kline_stream import kline_hub, sse_format
|
||||
from modules.market.market import get_quote_source_label
|
||||
from queue import Empty
|
||||
|
||||
@app.route("/api/symbols/search")
|
||||
@login_required
|
||||
def api_symbol_search():
|
||||
q = request.args.get("q", "")
|
||||
conn = get_db()
|
||||
try:
|
||||
from modules.core.trading_context import get_account_capital, is_ctp_connected
|
||||
capital = get_account_capital(conn, get_setting)
|
||||
ctp_connected = is_ctp_connected(get_setting)
|
||||
finally:
|
||||
conn.close()
|
||||
return jsonify(search_symbols(q, capital=capital, ctp_connected=ctp_connected))
|
||||
|
||||
|
||||
@app.route("/api/symbols/mains")
|
||||
@login_required
|
||||
def api_symbols_mains():
|
||||
return jsonify(list_main_contracts_grouped())
|
||||
|
||||
|
||||
@app.route("/api/symbols/recommended")
|
||||
@login_required
|
||||
def api_symbols_recommended():
|
||||
"""品种下拉:仅展示当前资金下可开仓品种(与下方可开仓品种表一致)。"""
|
||||
from modules.trading.recommend_store import recommend_payload
|
||||
from modules.core.trading_context import (
|
||||
get_fixed_lots,
|
||||
get_max_margin_pct,
|
||||
get_recommend_capital,
|
||||
get_sizing_mode,
|
||||
get_trading_mode,
|
||||
)
|
||||
|
||||
conn = get_db()
|
||||
try:
|
||||
capital = get_recommend_capital(conn, get_setting)
|
||||
payload = recommend_payload(
|
||||
conn,
|
||||
live_capital=capital,
|
||||
max_margin_pct=get_max_margin_pct(get_setting),
|
||||
trading_mode=get_trading_mode(get_setting),
|
||||
sizing_mode=get_sizing_mode(get_setting),
|
||||
fixed_lots=get_fixed_lots(get_setting),
|
||||
)
|
||||
return jsonify(list_recommended_symbols_grouped(payload.get("rows") or []))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@app.route("/market")
|
||||
@login_required
|
||||
@require_nav("market")
|
||||
def market_page():
|
||||
symbol = request.args.get("symbol", "").strip()
|
||||
period = request.args.get("period", "15m").strip()
|
||||
valid = {p["key"] for p in MARKET_PERIODS}
|
||||
if period not in valid:
|
||||
period = "15m"
|
||||
ctp_st = {}
|
||||
try:
|
||||
from modules.ctp.vnpy_bridge import ctp_status
|
||||
from modules.core.trading_context import get_trading_mode
|
||||
|
||||
ctp_st = ctp_status(get_trading_mode(get_setting))
|
||||
except Exception:
|
||||
pass
|
||||
return render_template(
|
||||
"market.html",
|
||||
symbol=symbol,
|
||||
period=period,
|
||||
market_periods=MARKET_PERIODS,
|
||||
quote_label=get_quote_source_label(ctp_connected=bool(ctp_st.get("connected"))),
|
||||
ctp_connected=bool(ctp_st.get("connected")),
|
||||
)
|
||||
|
||||
|
||||
@app.route("/api/kline")
|
||||
@login_required
|
||||
def api_kline():
|
||||
symbol = request.args.get("symbol", "").strip()
|
||||
period = request.args.get("period", "15m").strip()
|
||||
if not symbol:
|
||||
return jsonify({"error": "请提供合约代码"}), 400
|
||||
try:
|
||||
from modules.core.trading_context import get_trading_mode
|
||||
|
||||
data = fetch_market_klines(
|
||||
symbol, period, DB_PATH, prefer_ctp=False,
|
||||
)
|
||||
except Exception as exc:
|
||||
app.logger.warning("kline api failed: %s", exc)
|
||||
return jsonify({"error": str(exc)}), 500
|
||||
if not data.get("chart_symbol"):
|
||||
return jsonify({"error": "无法识别合约代码"}), 400
|
||||
if not data.get("bars"):
|
||||
return jsonify({"error": "未获取到K线数据,请稍后重试或更换合约"}), 404
|
||||
return jsonify(data)
|
||||
|
||||
|
||||
@app.route("/api/kline/stream")
|
||||
@login_required
|
||||
def api_kline_stream():
|
||||
from queue import Empty
|
||||
|
||||
symbol = request.args.get("symbol", "").strip()
|
||||
period = request.args.get("period", "15m").strip()
|
||||
market_code = request.args.get("market_code", "").strip()
|
||||
sina_code = request.args.get("sina_code", "").strip()
|
||||
if not symbol:
|
||||
return jsonify({"error": "请提供合约代码"}), 400
|
||||
|
||||
def generate():
|
||||
sub = kline_hub.subscribe(symbol, period, market_code, sina_code)
|
||||
try:
|
||||
kline_data = fetch_market_klines(
|
||||
symbol, period, DB_PATH, prefer_ctp=False,
|
||||
)
|
||||
if kline_data.get("bars"):
|
||||
yield sse_format("kline", kline_data)
|
||||
yield sse_format(
|
||||
"quote",
|
||||
build_market_quote_payload(
|
||||
symbol, market_code, sina_code, prefer_sina=True,
|
||||
),
|
||||
)
|
||||
while True:
|
||||
try:
|
||||
msg = sub.queue.get(timeout=20)
|
||||
yield sse_format(msg["event"], msg["data"])
|
||||
except Empty:
|
||||
yield ": heartbeat\n\n"
|
||||
finally:
|
||||
kline_hub.unsubscribe(sub)
|
||||
|
||||
return Response(
|
||||
stream_with_context(generate()),
|
||||
mimetype="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.route("/api/market_quote")
|
||||
@login_required
|
||||
def api_market_quote():
|
||||
symbol = request.args.get("symbol", "").strip()
|
||||
market_code = request.args.get("market_code", "").strip()
|
||||
sina_code = request.args.get("sina_code", "").strip()
|
||||
if not symbol and not market_code:
|
||||
return jsonify({"error": "请提供合约"}), 400
|
||||
return jsonify(build_market_quote_payload(
|
||||
symbol, market_code, sina_code, prefer_sina=True,
|
||||
))
|
||||
|
||||
|
||||
@app.route("/contract")
|
||||
@login_required
|
||||
def contract_profile_page():
|
||||
return redirect(url_for("positions"))
|
||||
|
||||
|
||||
@app.route("/api/contract_profile")
|
||||
@login_required
|
||||
def api_contract_profile():
|
||||
return jsonify({"error": "品种简介功能已移除"}), 404
|
||||
@@ -0,0 +1,5 @@
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
|
||||
from modules.notify.routes import register
|
||||
|
||||
__all__ = ["register"]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,5 @@
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
|
||||
from modules.plans.routes import register
|
||||
|
||||
__all__ = ["register"]
|
||||
@@ -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"))
|
||||
@@ -0,0 +1,5 @@
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
|
||||
from modules.records.routes import register
|
||||
|
||||
__all__ = ["register"]
|
||||
@@ -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"))
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
|
||||
from modules.settings.routes import register
|
||||
|
||||
__all__ = ["register"]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"),
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
|
||||
from modules.stats.routes import register
|
||||
|
||||
__all__ = ["register"]
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"])),
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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, "开仓委托已撤销"
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
|
||||
from modules.web.routes import register
|
||||
|
||||
__all__ = ["register"]
|
||||
@@ -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"))
|
||||
@@ -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)}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 |
@@ -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>
|
||||
@@ -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
Reference in New Issue
Block a user