Restructure into modules/ with single-process CTP and config/ layout.

Move business code under modules/, env template to config/, PM2 single qihuo process, and _legacy shims for old imports.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-01 14:42:16 +08:00
parent b354d6c701
commit e5a586f903
209 changed files with 21962 additions and 20963 deletions
+2 -61
View File
@@ -1,61 +1,2 @@
# 服务配置
HOST=0.0.0.0
PORT=6600
DEBUG=false
SECRET_KEY=change-this-to-a-random-secret-key
ADMIN_USERNAME=admin
ADMIN_PASSWORD=change-me-on-first-login
ADMIN_SYNC_FROM_ENV=false
WECHAT_WEBHOOK=
QUOTE_SOURCE=sina
THS_REFRESH_TOKEN=
# 交易模式:simulation=SimNowlive=期货公司(系统设置页可改)
TRADING_MODE=simulation
POSITION_SIZING_MODE=risk
RISK_PERCENT=1
# CTP 断线后后台自动重连(true/false)
CTP_AUTO_RECONNECT=true
# —— SimNow 模拟盘(也可在「系统设置 → CTP 连接」配置,优先于本文件)——
SIMNOW_USER=
SIMNOW_PASSWORD=
SIMNOW_BROKER_ID=9999
# 7×24 / 日盘前置(deploy.sh 会自动 nc 探测并写入可用线路)
SIMNOW_TD_ADDRESS=tcp://180.168.146.187:10201
SIMNOW_MD_ADDRESS=tcp://180.168.146.187:10211
SIMNOW_APP_ID=simnow_client_test
SIMNOW_AUTH_CODE=0000000000000000
# SimNow 看穿式前置固定用「实盘」;仅穿透式测评才用「测试」
SIMNOW_ENV=实盘
# —— 期货公司实盘(后期接入)——
CTP_LIVE_USER=
CTP_LIVE_PASSWORD=
CTP_LIVE_BROKER_ID=
CTP_LIVE_TD_ADDRESS=
CTP_LIVE_MD_ADDRESS=
CTP_LIVE_APP_ID=
CTP_LIVE_AUTH_CODE=
CTP_LIVE_PRODUCT_INFO=
# 账户冷静期
RISK_CONTROL_ENABLED=true
RISK_COOLING_HOURS_MANUAL=4
RISK_COOLING_HOURS_MANUAL_JOURNAL=1
RISK_MANUAL_CLOSE_DAILY_LIMIT=2
MAX_ACTIVE_POSITIONS=1
RISK_DAILY_POSITION_LIMIT=5
RISK_DAILY_TRADING_RISK_PCT=2
TRADING_DAY_RESET_HOUR=8
# —— 数据库(生产推荐 PostgreSQL,见 docs/POSTGRES.md)——
# 未配置 DATABASE_URL 时使用本地 SQLite futures.db
# DATABASE_URL=postgresql://qihuo:your_password@127.0.0.1:5432/qihuo
# PG_POOL_MIN=2
# PG_POOL_MAX=20
# 环境变量模板已迁移至 config/.env.example
# 使用: cp config/.env.example config/.env
+1
View File
@@ -1,4 +1,5 @@
.env
config/.env
*.db
__pycache__/
*.py[cod]
+2
View File
@@ -0,0 +1,2 @@
# Compatibility shim — use modules.settings.admin_settings
from modules.settings.admin_settings import * # noqa: F401,F403
+2
View File
@@ -0,0 +1,2 @@
# Compatibility shim — use modules.notify.ai_client
from modules.notify.ai_client import * # noqa: F401,F403
+2
View File
@@ -0,0 +1,2 @@
# Compatibility shim — use modules.notify.ai_messages
from modules.notify.ai_messages import * # noqa: F401,F403
+2
View File
@@ -0,0 +1,2 @@
# Compatibility shim — use modules.notify.ai_worker
from modules.notify.ai_worker import * # noqa: F401,F403
+2
View File
@@ -0,0 +1,2 @@
# Compatibility shim — use modules.core.contract_profile
from modules.core.contract_profile import * # noqa: F401,F403
+2
View File
@@ -0,0 +1,2 @@
# Compatibility shim — use modules.core.contract_specs
from modules.core.contract_specs import * # noqa: F401,F403
+2
View File
@@ -0,0 +1,2 @@
# Compatibility shim — use modules.ctp.ctp_entry_price
from modules.ctp.ctp_entry_price import * # noqa: F401,F403
+2
View File
@@ -0,0 +1,2 @@
# Compatibility shim — use modules.ctp.ctp_fee_sync
from modules.ctp.ctp_fee_sync import * # noqa: F401,F403
+2
View File
@@ -0,0 +1,2 @@
# Compatibility shim — use modules.ctp.ctp_fee_worker
from modules.ctp.ctp_fee_worker import * # noqa: F401,F403
+2
View File
@@ -0,0 +1,2 @@
# Compatibility shim — use modules.ctp.ctp_ipc_client
from modules.ctp.ctp_ipc_client import * # noqa: F401,F403
+2
View File
@@ -0,0 +1,2 @@
# Compatibility shim — use modules.ctp.ctp_kline
from modules.ctp.ctp_kline import * # noqa: F401,F403
+2
View File
@@ -0,0 +1,2 @@
# Compatibility shim — use modules.ctp.ctp_premarket_connect
from modules.ctp.ctp_premarket_connect import * # noqa: F401,F403
+2
View File
@@ -0,0 +1,2 @@
# Compatibility shim — use modules.ctp.ctp_reconnect
from modules.ctp.ctp_reconnect import * # noqa: F401,F403
+2
View File
@@ -0,0 +1,2 @@
# Compatibility shim — use modules.ctp.ctp_settings
from modules.ctp.ctp_settings import * # noqa: F401,F403
+2
View File
@@ -0,0 +1,2 @@
# Compatibility shim — use modules.ctp.ctp_symbol
from modules.ctp.ctp_symbol import * # noqa: F401,F403
+2
View File
@@ -0,0 +1,2 @@
# Compatibility shim — use modules.ctp.ctp_trade_sync
from modules.ctp.ctp_trade_sync import * # noqa: F401,F403
+2
View File
@@ -0,0 +1,2 @@
# Compatibility shim — use modules.ctp.ctp_trading_state
from modules.ctp.ctp_trading_state import * # noqa: F401,F403
+2
View File
@@ -0,0 +1,2 @@
# Compatibility shim — use modules.ctp.ctp_worker
from modules.ctp.ctp_worker import * # noqa: F401,F403
+2
View File
@@ -0,0 +1,2 @@
# Compatibility shim — use modules.stats.dashboard_lib
from modules.stats.dashboard_lib import * # noqa: F401,F403
+2
View File
@@ -0,0 +1,2 @@
# Compatibility shim — use modules.backup.db_backup
from modules.backup.db_backup import * # noqa: F401,F403
+2
View File
@@ -0,0 +1,2 @@
# Compatibility shim — use modules.core.db_conn
from modules.core.db_conn import * # noqa: F401,F403
+2
View File
@@ -0,0 +1,2 @@
# Compatibility shim — use modules.core.doc_render
from modules.core.doc_render import * # noqa: F401,F403
+2
View File
@@ -0,0 +1,2 @@
# Compatibility shim — use modules.core.env_file
from modules.core.env_file import * # noqa: F401,F403
+2
View File
@@ -0,0 +1,2 @@
# Compatibility shim — use modules.fees.fee_specs
from modules.fees.fee_specs import * # noqa: F401,F403
+2
View File
@@ -0,0 +1,2 @@
# Compatibility shim — use modules.fees.fee_sync
from modules.fees.fee_sync import * # noqa: F401,F403
+2
View File
@@ -0,0 +1,2 @@
# Compatibility shim — use modules.keys.key_monitor_lib
from modules.keys.key_monitor_lib import * # noqa: F401,F403
+2
View File
@@ -0,0 +1,2 @@
# Compatibility shim — use modules.market.kline_chart
from modules.market.kline_chart import * # noqa: F401,F403
+2
View File
@@ -0,0 +1,2 @@
# Compatibility shim — use modules.market.kline_store
from modules.market.kline_store import * # noqa: F401,F403
+2
View File
@@ -0,0 +1,2 @@
# Compatibility shim — use modules.market.kline_stream
from modules.market.kline_stream import * # noqa: F401,F403
+2
View File
@@ -0,0 +1,2 @@
# Compatibility shim — use modules.core.locale_fix
from modules.core.locale_fix import * # noqa: F401,F403
+2
View File
@@ -0,0 +1,2 @@
# Compatibility shim — use modules.market.market
from modules.market.market import * # noqa: F401,F403
+2
View File
@@ -0,0 +1,2 @@
# Compatibility shim — use modules.market.market_sessions
from modules.market.market_sessions import * # noqa: F401,F403
+2
View File
@@ -0,0 +1,2 @@
# Compatibility shim — use modules.settings.nav_settings
from modules.settings.nav_settings import * # noqa: F401,F403
+2
View File
@@ -0,0 +1,2 @@
# Compatibility shim — use modules.trading.order_pending
from modules.trading.order_pending import * # noqa: F401,F403
+2
View File
@@ -0,0 +1,2 @@
# Compatibility shim — use modules.trading.pending_order_worker
from modules.trading.pending_order_worker import * # noqa: F401,F403
+2
View File
@@ -0,0 +1,2 @@
# Compatibility shim — use modules.trading.position_sizing
from modules.trading.position_sizing import * # noqa: F401,F403
+2
View File
@@ -0,0 +1,2 @@
# Compatibility shim — use modules.trading.position_stream
from modules.trading.position_stream import * # noqa: F401,F403
+2
View File
@@ -0,0 +1,2 @@
# Compatibility shim — use modules.trading.product_recommend
from modules.trading.product_recommend import * # noqa: F401,F403
+2
View File
@@ -0,0 +1,2 @@
# Compatibility shim — use modules.trading.recommend_store
from modules.trading.recommend_store import * # noqa: F401,F403
+2
View File
@@ -0,0 +1,2 @@
# Compatibility shim — use modules.trading.recommend_stream
from modules.trading.recommend_stream import * # noqa: F401,F403
+2
View File
@@ -0,0 +1,2 @@
# Compatibility shim — use modules.trading.recommend_trend
from modules.trading.recommend_trend import * # noqa: F401,F403
+2
View File
@@ -0,0 +1,2 @@
# Compatibility shim — use modules.risk.account_risk_lib
from modules.risk.account_risk_lib import * # noqa: F401,F403
+2
View File
@@ -0,0 +1,2 @@
# Compatibility shim — use modules.trading.sl_tp_guard
from modules.trading.sl_tp_guard import * # noqa: F401,F403
+2
View File
@@ -0,0 +1,2 @@
# Compatibility shim — use modules.stats.stats_engine
from modules.stats.stats_engine import * # noqa: F401,F403
+2
View File
@@ -0,0 +1,2 @@
# Compatibility shim — use modules.strategy.fib_lib
from modules.strategy.fib_lib import * # noqa: F401,F403
+2
View File
@@ -0,0 +1,2 @@
# Compatibility shim — use modules.strategy.strategy_db
from modules.strategy.strategy_db import * # noqa: F401,F403
+2
View File
@@ -0,0 +1,2 @@
# Compatibility shim — use modules.strategy.strategy_roll_lib
from modules.strategy.strategy_roll_lib import * # noqa: F401,F403
@@ -0,0 +1,2 @@
# Compatibility shim — use modules.strategy.strategy_roll_monitor_lib
from modules.strategy.strategy_roll_monitor_lib import * # noqa: F401,F403
@@ -0,0 +1,2 @@
# Compatibility shim — use modules.strategy.strategy_snapshot_lib
from modules.strategy.strategy_snapshot_lib import * # noqa: F401,F403
+2
View File
@@ -0,0 +1,2 @@
# Compatibility shim — use modules.strategy.strategy_trend_lib
from modules.strategy.strategy_trend_lib import * # noqa: F401,F403
+2
View File
@@ -0,0 +1,2 @@
# Compatibility shim — use modules.core.symbols
from modules.core.symbols import * # noqa: F401,F403
+2
View File
@@ -0,0 +1,2 @@
# Compatibility shim — use modules.trading.trade_log_lib
from modules.trading.trade_log_lib import * # noqa: F401,F403
+2
View File
@@ -0,0 +1,2 @@
# Compatibility shim — use modules.trading.trade_notify
from modules.trading.trade_notify import * # noqa: F401,F403
+2
View File
@@ -0,0 +1,2 @@
# Compatibility shim — use modules.core.trading_context
from modules.core.trading_context import * # noqa: F401,F403
+2
View File
@@ -0,0 +1,2 @@
# Compatibility shim — use modules.ctp.vnpy_bridge
from modules.ctp.vnpy_bridge import * # noqa: F401,F403
+2
View File
@@ -0,0 +1,2 @@
# Compatibility shim — use modules.notify.wechat_notify
from modules.notify.wechat_notify import * # noqa: F401,F403
+868 -2259
View File
File diff suppressed because it is too large Load Diff
+61
View File
@@ -0,0 +1,61 @@
# 服务配置
HOST=0.0.0.0
PORT=6600
DEBUG=false
SECRET_KEY=change-this-to-a-random-secret-key
ADMIN_USERNAME=admin
ADMIN_PASSWORD=change-me-on-first-login
ADMIN_SYNC_FROM_ENV=false
WECHAT_WEBHOOK=
QUOTE_SOURCE=sina
THS_REFRESH_TOKEN=
# 交易模式:simulation=SimNowlive=期货公司(系统设置页可改)
TRADING_MODE=simulation
POSITION_SIZING_MODE=risk
RISK_PERCENT=1
# CTP 断线后后台自动重连(true/false)
CTP_AUTO_RECONNECT=true
# —— SimNow 模拟盘(也可在「系统设置 → CTP 连接」配置,优先于本文件)——
SIMNOW_USER=
SIMNOW_PASSWORD=
SIMNOW_BROKER_ID=9999
# 7×24 / 日盘前置(deploy.sh 会自动 nc 探测并写入可用线路)
SIMNOW_TD_ADDRESS=tcp://180.168.146.187:10201
SIMNOW_MD_ADDRESS=tcp://180.168.146.187:10211
SIMNOW_APP_ID=simnow_client_test
SIMNOW_AUTH_CODE=0000000000000000
# SimNow 看穿式前置固定用「实盘」;仅穿透式测评才用「测试」
SIMNOW_ENV=实盘
# —— 期货公司实盘(后期接入)——
CTP_LIVE_USER=
CTP_LIVE_PASSWORD=
CTP_LIVE_BROKER_ID=
CTP_LIVE_TD_ADDRESS=
CTP_LIVE_MD_ADDRESS=
CTP_LIVE_APP_ID=
CTP_LIVE_AUTH_CODE=
CTP_LIVE_PRODUCT_INFO=
# 账户冷静期
RISK_CONTROL_ENABLED=true
RISK_COOLING_HOURS_MANUAL=4
RISK_COOLING_HOURS_MANUAL_JOURNAL=1
RISK_MANUAL_CLOSE_DAILY_LIMIT=2
MAX_ACTIVE_POSITIONS=1
RISK_DAILY_POSITION_LIMIT=5
RISK_DAILY_TRADING_RISK_PCT=2
TRADING_DAY_RESET_HOUR=8
# —— 数据库(生产推荐 PostgreSQL,见 docs/POSTGRES.md)——
# 未配置 DATABASE_URL 时使用本地 SQLite futures.db
# DATABASE_URL=postgresql://qihuo:your_password@127.0.0.1:5432/qihuo
# PG_POOL_MIN=2
# PG_POOL_MAX=20
+15 -13
View File
@@ -161,25 +161,27 @@ pip install --upgrade pip -q
pip install -r "$APP_DIR/requirements.txt"
python -c "from vnpy_ctp import CtpGateway; print('vnpy_ctp OK')"
echo "==> 配置 .env..."
if [ ! -f "$APP_DIR/.env" ]; then
cp "$APP_DIR/.env.example" "$APP_DIR/.env"
echo "==> 配置 config/.env..."
ENV_FILE="$APP_DIR/config/.env"
mkdir -p "$APP_DIR/config"
if [ ! -f "$ENV_FILE" ]; then
cp "$APP_DIR/config/.env.example" "$ENV_FILE"
RAND_KEY=$(python3 -c "import secrets; print(secrets.token_hex(32))")
sed -i "s/change-this-to-a-random-secret-key/${RAND_KEY}/" "$APP_DIR/.env"
echo " 已生成 .env,请编辑 SIMNOW_USER / ADMIN_PASSWORD"
sed -i "s/change-this-to-a-random-secret-key/${RAND_KEY}/" "$ENV_FILE"
echo " 已生成 config/.env,请编辑 SIMNOW_USER / ADMIN_PASSWORD"
fi
ensure_env_key "$APP_DIR/.env" "SIMNOW_ENV" "实盘"
ensure_env_key "$APP_DIR/.env" "CTP_AUTO_RECONNECT" "true"
ensure_env_key "$APP_DIR/.env" "SIMNOW_BROKER_ID" "9999"
ensure_env_key "$APP_DIR/.env" "SIMNOW_APP_ID" "simnow_client_test"
ensure_env_key "$APP_DIR/.env" "SIMNOW_AUTH_CODE" "0000000000000000"
update_simnow_front_in_env "$APP_DIR/.env" || true
ensure_env_key "$ENV_FILE" "SIMNOW_ENV" "实盘"
ensure_env_key "$ENV_FILE" "CTP_AUTO_RECONNECT" "true"
ensure_env_key "$ENV_FILE" "SIMNOW_BROKER_ID" "9999"
ensure_env_key "$ENV_FILE" "SIMNOW_APP_ID" "simnow_client_test"
ensure_env_key "$ENV_FILE" "SIMNOW_AUTH_CODE" "0000000000000000"
update_simnow_front_in_env "$ENV_FILE" || true
mkdir -p "$APP_DIR/logs"
echo "==> 验证 CTP 环境..."
if grep -q "^SIMNOW_USER=.\+" "$APP_DIR/.env" 2>/dev/null && \
grep -q "^SIMNOW_PASSWORD=.\+" "$APP_DIR/.env" 2>/dev/null; then
if grep -q "^SIMNOW_USER=.\+" "$ENV_FILE" 2>/dev/null && \
grep -q "^SIMNOW_PASSWORD=.\+" "$ENV_FILE" 2>/dev/null; then
set +e
python "$APP_DIR/scripts/test_simnow.py"
CTP_TEST=$?
+50
View File
@@ -0,0 +1,50 @@
# 主目录结构
```
qihuo/ # 主文件夹(仓库根)
├── app.py # 主程序入口(Flask 启动)
├── requirements.txt
├── deploy.sh # 一键部署脚本
├── ecosystem.config.cjs # PM2 启动配置
├── config/
│ ├── .env.example # 环境变量模板
│ └── .env # 运行时配置(git 忽略)
├── modules/ # 业务模块(每个模块 register(deps)
│ ├── core/ # DB、路径、公共工具
│ ├── web/ # 页面路由 + static/ + templates/
│ ├── trading/ # 下单监控、持仓、推荐
│ ├── ctp/ # vn.py / CTP 连接与报单
│ ├── risk/ # 账户风控
│ ├── strategy/ # 趋势、滚仓策略
│ ├── keys/ # 关键位
│ ├── plans/ # 开单计划
│ ├── market/ # 行情、K 线
│ ├── records/ # 交易记录、复盘
│ ├── stats/ # 统计、看板
│ ├── settings/ # 系统设置
│ ├── notify/ # 微信、AI 消息
│ ├── fees/ # 手续费
│ └── backup/ # 备份
├── _legacy/ # 旧 import 兼容 shimPM2 PYTHONPATH
├── data/ # 静态数据(如 fee_rates.json
├── docs/ # 文档
├── scripts/ # 运维/诊断脚本(非运行时)
├── futures.db # SQLite(未配 PG 时)
├── uploads/
└── logs/
```
根目录 `_legacy/` 为旧 `import db_conn` 等路径的兼容层;新代码请 `from modules.xxx import ...`
## 进程模型
- **单进程**PM2 仅 `qihuo``app.py` + CTP 同进程)
- 详见 [DEPLOY.md](./DEPLOY.md)
## 模块契约
每个 `modules/<name>/` 提供 `register(deps: AppDeps)`;主程序 `app.py` 只做串联,不写业务。
## 发布
见 [DEPLOY.md](./DEPLOY.md)**本地修改 → git push → 服务器 git pull**,禁止 SCP。
+15 -16
View File
@@ -43,7 +43,7 @@ pm2 save
以下文件 **不**`git pull` 更新,卸载/重装时须 **单独备份与恢复**
- `/opt/qihuo/.env`
- `/opt/qihuo/config/.env`(兼容旧版 `/opt/qihuo/.env`
- `/opt/qihuo/futures.db`SQLite)或 PostgreSQL 数据
- `/opt/qihuo/uploads/`
- `/opt/qihuo/backups/`(若有)
@@ -58,18 +58,17 @@ pm2 save
| 运行用户 | `root`(与 `deploy.sh` / PM2 配置一致) |
| Web 端口 | `6600`(对外) |
| CTP Worker 端口 | `6601`(仅 `127.0.0.1`,Web 进程 IPC 调用,勿对外开放) |
| 进程管理 | PM2`qihuo`Flask Web+ `qihuo-ctp`CTP / vn.py 独立进程) |
| 进程管理 | PM2**仅** `qihuo`Flask + CTP 单进程) |
| 数据库 | **生产推荐 PostgreSQL**(见 [POSTGRES.md](./POSTGRES.md));未配置 `DATABASE_URL` 时使用 SQLite `futures.db` |
| 仓库 | https://git.bz121.com/dekun/qihuo.git |
### 进程架构(2026-03 起)
### 进程架构(2026-07:单进程
| PM2 应用 | 角色 | 说明 |
|----------|------|------|
| `qihuo` | Web`QIHUO_CTP_ROLE=client`) | Flask、页面、API、数据库;通过 HTTP 调用本机 Worker |
| `qihuo-ctp` | Worker`QIHUO_CTP_ROLE=worker` | **唯一** 加载 vn.py / vnpy_ctp;CTP 连接、报单、持仓回调、止盈止损 tick、滚仓监控 |
| PM2 应用 | 说明 |
|----------|------|
| `qihuo` | Flask Web + **vn.py / CTP 同进程**`vnpy_bridge.CtpBridge` |
Web 进程崩溃或重启 **不会** 直接带走 CTP 原生连接;Worker 重启后 Web 会自动通过 IPC 恢复读写。两个进程的 Token 须一致(见 `ecosystem.config.cjs``QIHUO_CTP_WORKER_TOKEN`
详见 [ARCHITECTURE.md](./ARCHITECTURE.md)。旧版 `qihuo-ctp` 独立 Worker **已废弃**`ecosystem.config.cjs` 不再启动该进程
---
@@ -110,7 +109,7 @@ bash deploy.sh
6. 首次生成 `.env`,并补全 `SIMNOW_ENV=实盘``CTP_AUTO_RECONNECT=true` 等缺项
7. **自动探测 SimNow 前置**`nc` 测端口),写入可用的 `SIMNOW_TD/MD_ADDRESS`(优先 `182.254.243.31`,其次 `180.168.146.187`
8. 若已配置 SimNow 账号,运行 `scripts/test_simnow.py` 验证连接
9. `pm2 restart ecosystem.config.cjs --update-env` 或首次 `pm2 start ecosystem.config.cjs`,并 `pm2 save`同时启动 **`qihuo`** **`qihuo-ctp`**
9. `pm2 restart ecosystem.config.cjs --update-env` 或首次 `pm2 start ecosystem.config.cjs`,并 `pm2 save` **`qihuo`** 一个进程
部署完成后访问:`http://<服务器IP>:6600`
@@ -141,7 +140,7 @@ MIGRATE_SQLITE=1 sudo bash scripts/deploy_postgres.sh
```bash
# 在服务器上
cp /opt/qihuo/.env /root/qihuo.env.bak
cp /opt/qihuo/config/.env /root/qihuo.env.bak 2>/dev/null || cp /opt/qihuo/.env /root/qihuo.env.bak 2>/dev/null || true
# SQLite
cp /opt/qihuo/futures.db /root/futures.db.bak 2>/dev/null || true
# PostgreSQL 见 POSTGRES.md 备份命令
@@ -151,8 +150,8 @@ tar czf /root/qihuo_uploads.bak.tar.gz -C /opt/qihuo uploads 2>/dev/null || true
### 2. 卸载 PM2 与代码目录
```bash
pm2 stop qihuo qihuo-ctp 2>/dev/null || true
pm2 delete qihuo qihuo-ctp 2>/dev/null || true
pm2 stop qihuo 2>/dev/null || true
pm2 delete qihuo 2>/dev/null || true
pm2 save
rm -rf /opt/qihuo
```
@@ -174,7 +173,7 @@ bash deploy.sh
```bash
cd /opt/qihuo && git log -1 --oneline # 须与远端 main 最新提交一致
pm2 status # qihuo、qihuo-ctp 均为 online
pm2 status # qihuo 为 online
```
浏览器访问 `http://<服务器IP>:6600` 登录验证。
@@ -225,8 +224,8 @@ python -c "from vnpy_ctp import CtpGateway; print('vnpy_ctp OK')"
若提示找不到模块,查看本文「CTP / vnpy 故障排查」一节。
```bash
cp .env.example .env
nano .env
cp config/.env.example config/.env
nano config/.env
```
| 变量 | 说明 |
@@ -318,7 +317,7 @@ pm2 restart ecosystem.config.cjs --update-env
pm2 save
```
> **同时重启** `qihuo``qihuo-ctp`。仅 `pm2 restart qihuo` 会导致 Web 与 Worker 代码/协议不一致
> 更新后执行 `pm2 restart ecosystem.config.cjs --update-env` 即可(仅 `qihuo`
若服务器曾用 SCP 覆盖文件导致 `git pull` 冲突,用 `git reset --hard origin/main` 与远端对齐。
+1 -27
View File
@@ -21,12 +21,10 @@ module.exports = {
max_memory_restart: "8192M",
env: {
NODE_ENV: "production",
PYTHONPATH: path.join(ROOT, "_legacy"),
LANG: "zh_CN.UTF-8",
LC_ALL: "zh_CN.UTF-8",
LC_CTYPE: "zh_CN.UTF-8",
QIHUO_CTP_ROLE: "client",
QIHUO_CTP_WORKER_URL: "http://127.0.0.1:6601",
QIHUO_CTP_WORKER_TOKEN: "qihuo-local-ctp",
QIHUO_STARTUP_WORKERS: "8",
QIHUO_MEMORY_MB: "8192",
},
@@ -34,29 +32,5 @@ module.exports = {
out_file: path.join(ROOT, "logs", "pm2-out.log"),
time: true,
},
{
name: "qihuo-ctp",
script: "ctp_worker.py",
cwd: ROOT,
interpreter,
instances: 1,
autorestart: true,
watch: false,
max_memory_restart: "8192M",
env: {
NODE_ENV: "production",
LANG: "zh_CN.UTF-8",
LC_ALL: "zh_CN.UTF-8",
LC_CTYPE: "zh_CN.UTF-8",
QIHUO_CTP_ROLE: "worker",
QIHUO_CTP_WORKER_HOST: "127.0.0.1",
QIHUO_CTP_WORKER_PORT: "6601",
QIHUO_CTP_WORKER_TOKEN: "qihuo-local-ctp",
QIHUO_MEMORY_MB: "8192",
},
error_file: path.join(ROOT, "logs", "pm2-ctp-error.log"),
out_file: path.join(ROOT, "logs", "pm2-ctp-out.log"),
time: true,
},
],
};
+3 -4684
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
"""Qihuo feature modules package."""
+5
View File
@@ -0,0 +1,5 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
from modules.backup.routes import register
__all__ = ["register"]
+403 -402
View File
@@ -1,402 +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 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:
return Path(os.path.dirname(os.path.abspath(__file__)))
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()
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""数据库备份:SQLite futures.db 或 PostgreSQL pg_dump,含 uploads 与一键恢复脚本。"""
from __future__ import annotations
import json
import logging
import os
import re
import shutil
import sqlite3
import subprocess
import tarfile
import tempfile
import threading
import time
from datetime import datetime
from pathlib import Path
from typing import Callable, Optional
from zoneinfo import ZoneInfo
from modules.core.db_conn import DB_PATH, db_backend
logger = logging.getLogger(__name__)
TZ = ZoneInfo("Asia/Shanghai")
BACKUP_FILENAME_RE = re.compile(r"^qihuo_backup_\d{8}_\d{6}\.tar\.gz$")
BACKUP_LAST_KEY = "backup_last_at"
BACKUP_KEEP_KEY = "backup_keep_count"
BACKUP_AUTO_KEY = "backup_auto_enabled"
BACKUP_HOUR_KEY = "backup_auto_hour"
DEFAULT_KEEP_COUNT = 30
DEFAULT_AUTO_HOUR = 3
CHECK_INTERVAL_SEC = 3600
_backup_lock = threading.Lock()
RESTORE_MD = """# qihuo 备份恢复说明
本压缩包由 qihuo 系统自动生成可在另一台 Linux 服务器上恢复交易数据
## 包内文件
| 文件/目录 | 说明 |
|-----------|------|
| `futures.db` | SQLite 主库 SQLite 模式备份 |
| `postgres_dump.sql` | PostgreSQL 逻辑备份 PostgreSQL 模式 |
| `uploads/` | 复盘截图与 K 线图若备份时存在 |
| `manifest.json` | 备份元数据 `backend` 字段 |
| `restore.sh` | 一键恢复脚本 |
## 快速恢复(推荐)
1. 将本压缩包上传到目标服务器例如 `/root/`
2. 解压并执行恢复脚本
```bash
cd /root
tar -xzf qihuo_backup_YYYYMMDD_HHMMSS.tar.gz
cd qihuo_backup_YYYYMMDD_HHMMSS
chmod +x restore.sh
./restore.sh
```
默认恢复到 **`/root/qihuo`**SQLite或导入到 `.env` 中的 PostgreSQL manifest
指定应用目录
```bash
RESTORE_DIR=/opt/qihuo ./restore.sh
```
3. 在新服务器部署 qihuo 代码与 Python 环境 `docs/POSTGRES.md` / `docs/DEPLOY.md`
4. 配置 `.env``DATABASE_URL` SQLite`SECRET_KEY`CTP 账号等
5. 重启服务`pm2 restart qihuo`
## PostgreSQL 恢复
`manifest.json` `"backend": "postgres"`
1. 确保目标机已安装 PostgreSQL `.env` `DATABASE_URL` 指向空库或待覆盖库
2. 执行 `./restore.sh`会调用 `psql` 导入 `postgres_dump.sql`
手工导入
```bash
export DATABASE_URL=postgresql://qihuo:密码@127.0.0.1:5432/qihuo
psql "$DATABASE_URL" -f postgres_dump.sql
```
## SQLite 手工恢复
```bash
mkdir -p /opt/qihuo/uploads
cp futures.db /opt/qihuo/futures.db
cp -a uploads/. /opt/qihuo/uploads/
```
## 注意
- 恢复前请停止 qihuo 进程
- `.env` 含敏感信息请单独安全传输
- 详见 `docs/POSTGRES.md` `docs/BACKUP.md`
"""
def _app_root() -> Path:
from modules.core.paths import ROOT
return ROOT
def default_backup_dir() -> str:
env = (os.getenv("QIHUO_BACKUP_DIR") or "").strip()
if env:
return env
if os.name == "nt":
return str(_app_root() / "qihuo_backup")
return "/root/qihuo_backup"
def default_restore_dir() -> str:
env = (os.getenv("QIHUO_RESTORE_DIR") or "").strip()
if env:
return env
if os.name == "nt":
return str(_app_root())
return "/root/qihuo"
def backup_dir() -> Path:
path = Path(default_backup_dir())
path.mkdir(parents=True, exist_ok=True)
return path
def backup_in_progress() -> bool:
return _backup_lock.locked()
def get_backup_last_at(get_setting: Callable[[str, str], str]) -> str:
return (get_setting(BACKUP_LAST_KEY, "") or "").strip()
def _backup_sqlite(src_path: str, dst_path: str) -> None:
src = sqlite3.connect(src_path, timeout=30)
try:
try:
src.execute("PRAGMA wal_checkpoint(TRUNCATE)")
except sqlite3.OperationalError:
pass
dst = sqlite3.connect(dst_path)
try:
src.backup(dst)
dst.commit()
finally:
dst.close()
finally:
src.close()
def _backup_postgres(dst_path: str) -> None:
url = (os.getenv("DATABASE_URL") or "").strip()
if not url:
raise RuntimeError("PostgreSQL 备份需要 DATABASE_URL")
env = os.environ.copy()
proc = subprocess.run(
["pg_dump", "--no-owner", "--no-acl", "-f", dst_path, url],
capture_output=True,
text=True,
env=env,
check=False,
)
if proc.returncode != 0:
raise RuntimeError(f"pg_dump 失败: {proc.stderr.strip() or proc.stdout.strip()}")
def _write_restore_script(dest: Path, *, backend: str) -> None:
pg_block = ""
if backend == "postgres":
pg_block = """
if [ -f "$SCRIPT_DIR/postgres_dump.sql" ]; then
if [ -z "${DATABASE_URL:-}" ]; then
if [ -f "$RESTORE_DIR/.env" ]; then
set -a
# shellcheck disable=SC1090
source "$RESTORE_DIR/.env"
set +a
fi
fi
if [ -z "${DATABASE_URL:-}" ]; then
echo "错误: PostgreSQL 恢复需要 DATABASE_URL(环境变量或 $RESTORE_DIR/.env"
exit 1
fi
if ! command -v psql >/dev/null; then
echo "错误: 未找到 psql,请先安装 PostgreSQL 客户端"
exit 1
fi
echo "导入 PostgreSQL: postgres_dump.sql"
psql "$DATABASE_URL" -f "$SCRIPT_DIR/postgres_dump.sql"
echo "PostgreSQL 导入完成"
fi
"""
script = f"""#!/bin/bash
set -euo pipefail
RESTORE_DIR="${{RESTORE_DIR:-{default_restore_dir()}}}"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
mkdir -p "$RESTORE_DIR/uploads"
{pg_block}
if [ -f "$SCRIPT_DIR/futures.db" ]; then
cp -f "$SCRIPT_DIR/futures.db" "$RESTORE_DIR/futures.db"
echo "已复制 futures.db -> $RESTORE_DIR/futures.db"
fi
if [ -d "$SCRIPT_DIR/uploads" ]; then
cp -a "$SCRIPT_DIR/uploads/." "$RESTORE_DIR/uploads/"
echo "已复制 uploads -> $RESTORE_DIR/uploads/"
fi
echo ""
echo "恢复完成。目标目录: $RESTORE_DIR"
echo "下一步: 确认 .env、pm2 restart qihuo"
echo "详见 RESTORE.md 与 docs/POSTGRES.md"
"""
dest.write_text(script, encoding="utf-8")
def create_backup(*, include_uploads: bool = True) -> tuple[str, str]:
"""创建 tar.gz 备份,返回 (文件名, 说明)。"""
backend = db_backend()
if backend == "sqlite" and not os.path.isfile(DB_PATH):
raise FileNotFoundError(f"数据库不存在: {DB_PATH}")
if backend == "postgres" and not (os.getenv("DATABASE_URL") or "").strip():
raise RuntimeError("PostgreSQL 模式需要 DATABASE_URL")
with _backup_lock:
stamp = datetime.now(TZ).strftime("%Y%m%d_%H%M%S")
folder_name = f"qihuo_backup_{stamp}"
filename = f"{folder_name}.tar.gz"
out_path = backup_dir() / filename
app_root = _app_root()
upload_src = app_root / "uploads"
with tempfile.TemporaryDirectory(prefix="qihuo_bak_") as tmp:
work = Path(tmp) / folder_name
work.mkdir()
if backend == "postgres":
_backup_postgres(str(work / "postgres_dump.sql"))
else:
_backup_sqlite(DB_PATH, str(work / "futures.db"))
if include_uploads and upload_src.is_dir():
shutil.copytree(upload_src, work / "uploads", dirs_exist_ok=True)
manifest = {
"app": "qihuo",
"backend": backend,
"created_at": datetime.now(TZ).isoformat(timespec="seconds"),
"db_path": DB_PATH if backend == "sqlite" else (os.getenv("DATABASE_URL") or ""),
"includes_uploads": include_uploads and upload_src.is_dir(),
"default_restore_dir": default_restore_dir(),
"files": sorted(p.name for p in work.iterdir()),
}
(work / "manifest.json").write_text(
json.dumps(manifest, ensure_ascii=False, indent=2),
encoding="utf-8",
)
(work / "RESTORE.md").write_text(RESTORE_MD, encoding="utf-8")
_write_restore_script(work / "restore.sh", backend=backend)
with tarfile.open(out_path, "w:gz") as tar:
tar.add(work, arcname=folder_name)
size_mb = out_path.stat().st_size / (1024 * 1024)
label = "PostgreSQL" if backend == "postgres" else "SQLite"
return filename, f"备份已生成 {filename}{label}{size_mb:.2f} MB"
def list_backups() -> list[dict]:
items: list[dict] = []
for path in sorted(backup_dir().glob("qihuo_backup_*.tar.gz"), reverse=True):
if not BACKUP_FILENAME_RE.match(path.name):
continue
stat = path.stat()
items.append(
{
"name": path.name,
"size": stat.st_size,
"size_mb": round(stat.st_size / (1024 * 1024), 2),
"mtime": datetime.fromtimestamp(stat.st_mtime, TZ).isoformat(timespec="seconds"),
}
)
return items
def resolve_backup_file(filename: str) -> Path:
name = (filename or "").strip()
if not BACKUP_FILENAME_RE.match(name):
raise ValueError("无效的备份文件名")
path = (backup_dir() / name).resolve()
root = backup_dir().resolve()
if not str(path).startswith(str(root) + os.sep) and path != root:
raise ValueError("无效的备份路径")
if not path.is_file():
raise FileNotFoundError("备份文件不存在")
return path
def prune_old_backups(keep: int) -> int:
keep_n = max(1, int(keep or DEFAULT_KEEP_COUNT))
files = list_backups()
removed = 0
for item in files[keep_n:]:
try:
resolve_backup_file(item["name"]).unlink()
removed += 1
except Exception as exc:
logger.warning("prune backup %s: %s", item["name"], exc)
return removed
def run_backup_job(
*,
get_setting: Callable[[str, str], str],
set_setting: Callable[[str, str], None],
include_uploads: bool = True,
) -> tuple[str, str]:
keep = DEFAULT_KEEP_COUNT
try:
keep = max(5, min(200, int(get_setting(BACKUP_KEEP_KEY, str(DEFAULT_KEEP_COUNT)) or DEFAULT_KEEP_COUNT)))
except ValueError:
pass
filename, msg = create_backup(include_uploads=include_uploads)
set_setting(BACKUP_LAST_KEY, datetime.now(TZ).isoformat(timespec="seconds"))
removed = prune_old_backups(keep)
if removed:
msg = f"{msg},已清理 {removed} 个旧备份"
return filename, msg
def schedule_backup(
*,
get_setting: Callable[[str, str], str],
set_setting: Callable[[str, str], None],
include_uploads: bool = True,
) -> tuple[bool, str]:
if _backup_lock.locked():
return False, "备份进行中,请稍后再试"
def _run() -> None:
try:
run_backup_job(
get_setting=get_setting,
set_setting=set_setting,
include_uploads=include_uploads,
)
except Exception as exc:
logger.exception("backup failed: %s", exc)
threading.Thread(target=_run, daemon=True, name="qihuo-backup-run").start()
return True, "已在后台开始备份,请稍后刷新本页查看"
def _should_auto_backup(get_setting: Callable[[str, str], str]) -> bool:
if (get_setting(BACKUP_AUTO_KEY, "1") or "1").strip() not in ("1", "true", "yes"):
return False
try:
hour = int(get_setting(BACKUP_HOUR_KEY, str(DEFAULT_AUTO_HOUR)) or DEFAULT_AUTO_HOUR)
except ValueError:
hour = DEFAULT_AUTO_HOUR
hour = max(0, min(23, hour))
now = datetime.now(TZ)
if now.hour != hour:
return False
last = get_backup_last_at(get_setting)
if last and last[:10] == now.date().isoformat():
return False
return True
def start_backup_worker(
*,
get_setting_fn: Callable[[str, str], str],
set_setting_fn: Callable[[str, str], None],
interval: int = CHECK_INTERVAL_SEC,
) -> None:
"""后台线程:按设定小时每日自动备份。"""
def _loop() -> None:
time.sleep(30)
while True:
try:
if _should_auto_backup(get_setting_fn):
filename, msg = run_backup_job(
get_setting=get_setting_fn,
set_setting=set_setting_fn,
include_uploads=True,
)
logger.info("auto backup: %s%s", filename, msg)
except Exception as exc:
logger.warning("backup worker: %s", exc)
time.sleep(max(300, interval))
threading.Thread(target=_loop, daemon=True, name="qihuo-backup-worker").start()
+78
View File
@@ -0,0 +1,78 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
"""HTTP routes for backup module."""
from __future__ import annotations
from datetime import date, datetime
from flask import (
Response,
flash,
jsonify,
redirect,
render_template,
request,
send_file,
session,
stream_with_context,
url_for,
)
def register(deps) -> None:
app = deps.app
login_required = deps.login_required
require_nav = deps.require_nav
get_db = deps.get_db
get_setting = deps.get_setting
set_setting = deps.set_setting
fetch_price = deps.fetch_price
send_wechat_msg = deps.send_wechat_msg
touch_stats_cache = deps.touch_stats_cache
get_stats_data = deps.get_stats_data
build_market_quote_payload = deps.build_market_quote_payload
today_str = deps.today_str
expire_old_plans = deps.expire_old_plans
TZ = deps.tz
DB_PATH = deps.db_path
UPLOAD_DIR = deps.upload_dir
OPEN_TYPES = deps.open_types
EXIT_TRIGGERS = deps.exit_triggers
BEHAVIOR_TAGS = deps.behavior_tags
KLINE_PERIODS = deps.kline_periods
KLINE_CUTOFFS = deps.kline_cutoffs
calc_holding_duration = deps.calc_holding_duration
holding_to_minutes = deps.holding_to_minutes
classify_close_result = deps.classify_close_result
calc_rr_ratio = deps.calc_rr_ratio
calc_theoretical_pnl = deps.calc_theoretical_pnl
parse_review_date_filter = deps.parse_review_date_filter
_trading_mode = deps.trading_mode
_ua_is_phone = deps.ua_is_phone
_static_asset_v = deps.static_asset_v
from modules.backup.db_backup import list_backups, resolve_backup_file
@app.route("/api/backup/list")
@login_required
def api_backup_list():
return jsonify(
{
"dir": str(backup_dir()),
"last_at": get_backup_last_at(get_setting),
"running": backup_in_progress(),
"items": list_backups(),
}
)
@app.route("/api/backup/download/<filename>")
@login_required
def api_backup_download(filename):
from flask import send_file
try:
path = resolve_backup_file(filename)
except (ValueError, FileNotFoundError) as exc:
return jsonify({"error": str(exc)}), 404
return send_file(path, as_attachment=True, download_name=path.name)
+8
View File
@@ -0,0 +1,8 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
"""Core bootstrap and shared types."""
from modules.core.bootstrap import register_all_modules, start_module_workers
from modules.core.deps import AppDeps
__all__ = ["AppDeps", "register_all_modules", "start_module_workers"]
+55
View File
@@ -0,0 +1,55 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
"""Application module registry and startup wiring."""
from __future__ import annotations
import importlib
import logging
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from modules.core.deps import AppDeps
logger = logging.getLogger(__name__)
# Registration order: core services first, trading last among features.
_MODULE_NAMES = (
"modules.web",
"modules.market",
"modules.keys",
"modules.plans",
"modules.notify",
"modules.records",
"modules.stats",
"modules.fees",
"modules.backup",
"modules.settings",
"modules.risk",
"modules.strategy",
"modules.ctp",
"modules.trading",
)
def register_all_modules(deps: "AppDeps") -> None:
for name in _MODULE_NAMES:
mod = importlib.import_module(name)
register = getattr(mod, "register", None)
if not callable(register):
logger.warning("module %s has no register()", name)
continue
register(deps)
logger.debug("registered %s", name)
def start_module_workers(deps: "AppDeps") -> None:
"""Background threads owned by feature modules."""
from modules.ctp.vnpy_bridge import try_init_vnpy
try_init_vnpy({})
for name in ("modules.market",):
mod = importlib.import_module(name)
start = getattr(mod, "start_workers", None)
if callable(start):
start(deps)
@@ -1,280 +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 contract_specs import get_contract_spec
from 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 "未知",
}
# 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 "未知",
}
@@ -1,166 +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 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,
}
# 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,
}
+3 -1
View File
@@ -13,7 +13,9 @@ import threading
import time
from typing import Any, Iterable, Optional, Sequence
DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "futures.db")
from modules.core.paths import DB_PATH as _ROOT_DB_PATH
DB_PATH = _ROOT_DB_PATH
_backend_lock = threading.Lock()
_backend: Optional[str] = None
+46
View File
@@ -0,0 +1,46 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
"""Shared dependencies passed into each feature module at register() time."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Callable, Optional
@dataclass
class AppDeps:
app: Any
get_db: Callable
get_setting: Callable
set_setting: Callable
login_required: Callable
require_nav: Callable
fetch_price: Callable
send_wechat_msg: Callable
touch_stats_cache: Callable
get_stats_data: Callable
build_market_quote_payload: Callable
today_str: Callable
expire_old_plans: Callable
check_order_plans: Callable
check_key_monitors: Callable
background_task: Callable
start_background_threads: Callable
tz: Any
db_path: str
upload_dir: str
open_types: list
exit_triggers: list
behavior_tags: list
kline_periods: list
kline_cutoffs: list
calc_holding_duration: Callable
holding_to_minutes: Callable
classify_close_result: Callable
calc_rr_ratio: Callable
calc_theoretical_pnl: Callable
parse_review_date_filter: Callable
trading_mode: Callable
static_asset_v: Callable
ua_is_phone: Callable
+16 -2
View File
@@ -9,12 +9,26 @@ from __future__ import annotations
import os
import re
ENV_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env")
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:
return path or ENV_PATH
if path:
return path
from modules.core.paths import resolve_env_file
return resolve_env_file()
def _quote_env_value(value: str) -> str:
+37
View File
@@ -0,0 +1,37 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
"""Repository layout paths — single source for config, data, uploads."""
from __future__ import annotations
import os
from pathlib import Path
# .../qihuo/modules/core/paths.py -> repo root
ROOT = Path(__file__).resolve().parents[2]
CONFIG_DIR = ROOT / "config"
ENV_FILE = CONFIG_DIR / ".env"
LEGACY_ENV_FILE = ROOT / ".env"
DATA_DIR = ROOT / "data"
UPLOADS_DIR = ROOT / "uploads"
LOGS_DIR = ROOT / "logs"
DB_PATH = str(ROOT / "futures.db")
def ensure_runtime_dirs() -> None:
DATA_DIR.mkdir(parents=True, exist_ok=True)
UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
LOGS_DIR.mkdir(parents=True, exist_ok=True)
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
def resolve_env_file() -> str:
"""Prefer config/.env, fall back to legacy root .env."""
if ENV_FILE.is_file():
return str(ENV_FILE)
if LEGACY_ENV_FILE.is_file():
return str(LEGACY_ENV_FILE)
return str(ENV_FILE)
File diff suppressed because it is too large Load Diff
@@ -1,184 +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 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 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 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 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 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 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 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 "期货公司实盘"
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""交易上下文:设置读取、资金、模式。"""
from __future__ import annotations
from typing import Callable, Optional
TRADING_MODE_SIM = "simulation" # SimNow CTP
TRADING_MODE_LIVE = "live" # 期货公司 CTP
def get_trading_mode(get_setting: Callable[[str, str], str]) -> str:
m = (get_setting("trading_mode", TRADING_MODE_SIM) or TRADING_MODE_SIM).strip().lower()
return m if m in (TRADING_MODE_SIM, TRADING_MODE_LIVE) else TRADING_MODE_SIM
def get_sizing_mode(get_setting: Callable[[str, str], str]) -> str:
from modules.trading.position_sizing import normalize_sizing_mode
return normalize_sizing_mode(get_setting("position_sizing_mode", "fixed"))
def get_fixed_lots(get_setting: Callable[[str, str], str]) -> int:
try:
return max(1, int(float(get_setting("fixed_lots", "1") or 1)))
except (TypeError, ValueError):
return 1
def get_fixed_amount(get_setting: Callable[[str, str], str]) -> float:
try:
return max(1.0, float(get_setting("fixed_amount", "5000") or 5000))
except (TypeError, ValueError):
return 5000.0
def get_risk_percent(get_setting: Callable[[str, str], str]) -> float:
try:
return max(0.1, float(get_setting("risk_percent", "1") or 1))
except (TypeError, ValueError):
return 1.0
def get_max_margin_pct(get_setting: Callable[[str, str], str]) -> float:
"""单笔/总仓位保证金占权益上限(%),默认 30。"""
try:
return max(1.0, min(100.0, float(get_setting("max_margin_pct", "30") or 30)))
except (TypeError, ValueError):
return 30.0
def get_roll_max_margin_pct(get_setting: Callable[[str, str], str]) -> float:
"""滚仓后总保证金占权益上限(%),默认 50。"""
try:
return max(1.0, min(100.0, float(get_setting("roll_max_margin_pct", "50") or 50)))
except (TypeError, ValueError):
return 50.0
def get_trailing_be_tick_buffer(get_setting: Callable[[str, str], str]) -> int:
"""移动保本:止损移至开仓价 ± N 个最小变动价位(默认 2)。"""
try:
return max(1, min(20, int(float(get_setting("trailing_be_tick_buffer", "2") or 2))))
except (TypeError, ValueError):
return 2
def get_pending_order_timeout_min(get_setting: Callable[[str, str], str]) -> int:
"""开仓限价委托未成交自动撤单时间(分钟),默认 5。"""
try:
return max(1, min(60, int(float(get_setting("pending_order_timeout_min", "5") or 5))))
except (TypeError, ValueError):
return 5
def get_pending_order_timeout_sec(get_setting: Callable[[str, str], str]) -> int:
return get_pending_order_timeout_min(get_setting) * 60
def _cached_ctp_account(mode: str) -> dict[str, float]:
"""CTP 未连接时,用最近一次 worker/持仓快照里的账户权益。"""
import json
try:
from modules.trading.position_stream import position_hub
snap = position_hub.get_snapshot() or {}
cap = float(snap.get("capital") or 0)
if cap > 0:
return {"balance": cap}
except Exception:
pass
try:
from modules.core.db_conn import connect_db
conn = connect_db()
try:
row = conn.execute(
"SELECT value FROM ctp_worker_snapshots WHERE key='account' LIMIT 1"
).fetchone()
finally:
conn.close()
if row and row["value"]:
acc = json.loads(row["value"])
balance = float(acc.get("balance") or 0)
available = acc.get("available")
out: dict[str, float] = {}
if balance > 0:
out["balance"] = balance
if available is not None:
out["available"] = float(available)
return out
except Exception:
pass
del mode
return {}
def _ctp_status_from_snapshot(mode: str) -> Optional[dict]:
"""读持仓快照中的 CTP 状态,避免页面渲染同步 IPC。"""
try:
from modules.trading.position_stream import position_hub
snap = position_hub.get_snapshot() or {}
st = snap.get("ctp_status")
if isinstance(st, dict) and st:
return st
except Exception:
pass
del mode
return None
def get_account_capital(conn, get_setting: Callable[[str, str], str]) -> float:
"""优先读持仓/Worker 快照权益;无快照时才同步问 CTP。"""
del conn
mode = get_trading_mode(get_setting)
cached = _cached_ctp_account(mode)
balance = float(cached.get("balance") or 0)
if balance > 0:
return balance
try:
from modules.ctp.vnpy_bridge import ctp_status, get_ctp_balance
st = ctp_status(mode)
if st.get("connected"):
bal = get_ctp_balance(mode)
if bal and bal > 0:
return float(bal)
except Exception:
pass
try:
return float(get_setting("live_capital", "0") or 0)
except (TypeError, ValueError):
return 0.0
def get_recommend_capital(conn, get_setting: Callable[[str, str], str]) -> float:
"""可开仓品种表用权益:已连接 CTP 用柜台权益,未连接固定 10 万。"""
from modules.trading.product_recommend import DISCONNECTED_RECOMMEND_CAPITAL
if is_ctp_connected(get_setting):
return get_account_capital(conn, get_setting)
return float(DISCONNECTED_RECOMMEND_CAPITAL)
def is_ctp_connected(get_setting: Callable[[str, str], str]) -> bool:
"""当前交易模式(SimNow / 实盘)是否已连接 CTP。"""
mode = get_trading_mode(get_setting)
st = _ctp_status_from_snapshot(mode)
if st is not None:
return bool(st.get("connected"))
try:
from modules.ctp.vnpy_bridge import ctp_status
return bool(ctp_status(mode).get("connected"))
except Exception:
return False
def trading_mode_label(get_setting: Callable[[str, str], str]) -> str:
return "SimNow" if get_trading_mode(get_setting) == TRADING_MODE_SIM else "期货公司实盘"
+10
View File
@@ -0,0 +1,10 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
"""CTP / vn.py integration — single-process mode."""
def register(deps) -> None:
del deps
__all__ = ["register"]
@@ -1,63 +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 contract_specs import get_contract_spec
from ctp_symbol import ths_to_vnpy_symbol
from 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"
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 详见 LICENSE.zh-CN.txt
"""CTP 持仓均价:仅使用柜台持仓回报(vnpy pos.price = PositionCost 加权)。"""
from __future__ import annotations
from typing import Any, Optional
from modules.core.contract_specs import get_contract_spec
from modules.ctp.ctp_symbol import ths_to_vnpy_symbol
from modules.core.symbols import ths_to_codes
def symbols_match(ctp_sym: str, ths: str) -> bool:
a = (ctp_sym or "").lower()
b = (ths or "").lower()
if a == b:
return True
if a and b and a.split(".")[0] == b.split(".")[0]:
return True
try:
vnpy_sym, _ = ths_to_vnpy_symbol(ths)
if a == vnpy_sym.lower():
return True
except Exception:
pass
try:
vnpy_sym, _ = ths_to_vnpy_symbol(ctp_sym)
if vnpy_sym.lower() == b.split(".")[0]:
return True
except Exception:
pass
return False
def _ths_code(sym: str) -> str:
codes = ths_to_codes(sym) or {}
return codes.get("ths_code") or sym
def round_to_tick(price: float, sym: str) -> float:
tick = float(get_contract_spec(_ths_code(sym)).get("tick_size") or 1.0)
if tick <= 0:
return round(price, 2)
return round(round(price / tick) * tick, 4)
def resolve_ctp_entry(
sym: str,
direction: str,
ctp: Optional[dict[str, Any]],
trades: Optional[list[dict[str, Any]]] = None,
*,
tick: Optional[float] = None,
) -> tuple[float, str]:
"""均价:仅柜台持仓价(trades/tick 参数保留兼容,不参与计算)。"""
del direction, trades, tick
if not ctp:
return 0.0, "none"
pos_avg = float(ctp.get("avg_price") or 0)
if pos_avg > 0:
return round_to_tick(pos_avg, sym), "ctp"
return 0.0, "none"
+144 -144
View File
@@ -1,144 +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 contract_specs import get_contract_spec
from fee_specs import upsert_fee_rate
from 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 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
# 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
@@ -1,131 +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 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 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()
# 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()
+89 -89
View File
@@ -1,89 +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 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 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
# 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
@@ -1,116 +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 market_sessions import (
in_premarket_connect_window,
in_postmarket_grace_window,
is_trading_session,
should_keep_ctp_connected,
)
from 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()
# 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()
@@ -1,59 +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 ctp_premarket_connect import premarket_minutes_before, should_auto_connect_now
from market_sessions import in_premarket_connect_window, is_trading_session
from 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()
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""CTP 断线自动重连(后台线程)。"""
from __future__ import annotations
import logging
import os
import threading
import time
from typing import Callable
from modules.ctp.ctp_premarket_connect import premarket_minutes_before, should_auto_connect_now
from modules.market.market_sessions import in_premarket_connect_window, is_trading_session
from modules.ctp.vnpy_bridge import ctp_try_auto_reconnect
logger = logging.getLogger(__name__)
RECONNECT_INTERVAL_SEC = 60
TRADING_RECONNECT_INTERVAL_SEC = 15
PREMARKET_RECONNECT_INTERVAL_SEC = 30
def _auto_reconnect_enabled() -> bool:
return (os.getenv("CTP_AUTO_RECONNECT", "true") or "true").strip().lower() in (
"1",
"true",
"yes",
)
def start_ctp_reconnect_worker(
*,
get_mode_fn: Callable[[], str],
get_setting_fn: Callable[[str, str], str] | None = None,
interval: int = RECONNECT_INTERVAL_SEC,
) -> None:
"""交易时段 / 盘前窗口内检测 CTP;断线则后台自动重连。"""
def _loop() -> None:
while True:
sleep_sec = max(5, interval)
try:
if _auto_reconnect_enabled() and should_auto_connect_now():
mode = get_mode_fn()
ctp_try_auto_reconnect(mode)
if is_trading_session():
sleep_sec = TRADING_RECONNECT_INTERVAL_SEC
elif in_premarket_connect_window(
minutes_before=premarket_minutes_before(),
):
sleep_sec = PREMARKET_RECONNECT_INTERVAL_SEC
except Exception as exc:
logger.warning("CTP reconnect worker: %s", exc)
time.sleep(sleep_sec)
threading.Thread(target=_loop, daemon=True, name="ctp-reconnect-worker").start()
+154 -154
View File
@@ -1,154 +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 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 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,
}
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""CTP / SimNow 配置:系统设置优先,.env 作兜底。"""
from __future__ import annotations
import os
from typing import Any, Callable
# (db_key, env_key, vnpy字段名, 默认值)
SIMNOW_FIELDS: tuple[tuple[str, str, str, str], ...] = (
("simnow_user", "SIMNOW_USER", "用户名", ""),
("simnow_password", "SIMNOW_PASSWORD", "密码", ""),
("simnow_broker_id", "SIMNOW_BROKER_ID", "经纪商代码", "9999"),
("simnow_td_address", "SIMNOW_TD_ADDRESS", "交易服务器", "tcp://180.168.146.187:10201"),
("simnow_md_address", "SIMNOW_MD_ADDRESS", "行情服务器", "tcp://180.168.146.187:10211"),
("simnow_app_id", "SIMNOW_APP_ID", "产品名称", "simnow_client_test"),
("simnow_auth_code", "SIMNOW_AUTH_CODE", "授权编码", "0000000000000000"),
("simnow_env", "SIMNOW_ENV", "柜台环境", "实盘"),
)
LIVE_FIELDS: tuple[tuple[str, str, str, str], ...] = (
("ctp_live_user", "CTP_LIVE_USER", "用户名", ""),
("ctp_live_password", "CTP_LIVE_PASSWORD", "密码", ""),
("ctp_live_broker_id", "CTP_LIVE_BROKER_ID", "经纪商代码", ""),
("ctp_live_td_address", "CTP_LIVE_TD_ADDRESS", "交易服务器", ""),
("ctp_live_md_address", "CTP_LIVE_MD_ADDRESS", "行情服务器", ""),
("ctp_live_app_id", "CTP_LIVE_APP_ID", "产品名称", ""),
("ctp_live_auth_code", "CTP_LIVE_AUTH_CODE", "授权编码", ""),
("ctp_live_env", "CTP_LIVE_ENV", "柜台环境", "实盘"),
)
PASSWORD_DB_KEYS = frozenset({"simnow_password", "ctp_live_password"})
CTP_AUTO_CONNECT_KEY = "ctp_auto_connect"
CTP_DISABLED_HINT = "CTP 自动连接已关闭(非交易时段不重连;开盘前 30 分钟及交易时段仍会按计划连接;断开请手动操作)"
def is_ctp_auto_connect_enabled(get_setting=None) -> bool:
"""系统设置:是否允许手动连接及非交易时段自动重连(盘前/交易时段计划连接不受此限制)。"""
if get_setting is None:
from modules.fees.fee_specs import get_setting as _gs
get_setting = _gs
val = (get_setting(CTP_AUTO_CONNECT_KEY, "1") or "1").strip().lower()
return val in ("1", "true", "yes", "on")
def save_ctp_auto_connect(form: Any, set_setting: Callable[[str, str], None]) -> bool:
enabled = (form.get("ctp_auto_connect") or "").strip().lower() in (
"1",
"on",
"true",
"yes",
)
set_setting(CTP_AUTO_CONNECT_KEY, "1" if enabled else "0")
return enabled
def _get_db_setting(key: str, default: str = "") -> str:
from modules.fees.fee_specs import get_setting
return (get_setting(key, default) or default).strip()
def resolve_ctp_value(db_key: str, env_key: str, default: str = "") -> str:
v = _get_db_setting(db_key, "")
if v:
return v
return (os.getenv(env_key) or default).strip()
def _build_setting_dict(fields: tuple[tuple[str, str, str, str], ...]) -> dict[str, str]:
out: dict[str, str] = {}
for db_key, env_key, vnpy_key, default in fields:
out[vnpy_key] = resolve_ctp_value(db_key, env_key, default)
return out
def simnow_setting_dict() -> dict[str, str]:
return _build_setting_dict(SIMNOW_FIELDS)
def live_setting_dict() -> dict[str, str]:
return _build_setting_dict(LIVE_FIELDS)
def seed_ctp_settings_from_env(set_setting: Callable[[str, str], None]) -> None:
"""首次启动:将 .env 中已有 CTP 配置写入 settings 表。"""
for db_key, env_key, _, _ in (*SIMNOW_FIELDS, *LIVE_FIELDS):
if _get_db_setting(db_key, ""):
continue
env_val = (os.getenv(env_key) or "").strip()
if env_val:
set_setting(db_key, env_val)
def get_ctp_settings_for_ui() -> dict[str, Any]:
ui: dict[str, Any] = {}
for db_key, env_key, _, default in SIMNOW_FIELDS:
ui[db_key] = resolve_ctp_value(db_key, env_key, default)
if db_key in PASSWORD_DB_KEYS:
ui[f"{db_key}_set"] = bool(ui[db_key])
ui[db_key] = ""
for db_key, env_key, _, default in LIVE_FIELDS:
ui[db_key] = resolve_ctp_value(db_key, env_key, default)
if db_key in PASSWORD_DB_KEYS:
ui[f"{db_key}_set"] = bool(ui[db_key])
ui[db_key] = ""
ui["ctp_auto_connect"] = is_ctp_auto_connect_enabled()
return ui
def save_ctp_settings_from_form(
form: Any,
set_setting: Callable[[str, str], None],
) -> dict[str, Any]:
"""保存 CTP 配置;密码留空表示不修改。返回摘要供页面提示。"""
passwords_updated: list[str] = []
passwords_submitted_empty: list[str] = []
for db_key, _, _, default in SIMNOW_FIELDS:
if db_key in PASSWORD_DB_KEYS:
raw = form.get(db_key)
val = (raw or "").strip()
if val:
set_setting(db_key, val)
passwords_updated.append(db_key)
else:
passwords_submitted_empty.append(db_key)
continue
val = (form.get(db_key) or "").strip()
set_setting(db_key, val or default)
for db_key, _, _, default in LIVE_FIELDS:
if db_key in PASSWORD_DB_KEYS:
raw = form.get(db_key)
val = (raw or "").strip()
if val:
set_setting(db_key, val)
passwords_updated.append(db_key)
else:
passwords_submitted_empty.append(db_key)
continue
val = (form.get(db_key) or "").strip()
if default or val:
set_setting(db_key, val or default)
return {
"passwords_updated": passwords_updated,
"passwords_submitted_empty": passwords_submitted_empty,
}
+66 -66
View File
@@ -1,66 +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 symbols import ths_to_codes
try:
from vnpy.trader.constant import Exchange
except ImportError:
Exchange = None # type: ignore
_EX_MAP = {
"SHFE": "SHFE",
"DCE": "DCE",
"CZCE": "CZCE",
"CFFEX": "CFFEX",
"INE": "INE",
}
def ths_to_vnpy_symbol(ths_code: str) -> Tuple[str, str]:
"""
返回 (symbol, exchange_enum_name)
rb2610 rb2610, SHFESR609 SR609, CZCE
"""
code = (ths_code or "").strip()
codes = ths_to_codes(code)
ex = (codes.get("ex") if codes else None)
if not ex and codes:
mc = (codes.get("market_code") or "")
if "." in mc:
ex = mc.rsplit(".", 1)[-1]
ex = _EX_MAP.get(ex or "SHFE", "SHFE")
m = re.match(r"^([A-Za-z]+)(\d+)$", code)
if not m:
return code, ex
letters, digits = m.group(1), m.group(2)
if ex == "CZCE":
# 郑商所 CTP 常为大写 + 3 位年月(如 SR509);4 位则取后 3 位
sym = letters.upper() + (digits[-3:] if len(digits) >= 3 else digits)
else:
sym = letters.lower() + digits
return sym, ex
def to_vnpy_exchange(ex_name: str):
if Exchange is None:
raise ImportError("vnpy 未安装")
mapping = {
"SHFE": Exchange.SHFE,
"DCE": Exchange.DCE,
"CZCE": Exchange.CZCE,
"CFFEX": Exchange.CFFEX,
"INE": Exchange.INE,
}
ex = mapping.get((ex_name or "").upper())
if ex is None:
raise ValueError(f"未知交易所: {ex_name}")
return ex
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""同花顺合约代码 → vnpy Symbol + Exchange。"""
from __future__ import annotations
import re
from typing import Optional, Tuple
from modules.core.symbols import ths_to_codes
try:
from vnpy.trader.constant import Exchange
except ImportError:
Exchange = None # type: ignore
_EX_MAP = {
"SHFE": "SHFE",
"DCE": "DCE",
"CZCE": "CZCE",
"CFFEX": "CFFEX",
"INE": "INE",
}
def ths_to_vnpy_symbol(ths_code: str) -> Tuple[str, str]:
"""
返回 (symbol, exchange_enum_name)
rb2610 rb2610, SHFESR609 SR609, CZCE
"""
code = (ths_code or "").strip()
codes = ths_to_codes(code)
ex = (codes.get("ex") if codes else None)
if not ex and codes:
mc = (codes.get("market_code") or "")
if "." in mc:
ex = mc.rsplit(".", 1)[-1]
ex = _EX_MAP.get(ex or "SHFE", "SHFE")
m = re.match(r"^([A-Za-z]+)(\d+)$", code)
if not m:
return code, ex
letters, digits = m.group(1), m.group(2)
if ex == "CZCE":
# 郑商所 CTP 常为大写 + 3 位年月(如 SR509);4 位则取后 3 位
sym = letters.upper() + (digits[-3:] if len(digits) >= 3 else digits)
else:
sym = letters.lower() + digits
return sym, ex
def to_vnpy_exchange(ex_name: str):
if Exchange is None:
raise ImportError("vnpy 未安装")
mapping = {
"SHFE": Exchange.SHFE,
"DCE": Exchange.DCE,
"CZCE": Exchange.CZCE,
"CFFEX": Exchange.CFFEX,
"INE": Exchange.INE,
}
ex = mapping.get((ex_name or "").upper())
if ex is None:
raise ValueError(f"未知交易所: {ex_name}")
return ex
@@ -1,337 +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 contract_specs import calc_position_metrics
from ctp_symbol import ths_to_vnpy_symbol
from fee_specs import calc_round_trip_fee
from symbols import ths_to_codes
from trade_log_lib import (
calc_equity_after,
purge_duplicate_local_trade_logs,
ensure_trade_log_columns,
refresh_trade_log_equity_chain,
)
from 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 trade_notify import notify_trade_log_close
from trading_context import trading_mode_label
from app import get_setting, send_wechat_msg
from ai_worker import schedule_ai_event_analysis
from 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 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
# 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
@@ -1,270 +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 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()
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 详见 LICENSE.zh-CN.txt
"""CTP 权威内存簿:委托、持仓、同步状态(事件增量 + 定期全量校准)。"""
from __future__ import annotations
import logging
import threading
import time
from typing import Any, Callable, Optional
logger = logging.getLogger(__name__)
CALIBRATE_INTERVAL_SEC = 30.0
def position_key(exchange: str, symbol: str, direction: str) -> str:
"""统一持仓键:exchange|symbol|direction"""
ex = (exchange or "").strip().upper()
sym = (symbol or "").strip().lower()
d = (direction or "long").strip().lower()
if ex:
return f"{ex}|{sym}|{d}"
return f"{sym}|{d}"
def parse_position_key(key: str) -> tuple[str, str, str]:
parts = (key or "").split("|")
if len(parts) >= 3:
return parts[0], parts[1], parts[2]
if len(parts) == 2:
return "", parts[0], parts[1]
return "", (key or "").lower(), "long"
def reconcile_position_avg(
old: Optional[dict[str, Any]],
new: dict[str, Any],
tick: Optional[float],
*,
trades: Optional[list[dict[str, Any]]] = None,
ths_sym: str = "",
) -> dict[str, Any]:
"""手数变化时采用柜台回报均价;手数不变时保持已锁定柜台价。"""
del tick, trades
from modules.ctp.ctp_entry_price import round_to_tick
row = dict(new)
lots = int(row.get("lots") or 0)
if lots <= 0:
return row
old_lots = int(old.get("lots") or 0) if old else 0
lots_changed = not old or old_lots != lots
sym = ths_sym or (row.get("symbol") or "")
pos_avg = float(row.get("avg_price") or 0)
if pos_avg > 0:
row["avg_price"] = round_to_tick(pos_avg, sym)
row["avg_price_locked"] = True
return row
if not lots_changed and old and float(old.get("avg_price") or 0) > 0:
row["avg_price"] = float(old["avg_price"])
row["avg_price_locked"] = True
return row
class CtpTradingState:
"""进程内 CTP 快照:柜台回报为准,SQLite 仅挂 SL/TP 元数据。"""
def __init__(self) -> None:
self._lock = threading.RLock()
self._orders: dict[str, dict[str, Any]] = {}
self._positions: dict[str, dict[str, Any]] = {}
self._tick_prices: dict[str, float] = {}
self._sync_state = "idle"
self._last_event_ts: float = 0.0
self._last_calibrate_ts: float = 0.0
self._on_change: Optional[Callable[[], None]] = None
def set_change_callback(self, fn: Optional[Callable[[], None]]) -> None:
self._on_change = fn
def _notify(self) -> None:
self._last_event_ts = time.time()
fn = self._on_change
if fn:
try:
fn()
except Exception as exc:
logger.debug("trading state change callback: %s", exc)
@property
def sync_state(self) -> str:
with self._lock:
return self._sync_state
def sync_label(self) -> str:
st = self.sync_state
if st == "syncing":
return "同步中…"
if st == "ready":
return "已同步"
return ""
def begin_sync(self) -> None:
with self._lock:
self._sync_state = "syncing"
def finish_sync(self) -> None:
with self._lock:
self._sync_state = "ready"
self._last_calibrate_ts = time.time()
def needs_calibrate(self) -> bool:
with self._lock:
if self._sync_state == "idle":
return True
return (time.time() - self._last_calibrate_ts) >= CALIBRATE_INTERVAL_SEC
def upsert_order(self, row: dict[str, Any], *, notify: bool = True) -> None:
oid = str(row.get("order_id") or row.get("vt_order_id") or "").strip()
if not oid:
return
with self._lock:
self._orders[oid] = dict(row)
if notify:
self._notify()
def remove_order(self, order_id: str, *, notify: bool = True) -> None:
oid = (order_id or "").strip()
if not oid:
return
removed = False
with self._lock:
if oid in self._orders:
del self._orders[oid]
removed = True
else:
for k in list(self._orders.keys()):
if k == oid or k.endswith(oid) or oid.endswith(k):
del self._orders[k]
removed = True
break
if removed and notify:
self._notify()
def get_position(self, pk: str) -> Optional[dict[str, Any]]:
with self._lock:
row = self._positions.get(pk)
return dict(row) if row else None
def try_lock_entry_prices(self) -> bool:
"""均价以柜台为准,不按 tick 反推(避免均价随行情跳动)。"""
return False
def upsert_position(
self,
row: dict[str, Any],
*,
notify: bool = True,
trades: Optional[list[dict[str, Any]]] = None,
ths_sym: str = "",
) -> None:
lots = int(row.get("lots") or 0)
ex = row.get("exchange") or ""
sym = row.get("symbol") or ""
direction = row.get("direction") or "long"
pk = position_key(ex, sym, direction)
tick = self.get_tick_price(ex, sym)
with self._lock:
if lots <= 0:
self._positions.pop(pk, None)
else:
old = self._positions.get(pk)
row = reconcile_position_avg(
old, dict(row), tick, trades=trades, ths_sym=ths_sym or sym,
)
row["position_key"] = pk
self._positions[pk] = row
if notify:
self._notify()
def remove_position(self, pk: str, *, notify: bool = True) -> None:
with self._lock:
self._positions.pop(pk, None)
if notify:
self._notify()
def set_tick_price(self, exchange: str, symbol: str, price: float) -> None:
if not symbol or price <= 0:
return
key = f"{(exchange or '').upper()}|{symbol.lower()}"
with self._lock:
self._tick_prices[key] = float(price)
def get_tick_price(self, exchange: str, symbol: str) -> Optional[float]:
key = f"{(exchange or '').upper()}|{symbol.lower()}"
with self._lock:
return self._tick_prices.get(key)
def get_active_orders(self) -> list[dict[str, Any]]:
with self._lock:
return list(self._orders.values())
def get_positions(self) -> list[dict[str, Any]]:
with self._lock:
return list(self._positions.values())
def position_keys(self) -> set[str]:
with self._lock:
return set(self._positions.keys())
def clear(self) -> None:
with self._lock:
self._orders.clear()
self._positions.clear()
self._tick_prices.clear()
self._sync_state = "idle"
def calibrate_from_lists(
self,
orders: list[dict[str, Any]],
positions: list[dict[str, Any]],
*,
trades: Optional[list[dict[str, Any]]] = None,
ths_for_vnpy_sym: Optional[Callable[[str, str], str]] = None,
preserve_positions_if_margin: float = 0.0,
) -> None:
"""全量校准:以 vnpy 内存为准重建订单/持仓簿。"""
self.begin_sync()
new_orders: dict[str, dict[str, Any]] = {}
for o in orders or []:
oid = str(o.get("order_id") or o.get("vt_order_id") or "").strip()
if oid:
new_orders[oid] = dict(o)
new_positions: dict[str, dict[str, Any]] = {}
for p in positions or []:
lots = int(p.get("lots") or 0)
if lots <= 0:
continue
ex = p.get("exchange") or ""
sym = p.get("symbol") or ""
direction = p.get("direction") or "long"
pk = position_key(ex, sym, direction)
row = dict(p)
row["position_key"] = pk
old = self._positions.get(pk)
tick = self.get_tick_price(ex, sym)
ths = sym
if ths_for_vnpy_sym:
try:
ths = ths_for_vnpy_sym(sym, ex) or sym
except Exception:
ths = sym
new_positions[pk] = reconcile_position_avg(
old, row, tick, trades=trades, ths_sym=ths,
)
if not new_positions and self._positions and preserve_positions_if_margin > 0:
with self._lock:
new_positions = {k: dict(v) for k, v in self._positions.items()}
with self._lock:
self._orders = new_orders
self._positions = new_positions
self.finish_sync()
self._notify()
trading_state = CtpTradingState()
+494 -494
View File
@@ -1,494 +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 ctp_ipc_client import worker_token
from db_conn import DB_PATH, commit_retry, connect_db
from fee_specs import get_setting, set_setting
from locale_fix import ensure_process_locale
from market_sessions import is_trading_session
from 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 trading_context import get_account_capital, get_trading_mode, get_trailing_be_tick_buffer
from 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 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 ctp_fee_worker import start_ctp_fee_worker
from ctp_premarket_connect import start_ctp_premarket_connect_worker
from ctp_reconnect import start_ctp_reconnect_worker
from order_pending import reconcile_pending_orders
from 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()
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""Isolated local CTP worker.
This process is the only process that should instantiate vn.py / vnpy_ctp.
The Flask web app talks to it through localhost HTTP via ctp_ipc_client.py.
"""
from __future__ import annotations
import logging
import os
import threading
import time
from typing import Any
os.environ.setdefault("QIHUO_CTP_ROLE", "worker")
from flask import Flask, jsonify, request
from modules.ctp.ctp_ipc_client import worker_token
from modules.core.db_conn import DB_PATH, commit_retry, connect_db
from modules.fees.fee_specs import get_setting, set_setting
from modules.core.locale_fix import ensure_process_locale
from modules.market.market_sessions import is_trading_session
from modules.trading.sl_tp_guard import check_sl_tp_on_tick, ensure_monitor_order_columns, start_sl_tp_guard_worker
from strategy.strategy_db import init_strategy_tables
from modules.core.trading_context import get_account_capital, get_trading_mode, get_trailing_be_tick_buffer
from modules.ctp.vnpy_bridge import (
_ctp_td_lock,
ctp_cancel_order,
ctp_disconnect,
ctp_estimate_margin_one_lot,
ctp_get_account,
ctp_get_tick_detail,
ctp_get_tick_price,
ctp_list_active_orders,
ctp_list_positions,
ctp_list_trades,
ctp_lookup_contract_spec,
ctp_start_connect,
ctp_status,
ctp_try_auto_reconnect,
execute_order,
get_bridge,
set_ctp_connected_callback,
set_position_refresh_callback,
set_tick_quote_callback,
set_tick_sl_tp_callback,
try_init_vnpy,
)
logging.basicConfig(
level=os.getenv("LOG_LEVEL", "INFO"),
format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
)
logger = logging.getLogger(__name__)
app = Flask(__name__)
_started_workers = False
_last_snapshot_ts = 0.0
_snapshot_lock = threading.Lock()
def _json_ok(**payload: Any):
return jsonify({"ok": True, **payload})
def _json_error(exc: Exception, *, status_code: int = 500):
return jsonify({"ok": False, "error": str(exc)}), status_code
def _require_token() -> None:
expected = worker_token()
got = request.headers.get("X-Qihuo-CTP-Token", "")
if expected and got != expected:
raise PermissionError("unauthorized")
@app.before_request
def _auth():
_require_token()
@app.errorhandler(Exception)
def _handle_error(exc: Exception):
code = 401 if isinstance(exc, PermissionError) else 500
logger.warning("ctp worker request failed: %s", exc)
return _json_error(exc, status_code=code)
def _mode_from_request() -> str:
data = request.get_json(silent=True) or {}
return (
data.get("mode")
or request.args.get("mode")
or get_trading_mode(get_setting)
or "simulation"
)
def _fast_status(mode: str) -> dict[str, Any]:
"""Return worker/native bridge state without slow network probing."""
from modules.ctp.ctp_settings import CTP_DISABLED_HINT, is_ctp_auto_connect_enabled
try:
st = dict(get_bridge().status(mode) or {})
except Exception as exc:
st = {
"connected": False,
"connecting": False,
"connected_mode": None,
"last_error": str(exc),
"mode_label": "SimNow" if mode == "simulation" else "期货公司实盘",
}
auto = is_ctp_auto_connect_enabled()
st["auto_connect_enabled"] = auto
st["worker_online"] = True
if not auto:
st["disabled_hint"] = CTP_DISABLED_HINT
if not st.get("connected") and not st.get("connecting"):
st["last_error"] = ""
st["td_reachable"] = None
return st
def _send_wechat_msg(content: str) -> None:
webhook = get_setting("wechat_webhook", "")
if not webhook:
return
try:
import requests
requests.post(
webhook,
json={"msgtype": "text", "text": {"content": f"【国内期货】\n{content}"}},
timeout=10,
)
except Exception as exc:
logger.debug("wechat notify failed: %s", exc)
def _init_worker_tables(conn) -> None:
init_strategy_tables(conn)
ensure_monitor_order_columns(conn)
def _capital(conn) -> float:
try:
return float(get_account_capital(get_setting, conn=conn) or 0)
except Exception:
return 0.0
def _persist_snapshot(mode: str) -> None:
global _last_snapshot_ts
with _snapshot_lock:
now = time.time()
if now - _last_snapshot_ts < 0.25:
return
_last_snapshot_ts = now
try:
import json
st = _fast_status(mode)
positions = ctp_list_positions(mode, refresh_if_empty=False, refresh_margin=False)
account = ctp_get_account(mode) if st.get("connected") else {}
conn = connect_db(DB_PATH)
try:
conn.execute(
"""CREATE TABLE IF NOT EXISTS ctp_worker_snapshots (
key TEXT PRIMARY KEY,
value TEXT,
updated_at REAL
)"""
)
for key, value in (
("status", st),
("positions", positions),
("account", account),
):
conn.execute(
"""INSERT INTO ctp_worker_snapshots(key, value, updated_at)
VALUES(?,?,?)
ON CONFLICT(key) DO UPDATE SET
value=excluded.value,
updated_at=excluded.updated_at""",
(key, json.dumps(value, ensure_ascii=False), now),
)
commit_retry(conn)
finally:
conn.close()
except Exception as exc:
logger.debug("persist ctp snapshot: %s", exc)
def _on_position_refresh() -> None:
try:
_persist_snapshot(get_trading_mode(get_setting))
except Exception as exc:
logger.debug("position refresh callback: %s", exc)
def _on_tick_quote() -> None:
_on_position_refresh()
def _on_tick_sl_tp(exchange: str, symbol: str, price: float) -> None:
mode = get_trading_mode(get_setting)
if not ctp_status(mode).get("connected"):
return
conn = connect_db(DB_PATH)
try:
_init_worker_tables(conn)
capital = _capital(conn)
n = check_sl_tp_on_tick(
conn,
mode,
exchange,
symbol,
price,
capital=capital,
notify_fn=_send_wechat_msg,
be_tick_mult=get_trailing_be_tick_buffer(get_setting),
)
if n:
commit_retry(conn)
_persist_snapshot(mode)
except Exception as exc:
logger.warning("worker tick sl/tp: %s", exc)
finally:
conn.close()
def _on_ctp_connected(mode: str) -> None:
try:
with _ctp_td_lock:
get_bridge().request_position_snapshot(force=True)
get_bridge().calibrate_trading_state()
_persist_snapshot(mode)
except Exception as exc:
logger.debug("worker ctp connected callback: %s", exc)
def _start_background_workers() -> None:
global _started_workers
if _started_workers:
return
_started_workers = True
set_position_refresh_callback(_on_position_refresh)
set_tick_quote_callback(_on_tick_quote)
set_tick_sl_tp_callback(_on_tick_sl_tp)
set_ctp_connected_callback(_on_ctp_connected)
from modules.ctp.ctp_fee_worker import start_ctp_fee_worker
from modules.ctp.ctp_premarket_connect import start_ctp_premarket_connect_worker
from modules.ctp.ctp_reconnect import start_ctp_reconnect_worker
from modules.trading.order_pending import reconcile_pending_orders
from modules.trading.pending_order_worker import start_pending_order_worker
def _mode() -> str:
return get_trading_mode(get_setting)
start_ctp_reconnect_worker(get_mode_fn=_mode, get_setting_fn=get_setting)
start_ctp_premarket_connect_worker(get_mode_fn=_mode, get_setting_fn=get_setting)
start_ctp_fee_worker(
get_mode_fn=_mode,
get_setting_fn=get_setting,
set_setting_fn=set_setting,
)
start_pending_order_worker(
db_path=DB_PATH,
get_mode_fn=_mode,
init_tables_fn=_init_worker_tables,
get_capital_fn=_capital,
reconcile_fn=reconcile_pending_orders,
on_changed_fn=lambda: _persist_snapshot(_mode()),
)
start_sl_tp_guard_worker(
db_path=DB_PATH,
get_mode_fn=_mode,
init_tables_fn=_init_worker_tables,
get_capital_fn=_capital,
get_be_tick_buffer_fn=lambda: get_trailing_be_tick_buffer(get_setting),
notify_fn=_send_wechat_msg,
)
def _snapshot_loop() -> None:
time.sleep(3)
while True:
try:
mode = _mode()
if _fast_status(mode).get("connected"):
_persist_snapshot(mode)
except Exception as exc:
logger.debug("worker snapshot loop: %s", exc)
time.sleep(2 if is_trading_session() else 15)
threading.Thread(target=_snapshot_loop, daemon=True, name="ctp-worker-snapshot").start()
@app.route("/health")
def health():
mode = request.args.get("mode") or get_trading_mode(get_setting)
st = _fast_status(mode)
return _json_ok(
worker_online=True,
role=os.getenv("QIHUO_CTP_ROLE", "worker"),
mode=mode,
status=st,
ts=time.time(),
)
@app.route("/ctp/status")
def api_status():
mode = _mode_from_request()
return _json_ok(status=_fast_status(mode))
@app.route("/ctp/connect", methods=["POST"])
def api_connect():
data = request.get_json(silent=True) or {}
mode = data.get("mode") or get_trading_mode(get_setting)
info = ctp_start_connect(mode, force=bool(data.get("force")))
st = info.get("status") or _fast_status(mode)
return _json_ok(status=st, **{k: v for k, v in info.items() if k != "status"})
@app.route("/ctp/start_connect", methods=["POST"])
def api_start_connect():
data = request.get_json(silent=True) or {}
mode = data.get("mode") or get_trading_mode(get_setting)
return _json_ok(**ctp_start_connect(
mode,
force=bool(data.get("force")),
scheduled=bool(data.get("scheduled")),
))
@app.route("/ctp/disconnect", methods=["POST"])
def api_disconnect():
data = request.get_json(silent=True) or {}
ctp_disconnect(set_disabled_hint=bool(data.get("set_disabled_hint")))
return _json_ok(disconnected=True)
@app.route("/ctp/account")
def api_account():
mode = _mode_from_request()
if not _fast_status(mode).get("connected"):
return _json_ok(account={})
return _json_ok(account=ctp_get_account(mode))
@app.route("/ctp/positions", methods=["POST"])
def api_positions():
data = request.get_json(silent=True) or {}
mode = data.get("mode") or get_trading_mode(get_setting)
return _json_ok(positions=ctp_list_positions(
mode,
refresh_if_empty=bool(data.get("refresh_if_empty", True)),
refresh_margin=bool(data.get("refresh_margin", False)),
))
@app.route("/ctp/trades", methods=["POST"])
def api_trades():
data = request.get_json(silent=True) or {}
mode = data.get("mode") or get_trading_mode(get_setting)
return _json_ok(trades=ctp_list_trades(mode, refresh=bool(data.get("refresh"))))
@app.route("/ctp/active_orders")
def api_active_orders():
mode = _mode_from_request()
return _json_ok(orders=ctp_list_active_orders(mode))
@app.route("/ctp/tick_price", methods=["POST"])
def api_tick_price():
data = request.get_json(silent=True) or {}
return _json_ok(price=ctp_get_tick_price(
data.get("mode") or get_trading_mode(get_setting),
data.get("symbol") or "",
))
@app.route("/ctp/tick_detail", methods=["POST"])
def api_tick_detail():
data = request.get_json(silent=True) or {}
return _json_ok(detail=ctp_get_tick_detail(
data.get("mode") or get_trading_mode(get_setting),
data.get("symbol") or "",
))
@app.route("/ctp/estimate_margin_one_lot", methods=["POST"])
def api_estimate_margin():
data = request.get_json(silent=True) or {}
return _json_ok(margin=ctp_estimate_margin_one_lot(
data.get("mode") or get_trading_mode(get_setting),
data.get("symbol") or "",
float(data.get("price") or 0),
direction=data.get("direction") or "long",
))
@app.route("/ctp/contract_spec", methods=["POST"])
def api_contract_spec():
data = request.get_json(silent=True) or {}
return _json_ok(spec=ctp_lookup_contract_spec(
data.get("mode") or get_trading_mode(get_setting),
data.get("symbol") or "",
))
@app.route("/ctp/order", methods=["POST"])
def api_order():
data = request.get_json(silent=True) or {}
mode = data.get("mode") or get_trading_mode(get_setting)
result = execute_order(
None,
mode=mode,
offset=data.get("offset") or "open",
symbol=data.get("symbol") or "",
direction=data.get("direction") or "long",
lots=int(data.get("lots") or 1),
price=float(data.get("price") or 0),
settings=data.get("settings") or {},
order_type=data.get("order_type") or "limit",
)
_persist_snapshot(mode)
return _json_ok(**result)
@app.route("/ctp/cancel", methods=["POST"])
def api_cancel():
data = request.get_json(silent=True) or {}
mode = data.get("mode") or get_trading_mode(get_setting)
cancelled = ctp_cancel_order(mode, data.get("vt_orderid") or "")
_persist_snapshot(mode)
return _json_ok(cancelled=cancelled)
@app.route("/ctp/bridge/<action>", methods=["POST"])
def api_bridge_action(action: str):
data = request.get_json(silent=True) or {}
b = get_bridge()
if action == "calibrate_trading_state":
return _json_ok(result=b.calibrate_trading_state())
if action == "request_position_snapshot":
return _json_ok(result=b.request_position_snapshot(force=bool(data.get("force"))))
if action == "subscribe_symbol":
return _json_ok(result=b.subscribe_symbol(data.get("symbol") or ""))
if action == "refresh_positions":
return _json_ok(result=b.refresh_positions())
if action == "connect_in_progress":
return _json_ok(result=b.connect_in_progress())
if action == "reconnect_after_settings_saved":
mode = data.get("mode") or get_trading_mode(get_setting)
return _json_ok(result=b.reconnect_after_settings_saved(mode))
if action == "query_all_commissions":
return _json_ok(result=b.query_all_commissions(
mode=data.get("mode") or get_trading_mode(get_setting),
))
if action == "query_instrument_commission":
return _json_ok(result=b.query_instrument_commission(
data.get("symbol") or "",
mode=data.get("mode") or get_trading_mode(get_setting),
))
if action == "get_kline_bars_1m":
return _json_ok(result=b.get_kline_bars_1m(
data.get("symbol") or "",
mode=data.get("mode") or get_trading_mode(get_setting),
))
return _json_error(ValueError(f"unsupported bridge action: {action}"), status_code=404)
def main() -> None:
ensure_process_locale()
try_init_vnpy({})
_start_background_workers()
host = os.getenv("QIHUO_CTP_WORKER_HOST", "127.0.0.1")
port = int(os.getenv("QIHUO_CTP_WORKER_PORT", "6601") or 6601)
logger.info("starting qihuo-ctp worker on %s:%s", host, port)
app.run(host=host, port=port, debug=False, threaded=True, use_reloader=False)
if __name__ == "__main__":
main()
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
from modules.fees.routes import register
__all__ = ["register"]
+385 -385
View File
@@ -1,385 +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 contract_specs import get_contract_spec
from 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 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()
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""期货手续费:仅 CTP 柜台同步入库,前端只读展示。"""
import json
import os
import re
from datetime import datetime
from typing import Optional
from modules.core.contract_specs import get_contract_spec
from modules.core.db_conn import connect_db, is_benign_migration_error
DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "futures.db")
DATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data")
DEFAULT_JSON = os.path.join(DATA_DIR, "fee_rates.json")
# 无配置时的兜底(已为交易所标准约 2 倍)
DEFAULT_FEE = {
"open_fixed": 2.0,
"open_ratio": 0.0,
"close_yesterday_fixed": 2.0,
"close_yesterday_ratio": 0.0,
"close_today_fixed": 4.0,
"close_today_ratio": 0.0,
}
_INDEX_PRODUCTS = {"if", "ih", "ic", "im"}
def product_from_code(ths_code: str) -> str:
code = (ths_code or "").strip()
m = re.match(r"^([A-Za-z]+)", code)
return m.group(1).lower() if m else ""
def _get_db():
return connect_db()
def ensure_fee_rates_schema(conn=None) -> None:
"""补齐 fee_rates 表结构(旧库可能缺少 source 列)。"""
close = False
if conn is None:
conn = _get_db()
close = True
try:
for sql in (
"ALTER TABLE fee_rates ADD COLUMN source TEXT DEFAULT 'local'",
):
try:
conn.execute(sql)
except Exception as exc:
if not is_benign_migration_error(exc):
raise
conn.commit()
finally:
if close:
conn.close()
def get_setting(key: str, default: str = "") -> str:
conn = _get_db()
row = conn.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone()
conn.close()
if not row:
return default
return (row["value"] or default) if row["value"] is not None else default
def set_setting(key: str, value: str) -> None:
conn = _get_db()
conn.execute(
"""INSERT INTO settings (key, value) VALUES (?,?)
ON CONFLICT(key) DO UPDATE SET value=excluded.value""",
(key, value),
)
conn.commit()
conn.close()
def get_fee_multiplier() -> float:
conn = _get_db()
row = conn.execute(
"SELECT value FROM settings WHERE key='fee_multiplier'"
).fetchone()
conn.close()
if row and row["value"]:
try:
return max(0.0, float(row["value"]))
except ValueError:
pass
return 2.0
def get_fee_source_mode() -> str:
"""固定 CTP 柜台。"""
return "ctp"
def purge_non_ctp_fee_rates() -> int:
"""删除非 CTP 来源的费率缓存。"""
conn = _get_db()
cur = conn.execute(
"DELETE FROM fee_rates WHERE COALESCE(source, '') != 'ctp'"
)
n = cur.rowcount
conn.commit()
conn.close()
return n
def _row_to_spec(row, mult: int) -> dict:
return {
"product": row["product"],
"exchange": row["exchange"] or "",
"mult": int(row["mult"] or mult),
"open_fixed": float(row["open_fixed"] or 0),
"open_ratio": float(row["open_ratio"] or 0),
"close_yesterday_fixed": float(row["close_yesterday_fixed"] or 0),
"close_yesterday_ratio": float(row["close_yesterday_ratio"] or 0),
"close_today_fixed": float(row["close_today_fixed"] or 0),
"close_today_ratio": float(row["close_today_ratio"] or 0),
"source": row["source"] if "source" in row.keys() else "local",
}
def get_fee_spec(ths_code: str, *, trading_mode: str = "simulation") -> dict:
product = product_from_code(ths_code)
if not product:
spec = get_contract_spec(ths_code)
return {**DEFAULT_FEE, "mult": spec["mult"], "product": "", "exchange": "", "source": "default"}
mult = get_contract_spec(ths_code)["mult"]
conn = _get_db()
ensure_fee_rates_schema(conn)
row = conn.execute(
"SELECT * FROM fee_rates WHERE product=? AND source='ctp'",
(product,),
).fetchone()
conn.close()
if row:
return _row_to_spec(row, mult)
try:
from modules.ctp.ctp_fee_sync import sync_fee_for_symbol
fields = sync_fee_for_symbol(trading_mode, ths_code)
if fields:
return {"product": product, **fields}
except Exception:
pass
if product in _INDEX_PRODUCTS:
return {
"product": product,
"exchange": "CFFEX",
"mult": mult,
"open_fixed": 0.0,
"open_ratio": 0.000092,
"close_yesterday_fixed": 0.0,
"close_yesterday_ratio": 0.000092,
"close_today_fixed": 0.0,
"close_today_ratio": 0.000276,
}
return {
"product": product,
"exchange": "",
"mult": mult,
**DEFAULT_FEE,
"source": "default",
}
def calc_side_fee(
price: float,
lots: float,
mult: int,
fixed: float,
ratio: float,
) -> float:
lots = lots or 1.0
fixed = fixed or 0.0
ratio = ratio or 0.0
return fixed * lots + ratio * price * mult * lots
def is_same_day(open_time: str, close_time: str) -> bool:
if not open_time or not close_time:
return True
o = open_time.strip().replace(" ", "T")[:10]
c = close_time.strip().replace(" ", "T")[:10]
return o == c
def calc_round_trip_fee(
ths_code: str,
entry_price: float,
close_price: float,
lots: float,
open_time: str = "",
close_time: str = "",
trading_mode: str = "simulation",
) -> float:
if not entry_price or not close_price:
return 0.0
spec = get_fee_spec(ths_code, trading_mode=trading_mode)
mult = spec["mult"]
lots = lots or 1.0
open_fee = calc_side_fee(
entry_price, lots, mult,
spec["open_fixed"], spec["open_ratio"],
)
if is_same_day(open_time, close_time):
close_fee = calc_side_fee(
close_price, lots, mult,
spec["close_today_fixed"], spec["close_today_ratio"],
)
else:
close_fee = calc_side_fee(
close_price, lots, mult,
spec["close_yesterday_fixed"], spec["close_yesterday_ratio"],
)
return round(open_fee + close_fee, 2)
def calc_fee_breakdown(
ths_code: str,
entry_price: float,
close_price: float,
lots: float,
open_time: str = "",
close_time: str = "",
trading_mode: str = "simulation",
) -> dict:
spec = get_fee_spec(ths_code, trading_mode=trading_mode)
mult = spec["mult"]
lots = lots or 1.0
open_fee = calc_side_fee(
entry_price, lots, mult, spec["open_fixed"], spec["open_ratio"],
)
same_day = is_same_day(open_time, close_time)
if same_day:
close_fee = calc_side_fee(
close_price, lots, mult,
spec["close_today_fixed"], spec["close_today_ratio"],
)
close_type = "平今"
else:
close_fee = calc_side_fee(
close_price, lots, mult,
spec["close_yesterday_fixed"], spec["close_yesterday_ratio"],
)
close_type = "平昨"
total = round(open_fee + close_fee, 2)
return {
"open_fee": round(open_fee, 2),
"close_fee": round(close_fee, 2),
"close_type": close_type,
"total_fee": total,
"same_day": same_day,
"fee_source": spec.get("source", "local"),
}
def load_fee_rates_from_json(path: Optional[str] = None) -> int:
path = path or DEFAULT_JSON
if not os.path.isfile(path):
return 0
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
conn = _get_db()
now = datetime.now().isoformat(timespec="seconds")
count = 0
for product, item in data.items():
if not isinstance(item, dict):
continue
conn.execute(
"""INSERT INTO fee_rates
(product, exchange, mult,
open_fixed, open_ratio,
close_yesterday_fixed, close_yesterday_ratio,
close_today_fixed, close_today_ratio, updated_at, source)
VALUES (?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(product) DO UPDATE SET
exchange=excluded.exchange, mult=excluded.mult,
open_fixed=excluded.open_fixed, open_ratio=excluded.open_ratio,
close_yesterday_fixed=excluded.close_yesterday_fixed,
close_yesterday_ratio=excluded.close_yesterday_ratio,
close_today_fixed=excluded.close_today_fixed,
close_today_ratio=excluded.close_today_ratio,
updated_at=excluded.updated_at,
source=excluded.source""",
(
product.lower(),
item.get("exchange", ""),
int(item.get("mult") or get_contract_spec(product)["mult"]),
float(item.get("open_fixed") or 0),
float(item.get("open_ratio") or 0),
float(item.get("close_yesterday_fixed") or 0),
float(item.get("close_yesterday_ratio") or 0),
float(item.get("close_today_fixed") or 0),
float(item.get("close_today_ratio") or 0),
now,
item.get("source", "json"),
),
)
count += 1
conn.commit()
conn.close()
return count
def list_ctp_fee_rates() -> list:
"""手续费页:仅展示 CTP 同步结果。"""
conn = _get_db()
rows = conn.execute(
"SELECT * FROM fee_rates WHERE source='ctp' ORDER BY product"
).fetchall()
conn.close()
return [dict(r) for r in rows]
def list_all_fee_rates() -> list:
conn = _get_db()
rows = conn.execute(
"SELECT * FROM fee_rates ORDER BY product"
).fetchall()
conn.close()
return [dict(r) for r in rows]
def list_fee_rates_for_ui() -> list:
return list_ctp_fee_rates()
def count_fee_rates_by_source() -> dict[str, int]:
conn = _get_db()
n = conn.execute(
"SELECT COUNT(*) FROM fee_rates WHERE source='ctp'"
).fetchone()[0]
conn.close()
return {"ctp": int(n or 0)}
def upsert_fee_rate(product: str, fields: dict) -> None:
product = product.lower().strip()
conn = _get_db()
now = datetime.now().isoformat(timespec="seconds")
source = fields.get("source", "manual")
conn.execute(
"""INSERT INTO fee_rates
(product, exchange, mult,
open_fixed, open_ratio,
close_yesterday_fixed, close_yesterday_ratio,
close_today_fixed, close_today_ratio, updated_at, source)
VALUES (?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(product) DO UPDATE SET
exchange=excluded.exchange, mult=excluded.mult,
open_fixed=excluded.open_fixed, open_ratio=excluded.open_ratio,
close_yesterday_fixed=excluded.close_yesterday_fixed,
close_yesterday_ratio=excluded.close_yesterday_ratio,
close_today_fixed=excluded.close_today_fixed,
close_today_ratio=excluded.close_today_ratio,
updated_at=excluded.updated_at,
source=excluded.source""",
(
product,
fields.get("exchange", ""),
int(fields.get("mult") or 10),
float(fields.get("open_fixed") or 0),
float(fields.get("open_ratio") or 0),
float(fields.get("close_yesterday_fixed") or 0),
float(fields.get("close_yesterday_ratio") or 0),
float(fields.get("close_today_fixed") or 0),
float(fields.get("close_today_ratio") or 0),
now,
source,
),
)
conn.commit()
conn.close()
+91 -91
View File
@@ -1,91 +1,91 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""从第三方(AKShare)同步交易所参考手续费,并按倍率写入本地表。"""
import re
from typing import Any, Optional
from contract_specs import get_contract_spec
from 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}"
# 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}"

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