);
}
-/** 委托主行 + 展开明细 */
-function OrderRows({ order, expanded, onToggle }) {
+/** 交易记录表格(主行 + 展开明细 + R-009 手动归属列) */
+function TradeTable({ rows, toggleSort, sortKey, sortDir, arrow, toggleExpand, expanded, attributionCandidates, loadCandidates, setAttribution }) {
+ return (
+
+ );
+}
+
+/** 委托主行 + 展开明细(含 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 (
<>
- |
+ |
@@ -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
|