Gate order cancel to trading hours and sync trade logs from CTP.

Disable cancel UI outside sessions, query exchange fills for records, and label local vs counterparty rows.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-26 00:35:51 +08:00
parent a23f2c80ca
commit 9f48f22d16
9 changed files with 606 additions and 8 deletions
+17
View File
@@ -1223,6 +1223,22 @@ def records():
start, end = parse_review_date_filter(preset, start, end)
conn = get_db()
ctp_sync_info = None
try:
from ctp_trade_sync import sync_trade_logs_from_ctp
from trading_context import get_account_capital, get_trading_mode
from vnpy_bridge import ctp_status
mode = get_trading_mode(get_setting)
if ctp_status(mode).get("connected"):
capital = get_account_capital(conn, get_setting)
ctp_sync_info = sync_trade_logs_from_ctp(
conn, mode, capital=capital, trading_mode=mode,
)
conn.commit()
except Exception as exc:
app.logger.warning("ctp trade sync on records page: %s", exc)
sql = "SELECT * FROM review_records WHERE 1=1"
params: list = []
if start:
@@ -1264,6 +1280,7 @@ def records():
trades=trades,
equity_curve=equity_curve,
auto_records=auto_list,
ctp_sync_info=ctp_sync_info,
preset=preset,
start=start,
end=end,
+262
View File
@@ -0,0 +1,262 @@
"""从 CTP 柜台同步成交,写入 trade_logs(以交易所成交为准)。"""
from __future__ import annotations
import logging
from collections import defaultdict
from datetime import datetime
from typing import Any, Callable, Optional
from zoneinfo import ZoneInfo
from contract_specs import calc_position_metrics
from ctp_symbol import ths_to_vnpy_symbol
from fee_specs import calc_round_trip_fee
from symbols import ths_to_codes
from trade_log_lib import calc_equity_after, ensure_trade_log_columns
from vnpy_bridge import ctp_list_trades, ctp_status
logger = logging.getLogger(__name__)
TZ = ZoneInfo("Asia/Shanghai")
def _match_symbol(ctp_sym: str, ths: str) -> bool:
a = (ctp_sym or "").lower()
b = (ths or "").lower()
if a == b:
return True
if a and b and a.split(".")[0] == b.split(".")[0]:
return True
try:
vnpy_sym, _ = ths_to_vnpy_symbol(ths)
if a == vnpy_sym.lower():
return True
except Exception:
pass
return False
def _to_ths_code(symbol: str) -> str:
sym = (symbol or "").strip()
if not sym:
return ""
codes = ths_to_codes(sym)
if codes:
return codes.get("ths_code") or sym
return sym.lower()
def build_round_trips(trades: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""按 FIFO 将开/平仓成交配对为完整回合。"""
stacks: dict[tuple[str, str], list[dict[str, Any]]] = defaultdict(list)
trips: list[dict[str, Any]] = []
ordered = sorted(
trades,
key=lambda t: ((t.get("datetime") or ""), str(t.get("trade_id") or "")),
)
for t in ordered:
sym = (t.get("symbol") or "").lower()
pos_dir = (t.get("position_direction") or "long").strip().lower()
offset = (t.get("offset") or "open").strip().lower()
lots = int(t.get("lots") or 0)
if not sym or lots <= 0:
continue
key = (sym, pos_dir)
if offset == "open":
stacks[key].append({
**t,
"remaining": lots,
})
continue
close_lots_left = lots
close_price = float(t.get("price") or 0)
close_time = t.get("datetime") or ""
close_trade_id = str(t.get("trade_id") or "")
while close_lots_left > 0 and stacks[key]:
open_t = stacks[key][0]
matched = min(close_lots_left, int(open_t.get("remaining") or 0))
if matched <= 0:
stacks[key].pop(0)
continue
open_t["remaining"] = int(open_t.get("remaining") or 0) - matched
if open_t["remaining"] <= 0:
stacks[key].pop(0)
close_lots_left -= matched
open_trade_id = str(open_t.get("trade_id") or "")
ctp_key = f"{open_trade_id}|{close_trade_id}|{sym}|{pos_dir}|{matched}"
trips.append({
"ctp_trade_key": ctp_key,
"symbol": sym,
"ths_code": _to_ths_code(sym),
"direction": pos_dir,
"lots": matched,
"entry_price": float(open_t.get("price") or 0),
"close_price": close_price,
"open_time": open_t.get("datetime") or "",
"close_time": close_time,
"open_trade_id": open_trade_id,
"close_trade_id": close_trade_id,
})
return trips
def _find_monitor_meta(
conn,
*,
symbol: str,
direction: str,
open_time: str,
match_symbol_fn: Callable[[str, str], bool] | None = None,
) -> dict[str, Any]:
match = match_symbol_fn or _match_symbol
direction = (direction or "long").strip().lower()
best: Optional[dict[str, Any]] = None
for r in conn.execute(
"SELECT * FROM trade_order_monitors ORDER BY id DESC LIMIT 200"
).fetchall():
row = dict(r)
if (row.get("direction") or "long").strip().lower() != direction:
continue
if not match(symbol, row.get("symbol") or ""):
continue
if best is None:
best = row
continue
ot = (row.get("open_time") or "").strip()
if open_time and ot and abs(len(ot) - len(open_time)) <= 2 and ot[:16] == open_time[:16]:
return row
return best or {}
def _holding_minutes(open_time: str, close_time: str) -> int:
try:
from app import holding_to_minutes
return int(holding_to_minutes(open_time, close_time) or 0)
except Exception:
return 0
def sync_trade_logs_from_ctp(
conn,
mode: str,
*,
capital: float = 0.0,
trading_mode: str = "simulation",
) -> dict[str, Any]:
"""查询 CTP 成交并 upsert 到 trade_logs。返回同步摘要。"""
stats = {"synced": 0, "updated": 0, "skipped": 0, "connected": False}
if not ctp_status(mode).get("connected"):
return stats
stats["connected"] = True
ensure_trade_log_columns(conn)
try:
conn.execute("ALTER TABLE trade_logs ADD COLUMN source TEXT DEFAULT 'local'")
except Exception:
pass
try:
conn.execute("ALTER TABLE trade_logs ADD COLUMN ctp_trade_key TEXT")
except Exception:
pass
trades = ctp_list_trades(mode, refresh=True)
trips = build_round_trips(trades)
for trip in trips:
key = trip.get("ctp_trade_key") or ""
if not key:
stats["skipped"] += 1
continue
existing = conn.execute(
"SELECT id FROM trade_logs WHERE ctp_trade_key=?",
(key,),
).fetchone()
ths = trip.get("ths_code") or trip.get("symbol") or ""
codes = ths_to_codes(ths) or {}
direction = trip.get("direction") or "long"
entry = float(trip.get("entry_price") or 0)
close_px = float(trip.get("close_price") or 0)
lots = float(trip.get("lots") or 0)
open_time = trip.get("open_time") or ""
close_time = trip.get("close_time") or datetime.now(TZ).strftime("%Y-%m-%dT%H:%M")
mon = _find_monitor_meta(
conn,
symbol=trip.get("symbol") or ths,
direction=direction,
open_time=open_time,
)
sl = mon.get("stop_loss")
tp = mon.get("take_profit")
try:
sl_f = float(sl) if sl is not None else entry
tp_f = float(tp) if tp is not None else entry
except (TypeError, ValueError):
sl_f, tp_f = entry, entry
metrics = calc_position_metrics(
direction, entry, sl_f, tp_f, lots, close_px, capital, ths,
)
pnl = float(metrics.get("float_pnl") or 0)
fee = calc_round_trip_fee(
ths, entry, close_px, lots, open_time, close_time, trading_mode=trading_mode,
)
pnl_net = round(pnl - fee, 2)
margin_pct = metrics.get("position_pct")
equity_after = calc_equity_after(capital, pnl_net)
minutes = _holding_minutes(open_time, close_time)
result = "CTP同步"
monitor_type = mon.get("monitor_type") or "CTP同步"
row_vals = (
ths,
codes.get("name") or mon.get("symbol_name") or ths,
codes.get("market_code") or mon.get("market_code") or "",
codes.get("sina_code") or mon.get("sina_code") or "",
monitor_type,
direction,
entry,
sl if sl is not None else None,
tp if tp is not None else None,
close_px,
lots,
metrics.get("margin"),
margin_pct,
minutes,
open_time,
close_time,
pnl,
fee,
pnl_net,
equity_after,
result,
)
if existing:
conn.execute(
"""UPDATE trade_logs SET
symbol=?, symbol_name=?, market_code=?, sina_code=?, monitor_type=?,
direction=?, entry_price=?, stop_loss=?, take_profit=?, close_price=?,
lots=?, margin=?, margin_pct=?, holding_minutes=?, open_time=?, close_time=?,
pnl=?, fee=?, pnl_net=?, equity_after=?, result=?, source='ctp', verified=1
WHERE ctp_trade_key=?""",
row_vals + (key,),
)
stats["updated"] += 1
else:
conn.execute(
"""INSERT INTO trade_logs
(symbol, symbol_name, market_code, sina_code, monitor_type, direction,
entry_price, stop_loss, take_profit, close_price, lots, margin,
margin_pct, holding_minutes, open_time, close_time, pnl, fee, pnl_net,
equity_after, result, source, ctp_trade_key, verified)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
row_vals + ("ctp", key, 1),
)
stats["synced"] += 1
if stats["synced"] or stats["updated"]:
try:
from stats_engine import refresh_stats_cache
refresh_stats_cache(conn, capital)
except Exception as exc:
logger.debug("stats refresh after ctp trade sync: %s", exc)
return stats
+54 -2
View File
@@ -80,6 +80,7 @@ from trading_context import (
)
from ctp_symbol import ths_to_vnpy_symbol
from vnpy_bridge import (
ctp_cancel_order,
ctp_connect,
ctp_get_account,
ctp_get_tick_price,
@@ -354,6 +355,25 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
"label": "止盈监控",
"price": float(tp),
})
for r in conn.execute(
"SELECT * FROM trade_order_monitors WHERE status='pending' ORDER BY id DESC"
).fetchall():
mon = dict(r)
sym = mon.get("symbol") or ""
pending.append({
"symbol_code": sym,
"symbol": mon.get("symbol_name") or sym,
"direction": mon.get("direction") or "long",
"direction_label": "做多" if (mon.get("direction") or "long") == "long" else "做空",
"lots": int(mon.get("lots") or 0),
"price": float(mon.get("order_price") or mon.get("entry_price") or 0),
"order_kind": "open_pending",
"label": "开仓挂单中",
"source": "monitor",
"monitor_id": mon.get("id"),
"can_cancel_order": is_trading_session(),
"cancel_allowed": is_trading_session(),
})
ctp_st = ctp_status(mode)
if ctp_st.get("connected"):
for o in _ctp_active_orders(mode):
@@ -374,6 +394,8 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
"label": label,
"source": "ctp",
"order_id": o.get("order_id"),
"can_cancel_order": is_trading_session(),
"cancel_allowed": is_trading_session(),
})
return pending
@@ -833,7 +855,8 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
"est_fee": None,
"can_close": False,
"close_allowed": False,
"can_cancel_order": True,
"can_cancel_order": is_trading_session(),
"cancel_allowed": is_trading_session(),
"auto_cancel_sec": remain,
"pending_timeout_sec": timeout_sec,
"pending_timeout_min": max(1, timeout_sec // 60),
@@ -1285,6 +1308,8 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
return jsonify({"ok": False, "error": "记录不存在或已关闭"}), 404
mon = dict(row)
if (mon.get("status") or "").strip().lower() == "pending":
if not is_trading_session():
return jsonify({"ok": False, "error": "不在交易时间段,无法撤单"}), 403
ok, msg = cancel_pending_monitor(conn, mon, mode)
_push_position_snapshot_async(fast=False)
return jsonify({"ok": ok, "message": msg})
@@ -1315,6 +1340,8 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
mode = get_trading_mode(get_setting)
if not ctp_status(mode).get("connected"):
return jsonify({"ok": False, "error": "请先连接 CTP"}), 400
if not is_trading_session():
return jsonify({"ok": False, "error": "不在交易时间段,无法撤单"}), 403
row = conn.execute(
"SELECT * FROM trade_order_monitors WHERE id=? AND status='pending'",
(monitor_id,),
@@ -1327,6 +1354,25 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
finally:
conn.close()
@app.route("/api/trading/order/cancel", methods=["POST"])
@login_required
def api_trading_order_cancel():
"""撤销柜台未成交委托(按 vt_order_id)。"""
d = request.get_json(silent=True) or {}
order_id = (d.get("order_id") or "").strip()
if not order_id:
return jsonify({"ok": False, "error": "无效的委托号"}), 400
mode = get_trading_mode(get_setting)
if not ctp_status(mode).get("connected"):
return jsonify({"ok": False, "error": "请先连接 CTP"}), 400
if not is_trading_session():
return jsonify({"ok": False, "error": "不在交易时间段,无法撤单"}), 403
ok = ctp_cancel_order(mode, order_id)
_push_position_snapshot_async(fast=False)
if not ok:
return jsonify({"ok": False, "error": "撤单失败,委托可能已成交或已撤销"}), 400
return jsonify({"ok": True, "message": "撤单已提交"})
@app.route("/api/trading/close", methods=["POST"])
@login_required
def api_trading_close():
@@ -1409,9 +1455,15 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
(mid,),
)
conn.commit()
try:
from ctp_trade_sync import sync_trade_logs_from_ctp
sync_trade_logs_from_ctp(conn, mode, capital=capital, trading_mode=mode)
conn.commit()
except Exception as exc:
logger.debug("sync trades after close: %s", exc)
conn.close()
_push_position_snapshot_async()
return jsonify({"ok": True, "message": "已平仓并记入交易记录(手动平仓)"})
return jsonify({"ok": True, "message": "已平仓交易记录将按柜台成交同步"})
except ValueError as exc:
conn.close()
return jsonify({"ok": False, "error": str(exc)}), 400
+2 -1
View File
@@ -7,6 +7,7 @@ from datetime import datetime
from typing import Any, Callable, Optional
from zoneinfo import ZoneInfo
from market_sessions import is_trading_session
from vnpy_bridge import ctp_cancel_order, ctp_list_active_orders, ctp_status
logger = logging.getLogger(__name__)
@@ -132,7 +133,7 @@ def reconcile_pending_orders(
continue
if vt_oid and vt_oid in active_orders:
if age >= limit_sec:
if age >= limit_sec and is_trading_session():
if ctp_cancel_order(mode, vt_oid):
conn.execute(
"UPDATE trade_order_monitors SET status='closed' WHERE id=?",
+1
View File
@@ -77,6 +77,7 @@
.pos-card.is-pending .pos-metrics .cell.pnl-pending label{color:var(--accent)}
.pos-close-btn{padding:.4rem .85rem;font-size:.78rem;border-radius:8px;border:1px solid var(--loss);background:var(--loss-bg);color:var(--loss);cursor:pointer;white-space:nowrap;width:auto;flex-shrink:0;min-height:36px}
.pos-close-btn:disabled,.pos-close-btn.is-session-off{opacity:.45;cursor:not-allowed;border-color:var(--text-muted);background:var(--card-inner);color:var(--text-muted)}
.pos-dismiss-btn:disabled,.pos-dismiss-btn.is-session-off{opacity:.45;cursor:not-allowed;color:var(--text-muted)}
.pos-card-meta-line{font-size:.78rem;line-height:1.65;color:var(--text-muted);margin-bottom:.55rem}
.pos-card-meta-line strong{color:var(--text)}
.pos-card-actions{display:flex;gap:.35rem;flex-shrink:0;align-items:center}
+62 -5
View File
@@ -204,17 +204,29 @@
if (pendingOnly.length) {
list.innerHTML = '<div class="empty-hint" style="margin-bottom:.75rem">暂无持仓</div>' +
pendingOnly.map(function (p) {
var dismissBtn = p.monitor_id ?
'<button type="button" class="pos-dismiss-btn" data-monitor-id="' + p.monitor_id + '">取消</button>' : '';
var cancelAllowed = p.cancel_allowed !== false && isTradingSession;
var actionBtn = '';
if (p.monitor_id) {
actionBtn = '<button type="button" class="pos-dismiss-btn' +
(cancelAllowed ? '' : ' is-session-off') + '"' +
(cancelAllowed ? '' : ' disabled title="不在交易时间段"') +
' data-monitor-id="' + p.monitor_id + '" data-pending-cancel="1">撤单</button>';
} else if (p.order_id && p.source === 'ctp') {
actionBtn = '<button type="button" class="pos-dismiss-btn' +
(cancelAllowed ? '' : ' is-session-off') + '"' +
(cancelAllowed ? '' : ' disabled title="不在交易时间段"') +
' data-cancel-order="' + encodeURIComponent(p.order_id) + '">撤单</button>';
}
return (
'<div class="pos-pending-item ' +
(p.order_kind === 'stop_loss' ? 'sl' : (p.order_kind === 'take_profit' ? 'tp' : 'ctp')) +
'"><span>' + (p.label || '挂单') + ' · ' + (p.symbol || p.symbol_code) + '</span>' +
'<span class="pos-pending-right"><strong>' + fmtNum(p.price) + '</strong> · ' +
(p.lots || 1) + ' 手' + dismissBtn + '</span></div>'
(p.lots || 1) + ' 手' + actionBtn + '</span></div>'
);
}).join('');
bindPendingDismiss(list);
bindCancelOrderButtons(list);
} else {
list.innerHTML = '<div class="empty-hint">暂无持仓。</div>';
}
@@ -674,6 +686,10 @@
opts = opts || {};
if (!monitorId) return;
var isPending = !!opts.pending;
if (isPending && !isTradingSession) {
alert('不在交易时间段,无法撤单');
return;
}
var confirmMsg = isPending
? '撤销该开仓委托?(将向柜台发送撤单)'
: '取消该本地止盈止损监控?(不影响柜台委托)';
@@ -706,6 +722,10 @@
if (!root) return;
root.querySelectorAll('[data-cancel-open]').forEach(function (btn) {
btn.addEventListener('click', function () {
if (!isTradingSession) {
alert('不在交易时间段,无法撤单');
return;
}
dismissMonitor(parseInt(btn.getAttribute('data-cancel-open'), 10), btn, { pending: true });
});
});
@@ -715,7 +735,41 @@
if (!root) return;
root.querySelectorAll('[data-monitor-id]').forEach(function (btn) {
btn.addEventListener('click', function () {
dismissMonitor(parseInt(btn.getAttribute('data-monitor-id'), 10), btn);
var isPendingCancel = btn.getAttribute('data-pending-cancel') === '1';
dismissMonitor(
parseInt(btn.getAttribute('data-monitor-id'), 10),
btn,
isPendingCancel ? { pending: true } : {}
);
});
});
}
function bindCancelOrderButtons(root) {
if (!root) return;
root.querySelectorAll('[data-cancel-order]').forEach(function (btn) {
btn.addEventListener('click', function () {
if (!isTradingSession) {
alert('不在交易时间段,无法撤单');
return;
}
var orderId = decodeURIComponent(btn.getAttribute('data-cancel-order') || '');
if (!orderId) return;
if (!confirm('撤销该柜台委托?')) return;
btn.disabled = true;
btn.textContent = '撤单中…';
fetch('/api/trading/order/cancel', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ order_id: orderId })
}).then(function (r) { return r.json(); }).then(function (d) {
if (!d.ok) throw new Error(d.error || d.message || '撤单失败');
pollPositions();
}).catch(function (e) {
alert(e.message || '撤单失败');
btn.disabled = false;
btn.textContent = '撤单';
});
});
});
}
@@ -751,8 +805,11 @@
var remainMin = row.pending_timeout_min != null
? row.pending_timeout_min
: (row.auto_cancel_sec != null ? Math.max(1, Math.ceil(row.auto_cancel_sec / 60)) : 5);
var cancelAllowed = row.cancel_allowed !== false && isTradingSession;
var cancelBtn = row.can_cancel_order ?
'<button type="button" class="pos-close-btn" data-cancel-open="' + row.monitor_id + '">撤单</button>' : '';
'<button type="button" class="pos-close-btn' + (cancelAllowed ? '' : ' is-session-off') + '"' +
(cancelAllowed ? '' : ' disabled title="不在交易时间段"') +
' data-cancel-open="' + row.monitor_id + '">撤单</button>' : '';
var metaLine =
'状态 <strong class="text-accent">挂单中</strong>' +
' · 委托价 <strong>' + fmtNum(orderPx) + '</strong>' +
+13
View File
@@ -11,6 +11,14 @@
<div class="card records-trade-card" style="margin-bottom:1.25rem">
<h2>交易记录</h2>
<div class="card-body">
{% if ctp_sync_info and ctp_sync_info.connected %}
<p class="hint" style="margin-top:0">
已连接 CTP,本页已自动同步柜台成交(新增 {{ ctp_sync_info.synced or 0 }} 条,更新 {{ ctp_sync_info.updated or 0 }} 条)。
带来源「柜台」的记录以交易所成交为准;「本地」为程序写入,可手动删除错误项。
</p>
{% else %}
<p class="hint" style="margin-top:0">CTP 未连接时仅显示本地数据库记录;连接后打开本页会自动同步柜台成交。</p>
{% endif %}
<label class="trade-switch-label">
<input type="checkbox" id="trade-edit-switch">
<span>修改/核对开关(开启后可编辑关键字段)</span>
@@ -32,6 +40,11 @@
<td><span class="cell-readonly">{{ t.symbol_name or t.symbol }}</span></td>
<td>
<span class="cell-readonly cell-edit-hide">{{ t.monitor_type }}</span>
{% if t.source == 'ctp' %}
<span class="badge" style="margin-left:.25rem;font-size:.65rem">柜台</span>
{% else %}
<span class="text-muted" style="margin-left:.25rem;font-size:.65rem">本地</span>
{% endif %}
<input class="cell-edit-show" type="hidden" name="monitor_type" value="{{ t.monitor_type }}">
</td>
<td>
+2
View File
@@ -7,6 +7,8 @@ from typing import Any
TRADE_LOG_EXTRA_COLUMNS = (
"ALTER TABLE trade_logs ADD COLUMN margin_pct REAL",
"ALTER TABLE trade_logs ADD COLUMN equity_after REAL",
"ALTER TABLE trade_logs ADD COLUMN source TEXT DEFAULT 'local'",
"ALTER TABLE trade_logs ADD COLUMN ctp_trade_key TEXT",
)
+193
View File
@@ -189,6 +189,10 @@ class CtpBridge:
self._position_margins: dict[str, float] = {}
self._position_open_times: dict[str, str] = {}
self._margin_hooked = False
self._trade_hooked = False
self._trade_query_results: list[dict[str, Any]] = []
self._trade_query_event = threading.Event()
self._last_trade_query_ts: float = 0.0
self._tick_hooked = False
self._bar_generators: dict[str, Any] = {}
self._bars_1m: dict[str, deque] = {}
@@ -1055,6 +1059,188 @@ class CtpBridge:
out = self._collect_positions()
return out
@staticmethod
def _parse_trade_offset(offset_obj: Any) -> str:
s = str(offset_obj or "").upper()
if "OPEN" in s:
return "open"
return "close"
@staticmethod
def _parse_trade_direction(direction_obj: Any) -> str:
return "long" if _is_long_direction(direction_obj) else "short"
@staticmethod
def _position_direction_from_trade(trade_direction: str, offset: str) -> str:
td = (trade_direction or "long").strip().lower()
if (offset or "open").strip().lower() == "open":
return td
return "short" if td == "long" else "long"
def _format_trade_datetime(self, dt_obj: Any, date_raw: str = "", time_raw: str = "") -> str:
if dt_obj is not None:
try:
if hasattr(dt_obj, "strftime"):
return dt_obj.strftime("%Y-%m-%d %H:%M:%S")
text = str(dt_obj).strip()
if text:
return text[:19].replace("T", " ")
except Exception:
pass
parsed = self._parse_ctp_open_datetime(date_raw, time_raw)
return parsed or ""
def _trade_row_from_vnpy(self, trade: Any) -> Optional[dict[str, Any]]:
try:
sym = (getattr(trade, "symbol", "") or "").strip()
vol = int(getattr(trade, "volume", 0) or 0)
if not sym or vol <= 0:
return None
direction = self._parse_trade_direction(getattr(trade, "direction", None))
offset = self._parse_trade_offset(getattr(trade, "offset", None))
exchange = getattr(trade, "exchange", None)
ex_name = str(exchange.value if hasattr(exchange, "value") else exchange or "")
dt = self._format_trade_datetime(getattr(trade, "datetime", None))
trade_id = str(getattr(trade, "tradeid", "") or getattr(trade, "vt_tradeid", "") or "")
order_id = str(getattr(trade, "orderid", "") or getattr(trade, "vt_orderid", "") or "")
if not trade_id:
trade_id = f"{order_id}:{sym}:{offset}:{direction}:{vol}:{getattr(trade, 'price', 0)}:{dt}"
return {
"trade_id": trade_id,
"order_id": order_id,
"symbol": sym,
"exchange": ex_name,
"direction": direction,
"offset": offset,
"position_direction": self._position_direction_from_trade(direction, offset),
"lots": vol,
"price": float(getattr(trade, "price", 0) or 0),
"datetime": dt,
}
except Exception as exc:
logger.debug("trade_row_from_vnpy: %s", exc)
return None
def _trade_row_from_ctp_dict(self, data: dict) -> Optional[dict[str, Any]]:
try:
sym = (data.get("InstrumentID") or data.get("instrument_id") or "").strip()
vol = int(float(data.get("Volume") or data.get("volume") or 0))
if not sym or vol <= 0:
return None
dir_raw = str(data.get("Direction") or data.get("direction") or "")
direction = "long" if dir_raw in ("0", "2") or "LONG" in dir_raw.upper() or dir_raw == "" else "short"
off_raw = str(data.get("OffsetFlag") or data.get("offset") or "")
if off_raw in ("0",) or "OPEN" in off_raw.upper():
offset = "open"
else:
offset = "close"
price = float(data.get("Price") or data.get("price") or 0)
trade_id = str(data.get("TradeID") or data.get("tradeid") or "").strip()
order_sys = str(data.get("OrderSysID") or data.get("orderid") or "").strip()
dt = self._format_trade_datetime(
None,
str(data.get("TradeDate") or data.get("trade_date") or ""),
str(data.get("TradeTime") or data.get("trade_time") or ""),
)
if not trade_id:
trade_id = f"{order_sys}:{sym}:{offset}:{direction}:{vol}:{price}:{dt}"
return {
"trade_id": trade_id,
"order_id": order_sys,
"symbol": sym,
"exchange": str(data.get("ExchangeID") or data.get("exchange") or ""),
"direction": direction,
"offset": offset,
"position_direction": self._position_direction_from_trade(direction, offset),
"lots": vol,
"price": price,
"datetime": dt,
}
except Exception as exc:
logger.debug("trade_row_from_ctp_dict: %s", exc)
return None
def _install_trade_query_hook(self) -> None:
if self._trade_hooked or not self._engine:
return
try:
gw = self._engine.get_gateway(GATEWAY_NAME)
td = getattr(gw, "td_api", None)
if not td or not hasattr(td, "onRspQryTrade"):
return
bridge = self
original = td.onRspQryTrade
def _wrapped(data, error, reqid, last):
try:
if data and isinstance(data, dict):
row = bridge._trade_row_from_ctp_dict(data)
if row:
bridge._trade_query_results.append(row)
except Exception as exc:
logger.debug("trade hook row: %s", exc)
result = original(data, error, reqid, last)
if last:
bridge._trade_query_event.set()
return result
td.onRspQryTrade = _wrapped
self._trade_hooked = True
except Exception as exc:
logger.debug("install trade hook: %s", exc)
def _collect_engine_trades(self) -> list[dict[str, Any]]:
if not self._engine:
return []
out: list[dict[str, Any]] = []
seen: set[str] = set()
try:
trades = self._engine.get_all_trades()
except Exception:
trades = {}
for trade in (trades or {}).values():
row = self._trade_row_from_vnpy(trade)
if not row:
continue
key = row["trade_id"]
if key in seen:
continue
seen.add(key)
out.append(row)
return out
def refresh_trades(self) -> None:
"""向柜台查询当日成交(并合并内存成交回报)。"""
if not self._engine:
return
now = time.time()
if now - self._last_trade_query_ts < 1.0:
return
self._last_trade_query_ts = now
self._trade_query_results = []
self._trade_query_event.clear()
try:
self._install_trade_query_hook()
gw = self._engine.get_gateway(GATEWAY_NAME)
td = getattr(gw, "td_api", None)
if td and hasattr(td, "query_trade"):
td.query_trade()
self._trade_query_event.wait(timeout=2.0)
except Exception as exc:
logger.debug("refresh_trades: %s", exc)
def list_trades(self, *, refresh: bool = False) -> list[dict[str, Any]]:
if refresh:
self.refresh_trades()
merged: dict[str, dict[str, Any]] = {}
for row in self._collect_engine_trades():
merged[row["trade_id"]] = row
for row in self._trade_query_results:
merged[row["trade_id"]] = row
out = list(merged.values())
out.sort(key=lambda r: (r.get("datetime") or "", r.get("trade_id") or ""))
return out
def list_active_orders(self) -> list[dict[str, Any]]:
if not self._engine:
return []
@@ -1282,6 +1468,13 @@ def ctp_cancel_order(mode: str, vt_orderid: str) -> bool:
return b.cancel_order(vt_orderid)
def ctp_list_trades(mode: str, *, refresh: bool = False) -> list[dict[str, Any]]:
b = get_bridge()
if b.connected_mode != mode or not b.ping():
return []
return b.list_trades(refresh=refresh)
def ctp_get_tick_price(mode: str, ths_code: str) -> Optional[float]:
"""CTP 柜台最新价(需已连接并订阅)。"""
b = get_bridge()