Files
one_divine_lot/src/data-source/QmtBridgeRestDataSource.js
T
kyugao a4902bb730 feat(R-013+重构): 策略自定义字段需求文档 + 结构归类优化
需求与文档:
- R-013 策略自定义字段配置(定义随策略 configSchema 存 settings、值落
  strategy_holdings.values JSON、类型化文本/数字/布尔/枚举)定稿并进入
  PLAN-012 / 迭代 11(含目标数据模型 + 端到端示例 + 技术方案 + 验收标准)
- R-011(Tab设置)/ R-012(UI主题适配)归档至 已完成/,索引标记已归档
- 沉淀约束: 产品约束-010 / 技术约束-015 / 技术约束-016 / UI约束-005

结构重构(技术约束-016):
- src/component/ 平铺还原为语义分域目录: data-source/ storage/ position/
  market/ trades/(git rename 保留历史)
- 清理死代码: 删除 AllocationStorage.js(0 引用)、DataStore
  setDataset/removeDataset(无调用方)
- 同步更新 src/index.js / scripts/*.mjs / tsdown.config.ts 引用
- typecheck + build + test-r009 回归 14/14 通过
2026-09-02 15:38:51 +08:00

282 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* QMT Bridge REST 数据源适配器
*
* 设计约束:
* - 技术约束-001:统一数据源抽象(业务层不感知具体数据源)
* - 技术约束-003:直连 QMT Bridge RESTful 接口(不经过 MCP
*
* 服务地址:http://192.168.3.43:8610(可通过 config 覆盖)
*/
const DEFAULT_BASE_URL = 'http://192.168.3.43:8610';
const REQUEST_TIMEOUT_MS = 15000;
export class QmtBridgeRestDataSource {
constructor({ baseUrl = DEFAULT_BASE_URL, timeoutMs = REQUEST_TIMEOUT_MS } = {}) {
this.name = 'qmt-bridge-rest';
this.baseUrl = baseUrl.replace(/\/$/, '');
this.timeoutMs = timeoutMs;
}
/**
* 运行期更新基础地址(R-004 激活热切换,技术约束-008)。
* baseUrl 为按请求读取的实例字段,改字段即对后续请求生效,无需重建实例或重启。
*/
setBaseUrl(url) {
this.baseUrl = String(url ?? '').trim().replace(/\/+$/, '');
}
/** 基础 HTTP 请求(fetch + 超时) */
async request(path, { method = 'GET', body } = {}) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
try {
const res = await fetch(`${this.baseUrl}${path}`, {
method,
headers: body ? { 'Content-Type': 'application/json' } : undefined,
body: body ? JSON.stringify(body) : undefined,
signal: controller.signal,
});
if (!res.ok) {
throw new Error(`QMT Bridge REST ${method} ${path} 失败: HTTP ${res.status}`);
}
return await res.json();
} catch (err) {
if (err.name === 'AbortError') {
throw new Error(`QMT Bridge REST 请求超时: ${path} (${this.timeoutMs}ms)`);
}
throw err;
} finally {
clearTimeout(timer);
}
}
/** 可用性检查 */
async isAvailable() {
try {
const data = await this.request('/health');
return data?.status === 'ok';
} catch {
return false;
}
}
/** 资金摘要(/trade/asset */
async getAsset() {
const res = await this.request('/trade/asset');
const a = Array.isArray(res?.data) ? res.data[0] : res?.data;
if (!a) throw new Error('QMT 资金数据为空');
return {
accountId: a.m_strAccountID ?? '',
status: a.m_strStatus ?? '',
tradingDate: a.m_strTradingDate ?? '',
totalAsset: a.m_dAssetBalance ?? 0,
available: a.m_dAvailable ?? 0,
stockValue: a.m_dStockValue ?? 0,
balance: a.m_dBalance ?? 0,
positionProfit: a.m_dPositionProfit ?? 0,
};
}
/** 全量持仓(/trade/positions */
async getPositions() {
const res = await this.request('/trade/positions');
const data = res?.data;
const list = Array.isArray(data) ? data : data?.positions;
if (!Array.isArray(list)) return [];
return list.map((p) => this.mapPosition(p));
}
/**
* 合约基本信息(GET /data/instrument,技术约束-001 统一数据源抽象)
* 语义化字段优先,回退 QMT 原始字段(实际返回比 OpenAPI 规范更全,含股本/状态等)
* @param {string} code 完整证券代码(含后缀,如 600519.SH)
* @returns {Promise<import('./data-source-types.js').Instrument>}
*/
async getInstrument(code) {
if (!code) throw new Error('QMT instrument 查询缺少 code');
const res = await this.request('/data/instrument?code=' + encodeURIComponent(code));
const d = res?.data;
if (!d || !d.InstrumentID) throw new Error('QMT 合约信息为空: ' + code);
return {
code,
instrumentId: d.InstrumentID ?? '',
name: d.InstrumentName ?? '',
exchange: d.ExchangeID ?? '',
openDate: d.OpenDate ?? 0,
preClose: +(d.PreClose ?? d.preClose ?? 0),
upStopPrice: +(d.UpStopPrice ?? d.upStopPrice ?? 0),
downStopPrice: +(d.DownStopPrice ?? d.downStopPrice ?? 0),
floatVolume: +(d.FloatVolume ?? d.FloatVolumn ?? 0),
totalVolume: +(d.TotalVolume ?? d.TotalVolumn ?? 0),
priceTick: +(d.PriceTick ?? 0),
volumeMultiple: +(d.VolumeMultiple ?? 1),
instrumentStatus: +(d.InstrumentStatus ?? 0),
tradingDay: +(d.TradingDay ?? 0),
};
}
/**
* 当日委托(GET /trade/ordersR-007
* 过滤:code600519 或 600519.SH)、statusactive=排除已撤54/废单57,或数字状态码 48-57
* 语义化映射(方向判定:m_nOffsetFlag 为主 48买/49卖 + m_strOptName 交叉验证,R-007 Q3 闭环)
* @param {object} [opts] { code, status }
* @returns {Promise<Array>} 语义化 order 列表
*/
async getOrders({ code, status, start, end } = {}) {
const params = new URLSearchParams();
if (code) params.set('code', code);
if (status) params.set('status', status);
const qs = params.toString();
const res = await this.request('/trade/orders' + (qs ? '?' + qs : ''));
const list = Array.isArray(res?.data) ? res.data : [];
return list.map((o) => this.mapOrder(o));
}
/**
* 当日成交(GET /trade/tradesR-007
* 无过滤参数,返回全部当日成交
* @returns {Promise<Array>} 语义化 trade 列表
*/
async getTrades({ start, end } = {}) {
// 本期:QMT Bridge 无历史参数(老师将完善);预留 start/end 透传通道
void start; void end;
const res = await this.request('/trade/trades');
const list = Array.isArray(res?.data) ? res.data : [];
return list.map((t) => this.mapTrade(t));
}
/**
* 交易日历(GET /data/calendar/trading_datesR-007 日期导航)
* @param {object} [opts] { start, end } YYYYMMDD
* @returns {Promise<{ market: string, dates: string[] }>}
*/
async getTradingDates({ start, end } = {}) {
const params = new URLSearchParams();
if (start) params.set('start', start);
if (end) params.set('end', end);
const qs = params.toString();
const res = await this.request('/data/calendar/trading_dates' + (qs ? '?' + qs : ''));
return {
market: res?.market ?? '',
dates: Array.isArray(res?.dates) ? res.dates : [],
};
}
/**
* 归一化证券代码: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.2R-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,
name: o.m_strInstrumentName ?? '',
exchange: o.m_strExchangeID ?? '',
direction, // 'buy' | 'sell' | ''
directionCode: o.m_nOffsetFlag ?? null, // 原始 48/49
optName: o.m_strOptName ?? '', // 中文操作名(限价卖出)
status: o.m_nOrderStatus ?? null, // 48-57
orderVolume: o.m_nVolumeTotalOriginal ?? 0,
tradedVolume: o.m_nVolumeTraded ?? 0,
totalVolume: o.m_nVolumeTotal ?? 0,
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 ?? '',
errorMsg: o.m_strErrorMsg ?? '',
};
}
/**
* 计算一笔委托的总费用(2026-09-01 老师定规则 + 修正)
* 核心规则:同一笔委托分多笔成交,按总成交金额合并算一次佣金(不逐笔重复收费);
* 多笔独立委托(分开下单)才各自算佣金、各自触发 5 元最低。
* - 佣金:Σ成交额 × (佣金费率万分之m_dCommission),不足 5 元按 5 元(账号没免五,一次)
* - 印花税:卖出单边,Σ成交额 × 万分之5(2023-08-28 起标准)
* - 过户费:双向,Σ成交额 × 万分之0.1(2022-04-29 起标准,无最低限额)
* @param {Array} trades 该委托的成交列表(每笔含 amount/direction
* @param {number} commissionRateWan 佣金费率(万分之,来自 m_dCommission
* @returns {{ totalAmount: number, commission: number, stampTax: number, transferFee: number, total: number }}
*/
calcOrderFees(trades, commissionRateWan) {
const list = Array.isArray(trades) ? trades : [];
// 合并总成交额(同一委托分笔成交合并计费)
const totalAmount = list.reduce((s, t) => s + Math.max(0, +(t.amount ?? 0)), 0);
const direction = list[0]?.direction ?? '';
// 佣金:合并总额 × 费率,不足 5 元按 5 元(一次)
let commission = totalAmount * (commissionRateWan / 10000);
if (commission < 5 && commission > 0) commission = 5;
// 印花税:卖出单边,万分之5
const stampTax = direction === 'sell' ? totalAmount * 0.0005 : 0;
// 过户费:双向,万分之0.1
const transferFee = totalAmount * 0.00001;
return {
totalAmount: +totalAmount.toFixed(2),
commission: +commission.toFixed(4),
stampTax: +stampTax.toFixed(4),
transferFee: +transferFee.toFixed(4),
total: +(commission + stampTax + transferFee).toFixed(4),
};
}
/** 成交字段映射:m_ 原始字段 → 语义字段(R-007 2.1/2.2
* m_dCommission 语义(2026-09-01 老师定):佣金费率(万分之),如 0.852 = 万分之0.852 */
mapTrade(t) {
const direction = t.m_nOffsetFlag === 48 ? 'buy' : t.m_nOffsetFlag === 49 ? 'sell' : '';
return {
tradeId: t.m_strTradeID ?? '',
orderId: t.m_strOrderSysID ?? '', // 关联委托
code: this.normalizeInstrumentCode(t.m_strInstrumentID, t.m_strExchangeID),
name: t.m_strInstrumentName ?? '',
exchange: t.m_strExchangeID ?? '',
direction, // 'buy' | 'sell' | ''
directionCode: t.m_nOffsetFlag ?? null,
optName: t.m_strOptName ?? '',
price: +(t.m_dPrice ?? 0),
volume: t.m_nVolume ?? 0,
amount: +(t.m_dTradeAmount ?? 0),
commissionRateWan: +(t.m_dCommission ?? t.m_dComssion ?? 0), // 佣金费率(万分之)
tradeDate: t.m_strTradeDate ?? '',
tradeTime: t.m_strTradeTime ?? '',
};
}
/** 持仓字段映射:语义字段优先,回退 m_ 原始字段 */
mapPosition(p) {
return {
code: p.stock_code ?? p.m_strInstrumentID ?? '',
name: p.stock_name ?? p.m_strInstrumentName ?? '',
exchange: p.m_strExchangeName ?? '',
volume: p.volume ?? p.m_nVolume ?? 0,
available: p.available ?? p.m_nCanUseVolume ?? 0,
frozenVolume: p.frozen_volume ?? p.m_nFrozenVolume ?? 0,
avgPrice: +(p.avg_price ?? p.m_dAvgOpenPrice ?? 0),
price: +(p.price ?? p.m_dLastPrice ?? 0),
marketValue: +(p.market_value ?? p.m_dInstrumentValue ?? 0),
profit: +(p.profit ?? p.m_dFloatProfit ?? 0),
profitPct: +(p.profit_pct ?? (p.m_dProfitRate != null ? p.m_dProfitRate * 100 : 0)),
};
}
}