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:
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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=?",
|
||||
|
||||
@@ -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
@@ -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>' +
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user