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:
+2
-61
@@ -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=SimNow,live=期货公司(系统设置页可改)
|
||||
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,4 +1,5 @@
|
||||
.env
|
||||
config/.env
|
||||
*.db
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# Compatibility shim — use modules.settings.admin_settings
|
||||
from modules.settings.admin_settings import * # noqa: F401,F403
|
||||
@@ -0,0 +1,2 @@
|
||||
# Compatibility shim — use modules.notify.ai_client
|
||||
from modules.notify.ai_client import * # noqa: F401,F403
|
||||
@@ -0,0 +1,2 @@
|
||||
# Compatibility shim — use modules.notify.ai_messages
|
||||
from modules.notify.ai_messages import * # noqa: F401,F403
|
||||
@@ -0,0 +1,2 @@
|
||||
# Compatibility shim — use modules.notify.ai_worker
|
||||
from modules.notify.ai_worker import * # noqa: F401,F403
|
||||
@@ -0,0 +1,2 @@
|
||||
# Compatibility shim — use modules.core.contract_profile
|
||||
from modules.core.contract_profile import * # noqa: F401,F403
|
||||
@@ -0,0 +1,2 @@
|
||||
# Compatibility shim — use modules.core.contract_specs
|
||||
from modules.core.contract_specs import * # noqa: F401,F403
|
||||
@@ -0,0 +1,2 @@
|
||||
# Compatibility shim — use modules.ctp.ctp_entry_price
|
||||
from modules.ctp.ctp_entry_price import * # noqa: F401,F403
|
||||
@@ -0,0 +1,2 @@
|
||||
# Compatibility shim — use modules.ctp.ctp_fee_sync
|
||||
from modules.ctp.ctp_fee_sync import * # noqa: F401,F403
|
||||
@@ -0,0 +1,2 @@
|
||||
# Compatibility shim — use modules.ctp.ctp_fee_worker
|
||||
from modules.ctp.ctp_fee_worker import * # noqa: F401,F403
|
||||
@@ -0,0 +1,2 @@
|
||||
# Compatibility shim — use modules.ctp.ctp_ipc_client
|
||||
from modules.ctp.ctp_ipc_client import * # noqa: F401,F403
|
||||
@@ -0,0 +1,2 @@
|
||||
# Compatibility shim — use modules.ctp.ctp_kline
|
||||
from modules.ctp.ctp_kline import * # noqa: F401,F403
|
||||
@@ -0,0 +1,2 @@
|
||||
# Compatibility shim — use modules.ctp.ctp_premarket_connect
|
||||
from modules.ctp.ctp_premarket_connect import * # noqa: F401,F403
|
||||
@@ -0,0 +1,2 @@
|
||||
# Compatibility shim — use modules.ctp.ctp_reconnect
|
||||
from modules.ctp.ctp_reconnect import * # noqa: F401,F403
|
||||
@@ -0,0 +1,2 @@
|
||||
# Compatibility shim — use modules.ctp.ctp_settings
|
||||
from modules.ctp.ctp_settings import * # noqa: F401,F403
|
||||
@@ -0,0 +1,2 @@
|
||||
# Compatibility shim — use modules.ctp.ctp_symbol
|
||||
from modules.ctp.ctp_symbol import * # noqa: F401,F403
|
||||
@@ -0,0 +1,2 @@
|
||||
# Compatibility shim — use modules.ctp.ctp_trade_sync
|
||||
from modules.ctp.ctp_trade_sync import * # noqa: F401,F403
|
||||
@@ -0,0 +1,2 @@
|
||||
# Compatibility shim — use modules.ctp.ctp_trading_state
|
||||
from modules.ctp.ctp_trading_state import * # noqa: F401,F403
|
||||
@@ -0,0 +1,2 @@
|
||||
# Compatibility shim — use modules.ctp.ctp_worker
|
||||
from modules.ctp.ctp_worker import * # noqa: F401,F403
|
||||
@@ -0,0 +1,2 @@
|
||||
# Compatibility shim — use modules.stats.dashboard_lib
|
||||
from modules.stats.dashboard_lib import * # noqa: F401,F403
|
||||
@@ -0,0 +1,2 @@
|
||||
# Compatibility shim — use modules.backup.db_backup
|
||||
from modules.backup.db_backup import * # noqa: F401,F403
|
||||
@@ -0,0 +1,2 @@
|
||||
# Compatibility shim — use modules.core.db_conn
|
||||
from modules.core.db_conn import * # noqa: F401,F403
|
||||
@@ -0,0 +1,2 @@
|
||||
# Compatibility shim — use modules.core.doc_render
|
||||
from modules.core.doc_render import * # noqa: F401,F403
|
||||
@@ -0,0 +1,2 @@
|
||||
# Compatibility shim — use modules.core.env_file
|
||||
from modules.core.env_file import * # noqa: F401,F403
|
||||
@@ -0,0 +1,2 @@
|
||||
# Compatibility shim — use modules.fees.fee_specs
|
||||
from modules.fees.fee_specs import * # noqa: F401,F403
|
||||
@@ -0,0 +1,2 @@
|
||||
# Compatibility shim — use modules.fees.fee_sync
|
||||
from modules.fees.fee_sync import * # noqa: F401,F403
|
||||
@@ -0,0 +1,2 @@
|
||||
# Compatibility shim — use modules.keys.key_monitor_lib
|
||||
from modules.keys.key_monitor_lib import * # noqa: F401,F403
|
||||
@@ -0,0 +1,2 @@
|
||||
# Compatibility shim — use modules.market.kline_chart
|
||||
from modules.market.kline_chart import * # noqa: F401,F403
|
||||
@@ -0,0 +1,2 @@
|
||||
# Compatibility shim — use modules.market.kline_store
|
||||
from modules.market.kline_store import * # noqa: F401,F403
|
||||
@@ -0,0 +1,2 @@
|
||||
# Compatibility shim — use modules.market.kline_stream
|
||||
from modules.market.kline_stream import * # noqa: F401,F403
|
||||
@@ -0,0 +1,2 @@
|
||||
# Compatibility shim — use modules.core.locale_fix
|
||||
from modules.core.locale_fix import * # noqa: F401,F403
|
||||
@@ -0,0 +1,2 @@
|
||||
# Compatibility shim — use modules.market.market
|
||||
from modules.market.market import * # noqa: F401,F403
|
||||
@@ -0,0 +1,2 @@
|
||||
# Compatibility shim — use modules.market.market_sessions
|
||||
from modules.market.market_sessions import * # noqa: F401,F403
|
||||
@@ -0,0 +1,2 @@
|
||||
# Compatibility shim — use modules.settings.nav_settings
|
||||
from modules.settings.nav_settings import * # noqa: F401,F403
|
||||
@@ -0,0 +1,2 @@
|
||||
# Compatibility shim — use modules.trading.order_pending
|
||||
from modules.trading.order_pending import * # noqa: F401,F403
|
||||
@@ -0,0 +1,2 @@
|
||||
# Compatibility shim — use modules.trading.pending_order_worker
|
||||
from modules.trading.pending_order_worker import * # noqa: F401,F403
|
||||
@@ -0,0 +1,2 @@
|
||||
# Compatibility shim — use modules.trading.position_sizing
|
||||
from modules.trading.position_sizing import * # noqa: F401,F403
|
||||
@@ -0,0 +1,2 @@
|
||||
# Compatibility shim — use modules.trading.position_stream
|
||||
from modules.trading.position_stream import * # noqa: F401,F403
|
||||
@@ -0,0 +1,2 @@
|
||||
# Compatibility shim — use modules.trading.product_recommend
|
||||
from modules.trading.product_recommend import * # noqa: F401,F403
|
||||
@@ -0,0 +1,2 @@
|
||||
# Compatibility shim — use modules.trading.recommend_store
|
||||
from modules.trading.recommend_store import * # noqa: F401,F403
|
||||
@@ -0,0 +1,2 @@
|
||||
# Compatibility shim — use modules.trading.recommend_stream
|
||||
from modules.trading.recommend_stream import * # noqa: F401,F403
|
||||
@@ -0,0 +1,2 @@
|
||||
# Compatibility shim — use modules.trading.recommend_trend
|
||||
from modules.trading.recommend_trend import * # noqa: F401,F403
|
||||
@@ -0,0 +1,2 @@
|
||||
# Compatibility shim — use modules.risk.account_risk_lib
|
||||
from modules.risk.account_risk_lib import * # noqa: F401,F403
|
||||
@@ -0,0 +1,2 @@
|
||||
# Compatibility shim — use modules.trading.sl_tp_guard
|
||||
from modules.trading.sl_tp_guard import * # noqa: F401,F403
|
||||
@@ -0,0 +1,2 @@
|
||||
# Compatibility shim — use modules.stats.stats_engine
|
||||
from modules.stats.stats_engine import * # noqa: F401,F403
|
||||
@@ -0,0 +1,2 @@
|
||||
# Compatibility shim — use modules.strategy.fib_lib
|
||||
from modules.strategy.fib_lib import * # noqa: F401,F403
|
||||
@@ -0,0 +1,2 @@
|
||||
# Compatibility shim — use modules.strategy.strategy_db
|
||||
from modules.strategy.strategy_db import * # noqa: F401,F403
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
# Compatibility shim — use modules.strategy.strategy_trend_lib
|
||||
from modules.strategy.strategy_trend_lib import * # noqa: F401,F403
|
||||
@@ -0,0 +1,2 @@
|
||||
# Compatibility shim — use modules.core.symbols
|
||||
from modules.core.symbols import * # noqa: F401,F403
|
||||
@@ -0,0 +1,2 @@
|
||||
# Compatibility shim — use modules.trading.trade_log_lib
|
||||
from modules.trading.trade_log_lib import * # noqa: F401,F403
|
||||
@@ -0,0 +1,2 @@
|
||||
# Compatibility shim — use modules.trading.trade_notify
|
||||
from modules.trading.trade_notify import * # noqa: F401,F403
|
||||
@@ -0,0 +1,2 @@
|
||||
# Compatibility shim — use modules.core.trading_context
|
||||
from modules.core.trading_context import * # noqa: F401,F403
|
||||
@@ -0,0 +1,2 @@
|
||||
# Compatibility shim — use modules.ctp.vnpy_bridge
|
||||
from modules.ctp.vnpy_bridge import * # noqa: F401,F403
|
||||
@@ -0,0 +1,2 @@
|
||||
# Compatibility shim — use modules.notify.wechat_notify
|
||||
from modules.notify.wechat_notify import * # noqa: F401,F403
|
||||
@@ -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=SimNow,live=期货公司(系统设置页可改)
|
||||
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
|
||||
@@ -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=$?
|
||||
|
||||
@@ -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 兼容 shim(PM2 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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
|
||||
"""Qihuo feature modules package."""
|
||||
@@ -0,0 +1,5 @@
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
|
||||
from modules.backup.routes import register
|
||||
|
||||
__all__ = ["register"]
|
||||
@@ -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()
|
||||
@@ -0,0 +1,78 @@
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
"""HTTP routes for backup module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
from flask import (
|
||||
Response,
|
||||
flash,
|
||||
jsonify,
|
||||
redirect,
|
||||
render_template,
|
||||
request,
|
||||
send_file,
|
||||
session,
|
||||
stream_with_context,
|
||||
url_for,
|
||||
)
|
||||
|
||||
|
||||
def register(deps) -> None:
|
||||
app = deps.app
|
||||
login_required = deps.login_required
|
||||
require_nav = deps.require_nav
|
||||
get_db = deps.get_db
|
||||
get_setting = deps.get_setting
|
||||
set_setting = deps.set_setting
|
||||
fetch_price = deps.fetch_price
|
||||
send_wechat_msg = deps.send_wechat_msg
|
||||
touch_stats_cache = deps.touch_stats_cache
|
||||
get_stats_data = deps.get_stats_data
|
||||
build_market_quote_payload = deps.build_market_quote_payload
|
||||
today_str = deps.today_str
|
||||
expire_old_plans = deps.expire_old_plans
|
||||
TZ = deps.tz
|
||||
DB_PATH = deps.db_path
|
||||
UPLOAD_DIR = deps.upload_dir
|
||||
OPEN_TYPES = deps.open_types
|
||||
EXIT_TRIGGERS = deps.exit_triggers
|
||||
BEHAVIOR_TAGS = deps.behavior_tags
|
||||
KLINE_PERIODS = deps.kline_periods
|
||||
KLINE_CUTOFFS = deps.kline_cutoffs
|
||||
calc_holding_duration = deps.calc_holding_duration
|
||||
holding_to_minutes = deps.holding_to_minutes
|
||||
classify_close_result = deps.classify_close_result
|
||||
calc_rr_ratio = deps.calc_rr_ratio
|
||||
calc_theoretical_pnl = deps.calc_theoretical_pnl
|
||||
parse_review_date_filter = deps.parse_review_date_filter
|
||||
_trading_mode = deps.trading_mode
|
||||
_ua_is_phone = deps.ua_is_phone
|
||||
_static_asset_v = deps.static_asset_v
|
||||
|
||||
from modules.backup.db_backup import list_backups, resolve_backup_file
|
||||
|
||||
@app.route("/api/backup/list")
|
||||
@login_required
|
||||
def api_backup_list():
|
||||
return jsonify(
|
||||
{
|
||||
"dir": str(backup_dir()),
|
||||
"last_at": get_backup_last_at(get_setting),
|
||||
"running": backup_in_progress(),
|
||||
"items": list_backups(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.route("/api/backup/download/<filename>")
|
||||
@login_required
|
||||
def api_backup_download(filename):
|
||||
from flask import send_file
|
||||
|
||||
try:
|
||||
path = resolve_backup_file(filename)
|
||||
except (ValueError, FileNotFoundError) as exc:
|
||||
return jsonify({"error": str(exc)}), 404
|
||||
return send_file(path, as_attachment=True, download_name=path.name)
|
||||
@@ -0,0 +1,8 @@
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
|
||||
"""Core bootstrap and shared types."""
|
||||
|
||||
from modules.core.bootstrap import register_all_modules, start_module_workers
|
||||
from modules.core.deps import AppDeps
|
||||
|
||||
__all__ = ["AppDeps", "register_all_modules", "start_module_workers"]
|
||||
@@ -0,0 +1,55 @@
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
|
||||
"""Application module registry and startup wiring."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from modules.core.deps import AppDeps
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Registration order: core services first, trading last among features.
|
||||
_MODULE_NAMES = (
|
||||
"modules.web",
|
||||
"modules.market",
|
||||
"modules.keys",
|
||||
"modules.plans",
|
||||
"modules.notify",
|
||||
"modules.records",
|
||||
"modules.stats",
|
||||
"modules.fees",
|
||||
"modules.backup",
|
||||
"modules.settings",
|
||||
"modules.risk",
|
||||
"modules.strategy",
|
||||
"modules.ctp",
|
||||
"modules.trading",
|
||||
)
|
||||
|
||||
|
||||
def register_all_modules(deps: "AppDeps") -> None:
|
||||
for name in _MODULE_NAMES:
|
||||
mod = importlib.import_module(name)
|
||||
register = getattr(mod, "register", None)
|
||||
if not callable(register):
|
||||
logger.warning("module %s has no register()", name)
|
||||
continue
|
||||
register(deps)
|
||||
logger.debug("registered %s", name)
|
||||
|
||||
|
||||
def start_module_workers(deps: "AppDeps") -> None:
|
||||
"""Background threads owned by feature modules."""
|
||||
from modules.ctp.vnpy_bridge import try_init_vnpy
|
||||
|
||||
try_init_vnpy({})
|
||||
for name in ("modules.market",):
|
||||
mod = importlib.import_module(name)
|
||||
start = getattr(mod, "start_workers", None)
|
||||
if callable(start):
|
||||
start(deps)
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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:
|
||||
@@ -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 "期货公司实盘"
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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, SHFE;SR609 → SR609, CZCE
|
||||
"""
|
||||
code = (ths_code or "").strip()
|
||||
codes = ths_to_codes(code)
|
||||
ex = (codes.get("ex") if codes else None)
|
||||
if not ex and codes:
|
||||
mc = (codes.get("market_code") or "")
|
||||
if "." in mc:
|
||||
ex = mc.rsplit(".", 1)[-1]
|
||||
ex = _EX_MAP.get(ex or "SHFE", "SHFE")
|
||||
m = re.match(r"^([A-Za-z]+)(\d+)$", code)
|
||||
if not m:
|
||||
return code, ex
|
||||
letters, digits = m.group(1), m.group(2)
|
||||
if ex == "CZCE":
|
||||
# 郑商所 CTP 常为大写 + 3 位年月(如 SR509);4 位则取后 3 位
|
||||
sym = letters.upper() + (digits[-3:] if len(digits) >= 3 else digits)
|
||||
else:
|
||||
sym = letters.lower() + digits
|
||||
return sym, ex
|
||||
|
||||
|
||||
def to_vnpy_exchange(ex_name: str):
|
||||
if Exchange is None:
|
||||
raise ImportError("vnpy 未安装")
|
||||
mapping = {
|
||||
"SHFE": Exchange.SHFE,
|
||||
"DCE": Exchange.DCE,
|
||||
"CZCE": Exchange.CZCE,
|
||||
"CFFEX": Exchange.CFFEX,
|
||||
"INE": Exchange.INE,
|
||||
}
|
||||
ex = mapping.get((ex_name or "").upper())
|
||||
if ex is None:
|
||||
raise ValueError(f"未知交易所: {ex_name}")
|
||||
return ex
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
# 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
|
||||
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
||||
|
||||
"""同花顺合约代码 → vnpy Symbol + Exchange。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from modules.core.symbols import ths_to_codes
|
||||
|
||||
try:
|
||||
from vnpy.trader.constant import Exchange
|
||||
except ImportError:
|
||||
Exchange = None # type: ignore
|
||||
|
||||
_EX_MAP = {
|
||||
"SHFE": "SHFE",
|
||||
"DCE": "DCE",
|
||||
"CZCE": "CZCE",
|
||||
"CFFEX": "CFFEX",
|
||||
"INE": "INE",
|
||||
}
|
||||
|
||||
|
||||
def ths_to_vnpy_symbol(ths_code: str) -> Tuple[str, str]:
|
||||
"""
|
||||
返回 (symbol, exchange_enum_name)。
|
||||
例:rb2610 → rb2610, SHFE;SR609 → SR609, CZCE
|
||||
"""
|
||||
code = (ths_code or "").strip()
|
||||
codes = ths_to_codes(code)
|
||||
ex = (codes.get("ex") if codes else None)
|
||||
if not ex and codes:
|
||||
mc = (codes.get("market_code") or "")
|
||||
if "." in mc:
|
||||
ex = mc.rsplit(".", 1)[-1]
|
||||
ex = _EX_MAP.get(ex or "SHFE", "SHFE")
|
||||
m = re.match(r"^([A-Za-z]+)(\d+)$", code)
|
||||
if not m:
|
||||
return code, ex
|
||||
letters, digits = m.group(1), m.group(2)
|
||||
if ex == "CZCE":
|
||||
# 郑商所 CTP 常为大写 + 3 位年月(如 SR509);4 位则取后 3 位
|
||||
sym = letters.upper() + (digits[-3:] if len(digits) >= 3 else digits)
|
||||
else:
|
||||
sym = letters.lower() + digits
|
||||
return sym, ex
|
||||
|
||||
|
||||
def to_vnpy_exchange(ex_name: str):
|
||||
if Exchange is None:
|
||||
raise ImportError("vnpy 未安装")
|
||||
mapping = {
|
||||
"SHFE": Exchange.SHFE,
|
||||
"DCE": Exchange.DCE,
|
||||
"CZCE": Exchange.CZCE,
|
||||
"CFFEX": Exchange.CFFEX,
|
||||
"INE": Exchange.INE,
|
||||
}
|
||||
ex = mapping.get((ex_name or "").upper())
|
||||
if ex is None:
|
||||
raise ValueError(f"未知交易所: {ex_name}")
|
||||
return ex
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
+2706
-2702
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
|
||||
from modules.fees.routes import register
|
||||
|
||||
__all__ = ["register"]
|
||||
@@ -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()
|
||||
@@ -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
Reference in New Issue
Block a user