diff --git a/src/api/index.js b/src/api/index.js index f6b4486..9310ef5 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -59,8 +59,8 @@ const HANDLERS = [ * @param {import('@deepseek-ai/cordis').Context} ctx * @param {object} runtime { manager, settings, dataSource, marketCache } */ -export function registerApi(ctx, { manager, settings, dataSource, marketHub, marketFeed, qmtHealthMonitor }) { - const runtime = { ctx, manager, settings, dataSource, marketHub, marketFeed, qmtHealthMonitor }; +export function registerApi(ctx, { manager, settings, dataSource, marketHub, marketFeed, qmtHealthMonitor, storage, tradeSync }) { + const runtime = { ctx, manager, settings, dataSource, marketHub, marketFeed, qmtHealthMonitor, storage, tradeSync }; ctx.effect(() => ctx.webServer.register({ kind: 'prefix', diff --git a/src/api/trades.js b/src/api/trades.js index a7bf8d1..20b9fe2 100644 --- a/src/api/trades.js +++ b/src/api/trades.js @@ -5,16 +5,24 @@ * orders → 当日委托(支持 code/status 过滤) * trades → 当日成交 * trading-dates → 交易日历(日期导航) + * trades/history → 本地 SQLite 历史查询(R-009:时间段/code/策略/方向过滤) + * trades/by-holding → 按 holding_id 查委托汇总(R-010:策略持仓行展开) * * 数据流:前端轮询 → /odl/api/orders|trades → dataSource.getOrders/getTrades * (服务端直连 QMT Bridge REST,技术约束-003) */ +import { getStrategies } from '../settings.js'; + /** 交易记录端点方法表 */ export const TRADE_METHODS = new Set([ 'orders', 'trades', 'trading-dates', + 'trades/history', // R-009:本地 SQLite 历史查询(策略过滤) + 'orders/attribution-candidates', // R-009:委托归属候选列表(该 code 当前持仓策略) + 'orders/set-attribution', // R-009:手动设置委托归属 + 'trades/by-holding', // R-010:按 holding_id 查委托汇总(策略持仓行展开) ]); /** @@ -23,15 +31,31 @@ export const TRADE_METHODS = new Set([ * @param {object} args * @param {object} runtime { dataSource } */ -export async function handleTrade(method, args, { dataSource }) { +export async function handleTrade(method, args, { dataSource, storage, settings }) { switch (method) { - case 'orders': - return await dataSource.getOrders({ + case 'orders': { + const orders = await dataSource.getOrders({ code: args.code, status: args.status, start: args.start, end: args.end, }); + // R-009:今日实时委托附加【持久化归属】(本地库中用户已设置的 strategy_id/holding_id) + // 未设置为 null(前端显示「未关联」,用户可手动设置);候选由 orders/attribution-candidates 提供 + if (storage && Array.isArray(orders) && orders.length > 0) { + const ids = orders.map((o) => o.orderId).filter(Boolean); + const ph = ids.map(() => '?').join(','); + const rows = storage.sqlite.db.prepare( + 'SELECT order_id, strategy_id, holding_id FROM trade_orders WHERE order_id IN (' + ph + ')' + ).all(...ids); + const attr = new Map(rows.map((r) => [r.order_id, { strategyId: r.strategy_id ?? null, holdingId: r.holding_id ?? null }])); + return orders.map((o) => { + const a = attr.get(o.orderId) ?? { strategyId: null, holdingId: null }; + return { ...o, strategyId: a.strategyId, holdingId: a.holdingId }; + }); + } + return orders; + } case 'trades': return await dataSource.getTrades({ start: args.start, @@ -42,6 +66,43 @@ export async function handleTrade(method, args, { dataSource }) { start: args.start, end: args.end, }); + case 'orders/attribution-candidates': { + // R-009:委托归属候选(该 code 当前持仓策略,Q3 全手动选) + if (!storage) throw Object.assign(new Error('本地存储不可用'), { code: 'storage-unavailable' }); + const list = await storage.getTradeAttributionCandidates(args.code); + // 附策略名(settings 的策略 name,便于前端展示) + const nameMap = new Map((getStrategies(settings) || []).map((s) => [s.id, s.name])); + return list.map((c) => ({ ...c, strategyName: nameMap.get(c.strategyId) ?? c.strategyId })); + } + case 'orders/set-attribution': { + // R-009:手动设置委托归属(Q4 可随时改,以最终为准) + if (!storage) throw Object.assign(new Error('本地存储不可用'), { code: 'storage-unavailable' }); + const ok = await storage.setTradeOrderAttribution(args.orderId, args.strategyId ?? null, args.holdingId ?? null); + return { ok }; + } + case 'trades/by-holding': { + // R-010:按 holding_id 查委托汇总(策略持仓行展开) + if (!storage) throw Object.assign(new Error('本地存储不可用'), { code: 'storage-unavailable' }); + return await storage.getTradeOrdersByHolding(args.holdingId); + } + case 'trades/history': { + // R-009:本地 SQLite 历史查询(时间段/code/策略/方向过滤) + const opts = { + start: args.start, + end: args.end, + code: args.code, + strategyId: args.strategyId, + direction: args.direction, + }; + if (!storage) { + throw Object.assign(new Error('本地存储不可用'), { code: 'storage-unavailable' }); + } + const [orders, fills] = await Promise.all([ + storage.getTradeOrderHistory(opts), + storage.getTradeFillHistory(opts), + ]); + return { orders, fills }; + } default: throw Object.assign(new Error('unknown trade method: ' + method), { code: 'not-found' }); } diff --git a/src/client/views/TradeRecordsTab.jsx b/src/client/views/TradeRecordsTab.jsx index 3a009ff..dd42b34 100644 --- a/src/client/views/TradeRecordsTab.jsx +++ b/src/client/views/TradeRecordsTab.jsx @@ -1,17 +1,21 @@ /** - * TradeRecordsTab —— 交易记录 tab(R-007 v3,2026-09-01) + * TradeRecordsTab —— 交易记录 tab(R-007 v3 + R-009 v5,2026-09-01) * * 单表合并视图: * - 主行 = 一笔委托(按 m_strOrderSysID 唯一),聚合成交信息(成交量/加权均价/金额/手续费) * - 点击展开/折叠 → 逐笔成交明细(成交时间/价/量/额/手续费/成交号) * - 排序:默认按委托时间降序(最新在前);点击表头手动排序 - * - 时间段查询:标题 + RangeSelector 同一行(今日/本周/上周快捷 + 手动起止日期) + * - 时间段查询:标题 + RangeSelector 同一行(今日/本周/本月快捷 + 手动起止日期) * - 轮询:仅「今日」范围 3-5s 自动刷新;其他范围一次性查询 * + * R-009(迭代 07): + * - 策略过滤下拉(全部 / 各策略 / 未关联):今日实时按归属过滤(前端),历史走服务端过滤 + * - 历史范围从「接口开发中」占位切换为查本地 SQLite(trades/history) + * * 方向:红买绿卖(Q7 定稿);状态中文映射;未成交显示 — */ -import { useState, useEffect, useCallback, useMemo } from 'react'; +import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useRpc } from './connection.jsx'; import { LoadState } from './LoadState.jsx'; import { RangeSelector } from './RangeSelector.jsx'; @@ -79,8 +83,7 @@ function mergeOrdersAndTrades(orders, trades) { const weightedPrice = totalVol > 0 ? ts.reduce((s, t) => s + t.price * t.volume, 0) / totalVol : 0; - // 费用:按整笔委托合并计算(同一委托分笔成交只收一次佣金,calcOrderFees) - const rateWan = ts[0]?.commissionRateWan ?? 0; // 佣金费率(万分之,来自成交,账号级一致) + const rateWan = ts[0]?.commissionRateWan ?? 0; const fees = calcOrderFeesLocal(ts, rateWan); return { ...o, @@ -102,10 +105,20 @@ export function TradeRecordsTab() { const [error, setError] = useState(null); const [loading, setLoading] = useState(true); const [range, setRange] = useState(null); // { start, end } YYYYMMDD - const [preset, setPreset] = useState('today'); // 快捷意图(单向,操作→状态→UI):'today'|'thisWeek'|'thisMonth'|null + const [preset, setPreset] = useState('today'); // 快捷意图(单向):'today'|'thisWeek'|'thisMonth'|null const [lastDate, setLastDate] = useState(null); // 最近交易日 + // R-009:策略过滤(''=全部;'__unassigned__'=未关联;其他=strategyId) + const [strategies, setStrategies] = useState([]); + const [strategyFilter, setStrategyFilter] = useState(''); + const [attributionSaved, setAttributionSaved] = useState(null); // 归属保存提示(R-009) + const [attributionCandidates, setAttributionCandidates] = useState({}); // R-009:code → 归属候选列表缓存 + const attributionCandidatesRef = useRef({}); // 同步缓存(loadCandidates 判重用) + // 同步 ref + useEffect(() => { attributionCandidatesRef.current = attributionCandidates; }, [attributionCandidates]); - // 默认加载「今日」(preset='today',单向意图):lastDate 到位且 range 未设置时自动设为今日 + const call = useRpc(); + + // 默认加载「今日」(preset='today',单向意图) useEffect(() => { if (lastDate && !range) { setRange({ start: lastDate, end: lastDate }); @@ -114,20 +127,48 @@ export function TradeRecordsTab() { const [expanded, setExpanded] = useState(new Set()); const [sortKey, setSortKey] = useState('insertTime'); const [sortDir, setSortDir] = useState('desc'); - const call = useRpc(); + + // 加载策略列表(过滤下拉用,R-009) + useEffect(() => { + call('one-divine-lot/strategies', { args: {} }) + .then((res) => { + if (res?.ok && Array.isArray(res.value)) setStrategies(res.value); + }) + .catch(() => {}); + }, [call]); // 是否「今日」意图(preset='today',单向)→ 轮询 const isTodayRange = preset === 'today'; - // 是否历史范围(非今日意图)→ 本期占位(QMT Bridge 历史接口开发中) + // 是否历史范围(非今日意图)→ 查本地 SQLite(R-009) const isHistoryRange = !!range && preset !== 'today'; const load = useCallback(async () => { if (!range) return; if (isHistoryRange) { - // 历史范围:本期占位(老师将完善 QMT Bridge 历史接口) - setOrders([]); - setTrades([]); - setLoading(false); + // R-009 历史范围:查本地 SQLite(trades/history),不再占位 + setLoading(true); + setError(null); + try { + const res = await call('one-divine-lot/trades/history', { + args: { + start: range.start, + end: range.end, + strategyId: strategyFilter || undefined, // '__unassigned__' 由服务端处理(未关联) + }, + }); + if (res?.ok && res.value) { + setOrders(res.value.orders ?? []); + setTrades(res.value.fills ?? []); + } else { + throw new Error((res && res.error && res.error.message) || '历史记录加载失败'); + } + } catch (e) { + setError(e.message); + setOrders([]); + setTrades([]); + } finally { + setLoading(false); + } return; } setLoading(true); @@ -148,7 +189,56 @@ export function TradeRecordsTab() { } finally { setLoading(false); } - }, [call, range, isHistoryRange]); + }, [call, range, isHistoryRange, strategyFilter]); + + // 拉取某 code 的归属候选(该 code 当前持仓策略;Q3 全手动选) + const loadCandidates = useCallback(async (code) => { + if (!code) return; + // 已有缓存不重复请求 + if (attributionCandidatesRef.current[code]) return; + try { + const res = await call('one-divine-lot/orders/attribution-candidates', { args: { code } }); + if (res?.ok && Array.isArray(res.value)) { + setAttributionCandidates((prev) => ({ ...prev, [code]: res.value })); + } + } catch { /* ignore */ } + }, [call]); + + // 手动设置归属(Q4:可随时改,以最终为准) + const setAttribution = useCallback(async (orderId, code, strategyId, holdingId) => { + try { + const res = await call('one-divine-lot/orders/set-attribution', { + args: { orderId, strategyId: strategyId || null, holdingId: holdingId || null }, + }); + if (res?.ok) { + setAttributionSaved('归属已保存: ' + orderId); + setTimeout(() => setAttributionSaved(null), 2000); + // 立即更新本地 state(避免竞态:不等 load() 重新拉,归属立刻生效) + setOrders((prev) => (prev ?? []).map((o) => + o.orderId === orderId ? { ...o, strategyId, holdingId } : o + )); + setTrades((prev) => (prev ?? []).map((t) => + t.orderId === orderId ? { ...t, strategyId, holdingId } : t + )); + load(); // 后台刷新兜底(同步 TradeSync 可能更新的数据) + } else { + setAttributionSaved('保存失败: ' + ((res && res.error && res.error.message) || 'unknown')); + setTimeout(() => setAttributionSaved(null), 3000); + } + } catch (e) { + setAttributionSaved('保存失败: ' + e.message); + setTimeout(() => setAttributionSaved(null), 3000); + } + }, [call, load]); + + // 归属候选自动预载:数据加载后,为所有委托 code 预取候选(下拉选项齐全,避免只有「未关联」) + useEffect(() => { + const codes = [...new Set((orders ?? []).map((o) => o.code).filter(Boolean))]; + if (codes.length === 0) return; + for (const code of codes) { + loadCandidates(code); + } + }, [orders, loadCandidates]); // 时间段变化 → 重新加载(展开重置) useEffect(() => { @@ -171,9 +261,15 @@ export function TradeRecordsTab() { return () => clearInterval(timer); }, [call, isTodayRange, range]); - // 合并 + 排序 + // 合并 + 策略过滤(今日实时按归属过滤前端;历史已在服务端过滤)+ 排序 const rows = useMemo(() => { - const merged = mergeOrdersAndTrades(orders ?? [], trades ?? []); + let merged = mergeOrdersAndTrades(orders ?? [], trades ?? []); + if (strategyFilter) { + merged = merged.filter((o) => { + if (strategyFilter === '__unassigned__') return !o.strategyId; + return o.strategyId === strategyFilter; + }); + } const dir = sortDir === 'asc' ? 1 : -1; return merged.slice().sort((a, b) => { const va = a[sortKey]; @@ -181,7 +277,7 @@ export function TradeRecordsTab() { if (typeof va === 'string' && typeof vb === 'string') return va.localeCompare(vb) * dir; return (va - vb) * dir; }); - }, [orders, trades, sortKey, sortDir]); + }, [orders, trades, sortKey, sortDir, strategyFilter]); const toggleSort = (key) => { if (sortKey === key) { @@ -204,69 +300,105 @@ export function TradeRecordsTab() { const arrow = (key) => (sortKey === key ? (sortDir === 'desc' ? ' ▼' : ' ▲') : ''); return ( -
- {/* 标题 + 时间段选择同一行(2026-09-01 老师反馈) */} +
+ {/* 标题 + 策略过滤 + 时间段选择同一行(R-007/R-009) */}

交易记录

- -
- - {isHistoryRange ? ( -
-
{range.start} ~ {range.end}(自定义时间段)
-
历史数据接口开发中,敬请期待(老师完善 QMT Bridge 历史接口后接入)
+
+ {/* R-009:策略过滤下拉(全部/各策略/未关联) */} + + +
+
+ {attributionSaved && ( +
+ {attributionSaved}
- ) : ( - - {!orders?.length ? ( -
该时间段暂无委托记录
- ) : ( - - - - - - - - - - - - - - - - - - - - - - {rows.map((o) => ( - toggleExpand(o.orderId)} - /> - ))} - -
toggleSort('insertTime')} style={thClickStyle}>时间{arrow('insertTime')} toggleSort('code')} style={thClickStyle}>代码{arrow('code')}名称 toggleSort('direction')} style={thClickStyle}>方向{arrow('direction')} toggleSort('status')} style={thClickStyle}>状态{arrow('status')} toggleSort('orderVolume')} style={thClickStyle} align="right">委托量{arrow('orderVolume')} toggleSort('aggVolume')} style={thClickStyle} align="right">成交量{arrow('aggVolume')} toggleSort('limitPrice')} style={thClickStyle} align="right">委托价{arrow('limitPrice')}成交均价 toggleSort('aggAmount')} style={thClickStyle} align="right">成交额{arrow('aggAmount')}总费用佣金印花税过户费
- )} -
- ■ 买 - ■ 卖 - 点击委托行展开成交明细 · 点击表头排序 -
-
)} + + + {!orders?.length ? ( +
+
+ {range ? range.start + ' ~ ' + range.end + (isHistoryRange ? '(本地历史)' : '') : ''} +
+
+ {isHistoryRange ? '该时间段暂无本地交易记录(数据自插件运行起逐日积累)' : '该时间段暂无委托记录'} +
+
+ ) : ( + + )} +
+ ■ 买 + ■ 卖 + 点击委托行展开成交明细 · 点击表头排序 + {isHistoryRange && · 历史数据 = 本地积累} +
+
); } -/** 委托主行 + 展开明细 */ -function OrderRows({ order, expanded, onToggle }) { +/** 交易记录表格(主行 + 展开明细 + R-009 手动归属列) */ +function TradeTable({ rows, toggleSort, sortKey, sortDir, arrow, toggleExpand, expanded, attributionCandidates, loadCandidates, setAttribution }) { + return ( + + + + + + + + + + + + + + + + + + + + + + + {rows.map((o) => ( + toggleExpand(o.orderId)} + attributionCandidates={attributionCandidates[o.code] ?? []} + onLoadCandidates={() => loadCandidates(o.code)} + setAttribution={setAttribution} + /> + ))} + +
toggleSort('insertTime')} style={thClickStyle}>时间{arrow('insertTime')} toggleSort('code')} style={thClickStyle}>代码{arrow('code')}名称 toggleSort('direction')} style={thClickStyle}>方向{arrow('direction')} toggleSort('status')} style={thClickStyle}>状态{arrow('status')} toggleSort('orderVolume')} style={thClickStyle} align="right">委托量{arrow('orderVolume')} toggleSort('aggVolume')} style={thClickStyle} align="right">成交量{arrow('aggVolume')} toggleSort('limitPrice')} style={thClickStyle} align="right">委托价{arrow('limitPrice')}成交均价 toggleSort('aggAmount')} style={thClickStyle} align="right">成交额{arrow('aggAmount')}总费用佣金印花税过户费归属
+ ); +} + +/** 委托主行 + 展开明细(含 R-009 手动归属下拉) */ +function OrderRows({ order, expanded, onToggle, attributionCandidates, onLoadCandidates, setAttribution }) { const hasTrades = order.trades.length > 0; + const strategyName = (sid) => { + if (!sid) return '未关联'; + return order.strategyName || sid; + }; return ( <> {hasTrades ? fmtNum(order.aggCommission, 2) : '—'} {hasTrades ? fmtNum(order.aggStampTax, 2) : '—'} {hasTrades ? fmtNum(order.aggTransferFee, 2) : '—'} + e.stopPropagation()}> + + {expanded && hasTrades && ( - + @@ -352,6 +492,48 @@ function OrderRows({ order, expanded, onToggle }) { ); } +/** 委托归属选择下拉(R-009,Q3 全手动选;Q4 可随时改) + * 候选 = 该 code 当前持仓策略(orders/attribution-candidates); + * 未设置显示「未关联」,用户点选后持久化(orders/set-attribution)。 + */ +function AttributionSelect({ order, candidates, onLoadCandidates, setAttribution }) { + const current = order.strategyId || ''; + + const handleSelect = (ev) => { + const val = ev.target.value; + if (val === current) return; + if (val === '__unassigned__') { + setAttribution(order.orderId, order.code, null, null); + return; + } + const cand = (candidates ?? []).find((c) => c.strategyId === val); + setAttribution(order.orderId, order.code, val, cand ? cand.holdingId : null); + }; + + return ( + + ); +} + const thStyle = { padding: '8px 6px', lineHeight: '24px', whiteSpace: 'nowrap' }; const thClickStyle = { ...thStyle, cursor: 'pointer', userSelect: 'none' }; const tdStyle = { padding: '8px 6px', lineHeight: '24px', whiteSpace: 'nowrap', fontVariantNumeric: 'tabular-nums' }; \ No newline at end of file diff --git a/src/component/DataStore.js b/src/component/DataStore.js index 1567b25..d0f5b08 100644 --- a/src/component/DataStore.js +++ b/src/component/DataStore.js @@ -156,8 +156,58 @@ export class DataStore { return this.sqlite.setMarketQuotes(quotes); } + // ===== 交易记录(R-009 迭代 07,委托 SqliteStore)===== + + /** UPSERT 委托(幂等) */ + async upsertTradeOrders(orders) { + await this._ensure(); + return this.sqlite.upsertOrders(orders); + } + + /** UPSERT 成交(幂等) */ + async upsertTradeFills(fills) { + await this._ensure(); + return this.sqlite.upsertFills(fills); + } + + /** 本地历史委托查询(含策略归属推导) */ + async getTradeOrderHistory(opts) { + await this._ensure(); + return this.sqlite.getOrderHistory(opts); + } + + /** 给任意委托列表附加策略/持仓归属(今日实时委托用) */ + async attachTradeStrategyAttribution(orders) { + await this._ensure(); + return this.sqlite.attachStrategyAttribution(orders); + } + + /** 本地历史成交查询(含策略归属推导) */ + async getTradeFillHistory(opts) { + await this._ensure(); + return this.sqlite.getFillHistory(opts); + } + + /** 设置委托归属(手动,Q4 可改) */ + async setTradeOrderAttribution(orderId, strategyId, holdingId) { + await this._ensure(); + return this.sqlite.setOrderAttribution(orderId, strategyId, holdingId); + } + + /** 委托归属候选列表(该 code 当前持仓策略,Q3 全手动选) */ + async getTradeAttributionCandidates(code) { + await this._ensure(); + return this.sqlite.getAttributionCandidates(code); + } + + /** 按 holding_id 查询委托汇总(R-010) */ + async getTradeOrdersByHolding(holdingId) { + await this._ensure(); + return this.sqlite.getOrdersByHolding(holdingId); + } + /** 关闭(插件释放时) */ close() { this.sqlite.close(); } -} +} \ No newline at end of file diff --git a/src/component/PositionManager.js b/src/component/PositionManager.js index 899e436..a6bb388 100644 --- a/src/component/PositionManager.js +++ b/src/component/PositionManager.js @@ -69,17 +69,40 @@ export class PositionManager { return { ...(map[code] ?? {}) }; } - /** 某策略下的持仓(份额 > 0 的标的) */ + /** 某策略下的持仓(份额 > 0 的标的;R-010 附加 holding_id + 成本价/最后一笔成交价) */ async getStrategyPositions(strategyId) { - const [positions, dataset] = await Promise.all([ + const [positions, dataset, holdings] = await Promise.all([ this.getAllPositions(), this.storage.getDataset(strategyId), + this.storage.getCurrentHoldings(strategyId), // 含 holding_id(同策略同 code 当前持仓唯一) ]); const shareMap = new Map(dataset.map((d) => [d.code, d.shares])); + const holdingMap = new Map(holdings.map((h) => [h.code, h.holdingId])); + // R-010 Q4:批量取各 holding 的关联委托(查最后一笔成交价) + const holdingIds = [...new Set(holdings.map((h) => h.holdingId).filter((x) => x != null))]; + const tradesByHolding = new Map(); + if (holdingIds.length > 0) { + for (const hid of holdingIds) { + const orders = await this.storage.getTradeOrdersByHolding(hid); + tradesByHolding.set(hid, orders); + } + } return positions .map((p) => { const shares = shareMap.get(p.code) ?? 0; - return shares > 0 ? { ...p, shares } : null; + if (shares <= 0) return null; + const holdingId = holdingMap.get(p.code) ?? null; + // 成本价 = QMT avgPrice(纸面数据已有) + const avgPrice = +(p.avgPrice ?? 0); + // 最后一笔成交价:关联委托按时间取最新一笔【有实际成交】(tradedVolume > 0)的 tradedPrice;无则 = 成本价 + let lastTradePrice = avgPrice; + const orders = holdingId != null ? (tradesByHolding.get(holdingId) ?? []) : []; + const filled = orders.filter((o) => (o.tradedVolume ?? 0) > 0 && (o.tradedPrice ?? 0) > 0); + if (filled.length > 0) { + // getOrdersByHolding 已按 insert_ts DESC,第一条即最新 + lastTradePrice = +filled[0].tradedPrice; + } + return { ...p, shares, holdingId, avgPrice, lastTradePrice }; }) .filter(Boolean); } @@ -246,4 +269,4 @@ export class PositionManager { } return before.map((h) => h.code); } -} +} \ No newline at end of file diff --git a/src/component/QmtBridgeRestDataSource.js b/src/component/QmtBridgeRestDataSource.js index 2a8c8aa..f822142 100644 --- a/src/component/QmtBridgeRestDataSource.js +++ b/src/component/QmtBridgeRestDataSource.js @@ -163,12 +163,32 @@ export class QmtBridgeRestDataSource { }; } - /** 委托字段映射:m_ 原始字段 → 语义字段(R-007 2.1/2.2) */ + /** + * 归一化证券代码:QMT 委托/成交返回无后缀(如 001330),持仓体系带后缀(001330.SZ)。 + * 规则(与持仓 mapPosition 一致):6 位数字 + 交易所后缀(SH/SZ/BJ);已有后缀原样保留。 + */ + normalizeInstrumentCode(raw, exchange) { + let code = String(raw ?? '').trim().toUpperCase(); + if (!code) return ''; + if (/^\d{6}\.(SH|SZ|BJ)$/.test(code)) return code; // 已带后缀 + if (/^\d{6}$/.test(code)) { + const ex = String(exchange ?? '').toUpperCase(); + if (ex === 'SH' || ex === 'SZ' || ex === 'BJ') return code + '.' + ex; + // 无交易所信息时按规则推断:6/9 开头沪市,0/3 开头深市 + const head = code[0]; + if (head === '6' || head === '9') return code + '.SH'; + return code + '.SZ'; + } + return code; + } + + /** 委托字段映射:m_ 原始字段 → 语义字段(R-007 2.1/2.2;R-009 迭代 07 补 tradeDate + code 归一化) */ mapOrder(o) { const direction = o.m_nOffsetFlag === 48 ? 'buy' : o.m_nOffsetFlag === 49 ? 'sell' : ''; + const code = this.normalizeInstrumentCode(o.m_strInstrumentID, o.m_strExchangeID); return { orderId: o.m_strOrderSysID ?? '', - code: o.m_strInstrumentID ?? '', + code, name: o.m_strInstrumentName ?? '', exchange: o.m_strExchangeID ?? '', direction, // 'buy' | 'sell' | '' @@ -181,6 +201,7 @@ export class QmtBridgeRestDataSource { limitPrice: +(o.m_dLimitPrice ?? 0), tradedPrice: +(o.m_dTradedPrice ?? 0), amount: +(o.m_dTradeAmount ?? 0), + tradeDate: o.m_strTradeDate ?? o.m_strInsertDate ?? '', // 交易日:委托无独立字段,用 insertDate 兜底 insertDate: o.m_strInsertDate ?? '', insertTime: o.m_strInsertTime ?? '', cancelInfo: o.m_strCancelInfo ?? '', @@ -227,7 +248,7 @@ export class QmtBridgeRestDataSource { return { tradeId: t.m_strTradeID ?? '', orderId: t.m_strOrderSysID ?? '', // 关联委托 - code: t.m_strInstrumentID ?? '', + code: this.normalizeInstrumentCode(t.m_strInstrumentID, t.m_strExchangeID), name: t.m_strInstrumentName ?? '', exchange: t.m_strExchangeID ?? '', direction, // 'buy' | 'sell' | '' diff --git a/src/component/SqliteStore.js b/src/component/SqliteStore.js index 8cfa265..f2e7109 100644 --- a/src/component/SqliteStore.js +++ b/src/component/SqliteStore.js @@ -7,6 +7,8 @@ * 表结构(技术实现方案): * - strategy_holdings 策略持仓生命周期表(一笔 = 一次「建仓→清仓」,历史保留) * - market_quotes_cache 行情快照缓存(code 维度一行一码,只存 UI 消费的 lastPrice/lastClose) + * - trade_orders 交易委托(R-009:委托主行,order_id 主键,UPSERT 幂等,零冗余) + * - trade_fills 交易成交(R-009:成交明细,trade_id 主键,order_id 关联委托) * * 方法集(替代 DataStore 原 getDataset/setDataset/removeDataset/getAllDatasets 整体读写): * 持仓生命周期:openHolding / addShares / reduceShares / closeHolding @@ -47,6 +49,53 @@ CREATE TABLE IF NOT EXISTS market_quotes_cache ( last_close REAL NOT NULL, updated_at INTEGER NOT NULL DEFAULT 0 ); +-- 交易委托(R-009 迭代 07:委托主行;order_id 唯一,UPSERT 幂等;零冗余 strategy_id/holding_id) +CREATE TABLE IF NOT EXISTS trade_orders ( + order_id TEXT PRIMARY KEY, + trade_date TEXT NOT NULL, + code TEXT NOT NULL, + name TEXT NOT NULL DEFAULT '', + exchange TEXT NOT NULL DEFAULT '', + direction TEXT NOT NULL DEFAULT '', + direction_code INTEGER, + opt_name TEXT NOT NULL DEFAULT '', + status INTEGER, + order_volume REAL NOT NULL DEFAULT 0, + traded_volume REAL NOT NULL DEFAULT 0, + limit_price REAL NOT NULL DEFAULT 0, + traded_price REAL NOT NULL DEFAULT 0, + amount REAL NOT NULL DEFAULT 0, + insert_date TEXT NOT NULL DEFAULT '', + insert_time TEXT NOT NULL DEFAULT '', + insert_ts INTEGER NOT NULL, + cancel_info TEXT NOT NULL DEFAULT '', + error_msg TEXT NOT NULL DEFAULT '', + strategy_id TEXT, -- 用户手动设置的策略归属(Q1 冗余) + holding_id INTEGER, -- 用户手动设置的持仓归属(Q1 冗余) + fetched_at INTEGER NOT NULL +); +-- 交易成交(trade_id 唯一,order_id 外键关联委托;零冗余 strategy_id/holding_id) +CREATE TABLE IF NOT EXISTS trade_fills ( + trade_id TEXT PRIMARY KEY, + order_id TEXT NOT NULL, + trade_date TEXT NOT NULL, + code TEXT NOT NULL, + name TEXT NOT NULL DEFAULT '', + exchange TEXT NOT NULL DEFAULT '', + direction TEXT NOT NULL DEFAULT '', + direction_code INTEGER, + opt_name TEXT NOT NULL DEFAULT '', + price REAL NOT NULL DEFAULT 0, + volume REAL NOT NULL DEFAULT 0, + amount REAL NOT NULL DEFAULT 0, + commission_rate_wan REAL NOT NULL DEFAULT 0, + trade_time TEXT NOT NULL DEFAULT '', + fetched_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_trade_orders_date ON trade_orders (trade_date); +CREATE INDEX IF NOT EXISTS idx_trade_orders_ts ON trade_orders (insert_ts); +CREATE INDEX IF NOT EXISTS idx_trade_fills_order ON trade_fills (order_id); +CREATE INDEX IF NOT EXISTS idx_trade_fills_date ON trade_fills (trade_date); `; export class SqliteStore { @@ -73,9 +122,35 @@ export class SqliteStore { mkdirSync(this.dataDir, { recursive: true }); this.db = new DatabaseSync(this.dbPath); this.db.exec(SCHEMA_SQL); + // 存量库迁移:trade_orders 补手动归属列(R-009 二次定稿;幂等) + this._ensureTradeAttributionColumns(); return this.db; } + /** + * 确保 trade_orders 存在 strategy_id / holding_id 列(旧库 ALTER 补列,幂等)。 + * 注意:只读打开(外部脚本、插件持锁时)会抛「readonly database」——此处容忍, + * 真实迁移由插件进程(持锁方)启动时执行;外部脚本只读验证不应因此崩溃。 + */ + _ensureTradeAttributionColumns() { + try { + const cols = new Set(this.db.prepare('PRAGMA table_info(trade_orders)').all().map((c) => c.name)); + if (!cols.has('strategy_id')) { + this.db.exec('ALTER TABLE trade_orders ADD COLUMN strategy_id TEXT'); + } + if (!cols.has('holding_id')) { + this.db.exec('ALTER TABLE trade_orders ADD COLUMN holding_id INTEGER'); + } + } catch (err) { + // 只读场景(如外部验证脚本在插件持锁时打开)→ 忽略,列迁移由插件进程完成 + if (String(err?.message ?? '').includes('readonly')) { + console.warn('[one-divine-lot] trade_orders 归属列迁移跳过(只读连接,插件持锁中)'); + } else { + throw err; + } + } + } + /** 关闭连接 */ close() { if (this.db) { @@ -344,4 +419,342 @@ export class SqliteStore { updatedAt: row.updated_at, }; } -} + + // ===== 交易记录(trade_orders / trade_fills,R-009 迭代 07)===== + + /** + * UPSERT 委托(幂等):order_id 已存在则覆盖(状态/成交量等当日流转更新) + * @param {Array} orders 语义化委托列表(QmtBridgeRestDataSource.mapOrder 输出) + * @returns {number} 写入条数 + */ + upsertOrders(orders) { + this.init(); + const list = Array.isArray(orders) ? orders : []; + if (list.length === 0) return 0; + const stmt = this.db.prepare(` + INSERT INTO trade_orders ( + order_id, trade_date, code, name, exchange, direction, direction_code, opt_name, + status, order_volume, traded_volume, limit_price, traded_price, amount, + insert_date, insert_time, insert_ts, cancel_info, error_msg, fetched_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(order_id) DO UPDATE SET + trade_date=excluded.trade_date, code=excluded.code, name=excluded.name, + exchange=excluded.exchange, direction=excluded.direction, + direction_code=excluded.direction_code, opt_name=excluded.opt_name, + status=excluded.status, order_volume=excluded.order_volume, + traded_volume=excluded.traded_volume, limit_price=excluded.limit_price, + traded_price=excluded.traded_price, amount=excluded.amount, + insert_date=excluded.insert_date, insert_time=excluded.insert_time, + insert_ts=excluded.insert_ts, cancel_info=excluded.cancel_info, + error_msg=excluded.error_msg, fetched_at=excluded.fetched_at + -- 注意:不覆盖 strategy_id / holding_id(手动归属为插件逻辑,Q2 老师确认) + `); + const now = Date.now(); + this.db.exec('BEGIN'); + try { + for (const o of list) { + if (!o || !o.orderId) continue; + const ts = this._orderInsertTs(o); + stmt.run( + o.orderId, o.tradeDate ?? '', o.code ?? '', o.name ?? '', o.exchange ?? '', + o.direction ?? '', o.directionCode ?? null, o.optName ?? '', o.status ?? null, + Number(o.orderVolume ?? 0), Number(o.tradedVolume ?? 0), + Number(o.limitPrice ?? 0), Number(o.tradedPrice ?? 0), Number(o.amount ?? 0), + o.insertDate ?? '', o.insertTime ?? '', ts, o.cancelInfo ?? '', o.errorMsg ?? '', now + ); + } + this.db.exec('COMMIT'); + } catch (err) { + this.db.exec('ROLLBACK'); + throw err; + } + return list.length; + } + + /** + * UPSERT 成交(幂等):trade_id 已存在则覆盖 + * @param {Array} fills 语义化成交列表(QmtBridgeRestDataSource.mapTrade 输出) + * @returns {number} 写入条数 + */ + upsertFills(fills) { + this.init(); + const list = Array.isArray(fills) ? fills : []; + if (list.length === 0) return 0; + const stmt = this.db.prepare(` + INSERT INTO trade_fills ( + trade_id, order_id, trade_date, code, name, exchange, direction, direction_code, + opt_name, price, volume, amount, commission_rate_wan, trade_time, fetched_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(trade_id) DO UPDATE SET + order_id=excluded.order_id, trade_date=excluded.trade_date, code=excluded.code, + name=excluded.name, exchange=excluded.exchange, direction=excluded.direction, + direction_code=excluded.direction_code, opt_name=excluded.opt_name, + price=excluded.price, volume=excluded.volume, amount=excluded.amount, + commission_rate_wan=excluded.commission_rate_wan, trade_time=excluded.trade_time, + fetched_at=excluded.fetched_at + `); + const now = Date.now(); + this.db.exec('BEGIN'); + try { + for (const t of list) { + if (!t || !t.tradeId) continue; + stmt.run( + t.tradeId, t.orderId ?? '', t.tradeDate ?? '', t.code ?? '', t.name ?? '', + t.exchange ?? '', t.direction ?? '', t.directionCode ?? null, t.optName ?? '', + Number(t.price ?? 0), Number(t.volume ?? 0), Number(t.amount ?? 0), + Number(t.commissionRateWan ?? 0), t.tradeTime ?? '', now + ); + } + this.db.exec('COMMIT'); + } catch (err) { + this.db.exec('ROLLBACK'); + throw err; + } + return list.length; + } + + /** + * 委托时间 → 毫秒时间戳(insert_date YYYYMMDD + insert_time HHMMSS) + * 时间字段缺失时回退 fetched_at(同步时间),保证 join 窗口可用 + */ + _orderInsertTs(o) { + const d = String(o.insertDate ?? ''); + const t = String(o.insertTime ?? ''); + if (d.length === 8 && t.length === 6) { + const y = Number(d.slice(0, 4)); + const m = Number(d.slice(4, 6)) - 1; + const day = Number(d.slice(6, 8)); + const hh = Number(t.slice(0, 2)); + const mm = Number(t.slice(2, 4)); + const ss = Number(t.slice(4, 6)); + // 用本地时间构造(与 strategy_holdings.created_at 的 Date.now() 同基准) + const ts = new Date(y, m, day, hh, mm, ss).getTime(); + if (Number.isFinite(ts) && ts > 0) return ts; + } + // 回退:以「当日」估算(用于极端缺字段场景) + const dateOnly = Date.parse(String(d).replace(/^(\d{4})(\d{2})(\d{2})$/, '$1-$2-$3')); + return Number.isFinite(dateOnly) ? dateOnly : Date.now(); + } + + /** + * 委托 → 策略归属推导(外键链:委托时间 join strategy_holdings 生命周期窗口) + * 规则(Q3 老师定稿):created_at ≤ insert_ts < closed_at(closed_at NULL=当前持仓);一码多策略取份额最大;无命中=null + * @param {Array} orders 委托行(含 insert_ts 合成) + * @returns {Map} order_id → 归属 + */ + _resolveStrategyAttribution(orders) { + this.init(); + const out = new Map(); + const list = Array.isArray(orders) ? orders.filter((o) => o && o.orderId) : []; + if (list.length === 0) return out; + // 收集涉及的 code + 时间窗 + const codes = [...new Set(list.map((o) => o.code).filter(Boolean))]; + if (codes.length === 0) return out; + const placeholders = codes.map(() => '?').join(','); + const rows = this.db.prepare( + 'SELECT holding_id, strategy_id, code, shares, created_at, closed_at FROM strategy_holdings WHERE code IN (' + placeholders + ')' + ).all(...codes); + const holdingsByCode = new Map(); + for (const h of rows) { + if (!holdingsByCode.has(h.code)) holdingsByCode.set(h.code, []); + holdingsByCode.get(h.code).push(h); + } + for (const o of list) { + const ts = this._orderInsertTs(o); + // 方案 A(2026-09-01 老师确认):当前持仓(closed_at IS NULL)只做同 code 匹配 + // (created_at 可能是迁移时间戳,不要求 ≤ 委托时间;现在持有=当日交易可归); + // 已清仓(closed_at 非空)才按时间窗口(created_at ≤ t < closed_at)判断。 + const candidates = (holdingsByCode.get(o.code) ?? []).filter((h) => { + if (h.closed_at == null) return true; // 当前持仓:同 code 即匹配 + if (h.created_at > ts) return false; + if (ts >= h.closed_at) return false; + return true; + }); + if (candidates.length === 0) { + out.set(o.orderId, { strategyId: null, holdingId: null }); + continue; + } + // 一码多策略消歧:取份额最大持仓(确定性规则) + candidates.sort((a, b) => (b.shares ?? 0) - (a.shares ?? 0)); + const pick = candidates[0]; + out.set(o.orderId, { strategyId: pick.strategy_id, holdingId: pick.holding_id }); + } + return out; + } + + /** + * 给任意委托列表附加策略/持仓归属(今日实时委托用;只读 strategy_holdings,不写库) + * @param {Array} orders 语义化委托列表 + * @returns {Array} 每项附加 strategyId/holdingId + */ + attachStrategyAttribution(orders) { + const list = Array.isArray(orders) ? orders : []; + const attribution = this._resolveStrategyAttribution(list); + return list.map((o) => { + const a = attribution.get(o?.orderId) ?? { strategyId: null, holdingId: null }; + return { ...o, strategyId: a.strategyId, holdingId: a.holdingId }; + }); + } + + /** + * 本地历史委托查询(R-009 Q4):按时间段/code/策略/方向过滤 + * @param {object} [opts] { start, end, code, strategyId, direction } 日期 YYYYMMDD + * @returns {Promise} 委托列表(每项含 strategyId/holdingId 归属字段) + */ + getOrderHistory({ start, end, code, strategyId, direction } = {}) { + this.init(); + const where = []; + const params = []; + if (start) { where.push('trade_date >= ?'); params.push(String(start)); } + if (end) { where.push('trade_date <= ?'); params.push(String(end)); } + if (code) { where.push('code = ?'); params.push(String(code)); } + if (direction) { where.push('direction = ?'); params.push(String(direction)); } + let sql = 'SELECT * FROM trade_orders'; + if (where.length) sql += ' WHERE ' + where.join(' AND '); + sql += ' ORDER BY insert_ts DESC'; + const rows = this.db.prepare(sql).all(...params); + // 归属 = 用户手动设置的持久化列(Q3 全手动选;未设置为 null=未关联) + if (strategyId === '__unassigned__') { + return rows.map((r) => this._mapOrderRow(r)).filter((o) => !o.strategyId); + } + if (strategyId) { + return rows.map((r) => this._mapOrderRow(r)).filter((o) => o.strategyId === strategyId); + } + return rows.map((r) => this._mapOrderRow(r)); + } + + /** + * 本地历史成交查询(R-009 Q4):策略过滤按所属委托归属(fill → order → holding → strategy) + * @param {object} [opts] { start, end, code, strategyId, direction } + * @returns {Array} 成交列表(每项含 strategyId/holdingId 归属字段) + */ + getFillHistory({ start, end, code, strategyId, direction } = {}) { + this.init(); + const where = []; + const params = []; + if (start) { where.push('f.trade_date >= ?'); params.push(String(start)); } + if (end) { where.push('f.trade_date <= ?'); params.push(String(end)); } + if (code) { where.push('f.code = ?'); params.push(String(code)); } + if (direction) { where.push('f.direction = ?'); params.push(String(direction)); } + let sql = 'SELECT f.* FROM trade_fills f'; + if (where.length) sql += ' WHERE ' + where.join(' AND '); + sql += ' ORDER BY f.trade_date DESC, f.trade_time DESC'; + const rows = this.db.prepare(sql).all(...params); + const fills = rows.map((r) => this._mapFillRow(r)); + if (!strategyId) return fills; + // 策略过滤:按所属委托的【持久化归属】strategy_id(fill → order 的 strategy_id) + const orderIds = [...new Set(fills.map((f) => f.orderId).filter(Boolean))]; + if (orderIds.length === 0) return []; + const ph = orderIds.map(() => '?').join(','); + const orderRows = this.db.prepare( + 'SELECT order_id, strategy_id FROM trade_orders WHERE order_id IN (' + ph + ')' + ).all(...orderIds); + const orderStrategy = new Map(orderRows.map((r) => [r.order_id, r.strategy_id ?? null])); + if (strategyId === '__unassigned__') { + return fills.filter((f) => !orderStrategy.get(f.orderId)); + } + return fills.filter((f) => orderStrategy.get(f.orderId) === strategyId); + } + + /** + * 设置委托归属(Q4 老师确认:可随时改,以最终修改为准) + * @param {string} orderId + * @param {string|null} strategyId null=清除归属(未关联) + * @param {number|null} holdingId null=清除归属 + * @returns {boolean} 是否更新成功 + */ + setOrderAttribution(orderId, strategyId, holdingId) { + this.init(); + const r = this.db.prepare( + 'UPDATE trade_orders SET strategy_id=?, holding_id=? WHERE order_id=?' + ).run(strategyId ?? null, holdingId ?? null, orderId); + return r.changes > 0; + } + + /** + * 按 holding_id 查询委托汇总(R-010 迭代 08:策略持仓行展开用) + * @param {number} holdingId 持仓 ID + * @returns {Array} 该 holding 的委托列表(含归属字段,按时间降序) + */ + getOrdersByHolding(holdingId) { + this.init(); + if (holdingId == null) return []; + return this.db.prepare( + 'SELECT * FROM trade_orders WHERE holding_id=? ORDER BY insert_ts DESC' + ).all(holdingId).map((r) => this._mapOrderRow(r)); + } + + /** + * 委托归属候选列表(用户手动设置用;候选 = 该 code 当前持仓的策略/持仓,Q3 全手动选) + * @param {string} code 证券代码(含后缀) + * @returns {Array<{strategyId: string, holdingId: number, shares: number}>} 候选列表(可空) + */ + getAttributionCandidates(code) { + this.init(); + if (!code) return []; + return this.db.prepare( + 'SELECT holding_id, strategy_id, shares FROM strategy_holdings WHERE code=? AND closed_at IS NULL ORDER BY shares DESC' + ).all(code).map((h) => ({ + strategyId: h.strategy_id, + holdingId: h.holding_id, + shares: h.shares, + })); + } + + _mapOrderRow(r) { + return { + orderId: r.order_id, + tradeDate: r.trade_date, + code: r.code, + name: r.name, + exchange: r.exchange, + direction: r.direction, + directionCode: r.direction_code, + optName: r.opt_name, + status: r.status, + orderVolume: r.order_volume, + tradedVolume: r.traded_volume, + limitPrice: r.limit_price, + tradedPrice: r.traded_price, + amount: r.amount, + insertDate: r.insert_date, + insertTime: r.insert_time, + insertTs: r.insert_ts, + cancelInfo: r.cancel_info, + errorMsg: r.error_msg, + strategyId: r.strategy_id ?? null, // 手动归属(Q1 冗余) + holdingId: r.holding_id ?? null, // 手动归属(Q1 冗余) + fetchedAt: r.fetched_at, + }; + } + + _mapFillRow(r) { + return { + tradeId: r.trade_id, + orderId: r.order_id, + tradeDate: r.trade_date, + code: r.code, + name: r.name, + exchange: r.exchange, + direction: r.direction, + directionCode: r.direction_code, + optName: r.opt_name, + price: r.price, + volume: r.volume, + amount: r.amount, + commissionRateWan: r.commission_rate_wan, + tradeTime: r.trade_time, + fetchedAt: r.fetched_at, + }; + } + + /** + * 统计某日委托/成交条数(调试/验证用) + */ + countTradeRows(tradeDate) { + this.init(); + const o = this.db.prepare('SELECT COUNT(*) AS n FROM trade_orders' + (tradeDate ? ' WHERE trade_date=?' : '')).get(...(tradeDate ? [tradeDate] : [])); + const f = this.db.prepare('SELECT COUNT(*) AS n FROM trade_fills' + (tradeDate ? ' WHERE trade_date=?' : '')).get(...(tradeDate ? [tradeDate] : [])); + return { orders: o?.n ?? 0, fills: f?.n ?? 0 }; + } +} \ No newline at end of file diff --git a/src/component/TradeSync.js b/src/component/TradeSync.js new file mode 100644 index 0000000..125feba --- /dev/null +++ b/src/component/TradeSync.js @@ -0,0 +1,88 @@ +/** + * TradeSync —— 交易记录本地同步(R-009 / 迭代 07,2026-09-01) + * + * 职责:把 QMT Bridge 当日交易数据(委托 + 成交)定时同步落 SQLite(trade_orders + trade_fills)。 + * - 启动预热:插件启动即同步一次(今日数据入库); + * - 定时同步:60s 轮询拉当日 orders+trades → UPSERT(幂等,状态覆盖更新); + * - 失败容忍:单次同步失败只记日志,不中断定时循环。 + * + * 数据来源:QmtBridgeRestDataSource.getOrders() / getTrades()(仅当日,QMT 无历史接口)。 + * 存储:DataStore.upsertTradeOrders / upsertTradeFills(委托 SqliteStore,UPSERT 幂等)。 + */ + +const SYNC_INTERVAL_MS = 60 * 1000; // 60s + +export class TradeSync { + /** + * @param {object} opts + * @param {object} opts.runtime { dataSource, storage } —— dataSource 拉当日数据,storage 落库 + * @param {object} [opts.logger] + */ + constructor({ runtime, logger } = {}) { + this.runtime = runtime; + this.logger = logger; + this.timer = null; + this.mounted = false; + this.syncing = false; + } + + /** 启动:立即同步一次(预热)+ 定时同步 */ + start() { + if (this.mounted) return; + this.mounted = true; + this.syncNow().catch((e) => { + this.logger?.warn?.('[one-divine-lot] TradeSync 启动预热失败: ' + (e?.message ?? e)); + }); + this.timer = setInterval(() => { + this.syncNow().catch((e) => { + this.logger?.debug?.('[one-divine-lot] TradeSync 定时同步失败: ' + (e?.message ?? e)); + }); + }, SYNC_INTERVAL_MS); + this.logger?.info?.('[one-divine-lot] TradeSync 启动(60s 定时同步交易记录)'); + } + + /** 停止(插件释放时) */ + stop() { + this.mounted = false; + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + } + + /** + * 同步一次:拉当日 orders+trades → UPSERT 落库(幂等) + * @returns {Promise<{orders: number, fills: number} | null>} 写入条数;数据源不可用返回 null + */ + async syncNow() { + if (!this.mounted || this.syncing) return null; + const { dataSource, storage } = this.runtime; + if (!dataSource || !storage) return null; + this.syncing = true; + try { + const [orders, fills] = await Promise.all([ + dataSource.getOrders({}).catch((e) => { + this.logger?.debug?.('[one-divine-lot] TradeSync 拉委托失败: ' + (e?.message ?? e)); + return null; + }), + dataSource.getTrades({}).catch((e) => { + this.logger?.debug?.('[one-divine-lot] TradeSync 拉成交失败: ' + (e?.message ?? e)); + return null; + }), + ]); + const nOrders = Array.isArray(orders) ? await storage.upsertTradeOrders(orders) : 0; + const nFills = Array.isArray(fills) ? await storage.upsertTradeFills(fills) : 0; + if (nOrders > 0 || nFills > 0) { + this.logger?.debug?.('[one-divine-lot] TradeSync 同步完成: 委托 ' + nOrders + ' / 成交 ' + nFills); + } + return { orders: nOrders, fills: nFills }; + } finally { + this.syncing = false; + } + } + + /** 手动触发同步(api 层可调用;返回最新同步结果) */ + async sync() { + return this.syncNow(); + } +} diff --git a/src/index.js b/src/index.js index c690d63..3849279 100644 --- a/src/index.js +++ b/src/index.js @@ -21,6 +21,7 @@ import { registerApi } from './api/index.js'; import { MarketDataHub } from './component/MarketDataHub.js'; import { MarketFeed } from './component/MarketFeed.js'; import { QmtHealthMonitor } from './component/QmtHealthMonitor.js'; +import { TradeSync } from './component/TradeSync.js'; const name = 'one-divine-lot'; @@ -71,8 +72,12 @@ async function apply(ctx, config) { // S5: 分仓逻辑(份额分配/查询) const manager = new PositionManager({ dataSource, storage }); - // S6: 服务端 HTTP API(R-004:注入 dataSource 以编排激活热切换) - registerApi(ctx, { manager, settings, dataSource, marketHub, marketFeed, qmtHealthMonitor }); + // R-009:交易记录本地同步(启动预热 + 60s 定时 UPSERT 落库) + const tradeSync = new TradeSync({ runtime: { dataSource, storage }, logger }); + tradeSync.start(); + + // S6: 服务端 HTTP API(R-004:注入 dataSource 以编排激活热切换;R-009:注入 storage 供 trades/history) + registerApi(ctx, { manager, settings, dataSource, marketHub, marketFeed, qmtHealthMonitor, storage, tradeSync }); // 启动时可用性检查(日志,不阻塞) dataSource.isAvailable().then((ok) => { @@ -86,10 +91,11 @@ async function apply(ctx, config) { return () => { marketFeed.stop(); qmtHealthMonitor.stop(); + tradeSync.stop(); // 停止交易记录定时同步(迭代 07) storage.close(); // 关闭 SQLite 连接(迭代 06) logger.info('[one-divine-lot] 插件已释放'); }; }, 'one-divine-lot.dispose'); } -export { name, inject, apply }; +export { name, inject, apply }; \ No newline at end of file