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:
2026-09-02 15:38:51 +08:00
parent f1e7e785a1
commit a4902bb730
31 changed files with 509 additions and 198 deletions
+282
View File
@@ -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/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)),
};
}
}