feat(交易记录): SQLite 本地存储 + 手动归属(迭代 07,R-009)
- trade_orders + trade_fills 两表(委托/成交,holding_id/strategy_id 手动归属) - TradeSync 定时同步(启动预热 + 60s UPSERT,不覆盖归属列) - 手动设置归属(candidates 候选 + set-attribution 端点 + 前端归属下拉) - 历史查询 trades/history + 策略过滤 - QMT code 归一化(补后缀)+ tradeDate 兜底 insertDate
This commit is contained in:
+2
-2
@@ -59,8 +59,8 @@ const HANDLERS = [
|
|||||||
* @param {import('@deepseek-ai/cordis').Context} ctx
|
* @param {import('@deepseek-ai/cordis').Context} ctx
|
||||||
* @param {object} runtime { manager, settings, dataSource, marketCache }
|
* @param {object} runtime { manager, settings, dataSource, marketCache }
|
||||||
*/
|
*/
|
||||||
export function registerApi(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 };
|
const runtime = { ctx, manager, settings, dataSource, marketHub, marketFeed, qmtHealthMonitor, storage, tradeSync };
|
||||||
|
|
||||||
ctx.effect(() => ctx.webServer.register({
|
ctx.effect(() => ctx.webServer.register({
|
||||||
kind: 'prefix',
|
kind: 'prefix',
|
||||||
|
|||||||
+64
-3
@@ -5,16 +5,24 @@
|
|||||||
* orders → 当日委托(支持 code/status 过滤)
|
* orders → 当日委托(支持 code/status 过滤)
|
||||||
* trades → 当日成交
|
* trades → 当日成交
|
||||||
* trading-dates → 交易日历(日期导航)
|
* trading-dates → 交易日历(日期导航)
|
||||||
|
* trades/history → 本地 SQLite 历史查询(R-009:时间段/code/策略/方向过滤)
|
||||||
|
* trades/by-holding → 按 holding_id 查委托汇总(R-010:策略持仓行展开)
|
||||||
*
|
*
|
||||||
* 数据流:前端轮询 → /odl/api/orders|trades → dataSource.getOrders/getTrades
|
* 数据流:前端轮询 → /odl/api/orders|trades → dataSource.getOrders/getTrades
|
||||||
* (服务端直连 QMT Bridge REST,技术约束-003)
|
* (服务端直连 QMT Bridge REST,技术约束-003)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { getStrategies } from '../settings.js';
|
||||||
|
|
||||||
/** 交易记录端点方法表 */
|
/** 交易记录端点方法表 */
|
||||||
export const TRADE_METHODS = new Set([
|
export const TRADE_METHODS = new Set([
|
||||||
'orders',
|
'orders',
|
||||||
'trades',
|
'trades',
|
||||||
'trading-dates',
|
'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} args
|
||||||
* @param {object} runtime { dataSource }
|
* @param {object} runtime { dataSource }
|
||||||
*/
|
*/
|
||||||
export async function handleTrade(method, args, { dataSource }) {
|
export async function handleTrade(method, args, { dataSource, storage, settings }) {
|
||||||
switch (method) {
|
switch (method) {
|
||||||
case 'orders':
|
case 'orders': {
|
||||||
return await dataSource.getOrders({
|
const orders = await dataSource.getOrders({
|
||||||
code: args.code,
|
code: args.code,
|
||||||
status: args.status,
|
status: args.status,
|
||||||
start: args.start,
|
start: args.start,
|
||||||
end: args.end,
|
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':
|
case 'trades':
|
||||||
return await dataSource.getTrades({
|
return await dataSource.getTrades({
|
||||||
start: args.start,
|
start: args.start,
|
||||||
@@ -42,6 +66,43 @@ export async function handleTrade(method, args, { dataSource }) {
|
|||||||
start: args.start,
|
start: args.start,
|
||||||
end: args.end,
|
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:
|
default:
|
||||||
throw Object.assign(new Error('unknown trade method: ' + method), { code: 'not-found' });
|
throw Object.assign(new Error('unknown trade method: ' + method), { code: 'not-found' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 唯一),聚合成交信息(成交量/加权均价/金额/手续费)
|
* - 主行 = 一笔委托(按 m_strOrderSysID 唯一),聚合成交信息(成交量/加权均价/金额/手续费)
|
||||||
* - 点击展开/折叠 → 逐笔成交明细(成交时间/价/量/额/手续费/成交号)
|
* - 点击展开/折叠 → 逐笔成交明细(成交时间/价/量/额/手续费/成交号)
|
||||||
* - 排序:默认按委托时间降序(最新在前);点击表头手动排序
|
* - 排序:默认按委托时间降序(最新在前);点击表头手动排序
|
||||||
* - 时间段查询:标题 + RangeSelector 同一行(今日/本周/上周快捷 + 手动起止日期)
|
* - 时间段查询:标题 + RangeSelector 同一行(今日/本周/本月快捷 + 手动起止日期)
|
||||||
* - 轮询:仅「今日」范围 3-5s 自动刷新;其他范围一次性查询
|
* - 轮询:仅「今日」范围 3-5s 自动刷新;其他范围一次性查询
|
||||||
*
|
*
|
||||||
|
* R-009(迭代 07):
|
||||||
|
* - 策略过滤下拉(全部 / 各策略 / 未关联):今日实时按归属过滤(前端),历史走服务端过滤
|
||||||
|
* - 历史范围从「接口开发中」占位切换为查本地 SQLite(trades/history)
|
||||||
|
*
|
||||||
* 方向:红买绿卖(Q7 定稿);状态中文映射;未成交显示 —
|
* 方向:红买绿卖(Q7 定稿);状态中文映射;未成交显示 —
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||||
import { useRpc } from './connection.jsx';
|
import { useRpc } from './connection.jsx';
|
||||||
import { LoadState } from './LoadState.jsx';
|
import { LoadState } from './LoadState.jsx';
|
||||||
import { RangeSelector } from './RangeSelector.jsx';
|
import { RangeSelector } from './RangeSelector.jsx';
|
||||||
@@ -79,8 +83,7 @@ function mergeOrdersAndTrades(orders, trades) {
|
|||||||
const weightedPrice = totalVol > 0
|
const weightedPrice = totalVol > 0
|
||||||
? ts.reduce((s, t) => s + t.price * t.volume, 0) / totalVol
|
? ts.reduce((s, t) => s + t.price * t.volume, 0) / totalVol
|
||||||
: 0;
|
: 0;
|
||||||
// 费用:按整笔委托合并计算(同一委托分笔成交只收一次佣金,calcOrderFees)
|
const rateWan = ts[0]?.commissionRateWan ?? 0;
|
||||||
const rateWan = ts[0]?.commissionRateWan ?? 0; // 佣金费率(万分之,来自成交,账号级一致)
|
|
||||||
const fees = calcOrderFeesLocal(ts, rateWan);
|
const fees = calcOrderFeesLocal(ts, rateWan);
|
||||||
return {
|
return {
|
||||||
...o,
|
...o,
|
||||||
@@ -102,10 +105,20 @@ export function TradeRecordsTab() {
|
|||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [range, setRange] = useState(null); // { start, end } YYYYMMDD
|
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); // 最近交易日
|
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(() => {
|
useEffect(() => {
|
||||||
if (lastDate && !range) {
|
if (lastDate && !range) {
|
||||||
setRange({ start: lastDate, end: lastDate });
|
setRange({ start: lastDate, end: lastDate });
|
||||||
@@ -114,20 +127,48 @@ export function TradeRecordsTab() {
|
|||||||
const [expanded, setExpanded] = useState(new Set());
|
const [expanded, setExpanded] = useState(new Set());
|
||||||
const [sortKey, setSortKey] = useState('insertTime');
|
const [sortKey, setSortKey] = useState('insertTime');
|
||||||
const [sortDir, setSortDir] = useState('desc');
|
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',单向)→ 轮询
|
// 是否「今日」意图(preset='today',单向)→ 轮询
|
||||||
const isTodayRange = preset === 'today';
|
const isTodayRange = preset === 'today';
|
||||||
// 是否历史范围(非今日意图)→ 本期占位(QMT Bridge 历史接口开发中)
|
// 是否历史范围(非今日意图)→ 查本地 SQLite(R-009)
|
||||||
const isHistoryRange = !!range && preset !== 'today';
|
const isHistoryRange = !!range && preset !== 'today';
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
if (!range) return;
|
if (!range) return;
|
||||||
if (isHistoryRange) {
|
if (isHistoryRange) {
|
||||||
// 历史范围:本期占位(老师将完善 QMT Bridge 历史接口)
|
// 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([]);
|
setOrders([]);
|
||||||
setTrades([]);
|
setTrades([]);
|
||||||
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -148,7 +189,56 @@ export function TradeRecordsTab() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
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(() => {
|
useEffect(() => {
|
||||||
@@ -171,9 +261,15 @@ export function TradeRecordsTab() {
|
|||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, [call, isTodayRange, range]);
|
}, [call, isTodayRange, range]);
|
||||||
|
|
||||||
// 合并 + 排序
|
// 合并 + 策略过滤(今日实时按归属过滤前端;历史已在服务端过滤)+ 排序
|
||||||
const rows = useMemo(() => {
|
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;
|
const dir = sortDir === 'asc' ? 1 : -1;
|
||||||
return merged.slice().sort((a, b) => {
|
return merged.slice().sort((a, b) => {
|
||||||
const va = a[sortKey];
|
const va = a[sortKey];
|
||||||
@@ -181,7 +277,7 @@ export function TradeRecordsTab() {
|
|||||||
if (typeof va === 'string' && typeof vb === 'string') return va.localeCompare(vb) * dir;
|
if (typeof va === 'string' && typeof vb === 'string') return va.localeCompare(vb) * dir;
|
||||||
return (va - vb) * dir;
|
return (va - vb) * dir;
|
||||||
});
|
});
|
||||||
}, [orders, trades, sortKey, sortDir]);
|
}, [orders, trades, sortKey, sortDir, strategyFilter]);
|
||||||
|
|
||||||
const toggleSort = (key) => {
|
const toggleSort = (key) => {
|
||||||
if (sortKey === key) {
|
if (sortKey === key) {
|
||||||
@@ -204,23 +300,60 @@ export function TradeRecordsTab() {
|
|||||||
const arrow = (key) => (sortKey === key ? (sortDir === 'desc' ? ' ▼' : ' ▲') : '');
|
const arrow = (key) => (sortKey === key ? (sortDir === 'desc' ? ' ▼' : ' ▲') : '');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 16, fontFamily: 'system-ui' }}>
|
<div className="odl-root" style={{ padding: 16, fontFamily: 'system-ui' }}>
|
||||||
{/* 标题 + 时间段选择同一行(2026-09-01 老师反馈) */}
|
{/* 标题 + 策略过滤 + 时间段选择同一行(R-007/R-009) */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12, flexWrap: 'wrap', gap: 12 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12, flexWrap: 'wrap', gap: 12 }}>
|
||||||
<h3 style={{ margin: 0 }}>交易记录</h3>
|
<h3 style={{ margin: 0 }}>交易记录</h3>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
{/* R-009:策略过滤下拉(全部/各策略/未关联) */}
|
||||||
|
<select
|
||||||
|
value={strategyFilter}
|
||||||
|
onChange={(e) => setStrategyFilter(e.target.value)}
|
||||||
|
style={{ fontSize: 12, padding: '2px 6px', border: '1px solid #ccc', borderRadius: 4, color: '#555' }}
|
||||||
|
title="按策略过滤"
|
||||||
|
>
|
||||||
|
<option value="">全部策略</option>
|
||||||
|
<option value="__unassigned__">未关联</option>
|
||||||
|
{strategies.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>{s.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
<RangeSelector preset={preset} onPresetChange={setPreset} range={range} onChange={setRange} onLastDate={setLastDate} />
|
<RangeSelector preset={preset} onPresetChange={setPreset} range={range} onChange={setRange} onLastDate={setLastDate} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isHistoryRange ? (
|
|
||||||
<div style={{ padding: 32, textAlign: 'center', color: '#999' }}>
|
|
||||||
<div style={{ fontSize: 15, marginBottom: 8 }}>{range.start} ~ {range.end}(自定义时间段)</div>
|
|
||||||
<div style={{ fontSize: 13 }}>历史数据接口开发中,敬请期待(老师完善 QMT Bridge 历史接口后接入)</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
{attributionSaved && (
|
||||||
|
<div style={{ marginBottom: 8, fontSize: 12, color: attributionSaved.startsWith('保存失败') ? '#c62828' : '#1b5e20', background: attributionSaved.startsWith('保存失败') ? '#fdecea' : '#e8f5e9', padding: '4px 8px', borderRadius: 4 }}>
|
||||||
|
{attributionSaved}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<LoadState loading={loading && !orders} error={error} onRetry={load} autoRetry={2}>
|
<LoadState loading={loading && !orders} error={error} onRetry={load} autoRetry={2}>
|
||||||
{!orders?.length ? (
|
{!orders?.length ? (
|
||||||
<div style={{ padding: 32, textAlign: 'center', color: '#999' }}>该时间段暂无委托记录</div>
|
<div style={{ padding: 32, textAlign: 'center', color: '#999' }}>
|
||||||
|
<div style={{ fontSize: 15, marginBottom: 8 }}>
|
||||||
|
{range ? range.start + ' ~ ' + range.end + (isHistoryRange ? '(本地历史)' : '') : ''}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13 }}>
|
||||||
|
{isHistoryRange ? '该时间段暂无本地交易记录(数据自插件运行起逐日积累)' : '该时间段暂无委托记录'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
<TradeTable rows={rows} toggleSort={toggleSort} sortKey={sortKey} sortDir={sortDir} arrow={arrow} toggleExpand={toggleExpand} expanded={expanded} attributionCandidates={attributionCandidates} loadCandidates={loadCandidates} setAttribution={setAttribution} />
|
||||||
|
)}
|
||||||
|
<div style={{ marginTop: 8, fontSize: 12, color: '#999' }}>
|
||||||
|
<span style={{ color: '#d32f2f' }}>■ 买</span>
|
||||||
|
<span style={{ marginLeft: 8, color: '#2e7d32' }}>■ 卖</span>
|
||||||
|
<span style={{ marginLeft: 12 }}>点击委托行展开成交明细 · 点击表头排序</span>
|
||||||
|
{isHistoryRange && <span style={{ marginLeft: 12 }}>· 历史数据 = 本地积累</span>}
|
||||||
|
</div>
|
||||||
|
</LoadState>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 交易记录表格(主行 + 展开明细 + R-009 手动归属列) */
|
||||||
|
function TradeTable({ rows, toggleSort, sortKey, sortDir, arrow, toggleExpand, expanded, attributionCandidates, loadCandidates, setAttribution }) {
|
||||||
|
return (
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ textAlign: 'left', borderBottom: '2px solid #ddd' }}>
|
<tr style={{ textAlign: 'left', borderBottom: '2px solid #ddd' }}>
|
||||||
@@ -239,6 +372,7 @@ export function TradeRecordsTab() {
|
|||||||
<th align="right">佣金</th>
|
<th align="right">佣金</th>
|
||||||
<th align="right">印花税</th>
|
<th align="right">印花税</th>
|
||||||
<th align="right">过户费</th>
|
<th align="right">过户费</th>
|
||||||
|
<th style={thStyle}>归属</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -248,25 +382,23 @@ export function TradeRecordsTab() {
|
|||||||
order={o}
|
order={o}
|
||||||
expanded={expanded.has(o.orderId)}
|
expanded={expanded.has(o.orderId)}
|
||||||
onToggle={() => toggleExpand(o.orderId)}
|
onToggle={() => toggleExpand(o.orderId)}
|
||||||
|
attributionCandidates={attributionCandidates[o.code] ?? []}
|
||||||
|
onLoadCandidates={() => loadCandidates(o.code)}
|
||||||
|
setAttribution={setAttribution}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
)}
|
|
||||||
<div style={{ marginTop: 8, fontSize: 12, color: '#999' }}>
|
|
||||||
<span style={{ color: '#d32f2f' }}>■ 买</span>
|
|
||||||
<span style={{ marginLeft: 8, color: '#2e7d32' }}>■ 卖</span>
|
|
||||||
<span style={{ marginLeft: 12 }}>点击委托行展开成交明细 · 点击表头排序</span>
|
|
||||||
</div>
|
|
||||||
</LoadState>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 委托主行 + 展开明细 */
|
/** 委托主行 + 展开明细(含 R-009 手动归属下拉) */
|
||||||
function OrderRows({ order, expanded, onToggle }) {
|
function OrderRows({ order, expanded, onToggle, attributionCandidates, onLoadCandidates, setAttribution }) {
|
||||||
const hasTrades = order.trades.length > 0;
|
const hasTrades = order.trades.length > 0;
|
||||||
|
const strategyName = (sid) => {
|
||||||
|
if (!sid) return '未关联';
|
||||||
|
return order.strategyName || sid;
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<tr
|
<tr
|
||||||
@@ -319,10 +451,18 @@ function OrderRows({ order, expanded, onToggle }) {
|
|||||||
<td style={{ ...tdStyle, textAlign: 'right' }}>{hasTrades ? fmtNum(order.aggCommission, 2) : '—'}</td>
|
<td style={{ ...tdStyle, textAlign: 'right' }}>{hasTrades ? fmtNum(order.aggCommission, 2) : '—'}</td>
|
||||||
<td style={{ ...tdStyle, textAlign: 'right' }}>{hasTrades ? fmtNum(order.aggStampTax, 2) : '—'}</td>
|
<td style={{ ...tdStyle, textAlign: 'right' }}>{hasTrades ? fmtNum(order.aggStampTax, 2) : '—'}</td>
|
||||||
<td style={{ ...tdStyle, textAlign: 'right' }}>{hasTrades ? fmtNum(order.aggTransferFee, 2) : '—'}</td>
|
<td style={{ ...tdStyle, textAlign: 'right' }}>{hasTrades ? fmtNum(order.aggTransferFee, 2) : '—'}</td>
|
||||||
|
<td style={{ ...tdStyle }} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<AttributionSelect
|
||||||
|
order={order}
|
||||||
|
candidates={attributionCandidates}
|
||||||
|
onLoadCandidates={onLoadCandidates}
|
||||||
|
setAttribution={setAttribution}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{expanded && hasTrades && (
|
{expanded && hasTrades && (
|
||||||
<tr style={{ borderBottom: '1px solid #eee', background: '#fafafa' }}>
|
<tr style={{ borderBottom: '1px solid #eee', background: '#fafafa' }}>
|
||||||
<td colSpan={15} style={{ padding: '4px 8px 12px' }}>
|
<td colSpan={16} style={{ padding: '4px 8px 12px' }}>
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12, marginTop: 4 }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12, marginTop: 4 }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ borderBottom: '1px solid #ddd', color: '#666' }}>
|
<tr style={{ borderBottom: '1px solid #ddd', color: '#666' }}>
|
||||||
@@ -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 (
|
||||||
|
<select
|
||||||
|
value={current || '__unassigned__'}
|
||||||
|
onChange={handleSelect}
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
padding: '2px 4px',
|
||||||
|
border: '1px solid ' + (current ? '#2e7d32' : '#ccc'),
|
||||||
|
borderRadius: 4,
|
||||||
|
background: current ? '#e8f5e9' : '#fff',
|
||||||
|
color: current ? '#1b5e20' : '#999',
|
||||||
|
maxWidth: 120,
|
||||||
|
}}
|
||||||
|
title="设置该委托的策略归属(可随时修改)"
|
||||||
|
>
|
||||||
|
<option value="__unassigned__">未关联</option>
|
||||||
|
{(candidates ?? []).map((c) => (
|
||||||
|
<option key={c.holdingId} value={c.strategyId}>{c.strategyName || c.strategyId}</option>
|
||||||
|
))}
|
||||||
|
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const thStyle = { padding: '8px 6px', lineHeight: '24px', whiteSpace: 'nowrap' };
|
const thStyle = { padding: '8px 6px', lineHeight: '24px', whiteSpace: 'nowrap' };
|
||||||
const thClickStyle = { ...thStyle, cursor: 'pointer', userSelect: 'none' };
|
const thClickStyle = { ...thStyle, cursor: 'pointer', userSelect: 'none' };
|
||||||
const tdStyle = { padding: '8px 6px', lineHeight: '24px', whiteSpace: 'nowrap', fontVariantNumeric: 'tabular-nums' };
|
const tdStyle = { padding: '8px 6px', lineHeight: '24px', whiteSpace: 'nowrap', fontVariantNumeric: 'tabular-nums' };
|
||||||
@@ -156,6 +156,56 @@ export class DataStore {
|
|||||||
return this.sqlite.setMarketQuotes(quotes);
|
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() {
|
close() {
|
||||||
this.sqlite.close();
|
this.sqlite.close();
|
||||||
|
|||||||
@@ -69,17 +69,40 @@ export class PositionManager {
|
|||||||
return { ...(map[code] ?? {}) };
|
return { ...(map[code] ?? {}) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 某策略下的持仓(份额 > 0 的标的) */
|
/** 某策略下的持仓(份额 > 0 的标的;R-010 附加 holding_id + 成本价/最后一笔成交价) */
|
||||||
async getStrategyPositions(strategyId) {
|
async getStrategyPositions(strategyId) {
|
||||||
const [positions, dataset] = await Promise.all([
|
const [positions, dataset, holdings] = await Promise.all([
|
||||||
this.getAllPositions(),
|
this.getAllPositions(),
|
||||||
this.storage.getDataset(strategyId),
|
this.storage.getDataset(strategyId),
|
||||||
|
this.storage.getCurrentHoldings(strategyId), // 含 holding_id(同策略同 code 当前持仓唯一)
|
||||||
]);
|
]);
|
||||||
const shareMap = new Map(dataset.map((d) => [d.code, d.shares]));
|
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
|
return positions
|
||||||
.map((p) => {
|
.map((p) => {
|
||||||
const shares = shareMap.get(p.code) ?? 0;
|
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);
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
mapOrder(o) {
|
||||||
const direction = o.m_nOffsetFlag === 48 ? 'buy' : o.m_nOffsetFlag === 49 ? 'sell' : '';
|
const direction = o.m_nOffsetFlag === 48 ? 'buy' : o.m_nOffsetFlag === 49 ? 'sell' : '';
|
||||||
|
const code = this.normalizeInstrumentCode(o.m_strInstrumentID, o.m_strExchangeID);
|
||||||
return {
|
return {
|
||||||
orderId: o.m_strOrderSysID ?? '',
|
orderId: o.m_strOrderSysID ?? '',
|
||||||
code: o.m_strInstrumentID ?? '',
|
code,
|
||||||
name: o.m_strInstrumentName ?? '',
|
name: o.m_strInstrumentName ?? '',
|
||||||
exchange: o.m_strExchangeID ?? '',
|
exchange: o.m_strExchangeID ?? '',
|
||||||
direction, // 'buy' | 'sell' | ''
|
direction, // 'buy' | 'sell' | ''
|
||||||
@@ -181,6 +201,7 @@ export class QmtBridgeRestDataSource {
|
|||||||
limitPrice: +(o.m_dLimitPrice ?? 0),
|
limitPrice: +(o.m_dLimitPrice ?? 0),
|
||||||
tradedPrice: +(o.m_dTradedPrice ?? 0),
|
tradedPrice: +(o.m_dTradedPrice ?? 0),
|
||||||
amount: +(o.m_dTradeAmount ?? 0),
|
amount: +(o.m_dTradeAmount ?? 0),
|
||||||
|
tradeDate: o.m_strTradeDate ?? o.m_strInsertDate ?? '', // 交易日:委托无独立字段,用 insertDate 兜底
|
||||||
insertDate: o.m_strInsertDate ?? '',
|
insertDate: o.m_strInsertDate ?? '',
|
||||||
insertTime: o.m_strInsertTime ?? '',
|
insertTime: o.m_strInsertTime ?? '',
|
||||||
cancelInfo: o.m_strCancelInfo ?? '',
|
cancelInfo: o.m_strCancelInfo ?? '',
|
||||||
@@ -227,7 +248,7 @@ export class QmtBridgeRestDataSource {
|
|||||||
return {
|
return {
|
||||||
tradeId: t.m_strTradeID ?? '',
|
tradeId: t.m_strTradeID ?? '',
|
||||||
orderId: t.m_strOrderSysID ?? '', // 关联委托
|
orderId: t.m_strOrderSysID ?? '', // 关联委托
|
||||||
code: t.m_strInstrumentID ?? '',
|
code: this.normalizeInstrumentCode(t.m_strInstrumentID, t.m_strExchangeID),
|
||||||
name: t.m_strInstrumentName ?? '',
|
name: t.m_strInstrumentName ?? '',
|
||||||
exchange: t.m_strExchangeID ?? '',
|
exchange: t.m_strExchangeID ?? '',
|
||||||
direction, // 'buy' | 'sell' | ''
|
direction, // 'buy' | 'sell' | ''
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
* 表结构(技术实现方案):
|
* 表结构(技术实现方案):
|
||||||
* - strategy_holdings 策略持仓生命周期表(一笔 = 一次「建仓→清仓」,历史保留)
|
* - strategy_holdings 策略持仓生命周期表(一笔 = 一次「建仓→清仓」,历史保留)
|
||||||
* - market_quotes_cache 行情快照缓存(code 维度一行一码,只存 UI 消费的 lastPrice/lastClose)
|
* - 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 整体读写):
|
* 方法集(替代 DataStore 原 getDataset/setDataset/removeDataset/getAllDatasets 整体读写):
|
||||||
* 持仓生命周期:openHolding / addShares / reduceShares / closeHolding
|
* 持仓生命周期:openHolding / addShares / reduceShares / closeHolding
|
||||||
@@ -47,6 +49,53 @@ CREATE TABLE IF NOT EXISTS market_quotes_cache (
|
|||||||
last_close REAL NOT NULL,
|
last_close REAL NOT NULL,
|
||||||
updated_at INTEGER NOT NULL DEFAULT 0
|
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 {
|
export class SqliteStore {
|
||||||
@@ -73,9 +122,35 @@ export class SqliteStore {
|
|||||||
mkdirSync(this.dataDir, { recursive: true });
|
mkdirSync(this.dataDir, { recursive: true });
|
||||||
this.db = new DatabaseSync(this.dbPath);
|
this.db = new DatabaseSync(this.dbPath);
|
||||||
this.db.exec(SCHEMA_SQL);
|
this.db.exec(SCHEMA_SQL);
|
||||||
|
// 存量库迁移:trade_orders 补手动归属列(R-009 二次定稿;幂等)
|
||||||
|
this._ensureTradeAttributionColumns();
|
||||||
return this.db;
|
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() {
|
close() {
|
||||||
if (this.db) {
|
if (this.db) {
|
||||||
@@ -344,4 +419,342 @@ export class SqliteStore {
|
|||||||
updatedAt: row.updated_at,
|
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<string, {strategyId: string|null, holdingId: number|null}>} 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<Array>} 委托列表(每项含 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 };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-2
@@ -21,6 +21,7 @@ import { registerApi } from './api/index.js';
|
|||||||
import { MarketDataHub } from './component/MarketDataHub.js';
|
import { MarketDataHub } from './component/MarketDataHub.js';
|
||||||
import { MarketFeed } from './component/MarketFeed.js';
|
import { MarketFeed } from './component/MarketFeed.js';
|
||||||
import { QmtHealthMonitor } from './component/QmtHealthMonitor.js';
|
import { QmtHealthMonitor } from './component/QmtHealthMonitor.js';
|
||||||
|
import { TradeSync } from './component/TradeSync.js';
|
||||||
|
|
||||||
const name = 'one-divine-lot';
|
const name = 'one-divine-lot';
|
||||||
|
|
||||||
@@ -71,8 +72,12 @@ async function apply(ctx, config) {
|
|||||||
// S5: 分仓逻辑(份额分配/查询)
|
// S5: 分仓逻辑(份额分配/查询)
|
||||||
const manager = new PositionManager({ dataSource, storage });
|
const manager = new PositionManager({ dataSource, storage });
|
||||||
|
|
||||||
// S6: 服务端 HTTP API(R-004:注入 dataSource 以编排激活热切换)
|
// R-009:交易记录本地同步(启动预热 + 60s 定时 UPSERT 落库)
|
||||||
registerApi(ctx, { manager, settings, dataSource, marketHub, marketFeed, qmtHealthMonitor });
|
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) => {
|
dataSource.isAvailable().then((ok) => {
|
||||||
@@ -86,6 +91,7 @@ async function apply(ctx, config) {
|
|||||||
return () => {
|
return () => {
|
||||||
marketFeed.stop();
|
marketFeed.stop();
|
||||||
qmtHealthMonitor.stop();
|
qmtHealthMonitor.stop();
|
||||||
|
tradeSync.stop(); // 停止交易记录定时同步(迭代 07)
|
||||||
storage.close(); // 关闭 SQLite 连接(迭代 06)
|
storage.close(); // 关闭 SQLite 连接(迭代 06)
|
||||||
logger.info('[one-divine-lot] 插件已释放');
|
logger.info('[one-divine-lot] 插件已释放');
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user