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:
@@ -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}
|
||||
|
||||
|
||||
|
||||
+4
-92
@@ -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"
|
||||
|
||||
+12
-67
@@ -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,
|
||||
|
||||
+68
-24
@@ -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:
|
||||
|
||||
@@ -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() {
|
||||
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');
|
||||
|
||||
+247
-1
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
@@ -54,6 +141,31 @@
|
||||
</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="stats-card-head">
|
||||
<h2>分项统计</h2>
|
||||
|
||||
+167
-12
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user