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 += '
' + day.count + ' 笔
'; + html += '
' + fmtPnlShort(day.total_net) + '
'; + if (day.has_emotion) { + html += '情绪' + (day.emotion_count > 1 ? '×' + day.emotion_count : '') + ''; + } + 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: