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