Add stats trading calendar and fix CTP position avg/sync.

Calendar shows daily closed trade count and PnL with emotion-day highlighting; day click loads review-first trade list. Use exchange-only entry average and improve vnpy position sync after CTP reconnect.
This commit is contained in:
dekun
2026-06-30 11:59:25 +08:00
parent d07fc4b70d
commit 8ebad6e8a2
8 changed files with 926 additions and 198 deletions
+43 -2
View File
@@ -13,7 +13,7 @@ import sqlite3
import time import time
import threading import threading
import requests import requests
from datetime import datetime, timedelta from datetime import date, datetime, timedelta
from typing import Optional from typing import Optional
from functools import wraps from functools import wraps
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
@@ -45,7 +45,14 @@ from fee_specs import (
purge_non_ctp_fee_rates, purge_non_ctp_fee_rates,
) )
from nav_settings import NAV_TOGGLES, get_nav_items, nav_enabled, save_nav_items 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_store import ensure_kline_tables
from kline_stream import kline_hub, sse_format from kline_stream import kline_hub, sse_format
from kline_chart import generate_review_kline_chart, fetch_market_klines, MARKET_PERIODS from kline_chart import generate_review_kline_chart, fetch_market_klines, MARKET_PERIODS
@@ -1635,6 +1642,40 @@ def api_stats_refresh():
return jsonify(data) 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} _dashboard_sync_tick = {"n": 0}
+4 -92
View File
@@ -1,7 +1,7 @@
# Copyright (c) 2025-2026 马建军. All rights reserved. # Copyright (c) 2025-2026 马建军. All rights reserved.
# 详见 LICENSE.zh-CN.txt # 详见 LICENSE.zh-CN.txt
"""CTP 持仓均价:成交加权 / 柜台持仓价 / 盈亏一致校正""" """CTP 持仓均价:仅使用柜台持仓回报(vnpy pos.price = PositionCost 加权)"""
from __future__ import annotations from __future__ import annotations
from typing import Any, Optional 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) 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( def resolve_ctp_entry(
sym: str, sym: str,
direction: str, direction: str,
@@ -121,31 +53,11 @@ def resolve_ctp_entry(
*, *,
tick: Optional[float] = None, tick: Optional[float] = None,
) -> tuple[float, str]: ) -> tuple[float, str]:
"""均价:成交加权 > 盈亏一致校正 > 柜台持仓价""" """均价:仅柜台持仓价(trades/tick 参数保留兼容,不参与计算)"""
del direction, trades, tick
if not ctp: if not ctp:
return 0.0, "none" 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) pos_avg = float(ctp.get("avg_price") or 0)
if pos_avg > 0: if pos_avg > 0:
pos_avg = round_to_tick(pos_avg, sym) return round_to_tick(pos_avg, sym), "ctp"
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 0.0, "none" return 0.0, "none"
+12 -67
View File
@@ -42,49 +42,27 @@ def reconcile_position_avg(
trades: Optional[list[dict[str, Any]]] = None, trades: Optional[list[dict[str, Any]]] = None,
ths_sym: str = "", ths_sym: str = "",
) -> dict[str, Any]: ) -> 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) row = dict(new)
lots = int(row.get("lots") or 0) lots = int(row.get("lots") or 0)
if lots <= 0: if lots <= 0:
return row return row
direction = (row.get("direction") or "long").strip().lower()
old_lots = int(old.get("lots") or 0) if old else 0 old_lots = int(old.get("lots") or 0) if old else 0
lots_changed = not old or old_lots != lots lots_changed = not old or old_lots != lots
sym = ths_sym or (row.get("symbol") or "") 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) pos_avg = float(row.get("avg_price") or 0)
if pos_avg > 0: if pos_avg > 0:
row["avg_price"] = pos_avg row["avg_price"] = round_to_tick(pos_avg, sym)
row["avg_price_locked"] = lots_changed or bool(tick) 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 return row
@@ -174,41 +152,8 @@ class CtpTradingState:
return dict(row) if row else None return dict(row) if row else None
def try_lock_entry_prices(self) -> bool: def try_lock_entry_prices(self) -> bool:
"""有 tick 后校正持仓均价(含已锁定但与柜台盈亏不一致的)。""" """均价以柜台为准,不按 tick 反推(避免均价随行情跳动)。"""
from ctp_entry_price import resolve_ctp_entry return False
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
def upsert_position( def upsert_position(
self, self,
+68 -24
View File
@@ -9,6 +9,7 @@ from __future__ import annotations
import json import json
import logging import logging
import threading import threading
import time
from datetime import datetime from datetime import datetime
from typing import Any, Callable, Optional from typing import Any, Callable, Optional
@@ -120,7 +121,7 @@ from trading_context import (
is_ctp_connected, is_ctp_connected,
trading_mode_label, 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_symbol import ths_to_vnpy_symbol
from ctp_trading_state import position_key, trading_state from ctp_trading_state import position_key, trading_state
from vnpy_bridge import ( from vnpy_bridge import (
@@ -549,14 +550,9 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
mode: str, mode: str,
fallback: float = 0.0, fallback: float = 0.0,
) -> float: ) -> float:
"""滚仓/展示用均价:优先柜台成交加权与持仓价。""" """滚仓/展示用均价:仅柜台持仓价。"""
if not ctp_status(mode).get("connected"): if not ctp_status(mode).get("connected"):
return fallback return fallback
trades: list = []
try:
trades = ctp_list_trades(mode)
except Exception:
pass
for p in trading_state.get_positions() or _ctp_positions( for p in trading_state.get_positions() or _ctp_positions(
mode, refresh_if_empty=False, mode, refresh_if_empty=False,
): ):
@@ -564,11 +560,9 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
continue continue
if not _match_ctp_symbol(p.get("symbol") or "", sym): if not _match_ctp_symbol(p.get("symbol") or "", sym):
continue continue
entry, _ = resolve_ctp_entry( avg = float(p.get("avg_price") or 0)
sym, direction, p, trades, tick=ctp_get_tick_price(mode, sym), if avg > 0:
) return avg
if entry > 0:
return float(entry)
return fallback return fallback
def _resolve_ctp_entry_price( def _resolve_ctp_entry_price(
@@ -577,17 +571,13 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
direction: str, direction: str,
ctp: Optional[dict], ctp: Optional[dict],
) -> tuple[float, str]: ) -> tuple[float, str]:
del mode, direction
if not ctp: if not ctp:
return 0.0, "none" return 0.0, "none"
trades: list = [] avg = float(ctp.get("avg_price") or 0)
tick = None if avg > 0:
if ctp_status(mode).get("connected"): return round_to_tick(avg, sym), "ctp"
try: return 0.0, "none"
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)
def _open_commission_from_ctp_trades( def _open_commission_from_ctp_trades(
mode: str, sym: str, direction: str, 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) ctp_margin = float(ctp.get("margin") or 0)
if (margin is None or float(margin or 0) <= 0) and ctp_margin > 0: if (margin is None or float(margin or 0) <= 0) and ctp_margin > 0:
margin = ctp_margin margin = ctp_margin
if ctp_status(mode).get("connected"):
source_label = "CTP 柜台"
codes = ths_to_codes(sym) codes = ths_to_codes(sym)
tick = calc_order_tick_metrics(sym, lots, entry, trading_mode=mode) 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) monitor_by_pk = _monitors_by_position_key(conn)
ctp_list: list[dict] = [] ctp_list: list[dict] = []
if ctp_status(mode).get("connected"): 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: if not ctp_list:
ctp_list = _ctp_positions( ctp_list = trading_state.get_positions()
mode, refresh_if_empty=True, refresh_margin=not fast, 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] = []
@@ -1740,6 +1739,51 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
continue continue
seen.add(rk) seen.add(rk)
deduped.append(row) 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 return deduped
def _build_trading_live_payload(conn, *, fast: bool = False) -> dict: def _build_trading_live_payload(conn, *, fast: bool = False) -> dict:
+273
View File
@@ -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 += '<div class="stats-cal-cell is-empty"></div>';
}
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 += '<div class="' + classes.join(' ') + '" data-date="' + day.date + '" role="button" tabindex="' + (day.count > 0 ? '0' : '-1') + '">';
html += '<div class="stats-cal-day-num">' + dayNum + '</div>';
if (day.count > 0) {
html += '<div class="stats-cal-meta"><div class="stats-cal-count">' + day.count + ' 笔</div>';
html += '<div class="stats-cal-pnl ' + pnlClass(day.total_net) + '">' + fmtPnlShort(day.total_net) + '</div>';
if (day.has_emotion) {
html += '<span class="stats-cal-emotion">情绪' + (day.emotion_count > 1 ? '×' + day.emotion_count : '') + '</span>';
}
html += '</div>';
}
html += '</div>';
});
grid.innerHTML = html;
}
function loadCalendar() {
fetch('/api/stats/calendar?year=' + calYear + '&month=' + calMonth, { credentials: 'same-origin' })
.then(function (r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
})
.then(function (data) {
renderCalendar(data);
if (selectedDate && selectedDate.slice(0, 7) === calYear + '-' + pad2(calMonth)) {
loadDayDetail(selectedDate, false);
} else {
hideDayDetail();
}
})
.catch(function () {
var grid = document.getElementById('stats-calendar-grid');
if (grid) grid.innerHTML = '<div class="text-muted" style="grid-column:1/-1">日历加载失败</div>';
});
}
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 = '<div class="text-muted">当日无平仓记录</div>';
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 += '<span class="stats-day-badge review">复盘</span>';
}
if (item.is_emotion) {
badges += '<span class="stats-day-badge emotion">情绪单</span>';
}
var metaParts = [
badges,
'平仓 ' + fmtTime(item.close_time),
item.lots != null ? item.lots + ' 手' : '',
item.entry_price != null ? '开 ' + item.entry_price : '',
item.close_price != null ? '平 ' + item.close_price : '',
];
if (item.source === 'review' && item.open_type) metaParts.push(item.open_type);
if (item.source === 'review' && item.exit_trigger) metaParts.push('出场: ' + item.exit_trigger);
if (item.result) metaParts.push(item.result);
meta.innerHTML = metaParts.filter(Boolean).join(' · ');
card.appendChild(head);
card.appendChild(meta);
var tags = fmtTags(item);
if (tags) {
var tagEl = document.createElement('div');
tagEl.className = '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 = '<img src="/uploads/' + item.screenshot + '" alt="复盘截图">';
card.appendChild(shot);
}
list.appendChild(card);
});
panel.hidden = false;
}
function loadDayDetail(dateStr, scroll) {
if (scroll === undefined) scroll = true;
selectedDate = dateStr;
document.querySelectorAll('.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 = '<div class="text-muted">加载失败</div>';
panel.hidden = false;
}
});
}
function shiftCalendarMonth(delta) {
calMonth += delta;
if (calMonth > 12) {
calMonth = 1;
calYear += 1;
} else if (calMonth < 1) {
calMonth = 12;
calYear -= 1;
}
loadCalendar();
}
function bindCalendar() {
var grid = document.getElementById('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() { function bootStatsPage() {
if (!document.getElementById('stats-summary')) return; if (!document.getElementById('stats-summary')) return;
var viewSel = document.getElementById('stats-view-select'); var viewSel = document.getElementById('stats-view-select');
@@ -160,6 +432,7 @@
}); });
} }
loadStats(); loadStats();
initCalendar();
} }
if (window.qihuoPageBoot) window.qihuoPageBoot(bootStatsPage, '#stats-summary'); if (window.qihuoPageBoot) window.qihuoPageBoot(bootStatsPage, '#stats-summary');
+247 -1
View File
@@ -6,9 +6,10 @@
"""交易统计计算与缓存结构。""" """交易统计计算与缓存结构。"""
from __future__ import annotations from __future__ import annotations
import calendar
import json import json
import threading import threading
from datetime import datetime from datetime import date, datetime
from typing import Any, Optional from typing import Any, Optional
from zoneinfo import ZoneInfo 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) data = build_all_stats(conn, live_capital)
save_stats_cache(conn, data) save_stats_cache(conn, data)
return 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,
}
+112
View File
@@ -28,6 +28,93 @@
.stats-card-head h2{margin-bottom:0} .stats-card-head h2{margin-bottom:0}
.stats-view-field{width:auto;min-width:200px} .stats-view-field{width:auto;min-width:200px}
.stats-view-field select{width:100%;min-width:180px} .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);
}
</style> </style>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
@@ -54,6 +141,31 @@
</div> </div>
</div> </div>
<div class="card stats-calendar-card">
<div class="stats-calendar-head">
<h2>交易日历</h2>
<div class="stats-calendar-nav">
<button type="button" id="stats-cal-prev" aria-label="上个月"></button>
<span class="stats-calendar-title" id="stats-cal-title"></span>
<button type="button" id="stats-cal-next" aria-label="下个月"></button>
<button type="button" id="stats-cal-today">本月</button>
</div>
</div>
<div class="stats-calendar-weekdays">
<span></span><span></span><span></span><span></span><span></span><span></span><span></span>
</div>
<div class="stats-calendar-grid" id="stats-calendar-grid">
<div class="text-muted" style="grid-column:1/-1;padding:.5rem 0">加载日历…</div>
</div>
<div class="stats-day-detail" id="stats-day-detail" hidden>
<div class="stats-day-detail-head">
<h3 id="stats-day-detail-title">当日交易</h3>
<span class="stats-day-summary" id="stats-day-summary"></span>
</div>
<div class="stats-day-list" id="stats-day-list"></div>
</div>
</div>
<div class="card"> <div class="card">
<div class="stats-card-head"> <div class="stats-card-head">
<h2>分项统计</h2> <h2>分项统计</h2>
+167 -12
View File
@@ -163,13 +163,61 @@ def _fire_position_refresh_callback_debounced(*, min_interval: float = 0.35) ->
def _fire_position_refresh_burst() -> None: def _fire_position_refresh_burst() -> None:
"""连接后持仓回报可能分批到达,分多次触发快照刷新。""" """连接后持仓回报可能分批到达,分多次触发快照刷新。"""
_fire_position_refresh_callback() _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() 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: Optional["CtpBridge"] = None
_bridge_lock = threading.Lock() _bridge_lock = threading.Lock()
_ctp_td_lock = threading.RLock() _ctp_td_lock = threading.RLock()
POSITION_QUERY_MIN_INTERVAL_SEC = 5.0 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 TRADE_QUERY_MIN_INTERVAL_SEC = 10.0
@@ -273,6 +321,10 @@ class CtpBridge:
self._margin_rate_lists: dict[int, list] = {} self._margin_rate_lists: dict[int, list] = {}
self._margin_rate_hooked = False self._margin_rate_hooked = False
self._instrument_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._instrument_margin_ratios: dict[str, dict[str, float]] = {}
self._margin_per_lot: dict[str, float] = {} self._margin_per_lot: dict[str, float] = {}
self._subscribed: set[str] = set() self._subscribed: set[str] = set()
@@ -306,6 +358,7 @@ class CtpBridge:
self._ensure_position_event_hook() self._ensure_position_event_hook()
self._ensure_order_event_hook() self._ensure_order_event_hook()
self._ensure_trade_event_hook() self._ensure_trade_event_hook()
self._ensure_ctp_log_hooks()
except ImportError: except ImportError:
self._last_error = "未安装 vnpy / vnpy_ctp,请 pip install vnpy vnpy_ctp" self._last_error = "未安装 vnpy / vnpy_ctp,请 pip install vnpy vnpy_ctp"
except Exception as exc: except Exception as exc:
@@ -479,18 +532,13 @@ class CtpBridge:
"td_volume": td, "td_volume": td,
} }
try: try:
from ctp_entry_price import entry_from_ctp_pnl, round_to_tick from ctp_entry_price import round_to_tick
from ctp_trading_state import trading_state
ths = CtpBridge._vnpy_sym_to_ths(sym, ex_name) or sym ths = CtpBridge._vnpy_sym_to_ths(sym, ex_name) or sym
tick = trading_state.get_tick_price(ex_name, sym) if price > 0:
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:
row["avg_price"] = round_to_tick(price, ths) row["avg_price"] = round_to_tick(price, ths)
except Exception as exc: except Exception as exc:
logger.debug("position avg refine: %s", exc) logger.debug("position avg round: %s", exc)
return row return row
except Exception as exc: except Exception as exc:
logger.debug("position_row_from_vnpy: %s", exc) logger.debug("position_row_from_vnpy: %s", exc)
@@ -579,6 +627,11 @@ class CtpBridge:
except Exception as exc: except Exception as exc:
logger.debug("gateway close: %s", exc) logger.debug("gateway close: %s", exc)
self._connected_mode = None 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: try:
from ctp_trading_state import trading_state from ctp_trading_state import trading_state
@@ -587,6 +640,27 @@ class CtpBridge:
pass pass
time.sleep(0.6) 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: def _login_rejected(self, ctp_logs: list[str]) -> bool:
return any( return any(
kw in m kw in m
@@ -723,7 +797,9 @@ class CtpBridge:
self.calibrate_trading_state() self.calibrate_trading_state()
except Exception as exc: except Exception as exc:
logger.debug("post-connect calibrate: %s", exc) logger.debug("post-connect calibrate: %s", exc)
self._ensure_instrument_margin_hooks()
_fire_position_refresh_burst() _fire_position_refresh_burst()
_schedule_position_query_retries(self)
_fire_ctp_connected_callback(mode) _fire_ctp_connected_callback(mode)
return return
finally: finally:
@@ -1040,7 +1116,7 @@ class CtpBridge:
self._instrument_margin_ratios[key] = ratios self._instrument_margin_ratios[key] = ratios
def _ensure_instrument_margin_hooks(self) -> None: def _ensure_instrument_margin_hooks(self) -> None:
"""登录前挂钩:合约查询回报缓存保证金率;支持按需 reqQryInstrumentMarginRate""" """登录前挂钩:合约/持仓查询回报td_api 重建后须重新挂钩"""
if not self._engine: if not self._engine:
return return
try: try:
@@ -1049,9 +1125,14 @@ class CtpBridge:
except Exception: except Exception:
return return
bridge = self 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: if not self._instrument_hooked:
orig = td.onRspQryInstrument orig_inst = td.onRspQryInstrument
def on_instrument(data: dict, error: dict, reqid: int, last: bool) -> None: def on_instrument(data: dict, error: dict, reqid: int, last: bool) -> None:
try: try:
@@ -1059,9 +1140,37 @@ class CtpBridge:
bridge._cache_margin_ratio(str(data["InstrumentID"]), data) bridge._cache_margin_ratio(str(data["InstrumentID"]), data)
except Exception as exc: except Exception as exc:
logger.debug("instrument margin cache: %s", 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] 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 self._instrument_hooked = True
if self._margin_rate_hooked: if self._margin_rate_hooked:
@@ -1679,6 +1788,52 @@ class CtpBridge:
"""vnpy 内存缓存持仓;禁止 query_positionvnctptd 并发查询会段错误)。""" """vnpy 内存缓存持仓;禁止 query_positionvnctptd 并发查询会段错误)。"""
return 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]]: def list_positions(self, *, refresh_if_empty: bool = True, refresh_margin: bool = False) -> list[dict[str, Any]]:
del refresh_if_empty, refresh_margin del refresh_if_empty, refresh_margin
with _ctp_td_lock: with _ctp_td_lock: