diff --git a/app.py b/app.py
index 5a5106f..0155083 100644
--- a/app.py
+++ b/app.py
@@ -13,7 +13,7 @@ import sqlite3
import time
import threading
import requests
-from datetime import datetime, timedelta
+from datetime import date, datetime, timedelta
from typing import Optional
from functools import wraps
from zoneinfo import ZoneInfo
@@ -45,7 +45,14 @@ from fee_specs import (
purge_non_ctp_fee_rates,
)
from nav_settings import NAV_TOGGLES, get_nav_items, nav_enabled, save_nav_items
-from stats_engine import STATS_VIEWS, build_all_stats, load_stats_cache, refresh_stats_cache
+from stats_engine import (
+ STATS_VIEWS,
+ build_all_stats,
+ get_calendar_day,
+ get_calendar_month,
+ load_stats_cache,
+ refresh_stats_cache,
+)
from kline_store import ensure_kline_tables
from kline_stream import kline_hub, sse_format
from kline_chart import generate_review_kline_chart, fetch_market_klines, MARKET_PERIODS
@@ -1635,6 +1642,40 @@ def api_stats_refresh():
return jsonify(data)
+@app.route("/api/stats/calendar")
+@login_required
+def api_stats_calendar():
+ now = datetime.now(TZ)
+ year = request.args.get("year", type=int) or now.year
+ month = request.args.get("month", type=int) or now.month
+ if month < 1 or month > 12:
+ return jsonify({"error": "invalid month"}), 400
+ conn = get_db()
+ try:
+ data = get_calendar_month(conn, year, month)
+ finally:
+ conn.close()
+ return jsonify(data)
+
+
+@app.route("/api/stats/calendar/day")
+@login_required
+def api_stats_calendar_day():
+ day = (request.args.get("date") or "").strip()
+ if not day:
+ return jsonify({"error": "date required"}), 400
+ try:
+ date.fromisoformat(day)
+ except ValueError:
+ return jsonify({"error": "invalid date"}), 400
+ conn = get_db()
+ try:
+ data = get_calendar_day(conn, day)
+ finally:
+ conn.close()
+ return jsonify(data)
+
+
_dashboard_sync_tick = {"n": 0}
diff --git a/ctp_entry_price.py b/ctp_entry_price.py
index d8efde2..aaf93e2 100644
--- a/ctp_entry_price.py
+++ b/ctp_entry_price.py
@@ -1,7 +1,7 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 详见 LICENSE.zh-CN.txt
-"""CTP 持仓均价:成交加权 / 柜台持仓价 / 盈亏一致校正。"""
+"""CTP 持仓均价:仅使用柜台持仓回报(vnpy pos.price = PositionCost 加权)。"""
from __future__ import annotations
from typing import Any, Optional
@@ -45,74 +45,6 @@ def round_to_tick(price: float, sym: str) -> float:
return round(round(price / tick) * tick, 4)
-def entry_from_ctp_pnl(
- ctp: dict[str, Any],
- tick: Optional[float],
- *,
- ths_sym: str = "",
-) -> Optional[float]:
- """用柜台持仓盈亏 + 现价反推均价(与 SimNow 浮动盈亏一致)。"""
- if not tick or tick <= 0:
- return None
- lots = int(ctp.get("lots") or 0)
- if lots <= 0:
- return None
- pnl = float(ctp.get("pnl") or 0)
- if not pnl:
- return None
- sym = ths_sym or (ctp.get("symbol") or "")
- mult = float(get_contract_spec(_ths_code(sym)).get("mult") or 10)
- if mult <= 0:
- return None
- direction = (ctp.get("direction") or "long").strip().lower()
- if direction == "long":
- derived = tick - pnl / (mult * lots)
- else:
- derived = tick + pnl / (mult * lots)
- if derived <= 0:
- return None
- return round_to_tick(derived, sym)
-
-
-def avg_from_trades(
- trades: list[dict[str, Any]],
- sym: str,
- direction: str,
- *,
- expect_lots: int = 0,
-) -> Optional[float]:
- """按成交回报移动加权均价(滚仓多笔开仓后应与柜台一致)。"""
- direction = (direction or "long").strip().lower()
- vol = 0
- cost = 0.0
- for t in sorted(trades, key=lambda x: (x.get("datetime") or "", x.get("trade_id") or "")):
- if not symbols_match(t.get("symbol") or "", sym):
- continue
- off = (t.get("offset") or "").strip().lower()
- pos_dir = (
- t.get("position_direction") or t.get("direction") or "long"
- ).strip().lower()
- if pos_dir != direction:
- continue
- lots = int(t.get("lots") or 0)
- px = float(t.get("price") or 0)
- if lots <= 0 or px <= 0:
- continue
- if off == "open":
- cost += px * lots
- vol += lots
- elif off == "close" and vol > 0:
- avg = cost / vol
- dec = min(lots, vol)
- cost -= avg * dec
- vol -= dec
- if vol <= 0:
- return None
- if expect_lots > 0 and vol != expect_lots:
- return None
- return round_to_tick(cost / vol, sym)
-
-
def resolve_ctp_entry(
sym: str,
direction: str,
@@ -121,31 +53,11 @@ def resolve_ctp_entry(
*,
tick: Optional[float] = None,
) -> tuple[float, str]:
- """均价:成交加权 > 盈亏一致校正 > 柜台持仓价。"""
+ """均价:仅柜台持仓价(trades/tick 参数保留兼容,不参与计算)。"""
+ del direction, trades, tick
if not ctp:
return 0.0, "none"
- direction = (direction or "long").strip().lower()
- lots = int(ctp.get("lots") or 0)
-
- if trades:
- trade_avg = avg_from_trades(trades, sym, direction, expect_lots=lots)
- if trade_avg and trade_avg > 0:
- return float(trade_avg), "trades"
-
pos_avg = float(ctp.get("avg_price") or 0)
if pos_avg > 0:
- pos_avg = round_to_tick(pos_avg, sym)
-
- pnl_avg = entry_from_ctp_pnl(ctp, tick, ths_sym=sym)
- tick_sz = float(get_contract_spec(_ths_code(sym)).get("tick_size") or 1.0)
-
- if pnl_avg and pos_avg > 0:
- if abs(pnl_avg - pos_avg) >= max(tick_sz * 0.5, 0.01):
- return float(pnl_avg), "pnl"
- return pos_avg, "ctp"
-
- if pos_avg > 0:
- return pos_avg, "ctp"
- if pnl_avg and pnl_avg > 0:
- return float(pnl_avg), "pnl"
+ return round_to_tick(pos_avg, sym), "ctp"
return 0.0, "none"
diff --git a/ctp_trading_state.py b/ctp_trading_state.py
index 3f54388..c311ff3 100644
--- a/ctp_trading_state.py
+++ b/ctp_trading_state.py
@@ -42,49 +42,27 @@ def reconcile_position_avg(
trades: Optional[list[dict[str, Any]]] = None,
ths_sym: str = "",
) -> dict[str, Any]:
- """手数不变时锁定均价;滚仓/加仓(手数变化)时以柜台加权均价为准。"""
- from ctp_entry_price import entry_from_ctp_pnl, resolve_ctp_entry
+ """手数变化时采用柜台回报均价;手数不变时保持已锁定柜台价。"""
+ 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
- direction = (row.get("direction") or "long").strip().lower()
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 "")
- if (
- not lots_changed
- and old
- and old.get("avg_price_locked")
- and float(old.get("avg_price") or 0) > 0
- ):
- locked = float(old["avg_price"])
- corrected, _ = resolve_ctp_entry(sym, direction, row, trades, tick=tick)
- pnl_entry = entry_from_ctp_pnl(row, tick, ths_sym=sym)
- if corrected > 0 and abs(corrected - locked) >= 0.5:
- row["avg_price"] = corrected
- row["avg_price_locked"] = True
- return row
- if pnl_entry and abs(pnl_entry - locked) >= 0.5:
- row["avg_price"] = pnl_entry
- row["avg_price_locked"] = True
- return row
- row["avg_price"] = locked
- row["avg_price_locked"] = True
- return row
-
- entry, _src = resolve_ctp_entry(sym, direction, row, trades, tick=tick)
- if entry > 0:
- row["avg_price"] = entry
- row["avg_price_locked"] = True
- return row
-
pos_avg = float(row.get("avg_price") or 0)
if pos_avg > 0:
- row["avg_price"] = pos_avg
- row["avg_price_locked"] = lots_changed or bool(tick)
+ 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
@@ -174,41 +152,8 @@ class CtpTradingState:
return dict(row) if row else None
def try_lock_entry_prices(self) -> bool:
- """有 tick 后校正持仓均价(含已锁定但与柜台盈亏不一致的)。"""
- from ctp_entry_price import resolve_ctp_entry
-
- changed = False
- with self._lock:
- for pk, row in list(self._positions.items()):
- ex = row.get("exchange") or ""
- sym = row.get("symbol") or ""
- tick = self.get_tick_price(ex, sym)
- if not tick or tick <= 0:
- continue
- ths = sym
- try:
- from vnpy_bridge import CtpBridge
- ths = CtpBridge._vnpy_sym_to_ths(sym, ex) or sym
- except Exception:
- pass
- entry, _ = resolve_ctp_entry(
- ths,
- row.get("direction") or "long",
- row,
- tick=tick,
- )
- if not entry or entry <= 0:
- continue
- current = float(row.get("avg_price") or 0)
- if row.get("avg_price_locked") and current > 0:
- if abs(entry - current) < 0.5:
- continue
- updated = dict(row)
- updated["avg_price"] = entry
- updated["avg_price_locked"] = True
- self._positions[pk] = updated
- changed = True
- return changed
+ """均价以柜台为准,不按 tick 反推(避免均价随行情跳动)。"""
+ return False
def upsert_position(
self,
diff --git a/install_trading.py b/install_trading.py
index 3dec526..b8b08f9 100644
--- a/install_trading.py
+++ b/install_trading.py
@@ -9,6 +9,7 @@ from __future__ import annotations
import json
import logging
import threading
+import time
from datetime import datetime
from typing import Any, Callable, Optional
@@ -120,7 +121,7 @@ from trading_context import (
is_ctp_connected,
trading_mode_label,
)
-from ctp_entry_price import resolve_ctp_entry
+from ctp_entry_price import round_to_tick
from ctp_symbol import ths_to_vnpy_symbol
from ctp_trading_state import position_key, trading_state
from vnpy_bridge import (
@@ -549,14 +550,9 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
mode: str,
fallback: float = 0.0,
) -> float:
- """滚仓/展示用均价:优先柜台成交加权与持仓价。"""
+ """滚仓/展示用均价:仅柜台持仓价。"""
if not ctp_status(mode).get("connected"):
return fallback
- trades: list = []
- try:
- trades = ctp_list_trades(mode)
- except Exception:
- pass
for p in trading_state.get_positions() or _ctp_positions(
mode, refresh_if_empty=False,
):
@@ -564,11 +560,9 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
continue
if not _match_ctp_symbol(p.get("symbol") or "", sym):
continue
- entry, _ = resolve_ctp_entry(
- sym, direction, p, trades, tick=ctp_get_tick_price(mode, sym),
- )
- if entry > 0:
- return float(entry)
+ avg = float(p.get("avg_price") or 0)
+ if avg > 0:
+ return avg
return fallback
def _resolve_ctp_entry_price(
@@ -577,17 +571,13 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
direction: str,
ctp: Optional[dict],
) -> tuple[float, str]:
+ del mode, direction
if not ctp:
return 0.0, "none"
- trades: list = []
- tick = None
- if ctp_status(mode).get("connected"):
- try:
- trades = ctp_list_trades(mode)
- except Exception:
- pass
- tick = ctp_get_tick_price(mode, sym)
- return resolve_ctp_entry(sym, direction, ctp, trades, tick=tick)
+ avg = float(ctp.get("avg_price") or 0)
+ if avg > 0:
+ return round_to_tick(avg, sym), "ctp"
+ return 0.0, "none"
def _open_commission_from_ctp_trades(
mode: str, sym: str, direction: str,
@@ -1246,6 +1236,8 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
ctp_margin = float(ctp.get("margin") or 0)
if (margin is None or float(margin or 0) <= 0) and ctp_margin > 0:
margin = ctp_margin
+ if ctp_status(mode).get("connected"):
+ source_label = "CTP 柜台"
codes = ths_to_codes(sym)
tick = calc_order_tick_metrics(sym, lots, entry, trading_mode=mode)
@@ -1677,10 +1669,17 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
monitor_by_pk = _monitors_by_position_key(conn)
ctp_list: list[dict] = []
if ctp_status(mode).get("connected"):
- ctp_list = trading_state.get_positions()
+ ctp_list = _ctp_positions(mode, refresh_if_empty=False, refresh_margin=False)
if not ctp_list:
- ctp_list = _ctp_positions(
- mode, refresh_if_empty=True, refresh_margin=not fast,
+ 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] = []
@@ -1740,6 +1739,51 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
continue
seen.add(rk)
deduped.append(row)
+
+ if not deduped and ctp_status(mode).get("connected") and monitor_by_pk:
+ margin_used = float(ctp_account_margin_used(mode) or 0)
+ since_connect = 9999.0
+ try:
+ since_connect = time.time() - float(
+ getattr(get_bridge(), "_last_connect_ok_ts", 0) or 0,
+ )
+ except Exception:
+ pass
+ if margin_used > 100 or since_connect < 300:
+ for mon in monitor_by_pk.values():
+ lots = int(mon.get("lots") or 0)
+ if lots <= 0:
+ continue
+ sym = (mon.get("symbol") or "").strip()
+ direction = (mon.get("direction") or "long").strip().lower()
+ if fast:
+ mon = _overlay_sl_tp_readonly(conn, mon, sym, direction) or mon
+ else:
+ mon = (
+ _restore_monitor_sl_tp_if_missing(conn, mon, sym, direction)
+ or mon
+ )
+ try:
+ row = _compose_position_row(
+ conn,
+ mon=mon,
+ ctp=None,
+ mode=mode,
+ capital=capital,
+ now_iso=now_iso,
+ fast=fast,
+ )
+ if not row:
+ continue
+ rk = row.get("key") or row.get("position_key") or ""
+ if rk and rk in seen:
+ continue
+ if rk:
+ seen.add(rk)
+ deduped.append(row)
+ except Exception as exc:
+ logger.warning("compose monitor fallback row failed: %s", exc)
+
return deduped
def _build_trading_live_payload(conn, *, fast: bool = False) -> dict:
diff --git a/static/js/stats.js b/static/js/stats.js
index be1cc45..ce08876 100644
--- a/static/js/stats.js
+++ b/static/js/stats.js
@@ -150,6 +150,278 @@
});
}
+ var calYear = 0;
+ var calMonth = 0;
+ var calDays = [];
+ var selectedDate = '';
+
+ function pad2(n) {
+ return n < 10 ? '0' + n : String(n);
+ }
+
+ function todayIso() {
+ var d = new Date();
+ return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate());
+ }
+
+ function pnlClass(v) {
+ if (v > 0) return 'is-profit';
+ if (v < 0) return 'is-loss';
+ return 'is-flat';
+ }
+
+ function fmtPnlShort(v) {
+ if (v === null || v === undefined) return '-';
+ var n = Number(v);
+ if (isNaN(n)) return '-';
+ var s = Number.isInteger(n) ? String(n) : n.toFixed(0);
+ return (n > 0 ? '+' : '') + s;
+ }
+
+ function fmtTime(v) {
+ if (!v) return '-';
+ return String(v).replace('T', ' ').slice(0, 16);
+ }
+
+ function fmtTags(item) {
+ var tags = item.behavior_tags || '';
+ if (item.is_emotion) {
+ return tags ? '情绪单 · ' + tags : '情绪单';
+ }
+ return tags || '';
+ }
+
+ function setCalendarTitle() {
+ var title = document.getElementById('stats-cal-title');
+ if (title) title.textContent = calYear + '年' + calMonth + '月';
+ }
+
+ function renderCalendar(data) {
+ var grid = document.getElementById('stats-calendar-grid');
+ if (!grid) return;
+ calDays = data.days || [];
+ setCalendarTitle();
+ var html = '';
+ var pad = data.weekday_start || 0;
+ var i;
+ for (i = 0; i < pad; i++) {
+ html += '
';
+ }
+ calDays.forEach(function (day) {
+ var dayNum = day.date.slice(8, 10).replace(/^0/, '');
+ var classes = ['stats-cal-cell'];
+ if (day.count > 0) classes.push('is-clickable');
+ if (day.date === todayIso()) classes.push('is-today');
+ if (day.date === selectedDate) classes.push('is-selected');
+ if (day.has_emotion) classes.push('is-emotion');
+ html += '';
+ html += '
' + dayNum + '
';
+ if (day.count > 0) {
+ html += '
';
+ }
+ html += '
';
+ });
+ grid.innerHTML = html;
+ }
+
+ function loadCalendar() {
+ fetch('/api/stats/calendar?year=' + calYear + '&month=' + calMonth, { credentials: 'same-origin' })
+ .then(function (r) {
+ if (!r.ok) throw new Error('HTTP ' + r.status);
+ return r.json();
+ })
+ .then(function (data) {
+ renderCalendar(data);
+ if (selectedDate && selectedDate.slice(0, 7) === calYear + '-' + pad2(calMonth)) {
+ loadDayDetail(selectedDate, false);
+ } else {
+ hideDayDetail();
+ }
+ })
+ .catch(function () {
+ var grid = document.getElementById('stats-calendar-grid');
+ if (grid) grid.innerHTML = '日历加载失败
';
+ });
+ }
+
+ function hideDayDetail() {
+ var panel = document.getElementById('stats-day-detail');
+ if (panel) panel.hidden = true;
+ }
+
+ function renderDayDetail(data) {
+ var panel = document.getElementById('stats-day-detail');
+ var title = document.getElementById('stats-day-detail-title');
+ var summary = document.getElementById('stats-day-summary');
+ var list = document.getElementById('stats-day-list');
+ if (!panel || !list) return;
+
+ var label = data.date.replace(/-/g, '/');
+ if (title) title.textContent = label + ' 交易记录';
+ if (summary) {
+ var parts = [data.count + ' 笔', '净盈亏 ' + fmtMoney(data.total_net)];
+ if (data.emotion_count) parts.push('情绪单 ' + data.emotion_count);
+ summary.textContent = parts.join(' · ');
+ }
+
+ list.innerHTML = '';
+ if (!data.items || !data.items.length) {
+ list.innerHTML = '当日无平仓记录
';
+ panel.hidden = false;
+ return;
+ }
+
+ data.items.forEach(function (item) {
+ var card = document.createElement('div');
+ card.className = 'stats-day-item' + (item.is_emotion ? ' is-emotion' : '');
+
+ var head = document.createElement('div');
+ head.className = 'stats-day-item-head';
+ var sym = document.createElement('div');
+ sym.className = 'stats-day-item-symbol';
+ sym.textContent = (item.symbol || item.symbol_code || '-') + ' · ' + (item.direction || '-');
+ var pnl = document.createElement('div');
+ pnl.className = 'stats-day-item-pnl ' + pnlClass(item.pnl_net);
+ pnl.textContent = fmtMoney(item.pnl_net);
+ head.appendChild(sym);
+ head.appendChild(pnl);
+
+ var meta = document.createElement('div');
+ meta.className = 'stats-day-item-meta';
+ var badges = '';
+ if (item.source === 'review') {
+ badges += '复盘';
+ }
+ if (item.is_emotion) {
+ badges += '情绪单';
+ }
+ var metaParts = [
+ badges,
+ '平仓 ' + fmtTime(item.close_time),
+ item.lots != null ? item.lots + ' 手' : '',
+ item.entry_price != null ? '开 ' + item.entry_price : '',
+ item.close_price != null ? '平 ' + item.close_price : '',
+ ];
+ if (item.source === 'review' && item.open_type) metaParts.push(item.open_type);
+ if (item.source === 'review' && item.exit_trigger) metaParts.push('出场: ' + item.exit_trigger);
+ if (item.result) metaParts.push(item.result);
+ meta.innerHTML = metaParts.filter(Boolean).join(' · ');
+
+ card.appendChild(head);
+ card.appendChild(meta);
+
+ var tags = fmtTags(item);
+ if (tags) {
+ var tagEl = document.createElement('div');
+ tagEl.className = 'stats-day-item-notes';
+ tagEl.textContent = tags;
+ card.appendChild(tagEl);
+ }
+ if (item.notes) {
+ var notes = document.createElement('div');
+ notes.className = 'stats-day-item-notes';
+ notes.textContent = item.notes;
+ card.appendChild(notes);
+ }
+ if (item.screenshot) {
+ var shot = document.createElement('div');
+ shot.className = 'stats-day-item-shot';
+ shot.innerHTML = '
';
+ card.appendChild(shot);
+ }
+ list.appendChild(card);
+ });
+ panel.hidden = false;
+ }
+
+ function loadDayDetail(dateStr, scroll) {
+ if (scroll === undefined) scroll = true;
+ selectedDate = dateStr;
+ document.querySelectorAll('.stats-cal-cell.is-selected').forEach(function (el) {
+ el.classList.remove('is-selected');
+ });
+ var cell = document.querySelector('.stats-cal-cell[data-date="' + dateStr + '"]');
+ if (cell) cell.classList.add('is-selected');
+
+ fetch('/api/stats/calendar/day?date=' + encodeURIComponent(dateStr), { credentials: 'same-origin' })
+ .then(function (r) {
+ if (!r.ok) throw new Error('HTTP ' + r.status);
+ return r.json();
+ })
+ .then(function (data) {
+ renderDayDetail(data);
+ if (scroll) {
+ var panel = document.getElementById('stats-day-detail');
+ if (panel) panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
+ }
+ })
+ .catch(function () {
+ var panel = document.getElementById('stats-day-detail');
+ var list = document.getElementById('stats-day-list');
+ if (panel && list) {
+ list.innerHTML = '加载失败
';
+ panel.hidden = false;
+ }
+ });
+ }
+
+ function shiftCalendarMonth(delta) {
+ calMonth += delta;
+ if (calMonth > 12) {
+ calMonth = 1;
+ calYear += 1;
+ } else if (calMonth < 1) {
+ calMonth = 12;
+ calYear -= 1;
+ }
+ loadCalendar();
+ }
+
+ function bindCalendar() {
+ var grid = document.getElementById('stats-calendar-grid');
+ if (!grid || grid.dataset.statsCalBound) return;
+ grid.dataset.statsCalBound = '1';
+
+ grid.addEventListener('click', function (e) {
+ var cell = e.target.closest('.stats-cal-cell.is-clickable');
+ if (!cell) return;
+ loadDayDetail(cell.getAttribute('data-date'));
+ });
+ grid.addEventListener('keydown', function (e) {
+ if (e.key !== 'Enter' && e.key !== ' ') return;
+ var cell = e.target.closest('.stats-cal-cell.is-clickable');
+ if (!cell) return;
+ e.preventDefault();
+ loadDayDetail(cell.getAttribute('data-date'));
+ });
+
+ var prev = document.getElementById('stats-cal-prev');
+ var next = document.getElementById('stats-cal-next');
+ var todayBtn = document.getElementById('stats-cal-today');
+ if (prev) prev.addEventListener('click', function () { shiftCalendarMonth(-1); });
+ if (next) next.addEventListener('click', function () { shiftCalendarMonth(1); });
+ if (todayBtn) todayBtn.addEventListener('click', function () {
+ var d = new Date();
+ calYear = d.getFullYear();
+ calMonth = d.getMonth() + 1;
+ loadCalendar();
+ });
+ }
+
+ function initCalendar() {
+ if (!document.getElementById('stats-calendar-grid')) return;
+ var d = new Date();
+ calYear = d.getFullYear();
+ calMonth = d.getMonth() + 1;
+ bindCalendar();
+ loadCalendar();
+ }
+
function bootStatsPage() {
if (!document.getElementById('stats-summary')) return;
var viewSel = document.getElementById('stats-view-select');
@@ -160,6 +432,7 @@
});
}
loadStats();
+ initCalendar();
}
if (window.qihuoPageBoot) window.qihuoPageBoot(bootStatsPage, '#stats-summary');
diff --git a/stats_engine.py b/stats_engine.py
index e221c43..72778b5 100644
--- a/stats_engine.py
+++ b/stats_engine.py
@@ -6,9 +6,10 @@
"""交易统计计算与缓存结构。"""
from __future__ import annotations
+import calendar
import json
import threading
-from datetime import datetime
+from datetime import date, datetime
from typing import Any, Optional
from zoneinfo import ZoneInfo
@@ -320,3 +321,248 @@ def refresh_stats_cache(conn, live_capital: float = 0.0) -> dict:
data = build_all_stats(conn, live_capital)
save_stats_cache(conn, data)
return data
+
+
+def _norm_symbol(symbol: str) -> str:
+ s = (symbol or "").strip().lower()
+ if "." in s:
+ s = s.split(".")[0]
+ return s
+
+
+def _close_day_key(row: dict) -> str:
+ dt = _parse_dt(row.get("close_time") or row.get("created_at") or "")
+ return dt.date().isoformat() if dt else ""
+
+
+def _close_ts(row: dict) -> float:
+ dt = _parse_dt(row.get("close_time") or row.get("created_at") or "")
+ return dt.timestamp() if dt else 0.0
+
+
+def _direction_label(direction: str) -> str:
+ if direction == "long":
+ return "做多"
+ if direction == "short":
+ return "做空"
+ return direction or ""
+
+
+def _index_reviews_by_day_sym(reviews: list[dict]) -> dict[tuple[str, str], list[dict]]:
+ index: dict[tuple[str, str], list[dict]] = {}
+ for review in reviews:
+ day = _close_day_key(review)
+ if not day:
+ continue
+ sym = _norm_symbol(review.get("symbol") or "")
+ index.setdefault((day, sym), []).append(review)
+ return index
+
+
+def _review_match_score(trade: dict, review: dict) -> float:
+ score = abs(_close_ts(trade) - _close_ts(review))
+ lots_t = trade.get("lots")
+ lots_r = review.get("lots")
+ if lots_t is not None and lots_r is not None and float(lots_t) != float(lots_r):
+ score += 86400.0
+ entry_t = trade.get("entry_price")
+ entry_r = review.get("entry_price")
+ if entry_t is not None and entry_r is not None and abs(float(entry_t) - float(entry_r)) > 0.01:
+ score += 3600.0
+ return score
+
+
+def _find_review_for_trade(
+ trade: dict,
+ review_index: dict[tuple[str, str], list[dict]],
+ used_review_ids: set[int],
+) -> Optional[dict]:
+ day = _close_day_key(trade)
+ sym = _norm_symbol(trade.get("symbol") or "")
+ candidates = [
+ r for r in review_index.get((day, sym), [])
+ if r.get("id") not in used_review_ids
+ ]
+ if not candidates:
+ return None
+ return min(candidates, key=lambda r: _review_match_score(trade, r))
+
+
+def _format_day_entry(
+ *,
+ trade: Optional[dict] = None,
+ review: Optional[dict] = None,
+ source: str,
+) -> dict:
+ row = review if source == "review" and review else trade or review or {}
+ symbol = row.get("symbol") or ""
+ pnl_net = _net_pnl(row)
+ tags = (row.get("behavior_tags") or "").strip()
+ is_emotion = bool(row.get("is_emotion"))
+ return {
+ "source": source,
+ "trade_id": trade.get("id") if trade else None,
+ "review_id": review.get("id") if review else None,
+ "symbol": row.get("symbol_name") or symbol,
+ "symbol_code": symbol,
+ "direction": _direction_label(row.get("direction") or ""),
+ "lots": row.get("lots"),
+ "entry_price": row.get("entry_price"),
+ "close_price": row.get("close_price"),
+ "stop_loss": row.get("stop_loss"),
+ "take_profit": row.get("take_profit"),
+ "open_time": row.get("open_time") or "",
+ "close_time": row.get("close_time") or "",
+ "pnl": row.get("pnl"),
+ "fee": row.get("fee"),
+ "pnl_net": pnl_net,
+ "result": row.get("result") if trade else None,
+ "monitor_type": row.get("monitor_type") if trade else None,
+ "is_emotion": is_emotion,
+ "behavior_tags": tags,
+ "open_type": row.get("open_type") if review else None,
+ "exit_trigger": row.get("exit_trigger") if review else None,
+ "exit_supplement": row.get("exit_supplement") if review else None,
+ "holding_duration": row.get("holding_duration") if review else None,
+ "initial_pnl": row.get("initial_pnl") if review else None,
+ "actual_pnl": row.get("actual_pnl") if review else None,
+ "timeframe": row.get("timeframe") if review else None,
+ "notes": row.get("notes") if review else None,
+ "screenshot": row.get("screenshot") if review else None,
+ }
+
+
+def build_day_detail(trades: list[dict], reviews: list[dict], day: str) -> list[dict]:
+ day_trades = [t for t in trades if _close_day_key(t) == day]
+ day_reviews = [r for r in reviews if _close_day_key(r) == day]
+ review_index = _index_reviews_by_day_sym(day_reviews)
+ used_review_ids: set[int] = set()
+ items: list[dict] = []
+
+ for trade in day_trades:
+ review = _find_review_for_trade(trade, review_index, used_review_ids)
+ if review:
+ used_review_ids.add(int(review["id"]))
+ items.append(_format_day_entry(trade=trade, review=review, source="review"))
+ else:
+ items.append(_format_day_entry(trade=trade, source="trade"))
+
+ for review in day_reviews:
+ if int(review.get("id") or 0) in used_review_ids:
+ continue
+ items.append(_format_day_entry(review=review, source="review"))
+
+ items.sort(key=lambda x: _close_ts(x), reverse=True)
+ return items
+
+
+def build_calendar_month(trades: list[dict], reviews: list[dict], year: int, month: int) -> dict:
+ review_index = _index_reviews_by_day_sym(reviews)
+ day_map: dict[str, dict] = {}
+ matched_review_ids: dict[str, set[int]] = {}
+
+ for trade in trades:
+ dt = _parse_dt(trade.get("close_time") or "")
+ if not dt or dt.year != year or dt.month != month:
+ continue
+ day = dt.date().isoformat()
+ bucket = day_map.setdefault(
+ day,
+ {
+ "date": day,
+ "count": 0,
+ "total_net": 0.0,
+ "review_count": 0,
+ "emotion_count": 0,
+ "has_emotion": False,
+ },
+ )
+ bucket["count"] += 1
+ used = matched_review_ids.setdefault(day, set())
+ review = _find_review_for_trade(trade, review_index, used)
+ if review:
+ rid = int(review["id"])
+ used.add(rid)
+ bucket["total_net"] = round(bucket["total_net"] + _net_pnl(review), 2)
+ bucket["review_count"] += 1
+ if review.get("is_emotion"):
+ bucket["emotion_count"] += 1
+ bucket["has_emotion"] = True
+ else:
+ bucket["total_net"] = round(bucket["total_net"] + _net_pnl(trade), 2)
+
+ for review in reviews:
+ if not review.get("is_emotion"):
+ continue
+ day = _close_day_key(review)
+ if not day:
+ continue
+ try:
+ dt = date.fromisoformat(day)
+ except ValueError:
+ continue
+ if dt.year != year or dt.month != month:
+ continue
+ bucket = day_map.setdefault(
+ day,
+ {
+ "date": day,
+ "count": 0,
+ "total_net": 0.0,
+ "review_count": 0,
+ "emotion_count": 0,
+ "has_emotion": False,
+ },
+ )
+ bucket["has_emotion"] = True
+ rid = int(review.get("id") or 0)
+ if rid and rid not in matched_review_ids.get(day, set()):
+ bucket["emotion_count"] += 1
+
+ _, last_day = calendar.monthrange(year, month)
+ days = []
+ for d in range(1, last_day + 1):
+ iso = date(year, month, d).isoformat()
+ if iso in day_map:
+ row = day_map[iso]
+ row["total_net"] = round(row["total_net"], 2)
+ days.append(row)
+ else:
+ days.append(
+ {
+ "date": iso,
+ "count": 0,
+ "total_net": 0.0,
+ "review_count": 0,
+ "emotion_count": 0,
+ "has_emotion": False,
+ }
+ )
+
+ return {
+ "year": year,
+ "month": month,
+ "days": days,
+ "weekday_start": date(year, month, 1).weekday(),
+ }
+
+
+def get_calendar_month(conn, year: int, month: int) -> dict:
+ trades = fetch_trade_rows(conn)
+ reviews = fetch_review_rows(conn)
+ return build_calendar_month(trades, reviews, year, month)
+
+
+def get_calendar_day(conn, day: str) -> dict:
+ trades = fetch_trade_rows(conn)
+ reviews = fetch_review_rows(conn)
+ items = build_day_detail(trades, reviews, day)
+ total_net = round(sum(float(i.get("pnl_net") or 0) for i in items), 2)
+ emotion_count = sum(1 for i in items if i.get("is_emotion"))
+ return {
+ "date": day,
+ "count": len(items),
+ "total_net": total_net,
+ "emotion_count": emotion_count,
+ "items": items,
+ }
diff --git a/templates/stats.html b/templates/stats.html
index 0b6181c..c81394e 100644
--- a/templates/stats.html
+++ b/templates/stats.html
@@ -28,6 +28,93 @@
.stats-card-head h2{margin-bottom:0}
.stats-view-field{width:auto;min-width:200px}
.stats-view-field select{width:100%;min-width:180px}
+
+/* 交易日历 */
+.stats-calendar-card{margin-bottom:1.25rem}
+.stats-calendar-head{display:flex;align-items:center;justify-content:space-between;gap:.75rem;flex-wrap:wrap;margin-bottom:.85rem}
+.stats-calendar-head h2{margin:0}
+.stats-calendar-nav{display:flex;align-items:center;gap:.5rem}
+.stats-calendar-nav button{
+ border:1px solid var(--card-border);background:var(--card-inner);color:var(--text-title);
+ border-radius:8px;padding:.35rem .65rem;cursor:pointer;font:inherit;font-size:.85rem;
+}
+.stats-calendar-nav button:hover{border-color:var(--accent)}
+.stats-calendar-title{font-size:.95rem;font-weight:600;color:var(--text-title);min-width:7rem;text-align:center}
+.stats-calendar-weekdays{
+ display:grid;grid-template-columns:repeat(7,1fr);gap:.35rem;margin-bottom:.35rem;
+}
+.stats-calendar-weekdays span{
+ text-align:center;font-size:.68rem;color:var(--text-muted);padding:.15rem 0;
+}
+.stats-calendar-grid{display:grid;grid-template-columns:repeat(7,1fr);gap:.35rem}
+.stats-cal-cell{
+ min-height:4.6rem;border:1px solid var(--card-border);border-radius:10px;
+ background:var(--card-inner);padding:.35rem .4rem;text-align:left;cursor:default;
+ transition:border-color .2s,box-shadow .2s;
+}
+.stats-cal-cell.is-empty{background:transparent;border-color:transparent}
+.stats-cal-cell.is-clickable{cursor:pointer}
+.stats-cal-cell.is-clickable:hover{border-color:var(--accent)}
+.stats-cal-cell.is-selected{
+ border-color:var(--accent);box-shadow:0 0 0 1px rgba(56,189,248,.25);
+}
+.stats-cal-cell.is-today .stats-cal-day-num{color:var(--accent);font-weight:700}
+.stats-cal-cell.is-emotion{
+ border-color:rgba(251,146,60,.65);
+ background:linear-gradient(145deg,rgba(251,146,60,.12),var(--card-inner));
+}
+.stats-cal-cell.is-emotion.is-selected{
+ border-color:rgba(251,146,60,.9);
+ box-shadow:0 0 0 1px rgba(251,146,60,.35);
+}
+.stats-cal-day-num{font-size:.78rem;font-weight:600;color:var(--text-title);line-height:1.2}
+.stats-cal-meta{margin-top:.2rem;font-size:.62rem;line-height:1.35;color:var(--text-muted)}
+.stats-cal-count{font-variant-numeric:tabular-nums}
+.stats-cal-pnl{font-weight:600;font-variant-numeric:tabular-nums;margin-top:.08rem}
+.stats-cal-pnl.is-profit{color:var(--profit)}
+.stats-cal-pnl.is-loss{color:var(--loss)}
+.stats-cal-emotion{
+ display:inline-block;margin-top:.12rem;padding:.05rem .28rem;border-radius:4px;
+ font-size:.58rem;font-weight:600;color:#fb923c;background:rgba(251,146,60,.15);
+}
+.stats-day-detail{margin-top:1rem;padding-top:.85rem;border-top:1px solid var(--table-border)}
+.stats-day-detail-head{
+ display:flex;align-items:center;justify-content:space-between;gap:.75rem;
+ flex-wrap:wrap;margin-bottom:.65rem;
+}
+.stats-day-detail-head h3{margin:0;font-size:.95rem}
+.stats-day-summary{font-size:.78rem;color:var(--text-muted)}
+.stats-day-list{display:flex;flex-direction:column;gap:.55rem}
+.stats-day-item{
+ border:1px solid var(--card-border);border-radius:12px;background:var(--card-inner);
+ padding:.65rem .75rem;
+}
+.stats-day-item.is-emotion{
+ border-color:rgba(251,146,60,.55);
+ background:linear-gradient(145deg,rgba(251,146,60,.1),var(--card-inner));
+}
+.stats-day-item-head{
+ display:flex;align-items:center;justify-content:space-between;gap:.5rem;margin-bottom:.35rem;
+}
+.stats-day-item-symbol{font-weight:600;font-size:.88rem;color:var(--text-title)}
+.stats-day-item-pnl{font-weight:600;font-size:.88rem;font-variant-numeric:tabular-nums}
+.stats-day-item-pnl.is-profit{color:var(--profit)}
+.stats-day-item-pnl.is-loss{color:var(--loss)}
+.stats-day-item-meta{
+ display:flex;flex-wrap:wrap;gap:.35rem .55rem;font-size:.72rem;color:var(--text-muted);
+}
+.stats-day-badge{
+ display:inline-block;padding:.08rem .35rem;border-radius:4px;font-size:.65rem;font-weight:600;
+}
+.stats-day-badge.review{background:rgba(56,189,248,.15);color:var(--accent)}
+.stats-day-badge.emotion{background:rgba(251,146,60,.18);color:#fb923c}
+.stats-day-item-notes{
+ margin-top:.4rem;font-size:.72rem;color:var(--text-muted);line-height:1.45;
+}
+.stats-day-item-shot{margin-top:.45rem}
+.stats-day-item-shot img{
+ max-width:100%;max-height:180px;border-radius:8px;border:1px solid var(--card-border);
+}
{% endblock %}
{% block content %}
@@ -54,6 +141,31 @@
+
+
+
交易日历
+
+
+ —
+
+
+
+
+
+ 一二三四五六日
+
+
+
+
+
分项统计
diff --git a/vnpy_bridge.py b/vnpy_bridge.py
index 33d9f85..0fc5d09 100644
--- a/vnpy_bridge.py
+++ b/vnpy_bridge.py
@@ -163,13 +163,61 @@ def _fire_position_refresh_callback_debounced(*, min_interval: float = 0.35) ->
def _fire_position_refresh_burst() -> None:
"""连接后持仓回报可能分批到达,分多次触发快照刷新。"""
_fire_position_refresh_callback()
- for delay in (1.5, 4.0, 10.0, 25.0):
+ for delay in (1.5, 4.0, 10.0, 18.0):
threading.Timer(delay, _fire_position_refresh_callback).start()
+
+def _schedule_after_instruments_ready(bridge: "CtpBridge") -> None:
+ """合约查询结束后查询持仓并校准(SimNow 登录后约 10–20s)。"""
+ if not getattr(bridge, "_connected_mode", None):
+ return
+ now = time.monotonic()
+ if now - float(getattr(bridge, "_last_instruments_ready_ts", 0) or 0) < 5.0:
+ return
+ bridge._last_instruments_ready_ts = now
+
+ def _run() -> None:
+ try:
+ if bridge._has_live_positions():
+ return
+ bridge._ensure_instrument_margin_hooks()
+ with _ctp_td_lock:
+ bridge.request_position_snapshot(force=True)
+ time.sleep(2.0)
+ with _ctp_td_lock:
+ bridge.calibrate_trading_state()
+ _fire_position_refresh_callback()
+ n = len(bridge._collect_positions())
+ logger.info("CTP 合约加载完成,持仓 %s 条,已刷新快照", n)
+ except Exception as exc:
+ logger.debug("instruments ready refresh: %s", exc)
+
+ threading.Timer(0.4, _run).start()
+
+
+def _schedule_position_query_retries(bridge: "CtpBridge") -> None:
+ def _run() -> None:
+ if not bridge._connected_mode or bridge._has_live_positions():
+ return
+ try:
+ bridge._ensure_instrument_margin_hooks()
+ with _ctp_td_lock:
+ bridge.request_position_snapshot(force=False)
+ time.sleep(1.0)
+ with _ctp_td_lock:
+ bridge.calibrate_trading_state()
+ _fire_position_refresh_callback()
+ except Exception as exc:
+ logger.debug("position query retry: %s", exc)
+
+ for delay in POSITION_QUERY_RETRY_DELAYS_SEC:
+ threading.Timer(delay, _run).start()
+
_bridge: Optional["CtpBridge"] = None
_bridge_lock = threading.Lock()
_ctp_td_lock = threading.RLock()
POSITION_QUERY_MIN_INTERVAL_SEC = 5.0
+POSITION_QUERY_RETRY_DELAYS_SEC = (22.0, 50.0, 95.0)
TRADE_QUERY_MIN_INTERVAL_SEC = 10.0
@@ -273,6 +321,10 @@ class CtpBridge:
self._margin_rate_lists: dict[int, list] = {}
self._margin_rate_hooked = False
self._instrument_hooked = False
+ self._hooks_td_api_id: Optional[int] = None
+ self._ctp_log_hooked = False
+ self._last_instruments_ready_ts: float = 0.0
+ self._last_position_rsp_ts: float = 0.0
self._instrument_margin_ratios: dict[str, dict[str, float]] = {}
self._margin_per_lot: dict[str, float] = {}
self._subscribed: set[str] = set()
@@ -306,6 +358,7 @@ class CtpBridge:
self._ensure_position_event_hook()
self._ensure_order_event_hook()
self._ensure_trade_event_hook()
+ self._ensure_ctp_log_hooks()
except ImportError:
self._last_error = "未安装 vnpy / vnpy_ctp,请 pip install vnpy vnpy_ctp"
except Exception as exc:
@@ -479,18 +532,13 @@ class CtpBridge:
"td_volume": td,
}
try:
- from ctp_entry_price import entry_from_ctp_pnl, round_to_tick
- from ctp_trading_state import trading_state
+ from ctp_entry_price import round_to_tick
ths = CtpBridge._vnpy_sym_to_ths(sym, ex_name) or sym
- tick = trading_state.get_tick_price(ex_name, sym)
- pnl_entry = entry_from_ctp_pnl(row, tick, ths_sym=ths)
- if pnl_entry and price > 0 and abs(pnl_entry - price) >= 0.5:
- row["avg_price"] = pnl_entry
- elif price > 0:
+ if price > 0:
row["avg_price"] = round_to_tick(price, ths)
except Exception as exc:
- logger.debug("position avg refine: %s", exc)
+ logger.debug("position avg round: %s", exc)
return row
except Exception as exc:
logger.debug("position_row_from_vnpy: %s", exc)
@@ -579,6 +627,11 @@ class CtpBridge:
except Exception as exc:
logger.debug("gateway close: %s", exc)
self._connected_mode = None
+ self._hooks_td_api_id = None
+ self._instrument_hooked = False
+ self._margin_rate_hooked = False
+ self._last_position_query_ts = 0.0
+ self._last_instruments_ready_ts = 0.0
try:
from ctp_trading_state import trading_state
@@ -587,6 +640,27 @@ class CtpBridge:
pass
time.sleep(0.6)
+ def _ensure_ctp_log_hooks(self) -> None:
+ """监听 vnpy 日志:合约查询成功时补触发持仓刷新(重连后 td_api 可能已换)。"""
+ if self._ctp_log_hooked or not self._ee:
+ return
+ try:
+ from vnpy.trader.event import EVENT_LOG
+ except ImportError:
+ return
+ bridge = self
+
+ def _on_persistent_log(event) -> None:
+ try:
+ msg = getattr(event.data, "msg", "") or str(event.data)
+ if "合约信息查询成功" in str(msg):
+ _schedule_after_instruments_ready(bridge)
+ except Exception as exc:
+ logger.debug("ctp log hook: %s", exc)
+
+ self._ee.register(EVENT_LOG, _on_persistent_log)
+ self._ctp_log_hooked = True
+
def _login_rejected(self, ctp_logs: list[str]) -> bool:
return any(
kw in m
@@ -723,7 +797,9 @@ class CtpBridge:
self.calibrate_trading_state()
except Exception as exc:
logger.debug("post-connect calibrate: %s", exc)
+ self._ensure_instrument_margin_hooks()
_fire_position_refresh_burst()
+ _schedule_position_query_retries(self)
_fire_ctp_connected_callback(mode)
return
finally:
@@ -1040,7 +1116,7 @@ class CtpBridge:
self._instrument_margin_ratios[key] = ratios
def _ensure_instrument_margin_hooks(self) -> None:
- """登录前挂钩:合约查询回报缓存保证金率;支持按需 reqQryInstrumentMarginRate。"""
+ """登录前挂钩:合约/持仓查询回报;td_api 重建后须重新挂钩。"""
if not self._engine:
return
try:
@@ -1049,9 +1125,14 @@ class CtpBridge:
except Exception:
return
bridge = self
+ td_id = id(td)
+ if td_id != self._hooks_td_api_id:
+ self._hooks_td_api_id = td_id
+ self._instrument_hooked = False
+ self._margin_rate_hooked = False
if not self._instrument_hooked:
- orig = td.onRspQryInstrument
+ orig_inst = td.onRspQryInstrument
def on_instrument(data: dict, error: dict, reqid: int, last: bool) -> None:
try:
@@ -1059,9 +1140,37 @@ class CtpBridge:
bridge._cache_margin_ratio(str(data["InstrumentID"]), data)
except Exception as exc:
logger.debug("instrument margin cache: %s", exc)
- return orig(data, error, reqid, last)
+ if last:
+ _schedule_after_instruments_ready(bridge)
+ return orig_inst(data, error, reqid, last)
td.onRspQryInstrument = on_instrument # type: ignore[method-assign]
+
+ orig_pos = td.onRspQryInvestorPosition
+
+ def on_rsp_position(
+ data: dict, error: dict, reqid: int, last: bool,
+ ) -> None:
+ ret = orig_pos(data, error, reqid, last)
+ if last:
+ now = time.monotonic()
+ if now - bridge._last_position_rsp_ts < 30.0:
+ return ret
+ bridge._last_position_rsp_ts = now
+
+ def _after_position_query() -> None:
+ try:
+ time.sleep(1.5)
+ with _ctp_td_lock:
+ bridge.calibrate_trading_state()
+ _fire_position_refresh_callback()
+ except Exception as exc:
+ logger.debug("position rsp refresh: %s", exc)
+
+ threading.Timer(0.2, _after_position_query).start()
+ return ret
+
+ td.onRspQryInvestorPosition = on_rsp_position # type: ignore[method-assign]
self._instrument_hooked = True
if self._margin_rate_hooked:
@@ -1679,6 +1788,52 @@ class CtpBridge:
"""vnpy 内存缓存持仓;禁止 query_position(vnctptd 并发查询会段错误)。"""
return
+ def _has_live_positions(self) -> bool:
+ if not self._engine:
+ return False
+ try:
+ with _ctp_td_lock:
+ return len(self._collect_positions()) > 0
+ except Exception:
+ return False
+
+ def request_position_snapshot(self, *, force: bool = False) -> None:
+ """合约加载后查询持仓,填充 vnpy 内存(已有持仓时跳过主动查询)。"""
+ if not self._engine or not self._connected_mode:
+ return
+ if not force and self._has_live_positions():
+ return
+ now = time.monotonic()
+ if not force and (now - self._last_position_query_ts) < POSITION_QUERY_MIN_INTERVAL_SEC:
+ return
+ try:
+ self._ensure_instrument_margin_hooks()
+ gw = self._engine.get_gateway(GATEWAY_NAME)
+ td = getattr(gw, "td_api", None) if gw else None
+ if not td or not getattr(td, "login_status", False):
+ logger.debug("CTP 持仓查询跳过:交易未登录")
+ return
+ if hasattr(td, "reqQryInvestorPosition"):
+ reqid = int(getattr(td, "reqid", 0)) + 1
+ td.reqid = reqid
+ req = {
+ "BrokerID": getattr(td, "brokerid", ""),
+ "InvestorID": getattr(td, "userid", ""),
+ }
+ with _ctp_td_lock:
+ ret = td.reqQryInvestorPosition(req, reqid)
+ if ret == 0:
+ self._last_position_query_ts = now
+ logger.info("CTP 已请求持仓查询 reqid=%s", reqid)
+ else:
+ logger.debug("CTP 持仓查询发送失败 ret=%s", ret)
+ elif gw and hasattr(gw, "query_position"):
+ gw.query_position()
+ self._last_position_query_ts = now
+ logger.info("CTP 已请求持仓查询(gateway)")
+ except Exception as exc:
+ logger.debug("request_position_snapshot: %s", exc)
+
def list_positions(self, *, refresh_if_empty: bool = True, refresh_margin: bool = False) -> list[dict[str, Any]]:
del refresh_if_empty, refresh_margin
with _ctp_td_lock: