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 通过
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* 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/orders,R-007)
|
||||
* 过滤:code(600519 或 600519.SH)、status(active=排除已撤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/trades,R-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_dates,R-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.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,
|
||||
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)),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user