Fix position flicker, drop futures cooloff, prioritize startup display.

Preserve trading state when CTP memory is empty, bootstrap equity/positions on page load, stabilize risk status from DB monitors, and remove app-layer manual close cooling periods.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-30 23:17:18 +08:00
parent 2386eca324
commit 1b3a7f1bdc
9 changed files with 218 additions and 153 deletions
+4
View File
@@ -226,6 +226,7 @@ class CtpTradingState:
*, *,
trades: Optional[list[dict[str, Any]]] = None, trades: Optional[list[dict[str, Any]]] = None,
ths_for_vnpy_sym: Optional[Callable[[str, str], str]] = None, ths_for_vnpy_sym: Optional[Callable[[str, str], str]] = None,
preserve_positions_if_margin: float = 0.0,
) -> None: ) -> None:
"""全量校准:以 vnpy 内存为准重建订单/持仓簿。""" """全量校准:以 vnpy 内存为准重建订单/持仓簿。"""
self.begin_sync() self.begin_sync()
@@ -256,6 +257,9 @@ class CtpTradingState:
new_positions[pk] = reconcile_position_avg( new_positions[pk] = reconcile_position_avg(
old, row, tick, trades=trades, ths_sym=ths, 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: with self._lock:
self._orders = new_orders self._orders = new_orders
self._positions = new_positions self._positions = new_positions
+5 -8
View File
@@ -35,8 +35,7 @@
|------|------| |------|------|
| 正常 · 可新开仓 | 未触发冻结,可新开仓 | | 正常 · 可新开仓 | 未触发冻结,可新开仓 |
| 仓位上限冻结 · 已达仓位上限 1/1 | 同时 active 持仓数已达上限,禁止新开仓,**滚仓/加仓仍允许** | | 仓位上限冻结 · 已达仓位上限 1/1 | 同时 active 持仓数已达上限,禁止新开仓,**滚仓/加仓仍允许** |
| 1h / 4h 冻结 | 手动平仓触发冷静期 | | 日冻结 | 复盘勾选情绪问题、当日手动平仓超限或日限额触发,禁止新开仓 |
| 日冻结 | 复盘勾选情绪问题或当日规则触发,禁止新开仓 |
- **绿色**:当前可交易(`can_trade=true` - **绿色**:当前可交易(`can_trade=true`
- **红色**:当前禁止新开仓(`can_trade=false` - **红色**:当前禁止新开仓(`can_trade=false`
@@ -47,14 +46,11 @@
| 指标 | 说明 | 配置来源 | | 指标 | 说明 | 配置来源 |
|------|------|----------| |------|------|----------|
| **风控开关** | 是否启用账户冷静期等风控 | `.env``RISK_CONTROL_ENABLED` | | **风控开关** | 是否启用账户风控(持仓/日限额等) | `.env``RISK_CONTROL_ENABLED` |
| **持仓限制** | 当前 active 持仓数 / 同时持仓上限 | `.env``MAX_ACTIVE_POSITIONS` | | **持仓限制** | 当前 active 持仓数 / 同时持仓上限 | `.env``MAX_ACTIVE_POSITIONS` |
| **日持仓限制** | 当日已开仓次数(含已平)/ 日开仓上限 | `.env``RISK_DAILY_POSITION_LIMIT`(默认 5 | | **日持仓限制** | 当日已开仓次数(含已平)/ 日开仓上限 | `.env``RISK_DAILY_POSITION_LIMIT`(默认 5 |
| **日交易风险** | 当日累计止损风险占权益 / 上限 | `.env``RISK_DAILY_TRADING_RISK_PCT`(默认 2% | | **日交易风险** | 当日累计止损风险占权益 / 上限 | `.env``RISK_DAILY_TRADING_RISK_PCT`(默认 2% |
| **手动平仓(冷静期触发)** | 当日手动平仓次数 / 上限 | `.env``RISK_MANUAL_CLOSE_DAILY_LIMIT` | | **手动平仓次数** | 当日手动平仓次数 / 上限(超限日冻结) | `.env``RISK_MANUAL_CLOSE_DAILY_LIMIT` |
| **冷静期(默认)** | 超限后默认冻结时长 | `.env``RISK_COOLING_HOURS_MANUAL`(默认 4h |
| **复盘后冷静** | 填写复盘情绪日记后缩短的冷静期 | `.env``RISK_COOLING_HOURS_MANUAL_JOURNAL`(默认 1h |
| **冷静剩余** | 当前冷静期剩余时间 | 运行时计算 |
| **综合保证金占比** | 占用保证金占权益 / **综合上限(50%** | 实时计算 + 系统设置 `roll_max_margin_pct` | | **综合保证金占比** | 占用保证金占权益 / **综合上限(50%** | 实时计算 + 系统设置 `roll_max_margin_pct` |
| **单仓保证金上限** | 新开仓保证金占权益上限 | 系统设置 `max_margin_pct`(默认 30% | | **单仓保证金上限** | 新开仓保证金占权益上限 | 系统设置 `max_margin_pct`(默认 30% |
| **滚仓/多仓保证金上限** | 单仓=滚仓上限;多仓=合计上限 | 系统设置 `roll_max_margin_pct`(默认 50% | | **滚仓/多仓保证金上限** | 单仓=滚仓上限;多仓=合计上限 | 系统设置 `roll_max_margin_pct`(默认 50% |
@@ -110,7 +106,8 @@
## 与全局风控的关系 ## 与全局风控的关系
- 看板 **实时展示** 账户风控状态;下单前各板块仍调用 `assert_can_open()` 做相同校验。 - 看板 **实时展示** 账户风控状态;下单前各板块仍调用 `assert_can_open()` 做相同校验。
- **日持仓限制**、**日交易风险** 与「同时持仓上限」「冷静期」并列生效,任一超限即禁止新开仓。 - **日持仓限制**、**日交易风险** 与「同时持仓上限」并列生效,任一超限即禁止新开仓。
- **期货不使用本系统「手动平仓冷静期」**(交易所自有规则);手动平仓仅计入当日次数,超限触发日冻结。
- **综合保证金占比** 使用 CTP 柜台权益与占用保证金实时计算;断线时可能短暂显示 `—` - **综合保证金占比** 使用 CTP 柜台权益与占用保证金实时计算;断线时可能短暂显示 `—`
--- ---
+17 -4
View File
@@ -1,21 +1,34 @@
const path = require("path");
const fs = require("fs");
const ROOT = __dirname;
const venvCandidates = [
path.join(ROOT, "venv", "bin", "python"),
path.join(ROOT, "venv", "Scripts", "python.exe"),
];
const interpreter = venvCandidates.find((p) => fs.existsSync(p)) || "python3";
module.exports = { module.exports = {
apps: [ apps: [
{ {
name: "qihuo", name: "qihuo",
script: "app.py", script: "app.py",
cwd: "/opt/qihuo", cwd: ROOT,
interpreter: "/opt/qihuo/venv/bin/python", interpreter,
instances: 1, instances: 1,
autorestart: true, autorestart: true,
watch: false, watch: false,
max_memory_restart: "8192M",
env: { env: {
NODE_ENV: "production", NODE_ENV: "production",
LANG: "zh_CN.UTF-8", LANG: "zh_CN.UTF-8",
LC_ALL: "zh_CN.UTF-8", LC_ALL: "zh_CN.UTF-8",
LC_CTYPE: "zh_CN.UTF-8", LC_CTYPE: "zh_CN.UTF-8",
QIHUO_STARTUP_WORKERS: "8",
QIHUO_MEMORY_MB: "8192",
}, },
error_file: "/opt/qihuo/logs/pm2-error.log", error_file: path.join(ROOT, "logs", "pm2-error.log"),
out_file: "/opt/qihuo/logs/pm2-out.log", out_file: path.join(ROOT, "logs", "pm2-out.log"),
time: true, time: true,
}, },
], ],
+99 -56
View File
@@ -8,6 +8,7 @@ from __future__ import annotations
import json import json
import logging import logging
import os
import threading import threading
import time import time
from datetime import datetime from datetime import datetime
@@ -67,11 +68,11 @@ from sl_tp_guard import (
) )
from risk.account_risk_lib import ( from risk.account_risk_lib import (
assert_can_open, assert_can_open,
count_active_trade_monitors,
get_risk_status, get_risk_status,
on_mood_journal_freeze, on_mood_journal_freeze,
on_user_initiated_close, on_user_initiated_close,
parse_mood_issues, parse_mood_issues,
reduce_cooloff_after_journal,
trading_day_label, trading_day_label,
) )
from strategy.strategy_db import init_strategy_tables from strategy.strategy_db import init_strategy_tables
@@ -701,12 +702,9 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
return reconcile_monitors_without_position(conn, mode) return reconcile_monitors_without_position(conn, mode)
def _effective_active_position_count(conn, mode: str) -> int: def _effective_active_position_count(conn, mode: str) -> int:
if ctp_status(mode).get("connected"): """风控持仓数以本地 active 监控为准,不随 CTP 内存空窗抖动。"""
return len(_ctp_position_keys(mode)) del mode
row = conn.execute( return count_active_trade_monitors(conn)
"SELECT COUNT(*) AS n FROM trade_order_monitors WHERE status='active'"
).fetchone()
return int(row["n"] or 0)
def _build_pending_orders(conn, mode: str) -> list[dict]: def _build_pending_orders(conn, mode: str) -> list[dict]:
pending: list[dict] = [] pending: list[dict] = []
@@ -1672,15 +1670,6 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
ctp_list = _ctp_positions(mode, refresh_if_empty=False, refresh_margin=False) ctp_list = _ctp_positions(mode, refresh_if_empty=False, refresh_margin=False)
if not ctp_list: if not ctp_list:
ctp_list = trading_state.get_positions() ctp_list = trading_state.get_positions()
if not ctp_list:
try:
with _ctp_td_lock:
get_bridge().calibrate_trading_state()
except Exception as exc:
logger.debug("live calibrate: %s", exc)
ctp_list = trading_state.get_positions() or _ctp_positions(
mode, refresh_if_empty=False, refresh_margin=False,
)
rows: list[dict] = [] rows: list[dict] = []
for p in ctp_list: for p in ctp_list:
@@ -1742,6 +1731,9 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
if not deduped and ctp_status(mode).get("connected") and monitor_by_pk: if not deduped and ctp_status(mode).get("connected") and monitor_by_pk:
margin_used = float(ctp_account_margin_used(mode) or 0) margin_used = float(ctp_account_margin_used(mode) or 0)
has_active_mon = any(
int(m.get("lots") or 0) > 0 for m in monitor_by_pk.values()
)
since_connect = 9999.0 since_connect = 9999.0
try: try:
since_connect = time.time() - float( since_connect = time.time() - float(
@@ -1749,7 +1741,7 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
) )
except Exception: except Exception:
pass pass
if margin_used > 100 or since_connect < 300: if margin_used > 0 or has_active_mon or since_connect < 300:
for mon in monitor_by_pk.values(): for mon in monitor_by_pk.values():
lots = int(mon.get("lots") or 0) lots = int(mon.get("lots") or 0)
if lots <= 0: if lots <= 0:
@@ -1849,6 +1841,19 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
init_strategy_tables(conn) init_strategy_tables(conn)
payload = _build_trading_live_payload(conn, fast=fast) payload = _build_trading_live_payload(conn, fast=fast)
commit_retry(conn) commit_retry(conn)
prev = position_hub.get_snapshot()
if (
prev
and ctp_status(mode).get("connected")
and not (payload.get("rows") or [])
and (prev.get("rows") or [])
):
margin_used = float(ctp_account_margin_used(mode) or 0)
if margin_used > 0 or trading_state.sync_state == "syncing":
payload = dict(payload)
payload["rows"] = prev["rows"]
payload["sync_state"] = "syncing"
payload["sync_label"] = "同步中…"
return payload return payload
finally: finally:
conn.close() conn.close()
@@ -1956,8 +1961,22 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
finally: finally:
conn.close() conn.close()
def _prime_position_snapshot() -> None:
"""进程启动同步预热:优先写入持仓/权益快照,页面打开即可读。"""
try:
payload = _refresh_trading_live_snapshot(fast=True)
position_hub.set_snapshot(payload)
n = len(payload.get("rows") or [])
logger.info(
"持仓快照已预热 capital=%s rows=%d",
payload.get("capital"),
n,
)
except Exception as exc:
logger.warning("prime position snapshot: %s", exc)
def _bootstrap_trading_runtime() -> None: def _bootstrap_trading_runtime() -> None:
"""进程启动:读 CTP 快照推送,事件驱动增量 + 定期全量校准""" """进程启动:并发预热持仓快照 + CTP 连接,不阻塞 HTTP 监听"""
set_position_refresh_callback( set_position_refresh_callback(
lambda: _push_position_snapshot_async(fast=True) lambda: _push_position_snapshot_async(fast=True)
) )
@@ -1969,6 +1988,7 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
try: try:
mode = get_trading_mode(get_setting) mode = get_trading_mode(get_setting)
if ctp_status(mode).get("connected"): if ctp_status(mode).get("connected"):
with _ctp_td_lock:
get_bridge().calibrate_trading_state() get_bridge().calibrate_trading_state()
payload = _refresh_trading_live_snapshot(fast=False) payload = _refresh_trading_live_snapshot(fast=False)
position_hub.set_snapshot(payload) position_hub.set_snapshot(payload)
@@ -1976,7 +1996,7 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
except Exception as exc: except Exception as exc:
logger.warning("bootstrap position snapshot: %s", exc) logger.warning("bootstrap position snapshot: %s", exc)
threading.Thread(target=_warm, daemon=True, name="position-bootstrap").start() def _start_ctp() -> None:
try: try:
from ctp_premarket_connect import should_auto_connect_now from ctp_premarket_connect import should_auto_connect_now
from vnpy_bridge import ctp_start_connect from vnpy_bridge import ctp_start_connect
@@ -1987,6 +2007,13 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
except Exception as exc: except Exception as exc:
logger.debug("bootstrap ctp connect: %s", exc) logger.debug("bootstrap ctp connect: %s", exc)
from concurrent.futures import ThreadPoolExecutor
workers = max(2, int(os.getenv("QIHUO_STARTUP_WORKERS", "8") or 8))
with ThreadPoolExecutor(max_workers=min(workers, 4), thread_name_prefix="boot") as pool:
pool.submit(_warm)
pool.submit(_start_ctp)
def _on_ctp_connected(mode: str) -> None: def _on_ctp_connected(mode: str) -> None:
if mode != get_trading_mode(get_setting): if mode != get_trading_mode(get_setting):
return return
@@ -2050,6 +2077,12 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
_schedule_recommend_refresh() _schedule_recommend_refresh()
ctp_connected = is_ctp_connected(get_setting) ctp_connected = is_ctp_connected(get_setting)
margin_rec = small_account_margin_recommendations() margin_rec = small_account_margin_recommendations()
bootstrap_live: dict = {}
try:
bootstrap_live = _build_trading_live_payload(conn, fast=True)
position_hub.set_snapshot(bootstrap_live)
except Exception as exc:
logger.debug("positions page bootstrap: %s", exc)
return render_template( return render_template(
"trade.html", "trade.html",
trading_mode=mode, trading_mode=mode,
@@ -2083,6 +2116,7 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
session_clock=trading_session_clock(), session_clock=trading_session_clock(),
roll_max_margin_pct=get_roll_max_margin_pct(get_setting), roll_max_margin_pct=get_roll_max_margin_pct(get_setting),
product_categories=PRODUCT_CATEGORIES, product_categories=PRODUCT_CATEGORIES,
bootstrap_live=bootstrap_live,
) )
finally: finally:
conn.close() conn.close()
@@ -3836,8 +3870,6 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
def hook_review_mood(conn, behavior_tags: str, exit_trigger: str, exit_supplement: str): def hook_review_mood(conn, behavior_tags: str, exit_trigger: str, exit_supplement: str):
if parse_mood_issues(behavior_tags): if parse_mood_issues(behavior_tags):
on_mood_journal_freeze(conn, trading_day=trading_day_label()) on_mood_journal_freeze(conn, trading_day=trading_day_label())
if (exit_trigger or "").strip() == "手动平仓" and (exit_supplement or "").strip():
reduce_cooloff_after_journal(conn, trading_day=trading_day_label())
app._risk_review_hook = hook_review_mood app._risk_review_hook = hook_review_mood
@@ -3846,33 +3878,8 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
def _init_tables(conn): def _init_tables(conn):
init_strategy_tables(conn) init_strategy_tables(conn)
start_recommend_worker( _prime_position_snapshot()
db_path=DB_PATH,
get_capital_fn=_recommend_capital,
quote_fn=_main_quote,
init_tables_fn=_init_tables,
get_mode_fn=lambda: get_trading_mode(get_setting),
get_max_margin_pct_fn=lambda: get_max_margin_pct(get_setting),
get_sizing_mode_fn=lambda: get_sizing_mode(get_setting),
get_fixed_lots_fn=lambda: get_fixed_lots(get_setting),
)
start_ctp_reconnect_worker(
get_mode_fn=lambda: get_trading_mode(get_setting),
get_setting_fn=get_setting,
)
start_ctp_premarket_connect_worker(
get_mode_fn=lambda: get_trading_mode(get_setting),
get_setting_fn=get_setting,
)
start_sl_tp_guard_worker(
db_path=DB_PATH,
get_mode_fn=lambda: get_trading_mode(get_setting),
init_tables_fn=_init_tables,
get_capital_fn=_capital,
get_be_tick_buffer_fn=lambda: get_trailing_be_tick_buffer(get_setting),
notify_fn=send_wechat_msg,
interval=1,
)
_pos_refresh_tick = {"n": 0} _pos_refresh_tick = {"n": 0}
_last_full_calibrate = {"ts": 0.0} _last_full_calibrate = {"ts": 0.0}
@@ -3904,6 +3911,44 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
idle_interval=3, idle_interval=3,
) )
_bootstrap_trading_runtime() _bootstrap_trading_runtime()
start_ctp_reconnect_worker(
get_mode_fn=lambda: get_trading_mode(get_setting),
get_setting_fn=get_setting,
)
start_ctp_premarket_connect_worker(
get_mode_fn=lambda: get_trading_mode(get_setting),
get_setting_fn=get_setting,
)
start_sl_tp_guard_worker(
db_path=DB_PATH,
get_mode_fn=lambda: get_trading_mode(get_setting),
init_tables_fn=_init_tables,
get_capital_fn=_capital,
get_be_tick_buffer_fn=lambda: get_trailing_be_tick_buffer(get_setting),
notify_fn=send_wechat_msg,
interval=1,
)
start_pending_order_worker(
db_path=DB_PATH,
get_mode_fn=lambda: get_trading_mode(get_setting),
init_tables_fn=_init_tables,
get_capital_fn=_capital,
reconcile_fn=_reconcile_pending,
on_changed_fn=lambda: _push_position_snapshot_async(fast=False),
)
def _start_deferred_workers() -> None:
time.sleep(2)
start_recommend_worker(
db_path=DB_PATH,
get_capital_fn=_recommend_capital,
quote_fn=_main_quote,
init_tables_fn=_init_tables,
get_mode_fn=lambda: get_trading_mode(get_setting),
get_max_margin_pct_fn=lambda: get_max_margin_pct(get_setting),
get_sizing_mode_fn=lambda: get_sizing_mode(get_setting),
get_fixed_lots_fn=lambda: get_fixed_lots(get_setting),
)
start_ctp_fee_worker( start_ctp_fee_worker(
get_mode_fn=lambda: get_trading_mode(get_setting), get_mode_fn=lambda: get_trading_mode(get_setting),
get_setting_fn=get_setting, get_setting_fn=get_setting,
@@ -3917,11 +3962,9 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
set_setting_fn=set_setting, set_setting_fn=set_setting,
send_wechat_fn=send_wechat_msg, send_wechat_fn=send_wechat_msg,
) )
start_pending_order_worker(
db_path=DB_PATH, threading.Thread(
get_mode_fn=lambda: get_trading_mode(get_setting), target=_start_deferred_workers,
init_tables_fn=_init_tables, daemon=True,
get_capital_fn=_capital, name="deferred-workers",
reconcile_fn=_reconcile_pending, ).start()
on_changed_fn=lambda: _push_position_snapshot_async(fast=False),
)
+15 -44
View File
@@ -51,17 +51,12 @@ def risk_control_enabled() -> bool:
def cooling_hours_manual() -> float: def cooling_hours_manual() -> float:
try: """期货版不使用应用层冷静期(交易所自有规则),恒为 0。"""
return max(0.0, float(os.getenv("RISK_COOLING_HOURS_MANUAL", "4"))) return 0.0
except (TypeError, ValueError):
return 4.0
def cooling_hours_manual_journal() -> float: def cooling_hours_manual_journal() -> float:
try: return 0.0
return max(0.0, float(os.getenv("RISK_COOLING_HOURS_MANUAL_JOURNAL", "1")))
except (TypeError, ValueError):
return 1.0
def manual_close_daily_limit() -> int: def manual_close_daily_limit() -> int:
@@ -286,15 +281,16 @@ def on_user_initiated_close(conn, *, trading_day: str, now: Optional[datetime] =
if count >= manual_close_daily_limit(): if count >= manual_close_daily_limit():
conn.execute( conn.execute(
"""UPDATE account_risk_state SET trading_day=?, manual_close_count=?, """UPDATE account_risk_state SET trading_day=?, manual_close_count=?,
daily_frozen=1, cooloff_until_ms=NULL, last_close_at_ms=?, updated_at=? WHERE id=1""", daily_frozen=1, cooloff_until_ms=NULL, cooloff_hours=NULL,
last_close_at_ms=?, updated_at=? WHERE id=1""",
(td, count, close_ms, datetime.now().strftime("%Y-%m-%d %H:%M:%S")), (td, count, close_ms, datetime.now().strftime("%Y-%m-%d %H:%M:%S")),
) )
return return
until = close_ms + int(cooling_hours_manual() * 3600 * 1000)
conn.execute( conn.execute(
"""UPDATE account_risk_state SET trading_day=?, manual_close_count=?, """UPDATE account_risk_state SET trading_day=?, manual_close_count=?,
daily_frozen=0, cooloff_until_ms=?, cooloff_hours=?, last_close_at_ms=?, updated_at=? WHERE id=1""", daily_frozen=0, cooloff_until_ms=NULL, cooloff_hours=NULL,
(td, count, until, int(cooling_hours_manual()), close_ms, datetime.now().strftime("%Y-%m-%d %H:%M:%S")), last_close_at_ms=?, updated_at=? WHERE id=1""",
(td, count, close_ms, datetime.now().strftime("%Y-%m-%d %H:%M:%S")),
) )
@@ -310,26 +306,9 @@ def on_mood_journal_freeze(conn, *, trading_day: str) -> None:
def reduce_cooloff_after_journal(conn, *, trading_day: str, now: Optional[datetime] = None) -> None: def reduce_cooloff_after_journal(conn, *, trading_day: str, now: Optional[datetime] = None) -> None:
"""复盘手动平仓说明后,4h 冷静期降为 1h""" """期货版无应用层冷静期,保留空实现兼容旧复盘钩子"""
if not risk_control_enabled(): del conn, trading_day, now
return return
ensure_account_risk_schema(conn)
row = conn.execute("SELECT * FROM account_risk_state WHERE id=1").fetchone()
if int(_row_get(row, "daily_frozen") or 0):
return
until = _row_get(row, "cooloff_until_ms")
if not until:
return
now_ms = _now_ms(now)
if int(until) <= now_ms:
return
last = int(_row_get(row, "last_close_at_ms") or now_ms)
journal_ms = int(cooling_hours_manual_journal() * 3600 * 1000)
new_until = max(now_ms, last + journal_ms)
conn.execute(
"""UPDATE account_risk_state SET cooloff_until_ms=?, cooloff_hours=?, updated_at=? WHERE id=1""",
(new_until, int(cooling_hours_manual_journal()), datetime.now().strftime("%Y-%m-%d %H:%M:%S")),
)
def get_risk_status( def get_risk_status(
@@ -355,6 +334,11 @@ def get_risk_status(
now_ms = _now_ms(now) now_ms = _now_ms(now)
daily = int(_row_get(row, "daily_frozen") or 0) == 1 daily = int(_row_get(row, "daily_frozen") or 0) == 1
until = _row_get(row, "cooloff_until_ms") until = _row_get(row, "cooloff_until_ms")
if until:
conn.execute(
"UPDATE account_risk_state SET cooloff_until_ms=NULL, cooloff_hours=NULL WHERE id=1"
)
conn.commit()
active = count_active_trade_monitors(conn) if active_count is None else int(active_count) active = count_active_trade_monitors(conn) if active_count is None else int(active_count)
mx = max_active_positions() mx = max_active_positions()
pos_limit = active >= mx pos_limit = active >= mx
@@ -387,19 +371,6 @@ def get_risk_status(
"can_roll": False, "can_roll": False,
"reason": "当日日冻结,禁止新开仓", "reason": "当日日冻结,禁止新开仓",
} }
if until and int(until) > now_ms:
rem = int((int(until) - now_ms) / 1000)
hours = float(_row_get(row, "cooloff_hours") or cooling_hours_manual())
st = STATUS_FREEZE_1H if hours <= cooling_hours_manual_journal() + 0.01 else STATUS_FREEZE_4H
return {
**base,
"status": st,
"status_label": STATUS_LABELS[st],
"can_trade": False,
"can_roll": pos_limit,
"reason": f"冷静期中,剩余约 {rem // 3600}h {(rem % 3600) // 60}m",
"freeze_remaining_sec": rem,
}
if daily_risk_limit_hit: if daily_risk_limit_hit:
return { return {
**base, **base,
+4 -4
View File
@@ -422,10 +422,7 @@
{ label: '持仓限制', value: active + ' / ' + (maxPos != null ? maxPos : '—') }, { label: '持仓限制', value: active + ' / ' + (maxPos != null ? maxPos : '—') },
{ label: '日持仓限制', value: dailyOpens + ' / ' + (dailyPosLim != null ? dailyPosLim : '—') }, { label: '日持仓限制', value: dailyOpens + ' / ' + (dailyPosLim != null ? dailyPosLim : '—') },
{ label: '日交易风险', value: dailyRiskText }, { label: '日交易风险', value: dailyRiskText },
{ label: '手动平仓(冷静期触发)', value: manualCnt + ' / ' + (manualLim != null ? manualLim : '—') }, { label: '手动平仓次数', value: manualCnt + ' / ' + (manualLim != null ? manualLim : '—') },
{ label: '冷静期(默认)', value: fmtHours(lim.cooling_hours_manual) },
{ label: '复盘后冷静', value: fmtHours(lim.cooling_hours_manual_journal) },
{ label: '冷静剩余', value: fmtRemainSec(st.freeze_remaining_sec) },
{ {
label: '综合保证金占比', label: '综合保证金占比',
valueHtml: riskMarginPctHtml(marginPct, rollMaxPct), valueHtml: riskMarginPctHtml(marginPct, rollMaxPct),
@@ -862,6 +859,9 @@
equityEl.textContent = fmtMoney(data.capital); equityEl.textContent = fmtMoney(data.capital);
} }
var rows = positionRows(data); var rows = positionRows(data);
if (!rows.length && data.sync_state === 'syncing' && lastPosRows.length) {
rows = lastPosRows;
}
var sig = rows.map(function (r) { var sig = rows.map(function (r) {
var key = r.key || r.position_key || ((r.symbol_code || '') + ':' + (r.direction || '')); var key = r.key || r.position_key || ((r.symbol_code || '') + ':' + (r.direction || ''));
return key + '|' + (isBreakevenLocked(r) ? '1' : '0') + '|' + slText(r) + '|' + tpText(r) + '|' + String(r.lots); return key + '|' + (isBreakevenLocked(r) ? '1' : '0') + '|' + slText(r) + '|' + tpText(r) + '|' + String(r.lots);
+37 -11
View File
@@ -34,6 +34,7 @@
var ctpConnecting = false; var ctpConnecting = false;
var ctpAutoConnectEnabled = true; var ctpAutoConnectEnabled = true;
var positionsRendered = false; var positionsRendered = false;
var lastPosRowCount = 0;
var selectedMaxLots = null; var selectedMaxLots = null;
var recommendMaxByProduct = {}; var recommendMaxByProduct = {};
var recommendMaxByCode = {}; var recommendMaxByCode = {};
@@ -63,6 +64,13 @@
window.TRADE_FIXED_AMOUNT = cfg.fixed_amount; window.TRADE_FIXED_AMOUNT = cfg.fixed_amount;
window.__RECOMMEND_ROWS__ = cfg.recommend_rows || []; window.__RECOMMEND_ROWS__ = cfg.recommend_rows || [];
if (cfg.session_clock) applySessionClock(cfg.session_clock); if (cfg.session_clock) applySessionClock(cfg.session_clock);
if (cfg.capital != null) {
var capEl = document.getElementById('cap-display');
if (capEl) capEl.textContent = Number(cfg.capital).toFixed(2);
}
if (cfg.bootstrap_live && (cfg.bootstrap_live.rows || cfg.bootstrap_live.capital != null)) {
window.__BOOTSTRAP_LIVE__ = cfg.bootstrap_live;
}
} catch (e) { /* ignore */ } } catch (e) { /* ignore */ }
} }
@@ -149,15 +157,15 @@
function savePosCache(data) { function savePosCache(data) {
try { try {
if (!data) return; if (!data) return;
var connected = data.ctp_status && data.ctp_status.connected; var hasRows = data.rows && data.rows.length;
if (!connected) { var hasOrders = data.active_orders && data.active_orders.length;
sessionStorage.removeItem(POS_CACHE_KEY); if (!hasRows && !hasOrders && data.capital == null) {
return; return;
} }
if (!data.rows || !data.rows.length) { if (!hasRows && lastPosRowCount > 0) {
if (!data.active_orders || !data.active_orders.length) { var prev = loadPosCache();
sessionStorage.removeItem(POS_CACHE_KEY); if (prev && prev.rows && prev.rows.length) {
return; data = Object.assign({}, data, { rows: prev.rows });
} }
} }
sessionStorage.setItem(POS_CACHE_KEY, JSON.stringify(data)); sessionStorage.setItem(POS_CACHE_KEY, JSON.stringify(data));
@@ -321,10 +329,22 @@
if (ctpAutoConnectEnabled) tryAutoCtpReconnect(); if (ctpAutoConnectEnabled) tryAutoCtpReconnect();
return; return;
} }
var syncing = data.sync_state === 'syncing';
var hadPos = lastPosRowCount > 0 || !!list.querySelector('.pos-card');
if (syncing || hadPos) {
if (syncBadge) {
syncBadge.hidden = false;
syncBadge.textContent = data.sync_label || '持仓同步中…';
syncBadge.className = 'sync-badge text-accent';
}
return;
}
list.innerHTML = '<div class="empty-hint">暂无持仓。</div>'; list.innerHTML = '<div class="empty-hint">暂无持仓。</div>';
lastPosRowCount = 0;
syncPositionListScroll(0); syncPositionListScroll(0);
return; return;
} }
lastPosRowCount = rows.length;
if (!connected && ctpAutoConnectEnabled) { if (!connected && ctpAutoConnectEnabled) {
tryAutoCtpReconnect(); tryAutoCtpReconnect();
} }
@@ -1926,12 +1946,16 @@
lotsCalc.value = String(window.TRADE_FIXED_LOTS || 1); lotsCalc.value = String(window.TRADE_FIXED_LOTS || 1);
if (lotsInput) lotsInput.value = lotsCalc.value; if (lotsInput) lotsInput.value = lotsCalc.value;
} }
var bootData = window.__BOOTSTRAP_LIVE__;
if (bootData) {
applyPositionsData(bootData);
savePosCache(bootData);
} else {
var cached = loadPosCache(); var cached = loadPosCache();
if (cached) { if (cached && ((cached.rows && cached.rows.length) || cached.capital != null)) {
if (cached.ctp_status) { if (cached.ctp_status) {
cached.ctp_status = Object.assign({}, cached.ctp_status, { connecting: false }); cached.ctp_status = Object.assign({}, cached.ctp_status, { connecting: false });
} }
if (cached.ctp_status && cached.ctp_status.connected) {
applyPositionsData(cached); applyPositionsData(cached);
} }
} }
@@ -1939,6 +1963,9 @@
connectPositionStream(); connectPositionStream();
bindSlTpModal(); bindSlTpModal();
initCtpOnLoad(); initCtpOnLoad();
updateSessionUi();
updateRRDisplay();
setTimeout(function () {
connectRecommendStream(); connectRecommendStream();
initRecommendSortControls(); initRecommendSortControls();
if (window.__RECOMMEND_ROWS__ && window.__RECOMMEND_ROWS__.length) { if (window.__RECOMMEND_ROWS__ && window.__RECOMMEND_ROWS__.length) {
@@ -1949,10 +1976,9 @@
.then(function (r) { return r.json(); }) .then(function (r) { return r.json(); })
.then(function (data) { if (data.ok) renderRecommendations(data); }) .then(function (data) { if (data.ok) renderRecommendations(data); })
.catch(function () {}); .catch(function () {});
updateSessionUi();
updateRRDisplay();
scheduleQuote(); scheduleQuote();
scheduleAutoCalc(); scheduleAutoCalc();
}, 400);
} }
document.addEventListener('visibilitychange', function () { document.addEventListener('visibilitychange', function () {
+4 -2
View File
@@ -74,7 +74,7 @@
<ul> <ul>
<li>成交后由程序<strong>本地监控</strong>;触及止盈或止损 → 市价平仓</li> <li>成交后由程序<strong>本地监控</strong>;触及止盈或止损 → 市价平仓</li>
<li><strong>移动保本</strong>:须填止损、不设固定止盈;达 1R 止损移至开仓±缓冲跳,2R 移 1R,依次类推</li> <li><strong>移动保本</strong>:须填止损、不设固定止盈;达 1R 止损移至开仓±缓冲跳,2R 移 1R,依次类推</li>
<li>手动平仓写入交易记录;当日手动平仓次数超限 → 进入冷静期</li> <li>手动平仓写入交易记录;当日手动平仓次数超限 → 当日禁止新开仓</li>
</ul> </ul>
<p><strong>可开仓品种表</strong></p> <p><strong>可开仓品种表</strong></p>
<ul> <ul>
@@ -304,7 +304,9 @@
'product_categories': product_categories | default([]), 'product_categories': product_categories | default([]),
'recommend_rows': recommend_rows | default([]), 'recommend_rows': recommend_rows | default([]),
'ctp_auto_connect': ctp_auto_connect, 'ctp_auto_connect': ctp_auto_connect,
'session_clock': session_clock 'session_clock': session_clock,
'capital': capital,
'bootstrap_live': bootstrap_live | default({})
} | tojson }}</script> } | tojson }}</script>
<script src="{{ url_for('static', filename='js/trade.js') }}?v={{ asset_v }}"></script> <script src="{{ url_for('static', filename='js/trade.js') }}?v={{ asset_v }}"></script>
{% endblock %} {% endblock %}
+9
View File
@@ -553,11 +553,20 @@ class CtpBridge:
orders = self.list_active_orders() orders = self.list_active_orders()
positions = self._collect_positions() positions = self._collect_positions()
trades = self.list_trades() trades = self.list_trades()
preserve_margin = 0.0
if self._connected_mode and not positions:
try:
preserve_margin = float(
ctp_account_margin_used(self._connected_mode) or 0,
)
except Exception:
preserve_margin = 0.0
trading_state.calibrate_from_lists( trading_state.calibrate_from_lists(
orders, orders,
positions, positions,
trades=trades, trades=trades,
ths_for_vnpy_sym=lambda s, e: CtpBridge._vnpy_sym_to_ths(s, e) or s, ths_for_vnpy_sym=lambda s, e: CtpBridge._vnpy_sym_to_ths(s, e) or s,
preserve_positions_if_margin=preserve_margin,
) )
except Exception as exc: except Exception as exc:
logger.debug("calibrate trading state: %s", exc) logger.debug("calibrate trading state: %s", exc)