feat: 迭代03-05 交易记录接入、行情实时、QMT连接配置、data store 标准化
- R-004 QMT连接配置:多配置管理 + 会话头部快捷切换(03-QMT连接配置) - R-005 WS盘中价格实时更新:服务端中转+缓存+前端轮询(04-WS盘中价格实时更新) - R-006 策略数据 JSON data store 标准化(store.json/market.json/schema.json) - R-007 交易记录接入:当日订单+成交单表合并(委托主行+展开成交明细)、时间段查询(今日/本周/本月+手动起止)、费用计算(佣金费率万m_dCommission+最低5元、印花税卖出万5、过户费双向万0.1) - 架构治理:api 按领域拆分、component 目录、QmtHealthMonitor
This commit is contained in:
-157
@@ -1,157 +0,0 @@
|
||||
/**
|
||||
* 服务端 HTTP API:通过 webServer 注册 /odl/api/* 路由
|
||||
*
|
||||
* 为什么不用 RPC(connection.rpc.intercept('/api')):
|
||||
* DSH 的 /api 通道只能有一个 interceptor(api-gateway 已占用),
|
||||
* 再 intercept 会抛 "already has an interceptor" 导致插件 apply 失败。
|
||||
* 因此改用 webServer 自开路由(与 dsh-better-sidebar 的 /sidebar/api 同模式)。
|
||||
*
|
||||
* 端点(POST /odl/api/<method>,body = { args: {...} }):
|
||||
* positions → 全量持仓
|
||||
* strategies → 策略列表(含 visible)
|
||||
* strategies/update → 更新策略列表(整表)
|
||||
* strategies/add → 新增策略 { name }(O3)
|
||||
* strategies/remove → 删除策略 { strategyId },联动清份额(O3)
|
||||
* strategies/move → 排序 { strategyId, dir: up|down }(O3)
|
||||
* strategy-positions → 某策略持仓 { strategyId }
|
||||
* unallocated → 未分配持仓
|
||||
* summary → 分仓摘要
|
||||
* add-shares → 添加份额 { code, strategyId, shares }
|
||||
* move-all-shares → 全部移入 { code, strategyId }(O6)
|
||||
* remove-shares → 移出份额 { code, strategyId, shares }
|
||||
* remove-all-shares → 一键清零 { strategyId }(O6)
|
||||
*/
|
||||
|
||||
import { getStrategies, updateStrategies, addStrategy, removeStrategy, moveStrategy, getTabConfig, updateTabConfig } from './settings.js';
|
||||
|
||||
const API_PREFIX = '/odl/api/';
|
||||
|
||||
/** 响应 JSON */
|
||||
function writeJson(res, status, data) {
|
||||
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(data));
|
||||
}
|
||||
|
||||
/** 读取 JSON body */
|
||||
async function readJsonBody(req) {
|
||||
const chunks = [];
|
||||
for await (const chunk of req) chunks.push(chunk);
|
||||
const raw = Buffer.concat(chunks).toString('utf8');
|
||||
if (!raw) return {};
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** 端点方法表 */
|
||||
const METHODS = new Set([
|
||||
'positions',
|
||||
'strategies',
|
||||
'strategies/update',
|
||||
'strategies/add',
|
||||
'strategies/remove',
|
||||
'strategies/move',
|
||||
'strategy-positions',
|
||||
'unallocated',
|
||||
'summary',
|
||||
'add-shares',
|
||||
'move-all-shares',
|
||||
'remove-shares',
|
||||
'remove-all-shares',
|
||||
'tabs',
|
||||
'tabs/update',
|
||||
]);
|
||||
|
||||
/**
|
||||
* 注册 HTTP API
|
||||
* @param {import('@deepseek-ai/cordis').Context} ctx
|
||||
* @param {object} runtime { manager, settings }
|
||||
*/
|
||||
export function registerApi(ctx, { manager, settings }) {
|
||||
ctx.effect(() => ctx.webServer.register({
|
||||
kind: 'prefix',
|
||||
path: '/odl/api',
|
||||
handler: async (req, res) => {
|
||||
if (req.method !== 'POST') {
|
||||
writeJson(res, 405, { ok: false, error: { code: 'method-error', message: 'method not allowed' } });
|
||||
return;
|
||||
}
|
||||
const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname;
|
||||
const method = pathname.startsWith(API_PREFIX) ? pathname.slice(API_PREFIX.length) : void 0;
|
||||
if (!method || !METHODS.has(method)) {
|
||||
writeJson(res, 404, { ok: false, error: { code: 'not-found', message: 'unknown method: ' + method } });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const body = await readJsonBody(req);
|
||||
const args = body?.args ?? {};
|
||||
let value;
|
||||
switch (method) {
|
||||
case 'positions':
|
||||
value = await manager.getAllPositions();
|
||||
break;
|
||||
case 'strategies':
|
||||
value = getStrategies(settings);
|
||||
break;
|
||||
case 'strategies/update':
|
||||
await updateStrategies(settings, args.strategies);
|
||||
value = getStrategies(settings);
|
||||
break;
|
||||
case 'strategies/add':
|
||||
value = await addStrategy(settings, args.name);
|
||||
break;
|
||||
case 'strategies/remove':
|
||||
// 删除策略 + 联动清空该策略下所有份额(份额回未分配)
|
||||
await removeStrategy(settings, args.strategyId);
|
||||
await manager.clearStrategyShares(args.strategyId);
|
||||
value = getStrategies(settings);
|
||||
break;
|
||||
case 'strategies/move':
|
||||
value = await moveStrategy(settings, args.strategyId, args.dir);
|
||||
break;
|
||||
case 'tabs':
|
||||
value = getTabConfig(settings);
|
||||
break;
|
||||
case 'tabs/update':
|
||||
await updateTabConfig(settings, args.tabs);
|
||||
value = getTabConfig(settings);
|
||||
break;
|
||||
case 'strategy-positions':
|
||||
value = await manager.getStrategyPositions(args.strategyId);
|
||||
break;
|
||||
case 'unallocated':
|
||||
value = await manager.getUnallocatedPositions();
|
||||
break;
|
||||
case 'summary':
|
||||
value = await manager.getSummary();
|
||||
break;
|
||||
case 'add-shares':
|
||||
value = await manager.addToStrategy(args.code, args.strategyId, args.shares);
|
||||
break;
|
||||
case 'move-all-shares':
|
||||
value = await manager.moveAllUnallocatedToStrategy(args.code, args.strategyId);
|
||||
break;
|
||||
case 'remove-shares':
|
||||
value = await manager.removeFromStrategy(args.code, args.strategyId, args.shares);
|
||||
break;
|
||||
case 'remove-all-shares':
|
||||
value = await manager.clearStrategyShares(args.strategyId);
|
||||
break;
|
||||
default:
|
||||
writeJson(res, 404, { ok: false, error: { code: 'not-found', message: 'unknown method' } });
|
||||
return;
|
||||
}
|
||||
writeJson(res, 200, { ok: true, value });
|
||||
} catch (error) {
|
||||
writeJson(res, 500, {
|
||||
ok: false,
|
||||
error: { code: error?.code ?? 'internal', message: error?.message ?? String(error), details: {} },
|
||||
});
|
||||
}
|
||||
},
|
||||
}), 'one-divine-lot.api');
|
||||
|
||||
ctx.logger?.info('[one-divine-lot] HTTP API 已注册 (/odl/api/*)');
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* 服务端 HTTP API 公共工具
|
||||
*
|
||||
* 为什么不用 RPC(connection.rpc.intercept('/api')):
|
||||
* DSH 的 /api 通道只能有一个 interceptor(api-gateway 已占用),
|
||||
* 再 intercept 会抛 "already has an interceptor" 导致插件 apply 失败。
|
||||
* 因此改用 webServer 自开路由(与 dsh-better-sidebar 的 /sidebar/api 同模式)。
|
||||
*/
|
||||
|
||||
export const API_PREFIX = '/odl/api/';
|
||||
|
||||
/** 响应 JSON */
|
||||
export function writeJson(res, status, data) {
|
||||
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(data));
|
||||
}
|
||||
|
||||
/** 读取 JSON body */
|
||||
export async function readJsonBody(req) {
|
||||
const chunks = [];
|
||||
for await (const chunk of req) chunks.push(chunk);
|
||||
const raw = Buffer.concat(chunks).toString('utf8');
|
||||
if (!raw) return {};
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** 从激活配置解析 baseUrl(连接列表为空时回退数据源当前 baseUrl) */
|
||||
export function resolveActiveBaseUrl(settings, dataSource) {
|
||||
const state = getQmtConnections(settings);
|
||||
const active = getActiveQmtConnection(state);
|
||||
return String(active?.baseUrl ?? dataSource?.baseUrl ?? '').replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
// 延迟引入 settings 函数避免循环依赖
|
||||
import {
|
||||
getQmtConnections,
|
||||
getActiveQmtConnection,
|
||||
} from '../settings.js';
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* 服务端 HTTP API 入口(2026-08-31 从 api.js 按领域拆分)
|
||||
*
|
||||
* 结构:
|
||||
* src/api/
|
||||
* ├── index.js # 路由入口:合并各领域 + 公共处理(405/404/500)
|
||||
* ├── common.js # 公共工具(writeJson/readJsonBody/resolveActiveBaseUrl)
|
||||
* ├── positions.js # 持仓域(positions)
|
||||
* ├── strategies.js # 策略/份额/tabs 域
|
||||
* ├── qmt-connections.js # QMT 连接配置域(含热切换编排)
|
||||
* └── market.js # 行情域(market-snapshot)
|
||||
*
|
||||
* 端点(POST /odl/api/<method>,body = { args: {...} }):
|
||||
* positions → 全量持仓
|
||||
* strategies → 策略列表(含 visible)
|
||||
* strategies/update → 更新策略列表(整表)
|
||||
* strategies/add → 新增策略 { name }
|
||||
* strategies/remove → 删除策略 { strategyId },联动清份额
|
||||
* strategies/move → 排序 { strategyId, dir }
|
||||
* strategy-positions → 某策略持仓 { strategyId }
|
||||
* unallocated → 未分配持仓
|
||||
* summary → 分仓摘要
|
||||
* add-shares → 添加份额 { code, strategyId, shares }
|
||||
* move-all-shares → 全部移入 { code, strategyId }
|
||||
* remove-shares → 移出份额 { code, strategyId, shares }
|
||||
* remove-all-shares → 一键清零 { strategyId }
|
||||
* tabs / tabs/update → 通用 tab 显隐配置
|
||||
* qmt-connections → QMT 连接配置(R-004)
|
||||
* qmt-connections/* → 增删改/激活/默认/测试/active-host/订阅代理
|
||||
* market-snapshot → 按 code 查询行情缓存(R-005)
|
||||
*/
|
||||
|
||||
import { API_PREFIX, writeJson, readJsonBody } from './common.js';
|
||||
import { POSITION_METHODS, handlePosition } from './positions.js';
|
||||
import { STRATEGY_METHODS, handleStrategy } from './strategies.js';
|
||||
import { QMT_METHODS, handleQmt } from './qmt-connections.js';
|
||||
import { MARKET_METHODS, handleMarket } from './market.js';
|
||||
import { TRADE_METHODS, handleTrade } from './trades.js';
|
||||
|
||||
/** 全部端点方法表(合并各领域) */
|
||||
const METHODS = new Set([
|
||||
...POSITION_METHODS,
|
||||
...STRATEGY_METHODS,
|
||||
...QMT_METHODS,
|
||||
...MARKET_METHODS,
|
||||
...TRADE_METHODS,
|
||||
]);
|
||||
|
||||
/** 领域分发:method → handler */
|
||||
const HANDLERS = [
|
||||
{ set: POSITION_METHODS, handle: handlePosition },
|
||||
{ set: STRATEGY_METHODS, handle: handleStrategy },
|
||||
{ set: QMT_METHODS, handle: handleQmt },
|
||||
{ set: MARKET_METHODS, handle: handleMarket },
|
||||
{ set: TRADE_METHODS, handle: handleTrade },
|
||||
];
|
||||
|
||||
/**
|
||||
* 注册 HTTP API
|
||||
* @param {import('@deepseek-ai/cordis').Context} ctx
|
||||
* @param {object} runtime { manager, settings, dataSource, marketCache }
|
||||
*/
|
||||
export function registerApi(ctx, { manager, settings, dataSource, marketHub, marketFeed, qmtHealthMonitor }) {
|
||||
const runtime = { ctx, manager, settings, dataSource, marketHub, marketFeed, qmtHealthMonitor };
|
||||
|
||||
ctx.effect(() => ctx.webServer.register({
|
||||
kind: 'prefix',
|
||||
path: '/odl/api',
|
||||
handler: async (req, res) => {
|
||||
if (req.method !== 'POST') {
|
||||
writeJson(res, 405, { ok: false, error: { code: 'method-error', message: 'method not allowed' } });
|
||||
return;
|
||||
}
|
||||
const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname;
|
||||
const method = pathname.startsWith(API_PREFIX) ? pathname.slice(API_PREFIX.length) : void 0;
|
||||
if (!method || !METHODS.has(method)) {
|
||||
writeJson(res, 404, { ok: false, error: { code: 'not-found', message: 'unknown method: ' + method } });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const body = await readJsonBody(req);
|
||||
const args = body?.args ?? {};
|
||||
// 按领域分发
|
||||
let value;
|
||||
for (const { set, handle } of HANDLERS) {
|
||||
if (set.has(method)) {
|
||||
value = await handle(method, args, runtime);
|
||||
break;
|
||||
}
|
||||
}
|
||||
writeJson(res, 200, { ok: true, value });
|
||||
} catch (error) {
|
||||
writeJson(res, 500, {
|
||||
ok: false,
|
||||
error: { code: error?.code ?? 'internal', message: error?.message ?? String(error), details: {} },
|
||||
});
|
||||
}
|
||||
},
|
||||
}), 'one-divine-lot.api');
|
||||
|
||||
ctx.logger?.info('[one-divine-lot] HTTP API 已注册 (/odl/api/*)');
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* 服务端 API:行情域(2026-08-31 拆分;2026-09-01 精简)
|
||||
*
|
||||
* 端点:
|
||||
* market-snapshot → 按 code 查询行情缓存(miss 自动补拉在 MarketDataHub 内部,此处只做编排)
|
||||
* qmt-health → QMT 连接健康检查(读服务端缓存;refresh=true 触发即时探测)
|
||||
*
|
||||
* 2026-09-01:QMT health 改为「服务端定时探测(5 分钟)+ 缓存」机制(QmtHealthMonitor),
|
||||
* 前端打开读缓存秒回,点击/悬停传 refresh=true 触发即时探测。
|
||||
*/
|
||||
|
||||
import { resolveActiveBaseUrl } from './common.js';
|
||||
|
||||
/** 行情端点方法表 */
|
||||
export const MARKET_METHODS = new Set([
|
||||
'market-snapshot',
|
||||
'qmt-health',
|
||||
'market-stats', // 临时诊断:WS/REST 通道统计
|
||||
]);
|
||||
|
||||
/**
|
||||
* 处理行情端点
|
||||
* @param {string} method
|
||||
* @param {object} args
|
||||
* @param {object} runtime { settings, dataSource, marketHub, marketFeed, qmtHealthMonitor }
|
||||
*/
|
||||
export async function handleMarket(method, args, { ctx, settings, dataSource, marketHub, marketFeed, qmtHealthMonitor }) {
|
||||
switch (method) {
|
||||
case 'market-snapshot': {
|
||||
const codes = Array.isArray(args.codes) ? args.codes : [];
|
||||
if (!marketHub) return {};
|
||||
return await marketHub.getByCodes(codes, { settings, dataSource });
|
||||
}
|
||||
case 'qmt-health': {
|
||||
// QMT 连接健康检查(服务端缓存 + 即时探测,2026-09-01)
|
||||
if (!qmtHealthMonitor) {
|
||||
return { healthy: null, baseUrl: resolveActiveBaseUrl(settings, dataSource), fromCache: false };
|
||||
}
|
||||
if (args.refresh) {
|
||||
// 前端点击/悬停触发即时探测(刷新缓存)
|
||||
return await qmtHealthMonitor.probe();
|
||||
}
|
||||
// 默认读缓存(秒回)
|
||||
return qmtHealthMonitor.getStatus();
|
||||
}
|
||||
case 'market-stats': {
|
||||
// 临时诊断:WS 推送 vs REST 拉取 计数(确认 WS 是否生效)
|
||||
return {
|
||||
stats: marketHub?.stats ?? null,
|
||||
cacheSize: marketHub?.size ?? 0,
|
||||
watchSize: marketHub?.watchCodes?.size ?? 0,
|
||||
serverTime: Date.now(),
|
||||
};
|
||||
}
|
||||
default:
|
||||
throw Object.assign(new Error('unknown market method: ' + method), { code: 'not-found' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* 服务端 API:持仓域(从 api.js 拆分,2026-08-31)
|
||||
*
|
||||
* 端点:
|
||||
* positions → 全量持仓
|
||||
*/
|
||||
|
||||
/** 持仓端点方法表 */
|
||||
export const POSITION_METHODS = new Set([
|
||||
'positions',
|
||||
]);
|
||||
|
||||
/**
|
||||
* 处理持仓端点
|
||||
* @param {string} method
|
||||
* @param {object} args
|
||||
* @param {object} runtime { manager }
|
||||
*/
|
||||
export async function handlePosition(method, args, { manager }) {
|
||||
switch (method) {
|
||||
case 'positions':
|
||||
return await manager.getAllPositions();
|
||||
default:
|
||||
throw Object.assign(new Error('unknown position method: ' + method), { code: 'not-found' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* 服务端 API:QMT 连接配置域(从 api.js 拆分,2026-08-31)
|
||||
*
|
||||
* 端点:
|
||||
* qmt-connections / add / update / remove / activate / set-default / test
|
||||
* active-host
|
||||
*
|
||||
* 热切换编排:激活/更新/删除连接导致生效地址变化时,同步 dataSource.setBaseUrl + marketFeed.setBaseUrl
|
||||
* (技术约束-008:按请求读取 baseUrl,改字段即生效)
|
||||
*
|
||||
* 2026-08-31:移除 subscribe-ws / unsubscribe-ws / tick-snapshot(架构演进后无人调用,
|
||||
* 订阅与快照统一由 MarketFeed/MarketDataHub 内部处理,不再暴露为 HTTP 端点)
|
||||
*/
|
||||
|
||||
import {
|
||||
getQmtConnections, getActiveQmtConnection,
|
||||
addQmtConnection, updateQmtConnection, removeQmtConnection,
|
||||
activateQmtConnection, setDefaultQmtConnection,
|
||||
} from '../settings.js';
|
||||
|
||||
/** QMT 连接端点方法表 */
|
||||
export const QMT_METHODS = new Set([
|
||||
'qmt-connections',
|
||||
'qmt-connections/add',
|
||||
'qmt-connections/update',
|
||||
'qmt-connections/remove',
|
||||
'qmt-connections/activate',
|
||||
'qmt-connections/set-default',
|
||||
'qmt-connections/test',
|
||||
'qmt-connections/active-host',
|
||||
]);
|
||||
|
||||
/** 服务端代理测试连接:GET {baseUrl}/health(浏览器跨域规避,Q3 手动测试) */
|
||||
async function testQmtConnection(ctx, args) {
|
||||
const baseUrl = String(args?.baseUrl ?? '').trim().replace(/\/+$/, '');
|
||||
if (!/^https?:\/\//i.test(baseUrl)) {
|
||||
throw Object.assign(new Error('HTTP 地址格式无效(需以 http:// 或 https:// 开头)'), { code: 'qmt-invalid-url' });
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 15000);
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const res = await fetch(baseUrl + '/health', { signal: controller.signal });
|
||||
const latencyMs = Date.now() - startedAt;
|
||||
let status = null;
|
||||
try { status = (await res.json())?.status ?? null; } catch { /* 非 JSON 响应按 status=null */ }
|
||||
return { ok: res.ok, latencyMs, httpStatus: res.status, status, reachable: res.ok };
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
latencyMs: Date.now() - startedAt,
|
||||
reachable: false,
|
||||
error: err.name === 'AbortError' ? '请求超时(15s)' : (err?.message ?? String(err)),
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 QMT 连接端点
|
||||
* @param {string} method
|
||||
* @param {object} args
|
||||
* @param {object} runtime { manager, settings, dataSource, marketCache }
|
||||
*/
|
||||
export async function handleQmt(method, args, { ctx, settings, dataSource, marketFeed, qmtHealthMonitor }) {
|
||||
switch (method) {
|
||||
case 'qmt-connections': {
|
||||
const state = getQmtConnections(settings);
|
||||
return { ...state, active: getActiveQmtConnection(state) };
|
||||
}
|
||||
case 'qmt-connections/add':
|
||||
return await addQmtConnection(settings, { name: args.name, baseUrl: args.baseUrl });
|
||||
case 'qmt-connections/update': {
|
||||
const before = getQmtConnections(settings);
|
||||
const value = await updateQmtConnection(settings, args.id, { name: args.name, baseUrl: args.baseUrl });
|
||||
// 编辑的是激活配置且地址变化 → 立即热切换(技术约束-008)
|
||||
if (before.activeId === args.id && value.baseUrl !== dataSource.baseUrl) {
|
||||
dataSource.setBaseUrl(value.baseUrl);
|
||||
marketFeed?.setBaseUrl(value.baseUrl);
|
||||
qmtHealthMonitor?.probe(); // 切换后立即补探健康
|
||||
ctx?.logger?.info('[one-divine-lot] 激活配置地址已变更,数据源热切换 → ' + value.baseUrl);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
case 'qmt-connections/remove': {
|
||||
const result = await removeQmtConnection(settings, args.id);
|
||||
// 生效连接因删除发生变化 → 立即热切换(产品约束-005 删除边界)
|
||||
const afterState = getQmtConnections(settings);
|
||||
const active = getActiveQmtConnection(afterState);
|
||||
if (active && active.baseUrl !== dataSource.baseUrl) {
|
||||
dataSource.setBaseUrl(active.baseUrl);
|
||||
marketFeed?.setBaseUrl(active.baseUrl);
|
||||
qmtHealthMonitor?.probe(); // 切换后立即补探健康
|
||||
ctx?.logger?.info('[one-divine-lot] 激活配置已删除,自动切换 → ' + active.name + ' (' + active.baseUrl + ')');
|
||||
}
|
||||
return { ...afterState, active, removedWasActive: result.removedWasActive, removedWasDefault: result.removedWasDefault };
|
||||
}
|
||||
case 'qmt-connections/activate': {
|
||||
const state = await activateQmtConnection(settings, args.id);
|
||||
const active = getActiveQmtConnection(state);
|
||||
if (active && active.baseUrl !== dataSource.baseUrl) {
|
||||
dataSource.setBaseUrl(active.baseUrl);
|
||||
marketFeed?.setBaseUrl(active.baseUrl);
|
||||
qmtHealthMonitor?.probe(); // 切换后立即补探健康
|
||||
ctx?.logger?.info('[one-divine-lot] QMT 连接激活: ' + active.name + ' (' + active.baseUrl + ')');
|
||||
}
|
||||
return { ...state, active };
|
||||
}
|
||||
case 'qmt-connections/set-default':
|
||||
return await setDefaultQmtConnection(settings, args.id);
|
||||
case 'qmt-connections/test': {
|
||||
// 支持传未保存的 baseUrl(表单先测后存),或传 id 用已存配置地址
|
||||
let url = args.baseUrl;
|
||||
if (!url && args.id) {
|
||||
const state = getQmtConnections(settings);
|
||||
url = state.list.find((c) => c.id === args.id)?.baseUrl;
|
||||
}
|
||||
return await testQmtConnection(ctx, { baseUrl: url });
|
||||
}
|
||||
case 'qmt-connections/active-host': {
|
||||
// R-005:返回激活配置的 host 与 ws 地址
|
||||
const state = getQmtConnections(settings);
|
||||
const active = getActiveQmtConnection(state) ?? { baseUrl: dataSource.baseUrl, name: '(fallback)' };
|
||||
const baseUrl = String(active.baseUrl ?? '').replace(/\/+$/, '');
|
||||
let host = '';
|
||||
let wsUrl = '';
|
||||
try {
|
||||
const u = new URL(baseUrl);
|
||||
host = u.host; // 含端口,如 100.110.38.78:8610
|
||||
wsUrl = 'ws://' + u.host + '/ws';
|
||||
} catch { /* baseUrl 非法时返回空 */ }
|
||||
return { name: active.name, baseUrl, host, wsUrl, activeId: state.activeId ?? '' };
|
||||
}
|
||||
default:
|
||||
throw Object.assign(new Error('unknown qmt method: ' + method), { code: 'not-found' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* 服务端 API:策略 / 份额 / tabs 域(从 api.js 拆分,2026-08-31)
|
||||
*
|
||||
* 端点:
|
||||
* strategies / strategies/update / strategies/add / strategies/remove / strategies/move
|
||||
* strategy-positions / unallocated / summary
|
||||
* add-shares / move-all-shares / remove-shares / remove-all-shares
|
||||
* tabs / tabs/update
|
||||
*/
|
||||
|
||||
import {
|
||||
getStrategies, updateStrategies, addStrategy, removeStrategy, moveStrategy,
|
||||
getTabConfig, updateTabConfig,
|
||||
} from '../settings.js';
|
||||
|
||||
/** 策略/份额/tabs 端点方法表 */
|
||||
export const STRATEGY_METHODS = new Set([
|
||||
'strategies',
|
||||
'strategies/update',
|
||||
'strategies/add',
|
||||
'strategies/remove',
|
||||
'strategies/move',
|
||||
'strategy-positions',
|
||||
'unallocated',
|
||||
'summary',
|
||||
'add-shares',
|
||||
'move-all-shares',
|
||||
'remove-shares',
|
||||
'remove-all-shares',
|
||||
'tabs',
|
||||
'tabs/update',
|
||||
]);
|
||||
|
||||
/**
|
||||
* 处理策略/份额/tabs 端点
|
||||
* @param {string} method
|
||||
* @param {object} args
|
||||
* @param {object} runtime { manager, settings }
|
||||
*/
|
||||
export async function handleStrategy(method, args, { manager, settings }) {
|
||||
switch (method) {
|
||||
case 'strategies':
|
||||
return getStrategies(settings);
|
||||
case 'strategies/update':
|
||||
await updateStrategies(settings, args.strategies);
|
||||
return getStrategies(settings);
|
||||
case 'strategies/add':
|
||||
return await addStrategy(settings, args.name);
|
||||
case 'strategies/remove':
|
||||
// 删除策略 + 联动清空该策略下所有份额(份额回未分配)
|
||||
await removeStrategy(settings, args.strategyId);
|
||||
await manager.clearStrategyShares(args.strategyId);
|
||||
return getStrategies(settings);
|
||||
case 'strategies/move':
|
||||
return await moveStrategy(settings, args.strategyId, args.dir);
|
||||
case 'tabs':
|
||||
return getTabConfig(settings);
|
||||
case 'tabs/update':
|
||||
await updateTabConfig(settings, args.tabs);
|
||||
return getTabConfig(settings);
|
||||
case 'strategy-positions':
|
||||
return await manager.getStrategyPositions(args.strategyId);
|
||||
case 'unallocated':
|
||||
return await manager.getUnallocatedPositions();
|
||||
case 'summary':
|
||||
return await manager.getSummary();
|
||||
case 'add-shares':
|
||||
return await manager.addToStrategy(args.code, args.strategyId, args.shares);
|
||||
case 'move-all-shares':
|
||||
return await manager.moveAllUnallocatedToStrategy(args.code, args.strategyId);
|
||||
case 'remove-shares':
|
||||
return await manager.removeFromStrategy(args.code, args.strategyId, args.shares);
|
||||
case 'remove-all-shares':
|
||||
return await manager.clearStrategyShares(args.strategyId);
|
||||
default:
|
||||
throw Object.assign(new Error('unknown strategy method: ' + method), { code: 'not-found' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* 服务端 API:交易记录域(R-007,2026-09-01)
|
||||
*
|
||||
* 端点:
|
||||
* orders → 当日委托(支持 code/status 过滤)
|
||||
* trades → 当日成交
|
||||
* trading-dates → 交易日历(日期导航)
|
||||
*
|
||||
* 数据流:前端轮询 → /odl/api/orders|trades → dataSource.getOrders/getTrades
|
||||
* (服务端直连 QMT Bridge REST,技术约束-003)
|
||||
*/
|
||||
|
||||
/** 交易记录端点方法表 */
|
||||
export const TRADE_METHODS = new Set([
|
||||
'orders',
|
||||
'trades',
|
||||
'trading-dates',
|
||||
]);
|
||||
|
||||
/**
|
||||
* 处理交易记录端点
|
||||
* @param {string} method
|
||||
* @param {object} args
|
||||
* @param {object} runtime { dataSource }
|
||||
*/
|
||||
export async function handleTrade(method, args, { dataSource }) {
|
||||
switch (method) {
|
||||
case 'orders':
|
||||
return await dataSource.getOrders({
|
||||
code: args.code,
|
||||
status: args.status,
|
||||
start: args.start,
|
||||
end: args.end,
|
||||
});
|
||||
case 'trades':
|
||||
return await dataSource.getTrades({
|
||||
start: args.start,
|
||||
end: args.end,
|
||||
});
|
||||
case 'trading-dates':
|
||||
return await dataSource.getTradingDates({
|
||||
start: args.start,
|
||||
end: args.end,
|
||||
});
|
||||
default:
|
||||
throw Object.assign(new Error('unknown trade method: ' + method), { code: 'not-found' });
|
||||
}
|
||||
}
|
||||
+14
-3
@@ -15,8 +15,11 @@ import { createElement } from 'react';
|
||||
import { AllPositionsTab } from './views/AllPositionsTab.jsx';
|
||||
import { StrategyTab } from './views/StrategyTab.jsx';
|
||||
import { PlaceholderTab } from './views/PlaceholderTab.jsx';
|
||||
import { TradeRecordsTab } from './views/TradeRecordsTab.jsx';
|
||||
import { SettingsSection } from './views/SettingsSection.jsx';
|
||||
import { QmtConnectionChip } from './views/QmtConnectionChip.jsx';
|
||||
import { ConnectionProvider } from './views/connection.jsx';
|
||||
import { MarketDataProvider } from './market/MarketDataProvider.jsx';
|
||||
|
||||
export const inject = ['slots', 'connection'];
|
||||
|
||||
@@ -61,7 +64,7 @@ const GENERAL_TABS = [
|
||||
{ key: 'allPositions', id: 'odl-all-positions', label: '全部持仓', order: 10,
|
||||
render: (withConnection) => withConnection((props) => createElement(AllPositionsTab, props)) },
|
||||
{ key: 'tradeRecords', id: 'odl-trade-records', label: '交易记录', order: 11,
|
||||
render: (withConnection) => withConnection((props) => createElement(PlaceholderTab, { ...props, title: '交易记录' })) },
|
||||
render: (withConnection) => withConnection((props) => createElement(TradeRecordsTab, props)) },
|
||||
{ key: 'watchlist', id: 'odl-watchlist', label: '关注列表', order: 12,
|
||||
render: (withConnection) => withConnection((props) => createElement(PlaceholderTab, { ...props, title: '关注列表' })) },
|
||||
];
|
||||
@@ -73,7 +76,8 @@ export function apply(ctx) {
|
||||
}
|
||||
|
||||
const withConnection = (render) => (props) =>
|
||||
createElement(ConnectionProvider, { connection }, render(props));
|
||||
createElement(ConnectionProvider, { connection },
|
||||
createElement(MarketDataProvider, null, render(props)));
|
||||
|
||||
ctx.slots.register({
|
||||
name: 'settings.section',
|
||||
@@ -130,10 +134,17 @@ export function apply(ctx) {
|
||||
|
||||
registerAllTabs();
|
||||
|
||||
// R-004 Q9:会话头部快捷切换 chip(与 DSH 内置 PTC 标签 order=-10 并排,技术约束-009)
|
||||
ctx.slots.register({
|
||||
name: 'conversation.session.header.actions',
|
||||
id: 'odl-qmt-switch',
|
||||
order: -9,
|
||||
}, withConnection((props) => createElement(QmtConnectionChip, props)));
|
||||
|
||||
ctx.effect(() => {
|
||||
return () => {
|
||||
disposed = true;
|
||||
disposeAllTabs();
|
||||
};
|
||||
}, 'one-divine-lot.client');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* MarketDataProvider —— 行情数据提供者(R-005 服务端中转架构,2026-08-31)
|
||||
*
|
||||
* 架构(服务端中转,老师确认):
|
||||
* - DSH 服务端做「一次 WS 订阅 + QMT 桥整合」,维护一份实时行情缓存(MarketDataHub + 持久化);
|
||||
* - 前端**统一轮询** DSH 接口(/odl/api/market-snapshot,按 code 查询),不做前端直连;
|
||||
* - 轮询间隔 5s(老师确认)。
|
||||
*
|
||||
* 关注列表(2026-08-31 去重优化):不再自拉 positions,
|
||||
* 改由表格组件在渲染时通过 registerCodes(codes) 上报,Provider 只轮询行情。
|
||||
*
|
||||
* 2026-09-01 修复:registerCodes 上报新 code 后**立即触发一次行情拉取**(不等 5s 定时器),
|
||||
* 解决「打开持仓 tab 价格要等很久才出现」的时序问题。
|
||||
*/
|
||||
|
||||
import { createContext, useContext, useEffect, useReducer, useRef, useState, useCallback } from 'react';
|
||||
import { useRpc } from '../views/connection.jsx';
|
||||
|
||||
const MarketContext = createContext(null);
|
||||
|
||||
/** 连接状态(轮询模式) */
|
||||
export const MARKET_STATUS = {
|
||||
IDLE: 'idle',
|
||||
CONNECTING: 'connecting',
|
||||
LIVE: 'live',
|
||||
RECONNECTING: 'reconnecting',
|
||||
DISCONNECTED: 'disconnected',
|
||||
};
|
||||
|
||||
/** 价格 reducer:code → snapshot */
|
||||
function pricesReducer(state, action) {
|
||||
if (action.type === 'update') {
|
||||
const next = {};
|
||||
for (const [code, snap] of Object.entries(action.data || {})) {
|
||||
if (!snap) continue;
|
||||
next[code] = { ...state[code], ...snap };
|
||||
}
|
||||
return { ...state, ...next };
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
const POLL_INTERVAL_MS = 5000; // 轮询间隔(老师确认 5s)
|
||||
|
||||
export function MarketDataProvider({ children }) {
|
||||
const call = useRpc();
|
||||
|
||||
/** 统一 setStatus:同步 ref(供轮询闭包读取最新值) */
|
||||
const updateStatus = useCallback((s) => {
|
||||
statusRef.current = s;
|
||||
setStatus(s);
|
||||
}, []);
|
||||
|
||||
const [prices, dispatch] = useReducer(pricesReducer, {});
|
||||
const [status, setStatus] = useState(MARKET_STATUS.IDLE);
|
||||
const statusRef = useRef(MARKET_STATUS.IDLE);
|
||||
const mountedRef = useRef(true);
|
||||
const watchCodesRef = useRef(new Set());
|
||||
const pollMarketRef = useRef(null); // 供 registerCodes 触发立即拉取
|
||||
|
||||
/** 轮询行情缓存(按关注 code 查询,服务端中转) */
|
||||
const pollMarket = useCallback(async () => {
|
||||
const codes = [...watchCodesRef.current];
|
||||
if (codes.length === 0) return;
|
||||
try {
|
||||
const res = await call('one-divine-lot/market-snapshot', {
|
||||
args: { codes },
|
||||
});
|
||||
if (res?.ok && res.value) {
|
||||
const n = Object.keys(res.value).length;
|
||||
if (n > 0) {
|
||||
dispatch({ type: 'update', data: res.value });
|
||||
updateStatus(MARKET_STATUS.LIVE);
|
||||
} else {
|
||||
updateStatus(MARKET_STATUS.CONNECTING); // 缓存未填充
|
||||
}
|
||||
} else {
|
||||
console.warn('[odl-mkt] market-snapshot 失败:', JSON.stringify(res).slice(0, 200));
|
||||
updateStatus(MARKET_STATUS.RECONNECTING);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[odl-mkt] market-snapshot 异常:', e?.message);
|
||||
updateStatus(MARKET_STATUS.RECONNECTING);
|
||||
}
|
||||
}, [call]);
|
||||
|
||||
/** 表格上报关注代码;上报新 code 后立即拉一次行情(不等定时器) */
|
||||
const registerCodes = useCallback((codes) => {
|
||||
const list = Array.isArray(codes) ? codes : [];
|
||||
const next = new Set(watchCodesRef.current);
|
||||
let changed = false;
|
||||
for (const c of list) {
|
||||
if (c && !next.has(c)) { next.add(c); changed = true; }
|
||||
}
|
||||
if (changed) {
|
||||
watchCodesRef.current = next;
|
||||
// 立即触发一次行情拉取(首屏快速显示价格)
|
||||
pollMarketRef.current?.();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 同步 pollMarket 到 ref(供 registerCodes 调用)
|
||||
useEffect(() => {
|
||||
pollMarketRef.current = pollMarket;
|
||||
}, [pollMarket]);
|
||||
|
||||
/** 主循环:定时轮询行情(关注 code 由表格上报) */
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
let cancelled = false;
|
||||
|
||||
const poll = async () => {
|
||||
if (cancelled || !mountedRef.current) return;
|
||||
if (watchCodesRef.current.size === 0) {
|
||||
// 关注代码尚未上报(表格未加载)——保持 IDLE
|
||||
if (statusRef.current !== MARKET_STATUS.IDLE) updateStatus(MARKET_STATUS.IDLE);
|
||||
return;
|
||||
}
|
||||
await pollMarket();
|
||||
};
|
||||
|
||||
poll();
|
||||
const timer = setInterval(poll, POLL_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
mountedRef.current = false;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [pollMarket]);
|
||||
|
||||
const value = {
|
||||
prices,
|
||||
status,
|
||||
getPrice: useCallback((code) => prices[code] ?? null, [prices]),
|
||||
registerCodes,
|
||||
wsInfo: null,
|
||||
};
|
||||
|
||||
return (
|
||||
<MarketContext.Provider value={value}>
|
||||
{children}
|
||||
</MarketContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/** 消费价格 Map */
|
||||
export function useMarket() {
|
||||
return useContext(MarketContext);
|
||||
}
|
||||
@@ -6,20 +6,27 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRpc } from './connection.jsx';
|
||||
import { LoadState } from './LoadState.jsx';
|
||||
import { useMarket } from '../market/MarketDataProvider.jsx';
|
||||
import { PriceCell } from './PriceCell.jsx';
|
||||
|
||||
export function AllPositionsTab(props) {
|
||||
const [positions, setPositions] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const call = useRpc();
|
||||
const { getPrice, registerCodes } = useMarket();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await call('one-divine-lot/summary');
|
||||
if (res && res.ok) setPositions(res.value);
|
||||
else setError((res && res.error && res.error.message) || '加载失败');
|
||||
if (res && res.ok) {
|
||||
setPositions(res.value);
|
||||
// 上报持仓 code 给行情 Provider(去重:不再由 Provider 自拉 positions)
|
||||
const codes = (res.value?.positions ?? []).map((p) => p.code).filter(Boolean);
|
||||
registerCodes(codes);
|
||||
} else setError((res && res.error && res.error.message) || '加载失败');
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
@@ -40,6 +47,7 @@ export function AllPositionsTab(props) {
|
||||
<tr style={{ textAlign: 'left', borderBottom: '2px solid #ddd' }}>
|
||||
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>代码</th>
|
||||
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>名称</th>
|
||||
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>现价</th>
|
||||
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>总持仓</th>
|
||||
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>已分配</th>
|
||||
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>未分配</th>
|
||||
@@ -51,6 +59,7 @@ export function AllPositionsTab(props) {
|
||||
<tr key={p.code} style={{ borderBottom: '1px solid #eee' }}>
|
||||
<td style={{ padding: '8px 6px', lineHeight: '24px' }}>{p.code}</td>
|
||||
<td style={{ padding: '8px 6px', lineHeight: '24px' }}>{p.name}</td>
|
||||
<PriceCell price={getPrice(p.code)?.lastPrice} lastClose={getPrice(p.code)?.lastClose} />
|
||||
<td style={{ padding: '8px 6px', lineHeight: '24px' }}>{p.totalVolume}</td>
|
||||
<td style={{ padding: '8px 6px', lineHeight: '24px' }}>{p.allocated}</td>
|
||||
<td style={{ padding: '8px 6px', lineHeight: '24px', color: p.unallocated > 0 ? '#e65100' : '#2e7d32' }}>{p.unallocated}</td>
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* PriceCell —— 现价单元格(R-005,产品约束-007)
|
||||
*
|
||||
* - 显示 lastPrice(保留 2~3 位小数,按价格幅度自适应)
|
||||
* - 红涨绿跌(对比昨收 lastClose,A股习惯:涨=红 #d32f2f,跌=绿 #2e7d32,平=灰)
|
||||
* - 价格变化时轻微高亮(背景闪一下,1.2s 渐隐)
|
||||
* - 无价格数据显示 —
|
||||
*
|
||||
* 2026-09-01:移除 MarketStatusBar —— 前端表格不再显示行情状态条
|
||||
* (行情刷新是服务端职责,前端只拿结果,对行情状态彻底无感)
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
|
||||
function formatPrice(v) {
|
||||
if (v == null || !isFinite(v)) return '—';
|
||||
// 价格 ≥100 用 2 位,<100 用 3 位(A股常规)
|
||||
const digits = Math.abs(v) >= 100 ? 2 : 3;
|
||||
return v.toFixed(digits);
|
||||
}
|
||||
|
||||
/** 变化高亮:props.price 变化时触发一次闪动 */
|
||||
function useFlash(price) {
|
||||
const [flash, setFlash] = useState(0);
|
||||
const prevRef = useRef(price);
|
||||
useEffect(() => {
|
||||
if (prevRef.current !== price && price != null) {
|
||||
prevRef.current = price;
|
||||
setFlash((n) => n + 1);
|
||||
}
|
||||
}, [price]);
|
||||
return flash;
|
||||
}
|
||||
|
||||
export function PriceCell({ price, lastClose }) {
|
||||
const flash = useFlash(price);
|
||||
const hasPrice = price != null && isFinite(price);
|
||||
let color = '#999';
|
||||
if (hasPrice && lastClose != null && isFinite(lastClose)) {
|
||||
if (price > lastClose) color = '#d32f2f';
|
||||
else if (price < lastClose) color = '#2e7d32';
|
||||
else color = '#666';
|
||||
}
|
||||
return (
|
||||
<td
|
||||
style={{
|
||||
padding: '8px 6px',
|
||||
fontWeight: 'bold',
|
||||
color,
|
||||
whiteSpace: 'nowrap',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
animation: flash ? 'odl-price-flash 1.2s ease-out' : undefined,
|
||||
}}
|
||||
>
|
||||
{formatPrice(price)}
|
||||
{flash > 0 && <style>{'@keyframes odl-price-flash{0%{background:rgba(255,215,0,.5)}100%{background:transparent}}'}</style>}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* 会话头部 QMT 连接快捷切换 chip + 健康状态控件(R-004 Q9/Q10;2026-09-01 重做)
|
||||
*
|
||||
* - 挂载 slot:conversation.session.header.actions(与 DSH 内置 PTC 模式标签并排)
|
||||
* - 形态:
|
||||
* - chip「QMT: <激活配置名> ▾」:点开下拉列出全部配置(激活项勾选),仅切换激活
|
||||
* - chip 右侧独立「QMT 健康」控件:小圆点(绿=健康 / 红=异常),展示 QMT 连接状态
|
||||
* - 数据:qmt-health 端点(服务端代理 GET 激活配置 /health)
|
||||
*
|
||||
* 2026-09-01:移除全盘订阅状态标记(暂缓,重开筛选做);QMT health 检查独立保留。
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useRpc } from './connection.jsx';
|
||||
import { ToastProvider, useToast } from './Toast.jsx';
|
||||
|
||||
/** 下拉菜单项 */
|
||||
function MenuItem({ item, selected, onSelect }) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => onSelect(item)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, width: '100%', textAlign: 'left',
|
||||
padding: '8px 12px', border: 'none', cursor: 'pointer', background: selected ? '#e8f5e9' : 'transparent',
|
||||
fontSize: 13, color: '#333',
|
||||
}}
|
||||
onMouseEnter={(e) => { if (!selected) e.currentTarget.style.background = '#f5f5f5'; }}
|
||||
onMouseLeave={(e) => { if (!selected) e.currentTarget.style.background = 'transparent'; }}
|
||||
>
|
||||
<span style={{ width: 16, flex: 'none', color: '#2e7d32', fontWeight: 700 }}>{selected ? '✓' : ''}</span>
|
||||
<span style={{ flex: 1, minWidth: 0 }}>
|
||||
<span style={{ display: 'block', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{item.name}</span>
|
||||
<span style={{ display: 'block', fontSize: 11, color: '#999', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{item.baseUrl}</span>
|
||||
</span>
|
||||
{item.isDefault && <span style={{ flex: 'none', fontSize: 11, color: '#1565c0', border: '1px solid #90caf9', borderRadius: 8, padding: '0 6px' }}>默认</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* QMT 健康状态控件(2026-09-01)
|
||||
* - 位置:QMT 下拉框右侧
|
||||
* - 显示:绿灯(health OK)/ 红灯(health 异常)
|
||||
* - 每 5s 轮询 qmt-health
|
||||
*/
|
||||
function QmtHealthIndicator() {
|
||||
const [health, setHealth] = useState(null); // { healthy, latencyMs, ... } | null(未获取)
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const call = useRpc();
|
||||
|
||||
// 读服务端缓存(默认,秒回)
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const res = await call('one-divine-lot/qmt-health', {});
|
||||
if (res && res.ok) setHealth(res.value);
|
||||
// 失败保留上次状态
|
||||
} catch (e) { /* 静默 */ }
|
||||
}, [call]);
|
||||
|
||||
// 点击触发即时探测(refresh=true,刷新服务端缓存)
|
||||
const refresh = useCallback(async () => {
|
||||
if (refreshing) return;
|
||||
setRefreshing(true);
|
||||
try {
|
||||
const res = await call('one-divine-lot/qmt-health', { args: { refresh: true } });
|
||||
if (res && res.ok) setHealth(res.value);
|
||||
} catch (e) { /* 静默 */ }
|
||||
finally { setRefreshing(false); }
|
||||
}, [call, refreshing]);
|
||||
|
||||
useEffect(() => {
|
||||
load(); // 打开即读缓存
|
||||
const timer = setInterval(load, 30000); // 30s 轮询缓存(服务端 5 分钟更新,前端低频同步)
|
||||
return () => clearInterval(timer);
|
||||
}, [load]);
|
||||
|
||||
// 状态推导:null=灰(未知/加载中),healthy=true=绿,false=红
|
||||
const color = health === null ? '#bbb' : (health.healthy ? '#2e7d32' : '#d32f2f');
|
||||
const title = health === null
|
||||
? 'QMT 健康状态获取中…(点击立即检测)'
|
||||
: (health.healthy
|
||||
? 'QMT 连接健康(' + (health.latencyMs ?? '-') + 'ms,检测于 ' + new Date(health.checkedAt ?? Date.now()).toLocaleTimeString() + ',点击重新检测)'
|
||||
: 'QMT 连接异常' + (health.healthError ? ':' + String(health.healthError).slice(0, 40) : '') + '(点击重新检测)');
|
||||
|
||||
return (
|
||||
<span
|
||||
title={title}
|
||||
onClick={refresh}
|
||||
role="button"
|
||||
aria-label="QMT 健康状态(点击重新检测)"
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 10, height: 10, borderRadius: '50%', background: color, flex: 'none',
|
||||
boxShadow: color !== '#bbb' ? '0 0 4px ' + color : 'none',
|
||||
cursor: 'pointer',
|
||||
opacity: refreshing ? 0.5 : 1,
|
||||
transition: 'opacity .2s',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ChipInner() {
|
||||
const [state, setState] = useState(null); // { list, activeId, defaultId, active }
|
||||
const [open, setOpen] = useState(false);
|
||||
const [switching, setSwitching] = useState(false);
|
||||
const rootRef = useRef(null);
|
||||
const call = useRpc();
|
||||
const toast = useToast();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const res = await call('one-divine-lot/qmt-connections', {});
|
||||
if (res && res.ok) setState(res.value);
|
||||
} catch (e) { /* 头部控件静默失败,不打扰会话 */ }
|
||||
}, [call]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
// 外点关闭
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDocClick = (e) => {
|
||||
if (rootRef.current && !rootRef.current.contains(e.target)) setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', onDocClick);
|
||||
return () => document.removeEventListener('mousedown', onDocClick);
|
||||
}, [open]);
|
||||
|
||||
const activate = async (item) => {
|
||||
setOpen(false);
|
||||
if (!item || switching) return;
|
||||
if (state && item.id === state.activeId) return;
|
||||
setSwitching(true);
|
||||
try {
|
||||
const res = await call('one-divine-lot/qmt-connections/activate', { args: { id: item.id } });
|
||||
if (res && res.ok) {
|
||||
setState(res.value);
|
||||
toast.success('QMT 连接已切换:' + item.name);
|
||||
} else {
|
||||
toast.error((res && res.error && res.error.message) || '切换失败');
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(e.message);
|
||||
} finally {
|
||||
setSwitching(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!state) return null; // 服务端未就绪/加载失败时不显示
|
||||
|
||||
const active = state.active || state.list.find((c) => c.id === state.activeId) || null;
|
||||
const label = active ? active.name : (state.list.length === 0 ? '未配置' : '未激活');
|
||||
|
||||
return (
|
||||
<div ref={rootRef} style={{ position: 'relative', display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
<button
|
||||
type="button"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
disabled={switching}
|
||||
onClick={() => setOpen(!open)}
|
||||
title="QMT 连接快捷切换(管理配置请进 设置 → 神之一手 → QMT 连接配置)"
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4, maxWidth: 180, height: 22,
|
||||
padding: '0 8px', borderRadius: 6, border: 'none', cursor: 'pointer', fontSize: 12,
|
||||
lineHeight: '22px', color: 'var(--dsw-alias-label-secondary, #666)',
|
||||
background: 'var(--dsw-alias-fill-tsp-secondary, rgba(0,0,0,0.05))',
|
||||
whiteSpace: 'nowrap', overflow: 'hidden',
|
||||
opacity: switching ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: 600 }}>QMT:</span>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{label}</span>
|
||||
<span style={{ fontSize: 10, flex: 'none' }}>▾</span>
|
||||
</button>
|
||||
{/* QMT 健康状态控件(下拉框右侧,绿灯=健康/红灯=异常) */}
|
||||
<QmtHealthIndicator />
|
||||
{open && (
|
||||
<div
|
||||
role="menu"
|
||||
style={{
|
||||
position: 'absolute', top: 'calc(100% + 4px)', right: 0, zIndex: 10001,
|
||||
minWidth: 220, maxWidth: 300, background: '#fff', border: '1px solid #ddd',
|
||||
borderRadius: 8, boxShadow: '0 4px 16px rgba(0,0,0,0.15)', padding: '4px 0',
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: '4px 12px', fontSize: 11, color: '#999' }}>切换 QMT 连接</div>
|
||||
{state.list.length === 0 ? (
|
||||
<div style={{ padding: '10px 12px', fontSize: 12, color: '#888' }}>
|
||||
暂无连接配置,请进 设置 → 神之一手 → QMT 连接配置 添加
|
||||
</div>
|
||||
) : (
|
||||
state.list.map((c) => (
|
||||
<MenuItem
|
||||
key={c.id}
|
||||
item={{ ...c, isDefault: c.id === state.defaultId }}
|
||||
selected={c.id === state.activeId}
|
||||
onSelect={activate}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
<div style={{ borderTop: '1px solid #eee', marginTop: 4, padding: '6px 12px', fontSize: 11, color: '#999' }}>
|
||||
管理配置请进 设置 → 神之一手 → QMT 连接配置
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 导出(包 ToastProvider,与 SettingsSection 同模式) */
|
||||
export function QmtConnectionChip(props) {
|
||||
return (
|
||||
<ToastProvider>
|
||||
<ChipInner {...props} />
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* RangeSelector —— 交易记录时间段选择(R-007 v4,2026-09-01 老师拍板)
|
||||
*
|
||||
* 交互模型(2026-09-01 老师纠正:操作 → 状态 → UI,单向,不做反向推断):
|
||||
* - 用户先有操作(点「本月」/「本周」/「今日」或手动改日期),才有时间范围
|
||||
* - **preset 意图状态**记录用户点了哪个快捷按钮('today' | 'thisWeek' | 'thisMonth' | null)
|
||||
* - 按钮高亮 = preset 匹配(单向:操作 → preset → UI 高亮),不反向从 range 推断
|
||||
* - 手动改起止日期 → 清除 preset(不再是任何快捷按钮的操作)
|
||||
* - 默认加载今日 = preset 'today'
|
||||
*
|
||||
* 快捷范围(完整时间段,互不重叠):
|
||||
* - 今日:lastDate ~ lastDate(单日)
|
||||
* - 本周:lastDate 所在自然周 周一 ~ 周五(交易日,不考虑周六日;2026-09-01 老师纠正)
|
||||
* - 本月:lastDate 所在自然月 1 号 ~ 月末(完整月)
|
||||
*
|
||||
* 数据来源:/odl/api/trading-dates(交易日历)
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useRpc } from './connection.jsx';
|
||||
|
||||
const PAST_DAYS = 45; // 向前取多少个自然日
|
||||
const FUTURE_DAYS = 7; // 向后取多少个自然日
|
||||
|
||||
/** 格式化 YYYYMMDD → YYYY-MM-DD(input type=date 需要) */
|
||||
export function fmt(dateStr) {
|
||||
if (!dateStr || dateStr.length !== 8) return dateStr ?? '';
|
||||
return dateStr.slice(0, 4) + '-' + dateStr.slice(4, 6) + '-' + dateStr.slice(6, 8);
|
||||
}
|
||||
|
||||
/** YYYY-MM-DD → YYYYMMDD */
|
||||
function toCompact(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
return dateStr.replace(/-/g, '');
|
||||
}
|
||||
|
||||
/** 当前本地日期 → YYYYMMDD */
|
||||
function todayStr() {
|
||||
const d = new Date();
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return y + m + day;
|
||||
}
|
||||
|
||||
/** 日期偏移(日历日):YYYYMMDD ± n 天 */
|
||||
function shiftDate(dateStr, days) {
|
||||
const d = new Date(
|
||||
Number(dateStr.slice(0, 4)),
|
||||
Number(dateStr.slice(4, 6)) - 1,
|
||||
Number(dateStr.slice(6, 8)) + days
|
||||
);
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return y + m + day;
|
||||
}
|
||||
|
||||
/** YYYYMMDD → 该日所在周的周一(自然周) */
|
||||
function weekMonday(dateStr) {
|
||||
const d = new Date(Number(dateStr.slice(0, 4)), Number(dateStr.slice(4, 6)) - 1, Number(dateStr.slice(6, 8)));
|
||||
const day = d.getDay(); // 0=周日 1=周一 ...
|
||||
const diff = day === 0 ? -6 : 1 - day; // 周一偏移
|
||||
return shiftDate(dateStr, diff);
|
||||
}
|
||||
|
||||
/** YYYYMMDD → 该日所在周的周五(周一+4;本周=周一~周五,不考虑周六日) */
|
||||
function weekFriday(dateStr) {
|
||||
return shiftDate(weekMonday(dateStr), 4);
|
||||
}
|
||||
|
||||
/** YYYYMMDD → 该日所在月的最后一天 */
|
||||
function monthLastDay(dateStr) {
|
||||
const y = Number(dateStr.slice(0, 4));
|
||||
const m = Number(dateStr.slice(4, 6));
|
||||
// 下月 0 号 = 本月最后一天
|
||||
const d = new Date(y, m, 0);
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
return String(d.getFullYear()) + mm + dd;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} props
|
||||
* @param {string|null} preset 快捷意图:'today' | 'thisWeek' | 'thisMonth' | null(手动自定义)
|
||||
* @param {(p: string|null) => void} onPresetChange 快捷意图变化回调
|
||||
* @param {{start: string, end: string}|null} range 当前时间段(YYYYMMDD)
|
||||
* @param {(r: {start: string, end: string}) => void} onChange 时间段变化回调
|
||||
* @param {(last: string) => void} [onLastDate] 最近交易日通知
|
||||
*/
|
||||
export function RangeSelector({ preset, onPresetChange, range, onChange, onLastDate }) {
|
||||
const [dates, setDates] = useState([]);
|
||||
const [error, setError] = useState(null);
|
||||
const call = useRpc();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setError(null);
|
||||
try {
|
||||
const today = todayStr();
|
||||
const res = await call('one-divine-lot/trading-dates', {
|
||||
args: { start: shiftDate(today, -PAST_DAYS), end: shiftDate(today, FUTURE_DAYS) },
|
||||
});
|
||||
if (res && res.ok && Array.isArray(res.value?.dates)) {
|
||||
setDates(res.value.dates.filter((d) => d >= shiftDate(today, -PAST_DAYS) && d <= shiftDate(today, FUTURE_DAYS)).sort());
|
||||
} else {
|
||||
setError((res && res.error && res.error.message) || '交易日历加载失败');
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
}, [call]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
// 最近交易日 = 列表最后一个
|
||||
const lastDate = dates.length ? dates[dates.length - 1] : null;
|
||||
|
||||
// 通知最近交易日
|
||||
useEffect(() => {
|
||||
if (lastDate && onLastDate) onLastDate(lastDate);
|
||||
}, [lastDate, onLastDate]);
|
||||
|
||||
// 快捷范围计算(完整互斥时间段)
|
||||
const quickRanges = useMemo(() => {
|
||||
if (!lastDate) return null;
|
||||
return {
|
||||
today: { start: lastDate, end: lastDate },
|
||||
thisWeek: { start: weekMonday(lastDate), end: weekFriday(lastDate) },
|
||||
thisMonth: { start: lastDate.slice(0, 6) + '01', end: monthLastDay(lastDate) },
|
||||
};
|
||||
}, [lastDate]);
|
||||
|
||||
// 操作 → 意图 + 范围(单向)
|
||||
const applyPreset = (key) => {
|
||||
const q = quickRanges;
|
||||
if (!q) return;
|
||||
onPresetChange(key);
|
||||
onChange({ start: q[key].start, end: q[key].end });
|
||||
};
|
||||
|
||||
// 手动改日期 → 清除意图(单向:手动操作不再是任何快捷按钮)
|
||||
const onStartChange = (e) => {
|
||||
const v = toCompact(e.target.value);
|
||||
if (!v) return;
|
||||
if (preset) onPresetChange(null);
|
||||
onChange({ ...(range ?? {}), start: v });
|
||||
};
|
||||
const onEndChange = (e) => {
|
||||
const v = toCompact(e.target.value);
|
||||
if (!v) return;
|
||||
if (preset) onPresetChange(null);
|
||||
onChange({ ...(range ?? {}), end: v });
|
||||
};
|
||||
|
||||
if (error || !lastDate) {
|
||||
return (
|
||||
<div style={{ fontSize: 13, color: '#c62828' }}>
|
||||
{error || '交易日历加载中...'}
|
||||
<button onClick={load} style={{ marginLeft: 8, padding: '2px 10px', cursor: 'pointer' }}>重试</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 高亮 = preset 意图(单向),不是 range 反推
|
||||
const activeToday = preset === 'today';
|
||||
const activeThisWeek = preset === 'thisWeek';
|
||||
const activeThisMonth = preset === 'thisMonth';
|
||||
|
||||
const btnStyle = (active) => ({
|
||||
padding: '2px 10px',
|
||||
fontSize: 12,
|
||||
cursor: 'pointer',
|
||||
border: active ? '1px solid #2e7d32' : '1px solid #ccc',
|
||||
borderRadius: 4,
|
||||
background: active ? '#e8f5e9' : '#fff',
|
||||
color: active ? '#1b5e20' : '#555',
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
|
||||
const inputStyle = {
|
||||
fontSize: 12,
|
||||
padding: '2px 4px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: 4,
|
||||
width: 112,
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'nowrap', whiteSpace: 'nowrap' }}>
|
||||
<button style={btnStyle(activeToday)} onClick={() => applyPreset('today')} title="最近交易日">今日</button>
|
||||
<button style={btnStyle(activeThisWeek)} onClick={() => applyPreset('thisWeek')} title="本周一至本周五(不考虑周六日)">本周</button>
|
||||
<button style={btnStyle(activeThisMonth)} onClick={() => applyPreset('thisMonth')} title="本月1号至本月最后一天(完整自然月)">本月</button>
|
||||
<span style={{ color: '#999' }}>|</span>
|
||||
<input type="date" value={range ? fmt(range.start) : ''} onChange={onStartChange} style={inputStyle} title="起始日期" />
|
||||
<span style={{ color: '#999' }}>~</span>
|
||||
<input type="date" value={range ? fmt(range.end) : ''} onChange={onEndChange} style={inputStyle} title="结束日期" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { todayStr, shiftDate, weekMonday, weekFriday, monthLastDay };
|
||||
@@ -2,7 +2,7 @@
|
||||
* 神之一手设置 section(迭代 02:设置 tab 化扩展)
|
||||
*
|
||||
* 结构:
|
||||
* - 两个子 tab:通用设置 / 策略分组
|
||||
* - 三个子 tab:通用设置 / 策略分组 / QMT 连接配置(迭代 03,R-004)
|
||||
* - 通用设置:三个 switch(全部持仓 / 交易记录 / 关注列表),控制对应会话 tab 显隐,切换后提示「刷新页面后生效」
|
||||
* - 策略分组:策略 CRUD(新增/重命名/删除确认/排序/显隐)+ 就地提示
|
||||
*/
|
||||
@@ -352,9 +352,9 @@ function StrategyGroupSettings() {
|
||||
);
|
||||
}
|
||||
|
||||
/** 设置 section 主组件:两个子 tab */
|
||||
/** 设置 section 主组件:三个子 tab */
|
||||
function SettingsSectionInner(props) {
|
||||
const [activeTab, setActiveTab] = useState('general'); // general | strategies
|
||||
const [activeTab, setActiveTab] = useState('general'); // general | strategies | qmt
|
||||
|
||||
return (
|
||||
<div style={{ padding: 20, fontFamily: 'system-ui' }}>
|
||||
@@ -365,6 +365,7 @@ function SettingsSectionInner(props) {
|
||||
{[
|
||||
{ key: 'general', label: '通用设置' },
|
||||
{ key: 'strategies', label: '策略分组' },
|
||||
{ key: 'qmt', label: 'QMT 连接配置' },
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
@@ -383,6 +384,7 @@ function SettingsSectionInner(props) {
|
||||
// 通用设置变更 → 就地提示(由 GeneralSettings 内 toast 提示,这里可额外提示)
|
||||
}} />}
|
||||
{activeTab === 'strategies' && <StrategyGroupSettings />}
|
||||
{activeTab === 'qmt' && <QmtConnectionsSettings />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -394,4 +396,327 @@ export function SettingsSection(props) {
|
||||
<SettingsSectionInner {...props} />
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** 长文本省略单元格:单行省略 + 悬停显示完整内容(避免错误信息等长文本撑行换行) */
|
||||
function EllipsisText({ text, maxWidth = 220, title, style }) {
|
||||
return (
|
||||
<span title={title || text} style={{
|
||||
display: 'inline-block', maxWidth, overflow: 'hidden',
|
||||
textOverflow: 'ellipsis', whiteSpace: 'nowrap', verticalAlign: 'bottom',
|
||||
...style,
|
||||
}}>{text}</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** QMT 连接配置卡片(R-004;产品约束-005、UI约束-002;2026-08-29 老师反馈改卡片形式) */
|
||||
function QmtConnectionCard({ conn, isActive, isDefault, busy, testResult, onActivate, onSetDefault, onTest, onEdit, onDelete }) {
|
||||
const btn = { padding: '3px 10px', cursor: 'pointer', border: '1px solid #ccc', borderRadius: 4, background: '#fff', fontSize: 12 };
|
||||
return (
|
||||
<div style={{
|
||||
border: isActive ? '1px solid #2e7d32' : '1px solid #ddd',
|
||||
background: isActive ? '#f1f8f2' : '#fff',
|
||||
borderRadius: 8,
|
||||
padding: '12px 14px',
|
||||
display: 'flex', flexDirection: 'column', gap: 8,
|
||||
minWidth: 0, overflow: 'hidden',
|
||||
boxShadow: '0 1px 2px rgba(0,0,0,0.04)',
|
||||
}}>
|
||||
{/* 标题行:名称 + 徽标 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, minWidth: 0 }}>
|
||||
<EllipsisText text={conn.name} maxWidth={140} style={{ fontWeight: 600, fontSize: 14 }} />
|
||||
{isActive && <span style={{ flex: 'none', fontSize: 11, color: '#2e7d32', border: '1px solid #2e7d32', borderRadius: 8, padding: '0 6px' }}>生效中</span>}
|
||||
{isDefault && <span style={{ flex: 'none', fontSize: 11, color: '#1565c0', border: '1px solid #90caf9', borderRadius: 8, padding: '0 6px' }}>默认</span>}
|
||||
</div>
|
||||
|
||||
{/* 地址行:省略+悬停 */}
|
||||
<div style={{ fontSize: 12, color: '#666', minWidth: 0 }}>
|
||||
<EllipsisText text={conn.baseUrl} maxWidth={240} />
|
||||
</div>
|
||||
|
||||
{/* 测试结果行:可换行但受卡片约束;超长悬停看全文 */}
|
||||
{testResult && (
|
||||
<div style={{
|
||||
fontSize: 12, lineHeight: '16px', color: testResult.ok ? '#2e7d32' : '#c62828',
|
||||
background: testResult.ok ? '#e8f5e9' : '#fdecea',
|
||||
borderRadius: 4, padding: '4px 8px', minWidth: 0,
|
||||
}}>
|
||||
<EllipsisText text={testResult.text} maxWidth={9999} title={testResult.fullText || testResult.text} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 操作行:按钮自动换行排列,不挤压卡片 */}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 2 }}>
|
||||
{!isActive && <button onClick={onActivate} disabled={busy} style={{ ...btn, color: '#2e7d32', borderColor: '#2e7d32' }}>激活</button>}
|
||||
{!isDefault && <button onClick={onSetDefault} disabled={busy} style={btn}>设为默认</button>}
|
||||
<button onClick={onTest} disabled={busy}>{busy ? '测试中…' : '测试连接'}</button>
|
||||
<button onClick={onEdit} disabled={busy} style={btn}>编辑</button>
|
||||
<button onClick={onDelete} disabled={busy} style={{ ...btn, color: '#c62828', borderColor: '#c62828' }}>删除</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** QMT 连接配置子 tab —— 卡片版(R-004;产品约束-005、UI约束-002) */
|
||||
function QmtConnectionsSettings() {
|
||||
const [state, setState] = useState(null); // { list, activeId, defaultId, active }
|
||||
const [error, setError] = useState(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testingId, setTestingId] = useState(null);
|
||||
const [testResult, setTestResult] = useState(null); // { key, ok, text, fullText }
|
||||
const [confirmDelete, setConfirmDelete] = useState(null);
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [formName, setFormName] = useState('');
|
||||
const [formUrl, setFormUrl] = useState('');
|
||||
const call = useRpc();
|
||||
const toast = useToast();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const res = await call('one-divine-lot/qmt-connections', {});
|
||||
if (res && res.ok) setState(res.value);
|
||||
else setError((res && res.error && res.error.message) || '加载失败');
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
}, [call]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const openAdd = () => { setEditingId(null); setFormName(''); setFormUrl(''); setFormOpen(true); setTestResult(null); };
|
||||
const openEdit = (c) => { setEditingId(c.id); setFormName(c.name); setFormUrl(c.baseUrl); setFormOpen(true); setTestResult(null); };
|
||||
const closeForm = () => { setFormOpen(false); setEditingId(null); };
|
||||
|
||||
const validateUrl = (raw) => {
|
||||
const url = String(raw || '').trim().replace(/\/+$/, '');
|
||||
if (!/^https?:\/\//i.test(url) || url.length <= 8) return null;
|
||||
return url;
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const name = formName.trim();
|
||||
const url = validateUrl(formUrl);
|
||||
if (!name) { toast.error('请输入连接名称'); return; }
|
||||
if (!url) { toast.error('HTTP 地址格式无效(需以 http:// 或 https:// 开头)'); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = editingId === null
|
||||
? await call('one-divine-lot/qmt-connections/add', { args: { name, baseUrl: url } })
|
||||
: await call('one-divine-lot/qmt-connections/update', { args: { id: editingId, name, baseUrl: url } });
|
||||
if (res && res.ok) {
|
||||
closeForm();
|
||||
toast.success(editingId === null ? '连接配置已添加' : '连接配置已保存');
|
||||
await load();
|
||||
} else {
|
||||
toast.error((res && res.error && res.error.message) || '保存失败');
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(e.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleActivate = async (c) => {
|
||||
if (state && c.id === state.activeId) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await call('one-divine-lot/qmt-connections/activate', { args: { id: c.id } });
|
||||
if (res && res.ok) {
|
||||
setState(res.value);
|
||||
toast.success('已激活:' + c.name + '(立即生效)');
|
||||
} else {
|
||||
toast.error((res && res.error && res.error.message) || '激活失败');
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(e.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetDefault = async (c) => {
|
||||
if (state && c.id === state.defaultId) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await call('one-divine-lot/qmt-connections/set-default', { args: { id: c.id } });
|
||||
if (res && res.ok) {
|
||||
setState((prev) => prev ? { ...prev, defaultId: c.id } : prev);
|
||||
toast.success('已设为默认:' + c.name + '(启动时自动激活)');
|
||||
} else {
|
||||
toast.error((res && res.error && res.error.message) || '设置默认失败');
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(e.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const showTest = (key, v) => {
|
||||
const text = v.reachable
|
||||
? '可达 · ' + v.latencyMs + 'ms' + (v.status ? ' · status=' + v.status : '')
|
||||
: '不可达 · ' + (v.error || ('HTTP ' + v.httpStatus)) + ' · ' + v.latencyMs + 'ms';
|
||||
setTestResult({ key, ok: v.reachable === true, text, fullText: text });
|
||||
};
|
||||
|
||||
const handleTest = async (c) => {
|
||||
setTestingId(c.id);
|
||||
try {
|
||||
const res = await call('one-divine-lot/qmt-connections/test', { args: { id: c.id } });
|
||||
if (res && res.ok) showTest(c.id, res.value);
|
||||
else {
|
||||
const msg = (res && res.error && res.error.message) || '测试失败';
|
||||
setTestResult({ key: c.id, ok: false, text: '不可达 · ' + msg, fullText: msg });
|
||||
}
|
||||
} catch (e) {
|
||||
setTestResult({ key: c.id, ok: false, text: '不可达 · ' + e.message, fullText: e.message });
|
||||
} finally {
|
||||
setTestingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestForm = async () => {
|
||||
const url = validateUrl(formUrl);
|
||||
if (!url) { toast.error('HTTP 地址格式无效(需以 http:// 或 https:// 开头)'); return; }
|
||||
setTestingId('__form__');
|
||||
try {
|
||||
const res = await call('one-divine-lot/qmt-connections/test', { args: { baseUrl: url } });
|
||||
if (res && res.ok) showTest('__form__', res.value);
|
||||
else {
|
||||
const msg = (res && res.error && res.error.message) || '测试失败';
|
||||
setTestResult({ key: '__form__', ok: false, text: '不可达 · ' + msg, fullText: msg });
|
||||
}
|
||||
} catch (e) {
|
||||
setTestResult({ key: '__form__', ok: false, text: '不可达 · ' + e.message, fullText: e.message });
|
||||
} finally {
|
||||
setTestingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await call('one-divine-lot/qmt-connections/remove', { args: { id } });
|
||||
setConfirmDelete(null);
|
||||
if (res && res.ok) {
|
||||
setState(res.value);
|
||||
const msgs = [];
|
||||
if (res.value.removedWasActive) msgs.push('激活已自动切换');
|
||||
if (res.value.removedWasDefault) msgs.push('默认标记已转移');
|
||||
toast.success('连接配置已删除' + (msgs.length ? '(' + msgs.join(';') + ')' : ''));
|
||||
} else {
|
||||
toast.error((res && res.error && res.error.message) || '删除失败');
|
||||
}
|
||||
} catch (e) {
|
||||
setConfirmDelete(null);
|
||||
toast.error(e.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 13, color: '#666', minWidth: 0 }}>
|
||||
激活配置为当前生效连接(切换立即生效);默认配置在插件启动时自动激活。
|
||||
</div>
|
||||
<button onClick={openAdd} disabled={saving} style={{ flex: 'none', padding: '6px 16px', cursor: 'pointer', border: 'none', borderRadius: 4, background: '#2e7d32', color: '#fff' }}>
|
||||
+ 新增连接
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div style={{ color: '#c62828', marginBottom: 12 }}><EllipsisText text={error} maxWidth={560} /></div>}
|
||||
{!state && !error && <div style={{ color: '#666' }}>加载中...</div>}
|
||||
|
||||
{state && state.list.length === 0 && (
|
||||
<div style={{ padding: '24px 0', color: '#888', fontSize: 13 }}>
|
||||
暂无连接配置。点击「新增连接」录入第一个 QMT Bridge 地址(如 http://192.168.3.43:8610)。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state && state.list.length > 0 && (
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))',
|
||||
gap: 12,
|
||||
}}>
|
||||
{state.list.map((c) => (
|
||||
<QmtConnectionCard
|
||||
key={c.id}
|
||||
conn={c}
|
||||
isActive={c.id === state.activeId}
|
||||
isDefault={c.id === state.defaultId}
|
||||
busy={saving || testingId === c.id}
|
||||
testResult={testResult && testResult.key === c.id ? testResult : null}
|
||||
onActivate={() => handleActivate(c)}
|
||||
onSetDefault={() => handleSetDefault(c)}
|
||||
onTest={() => handleTest(c)}
|
||||
onEdit={() => openEdit(c)}
|
||||
onDelete={() => setConfirmDelete({ id: c.id, name: c.name })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formOpen && (
|
||||
<div style={{ marginTop: 16, padding: 16, border: '1px solid #ddd', borderRadius: 8, background: '#fafafa', maxWidth: 640 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, marginBottom: 12 }}>{editingId === null ? '新增连接配置' : '编辑连接配置'}</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 10 }}>
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: 4, fontSize: 12, color: '#666' }}>
|
||||
连接名称
|
||||
<input type="text" placeholder="如:本机 QMT" value={formName}
|
||||
onChange={(e) => setFormName(e.target.value)}
|
||||
style={{ padding: '6px 10px', border: '1px solid #ccc', borderRadius: 4, fontSize: 13 }} />
|
||||
</label>
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: 4, fontSize: 12, color: '#666' }}>
|
||||
HTTP 地址
|
||||
<input type="text" placeholder="如 http://192.168.3.43:8610" value={formUrl}
|
||||
onChange={(e) => setFormUrl(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSave()}
|
||||
style={{ padding: '6px 10px', border: '1px solid #ccc', borderRadius: 4, fontSize: 13 }} />
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
|
||||
<button onClick={handleSave} disabled={saving}
|
||||
style={{ padding: '6px 16px', cursor: 'pointer', border: 'none', borderRadius: 4, background: '#2e7d32', color: '#fff' }}>
|
||||
保存
|
||||
</button>
|
||||
<button onClick={handleTestForm} disabled={testingId !== null}
|
||||
style={{ padding: '3px 10px', cursor: 'pointer', border: '1px solid #ccc', borderRadius: 4, background: '#fff', fontSize: 12 }}>
|
||||
{testingId === '__form__' ? '测试中…' : '测试连接'}
|
||||
</button>
|
||||
<button onClick={closeForm} disabled={saving}
|
||||
style={{ padding: '3px 10px', cursor: 'pointer', border: '1px solid #ccc', borderRadius: 4, background: '#fff', fontSize: 12 }}>
|
||||
取消
|
||||
</button>
|
||||
{testResult && testResult.key === '__form__' && (
|
||||
<span style={{
|
||||
fontSize: 12, color: testResult.ok ? '#2e7d32' : '#c62828',
|
||||
background: testResult.ok ? '#e8f5e9' : '#fdecea', borderRadius: 4, padding: '3px 8px',
|
||||
minWidth: 0,
|
||||
}}>
|
||||
<EllipsisText text={testResult.text} maxWidth={360} title={testResult.fullText || testResult.text} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{confirmDelete && (
|
||||
<ConfirmDialog
|
||||
title={'删除连接「' + confirmDelete.name + '」'}
|
||||
message={
|
||||
(state && state.list.length <= 1)
|
||||
? '这是最后一个连接配置,不能删除。'
|
||||
: '删除后:若为激活配置将自动切换到默认配置;若为默认配置,默认标记转移到列表第一条。确定删除?'
|
||||
}
|
||||
onConfirm={() => handleDelete(confirmDelete.id)}
|
||||
onCancel={() => setConfirmDelete(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRpc } from './connection.jsx';
|
||||
import { LoadState } from './LoadState.jsx';
|
||||
import { ToastProvider, useToast } from './Toast.jsx';
|
||||
import { useMarket } from '../market/MarketDataProvider.jsx';
|
||||
import { PriceCell } from './PriceCell.jsx';
|
||||
|
||||
/** 添加持仓区域响应式样式(内联注入,含媒体查询) */
|
||||
const ADD_FORM_CSS = `
|
||||
@@ -135,6 +137,7 @@ function StrategyTabInner({ strategyId, strategyName }) {
|
||||
const [confirmClear, setConfirmClear] = useState(false); // 一键清零确认
|
||||
const call = useRpc();
|
||||
const toast = useToast();
|
||||
const { getPrice, registerCodes } = useMarket();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -144,7 +147,12 @@ function StrategyTabInner({ strategyId, strategyName }) {
|
||||
call('one-divine-lot/strategy-positions', { args: { strategyId } }),
|
||||
call('one-divine-lot/unallocated', {}),
|
||||
]);
|
||||
if (strategyRes && strategyRes.ok) setPositions(strategyRes.value);
|
||||
if (strategyRes && strategyRes.ok) {
|
||||
setPositions(strategyRes.value);
|
||||
// 上报策略持仓 code 给行情 Provider(去重:不再由 Provider 自拉 positions)
|
||||
const codes = (strategyRes.value ?? []).map((p) => p.code).filter(Boolean);
|
||||
registerCodes(codes);
|
||||
}
|
||||
if (allRes && allRes.ok) setAllPositions(allRes.value);
|
||||
if ((strategyRes && !strategyRes.ok) || (allRes && !allRes.ok)) {
|
||||
setError((strategyRes?.error?.message) || (allRes?.error?.message) || '加载失败');
|
||||
@@ -244,6 +252,7 @@ function StrategyTabInner({ strategyId, strategyName }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<LoadState loading={loading} error={error} onRetry={load} autoRetry={2}>
|
||||
{showAdd && (
|
||||
<div className="odl-add-form">
|
||||
@@ -286,6 +295,7 @@ function StrategyTabInner({ strategyId, strategyName }) {
|
||||
<tr style={{ textAlign: 'left', borderBottom: '2px solid #ddd' }}>
|
||||
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>代码</th>
|
||||
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>名称</th>
|
||||
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>现价</th>
|
||||
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>策略份额</th>
|
||||
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>总持仓</th>
|
||||
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>操作</th>
|
||||
@@ -296,6 +306,7 @@ function StrategyTabInner({ strategyId, strategyName }) {
|
||||
<tr key={p.code} style={{ borderBottom: '1px solid #eee' }}>
|
||||
<td style={{ padding: '8px 6px' }}>{p.code}</td>
|
||||
<td style={{ padding: '8px 6px' }}>{p.name}</td>
|
||||
<PriceCell price={getPrice(p.code)?.lastPrice} lastClose={getPrice(p.code)?.lastClose} />
|
||||
<td style={{ padding: '8px 6px', fontWeight: 'bold' }}>{p.shares}</td>
|
||||
<td style={{ padding: '8px 6px' }}>{p.volume}</td>
|
||||
<td style={{ padding: '8px 6px', whiteSpace: 'nowrap' }}>
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* TradeRecordsTab —— 交易记录 tab(R-007 v3,2026-09-01)
|
||||
*
|
||||
* 单表合并视图:
|
||||
* - 主行 = 一笔委托(按 m_strOrderSysID 唯一),聚合成交信息(成交量/加权均价/金额/手续费)
|
||||
* - 点击展开/折叠 → 逐笔成交明细(成交时间/价/量/额/手续费/成交号)
|
||||
* - 排序:默认按委托时间降序(最新在前);点击表头手动排序
|
||||
* - 时间段查询:标题 + RangeSelector 同一行(今日/本周/上周快捷 + 手动起止日期)
|
||||
* - 轮询:仅「今日」范围 3-5s 自动刷新;其他范围一次性查询
|
||||
*
|
||||
* 方向:红买绿卖(Q7 定稿);状态中文映射;未成交显示 —
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useRpc } from './connection.jsx';
|
||||
import { LoadState } from './LoadState.jsx';
|
||||
import { RangeSelector } from './RangeSelector.jsx';
|
||||
|
||||
const POLL_INTERVAL_MS = 4000; // 3-5s 轮询(取 4s)
|
||||
|
||||
/** 委托状态码 → 中文(官方 ORDER_STATUS 字典) */
|
||||
const ORDER_STATUS = {
|
||||
48: '未报', 49: '待报', 50: '已报', 51: '已报待撤', 52: '部成待撤',
|
||||
53: '部撤', 54: '已撤', 55: '部成', 56: '已成', 57: '废单',
|
||||
};
|
||||
|
||||
/** HHMMSS → HH:MM:SS */
|
||||
function fmtTime(t) {
|
||||
if (!t || t.length !== 6) return t ?? '';
|
||||
return t.slice(0, 2) + ':' + t.slice(2, 4) + ':' + t.slice(4, 6);
|
||||
}
|
||||
|
||||
/** 数字格式化 */
|
||||
function fmtNum(v, digits = 2) {
|
||||
if (v == null || !isFinite(v)) return '—';
|
||||
return Number(v).toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits });
|
||||
}
|
||||
|
||||
/** 方向样式:红买绿卖 */
|
||||
function directionStyle(direction) {
|
||||
if (direction === 'buy') return { color: '#d32f2f', fontWeight: 'bold' };
|
||||
if (direction === 'sell') return { color: '#2e7d32', fontWeight: 'bold' };
|
||||
return { color: '#999' };
|
||||
}
|
||||
function directionText(direction) {
|
||||
if (direction === 'buy') return '买入';
|
||||
if (direction === 'sell') return '卖出';
|
||||
return '—';
|
||||
}
|
||||
|
||||
/** 合并逻辑:按 m_strOrderSysID 把成交聚合进委托 */
|
||||
function mergeOrdersAndTrades(orders, trades) {
|
||||
const tradeByOrder = new Map();
|
||||
for (const t of trades) {
|
||||
if (!tradeByOrder.has(t.orderId)) tradeByOrder.set(t.orderId, []);
|
||||
tradeByOrder.get(t.orderId).push(t);
|
||||
}
|
||||
return orders.map((o) => {
|
||||
const ts = tradeByOrder.get(o.orderId) ?? [];
|
||||
const totalVol = ts.reduce((s, t) => s + t.volume, 0);
|
||||
const totalAmount = ts.reduce((s, t) => s + t.amount, 0);
|
||||
// 费用逐笔计算后聚合(每笔 commission/stampTax/transferFee 已是金额)
|
||||
const aggCommission = ts.reduce((s, t) => s + t.commission, 0);
|
||||
const aggStampTax = ts.reduce((s, t) => s + t.stampTax, 0);
|
||||
const aggTransferFee = ts.reduce((s, t) => s + t.transferFee, 0);
|
||||
const aggFeeTotal = ts.reduce((s, t) => s + t.feeTotal, 0);
|
||||
const weightedPrice = totalVol > 0
|
||||
? ts.reduce((s, t) => s + t.price * t.volume, 0) / totalVol
|
||||
: 0;
|
||||
return {
|
||||
...o,
|
||||
trades: ts,
|
||||
aggVolume: totalVol,
|
||||
aggPrice: weightedPrice,
|
||||
aggAmount: totalAmount,
|
||||
aggCommission,
|
||||
aggStampTax,
|
||||
aggTransferFee,
|
||||
aggFeeTotal,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function TradeRecordsTab() {
|
||||
const [orders, setOrders] = useState(null);
|
||||
const [trades, setTrades] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [range, setRange] = useState(null); // { start, end } YYYYMMDD
|
||||
const [preset, setPreset] = useState('today'); // 快捷意图(单向,操作→状态→UI):'today'|'thisWeek'|'thisMonth'|null
|
||||
const [lastDate, setLastDate] = useState(null); // 最近交易日
|
||||
|
||||
// 默认加载「今日」(preset='today',单向意图):lastDate 到位且 range 未设置时自动设为今日
|
||||
useEffect(() => {
|
||||
if (lastDate && !range) {
|
||||
setRange({ start: lastDate, end: lastDate });
|
||||
}
|
||||
}, [lastDate, range]);
|
||||
const [expanded, setExpanded] = useState(new Set());
|
||||
const [sortKey, setSortKey] = useState('insertTime');
|
||||
const [sortDir, setSortDir] = useState('desc');
|
||||
const call = useRpc();
|
||||
|
||||
// 是否「今日」意图(preset='today',单向)→ 轮询
|
||||
const isTodayRange = preset === 'today';
|
||||
// 是否历史范围(非今日意图)→ 本期占位(QMT Bridge 历史接口开发中)
|
||||
const isHistoryRange = !!range && preset !== 'today';
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!range) return;
|
||||
if (isHistoryRange) {
|
||||
// 历史范围:本期占位(老师将完善 QMT Bridge 历史接口)
|
||||
setOrders([]);
|
||||
setTrades([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [oRes, tRes] = await Promise.all([
|
||||
call('one-divine-lot/orders', { args: { start: range.start, end: range.end } }),
|
||||
call('one-divine-lot/trades', { args: { start: range.start, end: range.end } }),
|
||||
]);
|
||||
if (oRes?.ok && Array.isArray(oRes.value)) setOrders(oRes.value);
|
||||
else throw new Error((oRes && oRes.error && oRes.error.message) || '委托加载失败');
|
||||
if (tRes?.ok && Array.isArray(tRes.value)) setTrades(tRes.value);
|
||||
else throw new Error((tRes && tRes.error && tRes.error.message) || '成交加载失败');
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
setOrders([]);
|
||||
setTrades([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [call, range, isHistoryRange]);
|
||||
|
||||
// 时间段变化 → 重新加载(展开重置)
|
||||
useEffect(() => {
|
||||
setExpanded(new Set());
|
||||
load();
|
||||
}, [load, range]);
|
||||
|
||||
// 仅「今日」范围轮询
|
||||
useEffect(() => {
|
||||
if (!isTodayRange) return;
|
||||
const timer = setInterval(() => {
|
||||
Promise.all([
|
||||
call('one-divine-lot/orders', { args: { start: range.start, end: range.end } }),
|
||||
call('one-divine-lot/trades', { args: { start: range.start, end: range.end } }),
|
||||
]).then(([o, t]) => {
|
||||
if (o?.ok && Array.isArray(o.value)) setOrders(o.value);
|
||||
if (t?.ok && Array.isArray(t.value)) setTrades(t.value);
|
||||
}).catch(() => {});
|
||||
}, POLL_INTERVAL_MS);
|
||||
return () => clearInterval(timer);
|
||||
}, [call, isTodayRange, range]);
|
||||
|
||||
// 合并 + 排序
|
||||
const rows = useMemo(() => {
|
||||
const merged = mergeOrdersAndTrades(orders ?? [], trades ?? []);
|
||||
const dir = sortDir === 'asc' ? 1 : -1;
|
||||
return merged.slice().sort((a, b) => {
|
||||
const va = a[sortKey];
|
||||
const vb = b[sortKey];
|
||||
if (typeof va === 'string' && typeof vb === 'string') return va.localeCompare(vb) * dir;
|
||||
return (va - vb) * dir;
|
||||
});
|
||||
}, [orders, trades, sortKey, sortDir]);
|
||||
|
||||
const toggleSort = (key) => {
|
||||
if (sortKey === key) {
|
||||
setSortDir((d) => (d === 'desc' ? 'asc' : 'desc'));
|
||||
} else {
|
||||
setSortKey(key);
|
||||
setSortDir('desc');
|
||||
}
|
||||
};
|
||||
|
||||
const toggleExpand = (orderId) => {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(orderId)) next.delete(orderId);
|
||||
else next.add(orderId);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const arrow = (key) => (sortKey === key ? (sortDir === 'desc' ? ' ▼' : ' ▲') : '');
|
||||
|
||||
return (
|
||||
<div style={{ padding: 16, fontFamily: 'system-ui' }}>
|
||||
{/* 标题 + 时间段选择同一行(2026-09-01 老师反馈) */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12, flexWrap: 'wrap', gap: 12 }}>
|
||||
<h3 style={{ margin: 0 }}>交易记录</h3>
|
||||
<RangeSelector preset={preset} onPresetChange={setPreset} range={range} onChange={setRange} onLastDate={setLastDate} />
|
||||
</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>
|
||||
) : (
|
||||
<LoadState loading={loading && !orders} error={error} onRetry={load} autoRetry={2}>
|
||||
{!orders?.length ? (
|
||||
<div style={{ padding: 32, textAlign: 'center', color: '#999' }}>该时间段暂无委托记录</div>
|
||||
) : (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr style={{ textAlign: 'left', borderBottom: '2px solid #ddd' }}>
|
||||
<th style={thStyle}> </th>
|
||||
<th onClick={() => toggleSort('insertTime')} style={thClickStyle}>时间{arrow('insertTime')}</th>
|
||||
<th onClick={() => toggleSort('code')} style={thClickStyle}>代码{arrow('code')}</th>
|
||||
<th style={thStyle}>名称</th>
|
||||
<th onClick={() => toggleSort('direction')} style={thClickStyle}>方向{arrow('direction')}</th>
|
||||
<th onClick={() => toggleSort('status')} style={thClickStyle}>状态{arrow('status')}</th>
|
||||
<th onClick={() => toggleSort('orderVolume')} style={thClickStyle} align="right">委托量{arrow('orderVolume')}</th>
|
||||
<th onClick={() => toggleSort('aggVolume')} style={thClickStyle} align="right">成交量{arrow('aggVolume')}</th>
|
||||
<th onClick={() => toggleSort('limitPrice')} style={thClickStyle} align="right">委托价{arrow('limitPrice')}</th>
|
||||
<th align="right">成交均价</th>
|
||||
<th onClick={() => toggleSort('aggAmount')} style={thClickStyle} align="right">成交额{arrow('aggAmount')}</th>
|
||||
<th align="right">总费用</th>
|
||||
<th align="right">佣金</th>
|
||||
<th align="right">印花税</th>
|
||||
<th align="right">过户费</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((o) => (
|
||||
<OrderRows
|
||||
key={o.orderId}
|
||||
order={o}
|
||||
expanded={expanded.has(o.orderId)}
|
||||
onToggle={() => toggleExpand(o.orderId)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
/** 委托主行 + 展开明细 */
|
||||
function OrderRows({ order, expanded, onToggle }) {
|
||||
const hasTrades = order.trades.length > 0;
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
onClick={onToggle}
|
||||
style={{
|
||||
borderBottom: '1px solid #eee',
|
||||
cursor: hasTrades ? 'pointer' : 'default',
|
||||
background: expanded ? '#f5f5f5' : 'transparent',
|
||||
}}
|
||||
>
|
||||
<td style={tdStyle}>{hasTrades ? (expanded ? '▾' : '▸') : <span style={{ color: '#ccc' }}>·</span>}</td>
|
||||
<td style={tdStyle}>{fmtTime(order.insertTime)}</td>
|
||||
<td style={tdStyle}>{order.code}</td>
|
||||
<td style={tdStyle}>{order.name}</td>
|
||||
<td style={{ ...tdStyle, ...directionStyle(order.direction) }} title={order.optName}>{directionText(order.direction)}</td>
|
||||
<td style={tdStyle}>{ORDER_STATUS[order.status] ?? order.status ?? '—'}</td>
|
||||
<td style={{ ...tdStyle, textAlign: 'right' }}>{fmtNum(order.orderVolume, 0)}</td>
|
||||
<td style={{ ...tdStyle, textAlign: 'right' }}>{hasTrades ? fmtNum(order.aggVolume, 0) : '—'}</td>
|
||||
<td style={{ ...tdStyle, textAlign: 'right' }}>{fmtNum(order.limitPrice)}</td>
|
||||
<td style={{ ...tdStyle, textAlign: 'right' }}>{hasTrades ? fmtNum(order.aggPrice) : '—'}</td>
|
||||
<td style={{ ...tdStyle, textAlign: 'right' }}>{hasTrades ? fmtNum(order.aggAmount) : '—'}</td>
|
||||
<td style={{ ...tdStyle, textAlign: 'right' }}>{hasTrades ? fmtNum(order.aggFeeTotal, 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.aggTransferFee, 2) : '—'}</td>
|
||||
</tr>
|
||||
{expanded && hasTrades && (
|
||||
<tr style={{ borderBottom: '1px solid #eee', background: '#fafafa' }}>
|
||||
<td colSpan={15} style={{ padding: '4px 8px 12px' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12, marginTop: 4 }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid #ddd', color: '#666' }}>
|
||||
<th style={{ ...tdStyle, textAlign: 'left' }}>成交时间</th>
|
||||
<th style={{ ...tdStyle, textAlign: 'right' }}>成交价</th>
|
||||
<th style={{ ...tdStyle, textAlign: 'right' }}>成交量</th>
|
||||
<th style={{ ...tdStyle, textAlign: 'right' }}>成交额</th>
|
||||
<th style={{ ...tdStyle, textAlign: 'right' }}>总费用</th>
|
||||
<th style={{ ...tdStyle, textAlign: 'right' }}>佣金</th>
|
||||
<th style={{ ...tdStyle, textAlign: 'right' }}>印花税</th>
|
||||
<th style={{ ...tdStyle, textAlign: 'right' }}>过户费</th>
|
||||
<th style={{ ...tdStyle, textAlign: 'right' }}>成交号</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{order.trades.map((t) => (
|
||||
<tr key={t.tradeId} style={{ borderBottom: '1px solid #f0f0f0' }}>
|
||||
<td style={tdStyle}>{fmtTime(t.tradeTime)}</td>
|
||||
<td style={{ ...tdStyle, textAlign: 'right' }}>{fmtNum(t.price)}</td>
|
||||
<td style={{ ...tdStyle, textAlign: 'right' }}>{fmtNum(t.volume, 0)}</td>
|
||||
<td style={{ ...tdStyle, textAlign: 'right' }}>{fmtNum(t.amount)}</td>
|
||||
<td style={{ ...tdStyle, textAlign: 'right' }}>{fmtNum(t.feeTotal, 2)}</td>
|
||||
<td style={{ ...tdStyle, textAlign: 'right' }}>{fmtNum(t.commission, 2)}</td>
|
||||
<td style={{ ...tdStyle, textAlign: 'right' }}>{fmtNum(t.stampTax, 2)}</td>
|
||||
<td style={{ ...tdStyle, textAlign: 'right' }}>{fmtNum(t.transferFee, 2)}</td>
|
||||
<td style={{ ...tdStyle, textAlign: 'right', color: '#999' }}>{t.tradeId}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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' };
|
||||
@@ -21,12 +21,19 @@ import { homedir } from 'node:os';
|
||||
const DEFAULT_DATA_DIR = join(homedir(), '.dsh', 'one-divine-lot');
|
||||
const FILE_NAME = 'allocations.json';
|
||||
|
||||
/**
|
||||
* 测试模式数据目录(防误删真实数据,2026-08-31 老师定):
|
||||
* - 环境变量 ODL_TEST_DATA_DIR 设置时,存储落到独立测试目录,不影响真实 allocations.json;
|
||||
* - 测试脚本必须显式注入 dataDir 或设置该环境变量,禁止在真实数据上执行写操作。
|
||||
*/
|
||||
const TEST_DATA_DIR = process.env.ODL_TEST_DATA_DIR || '';
|
||||
|
||||
export class AllocationStorage {
|
||||
/**
|
||||
* @param {object} [options]
|
||||
* @param {string} [options.dataDir] 数据目录(默认 ~/.dsh/one-divine-lot)
|
||||
*/
|
||||
constructor({ dataDir = DEFAULT_DATA_DIR } = {}) {
|
||||
constructor({ dataDir = TEST_DATA_DIR || DEFAULT_DATA_DIR } = {}) {
|
||||
this.dataDir = dataDir;
|
||||
this.filePath = join(dataDir, FILE_NAME);
|
||||
this.data = { version: 1, allocations: {} };
|
||||
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* DataStore —— 策略数据集 JSON data store(R-006,2026-08-31 老师定)
|
||||
*
|
||||
* 架构(定稿结论):
|
||||
* - 策略定义仍留 DSH settings(设置页交互不变);
|
||||
* - 份额分配(数据集)标准化为 JSON data store:
|
||||
* - store.schema.json:数据集 schema(类 JSON Schema,描述 dataset 字段);
|
||||
* - store.json:实际数据(策略为中心:strategies[].dataset = [{code, shares}]);
|
||||
* - 旧 allocations.json 启动时自动迁移(保留 .bak 备份);
|
||||
* - 删除策略时由 API 层联动删对应 dataset。
|
||||
*
|
||||
* 2026-08-31 扩展:行情持久化(R-006 后续,DataStore 为数据快速展现存在)
|
||||
* - store.market.json:最近一次行情快照(code → { lastPrice, lastClose, ... }),
|
||||
* 供宿主重启后首屏快速展示价格(不依赖实盘订阅),实盘轮询/订阅做增量更新并定期写回。
|
||||
*
|
||||
* 文件位置(默认 ~/.dsh/one-divine-lot/):
|
||||
* store.schema.json # schema 定义(只读,随代码发布)
|
||||
* store.json # 份额分配数据(原子写)
|
||||
* store.market.json # 行情快照缓存(原子写)
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
const DEFAULT_DATA_DIR = join(homedir(), '.dsh', 'one-divine-lot');
|
||||
const TEST_DATA_DIR = process.env.ODL_TEST_DATA_DIR || '';
|
||||
const SCHEMA_FILE = 'store.schema.json';
|
||||
const DATA_FILE = 'store.json';
|
||||
const MARKET_FILE = 'store.market.json';
|
||||
const LEGACY_FILE = 'allocations.json';
|
||||
|
||||
/** 数据集 schema(类 JSON Schema,R-006 定稿) */
|
||||
export const STORE_SCHEMA = {
|
||||
version: 1,
|
||||
kind: 'one-divine-lot-data-store',
|
||||
strategies: {
|
||||
description: '策略数据集:每个策略一个 dataset(份额分配)',
|
||||
type: 'array',
|
||||
items: {
|
||||
strategyId: { type: 'string', required: true, description: '策略 id(与 settings 中的策略一致)' },
|
||||
dataset: {
|
||||
type: 'array',
|
||||
description: '该策略的持仓数据集(份额分配)',
|
||||
items: {
|
||||
code: { type: 'string', required: true, description: '证券代码(含后缀,如 600719.SH)' },
|
||||
shares: { type: 'number', required: true, description: '份额(股数,>0)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export class DataStore {
|
||||
/**
|
||||
* @param {object} [options]
|
||||
* @param {string} [options.dataDir] 数据目录(默认 ~/.dsh/one-divine-lot;测试可用 ODL_TEST_DATA_DIR)
|
||||
*/
|
||||
constructor({ dataDir = TEST_DATA_DIR || DEFAULT_DATA_DIR } = {}) {
|
||||
this.dataDir = dataDir;
|
||||
this.schemaPath = join(dataDir, SCHEMA_FILE);
|
||||
this.dataPath = join(dataDir, DATA_FILE);
|
||||
this.marketPath = join(dataDir, MARKET_FILE);
|
||||
this.legacyPath = join(dataDir, LEGACY_FILE);
|
||||
this.data = { version: 1, strategies: [] };
|
||||
this.market = {}; // code → snapshot
|
||||
this.loaded = false;
|
||||
this.marketLoaded = false;
|
||||
}
|
||||
|
||||
/** 确保目录存在 */
|
||||
async _ensureDir() {
|
||||
await fs.mkdir(this.dataDir, { recursive: true });
|
||||
}
|
||||
|
||||
/** 写入 schema 文件(随 store.json 一起确保存在) */
|
||||
async _ensureSchema() {
|
||||
await this._ensureDir();
|
||||
try {
|
||||
await fs.access(this.schemaPath);
|
||||
} catch {
|
||||
await fs.writeFile(this.schemaPath, JSON.stringify(STORE_SCHEMA, null, 2), 'utf8');
|
||||
}
|
||||
}
|
||||
|
||||
/** 加载份额分配数据(首次从磁盘读,含旧数据迁移) */
|
||||
async load() {
|
||||
if (this.loaded) return this.data;
|
||||
await this._ensureDir();
|
||||
await this._ensureSchema();
|
||||
|
||||
// 1. 若旧 allocations.json 存在且 store.json 不存在 → 迁移
|
||||
const hasLegacy = await this._exists(this.legacyPath);
|
||||
const hasStore = await this._exists(this.dataPath);
|
||||
if (hasLegacy && !hasStore) {
|
||||
await this._migrateFromLegacy();
|
||||
}
|
||||
|
||||
// 2. 读 store.json
|
||||
try {
|
||||
const raw = await fs.readFile(this.dataPath, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
this.data = {
|
||||
version: parsed.version ?? 1,
|
||||
strategies: Array.isArray(parsed.strategies) ? parsed.strategies : [],
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.code !== 'ENOENT') {
|
||||
throw new Error(`数据读取失败: ${err.message}`);
|
||||
}
|
||||
// store.json 不存在且无旧数据:空 store
|
||||
await this.save();
|
||||
}
|
||||
this.loaded = true;
|
||||
return this.data;
|
||||
}
|
||||
|
||||
/** 保存份额分配(原子写:临时文件 + rename) */
|
||||
async save() {
|
||||
await this._ensureDir();
|
||||
const tmpPath = `${this.dataPath}.${process.pid}.tmp`;
|
||||
await fs.writeFile(tmpPath, JSON.stringify(this.data, null, 2), 'utf8');
|
||||
await fs.rename(tmpPath, this.dataPath);
|
||||
}
|
||||
|
||||
/** 迁移:allocations.json(code 为中心)→ store.json(策略为中心) */
|
||||
async _migrateFromLegacy() {
|
||||
try {
|
||||
const raw = await fs.readFile(this.legacyPath, 'utf8');
|
||||
const legacy = JSON.parse(raw);
|
||||
const alloc = legacy?.allocations ?? {};
|
||||
// code 为中心 → 策略为中心
|
||||
const strategyMap = new Map();
|
||||
for (const [code, shares] of Object.entries(alloc)) {
|
||||
for (const [strategyId, n] of Object.entries(shares ?? {})) {
|
||||
if (n > 0) {
|
||||
if (!strategyMap.has(strategyId)) strategyMap.set(strategyId, []);
|
||||
strategyMap.get(strategyId).push({ code, shares: n });
|
||||
}
|
||||
}
|
||||
}
|
||||
this.data = {
|
||||
version: 1,
|
||||
strategies: [...strategyMap.entries()].map(([strategyId, dataset]) => ({ strategyId, dataset })),
|
||||
};
|
||||
await this.save();
|
||||
// 保留旧文件为备份
|
||||
await fs.rename(this.legacyPath, this.legacyPath + '.bak');
|
||||
console.log('[one-divine-lot] 已迁移 allocations.json → store.json(旧文件备份为 allocations.json.bak)');
|
||||
} catch (err) {
|
||||
console.warn('[one-divine-lot] 迁移失败(忽略,使用空 store): ' + err.message);
|
||||
this.data = { version: 1, strategies: [] };
|
||||
await this.save();
|
||||
}
|
||||
}
|
||||
|
||||
async _exists(p) {
|
||||
try { await fs.access(p); return true; } catch { return false; }
|
||||
}
|
||||
|
||||
// ===== 行情持久化(store.market.json,R-006 扩展)=====
|
||||
|
||||
/** 加载行情快照缓存(启动时读盘,供首屏快速展示) */
|
||||
async loadMarket() {
|
||||
if (this.marketLoaded) return this.market;
|
||||
await this._ensureDir();
|
||||
try {
|
||||
const raw = await fs.readFile(this.marketPath, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === 'object' && parsed.quotes) {
|
||||
this.market = { ...parsed.quotes };
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.code !== 'ENOENT') {
|
||||
// 行情缓存损坏不阻塞(回退空缓存)
|
||||
console.warn('[one-divine-lot] 行情缓存读取失败(忽略): ' + err.message);
|
||||
}
|
||||
this.market = {};
|
||||
}
|
||||
this.marketLoaded = true;
|
||||
return this.market;
|
||||
}
|
||||
|
||||
/** 读取行情快照(code → snapshot),无则 undefined */
|
||||
async getMarketQuote(code) {
|
||||
await this.loadMarket();
|
||||
return this.market[code];
|
||||
}
|
||||
|
||||
/** 批量读取行情快照 */
|
||||
async getMarketQuotes(codes) {
|
||||
await this.loadMarket();
|
||||
const out = {};
|
||||
for (const c of codes) {
|
||||
if (this.market[c]) out[c] = this.market[c];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 写入行情快照(整体替换,2026-09-01 修复)——传入完整集合,清除不在其中的旧键 */
|
||||
async setMarketQuotes(quotes) {
|
||||
await this.loadMarket();
|
||||
const next = {};
|
||||
for (const [code, snap] of Object.entries(quotes || {})) {
|
||||
if (snap) next[code] = snap;
|
||||
}
|
||||
this.market = next; // 替换(不是合并),防止旧键残留导致膨胀
|
||||
await this.saveMarket();
|
||||
return this.market;
|
||||
}
|
||||
|
||||
/** 保存行情快照(原子写 store.market.json) */
|
||||
async saveMarket() {
|
||||
await this._ensureDir();
|
||||
const tmpPath = `${this.marketPath}.${process.pid}.tmp`;
|
||||
const payload = { version: 1, savedAt: Date.now(), quotes: this.market };
|
||||
await fs.writeFile(tmpPath, JSON.stringify(payload, null, 2), 'utf8');
|
||||
await fs.rename(tmpPath, this.marketPath);
|
||||
}
|
||||
|
||||
// ===== 份额分配 CRUD(store.json)=====
|
||||
|
||||
/** 读取某策略的数据集([{code, shares}]) */
|
||||
async getDataset(strategyId) {
|
||||
await this.load();
|
||||
const s = this.data.strategies.find((x) => x.strategyId === strategyId);
|
||||
return s ? [...s.dataset] : [];
|
||||
}
|
||||
|
||||
/** 设置某策略的数据集(整体替换) */
|
||||
async setDataset(strategyId, dataset) {
|
||||
await this.load();
|
||||
const items = Array.isArray(dataset) ? dataset.filter((x) => x && x.code && x.shares > 0) : [];
|
||||
const idx = this.data.strategies.findIndex((x) => x.strategyId === strategyId);
|
||||
if (idx >= 0) {
|
||||
this.data.strategies[idx].dataset = items;
|
||||
} else {
|
||||
this.data.strategies.push({ strategyId, dataset: items });
|
||||
}
|
||||
await this.save();
|
||||
return this.getDataset(strategyId);
|
||||
}
|
||||
|
||||
/** 删除某策略的数据集(删除策略时联动) */
|
||||
async removeDataset(strategyId) {
|
||||
await this.load();
|
||||
this.data.strategies = this.data.strategies.filter((x) => x.strategyId !== strategyId);
|
||||
await this.save();
|
||||
}
|
||||
|
||||
/** 全部数据集(策略为中心,供 summary/strategy-positions 使用) */
|
||||
async getAllDatasets() {
|
||||
await this.load();
|
||||
return this.data.strategies.map((s) => ({ strategyId: s.strategyId, dataset: [...s.dataset] }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* MarketDataHub —— 行情缓存服务(R-005,2026-08-31 从 market-cache.js 拆分;R-006 加持久化)
|
||||
*
|
||||
* 职责(单一):行情缓存的存储与查询。
|
||||
* - 持有行情缓存 Map(code → snapshot);
|
||||
* - getByCodes(codes):按 code 查询;miss 的 code 现场用 REST /data/tick 拉取补入(查询保证有值,调用方无感);
|
||||
* - 写入入口:ingest(code→snapshot 批量写入,由 MarketFeed 调用)。
|
||||
*
|
||||
* 2026-08-31 持久化(DataStore 为数据快速展现存在,老师确认):
|
||||
* - 接入 DataStore 的 store.market.json:启动时读盘填充内存缓存(首屏即有上次价格);
|
||||
* - 实盘增量更新(ingest)后写回磁盘(防抖),重启不丢价格;
|
||||
* - 查询命中顺序:内存 → 磁盘 → REST 补拉。
|
||||
*
|
||||
* 2026-09-01 修复:watchCodes 过滤(缓存膨胀 bug)——
|
||||
* - WS 推送是全市场(whole),若不过滤会把 5 万+ 只股票写进缓存(store.market.json 膨胀到 38MB);
|
||||
* - 只保留「关注集合」(前端查询过的 code + 持仓 code)的行情;
|
||||
* - 关注集合来源:getByCodes 查询的 code 自动加入;warmup/启动时加入持仓 code。
|
||||
*/
|
||||
|
||||
import { resolveActiveBaseUrl } from '../api/common.js';
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 10000;
|
||||
const PERSIST_DEBOUNCE_MS = 3000; // 写盘防抖
|
||||
|
||||
export class MarketDataHub {
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {object} [opts.logger]
|
||||
* @param {import('./DataStore.js').DataStore} [opts.dataStore] 持久化存储(可选;提供则启用行情落盘)
|
||||
*/
|
||||
constructor({ logger, dataStore } = {}) {
|
||||
this.logger = logger;
|
||||
this.dataStore = dataStore;
|
||||
/** 行情缓存:code → snapshot(内存,启动时从磁盘加载) */
|
||||
this.cache = new Map();
|
||||
/** 关注集合:只缓存/持久化这些 code 的行情(防止全市场推送膨胀) */
|
||||
this.watchCodes = new Set();
|
||||
this.stats = { totalTicks: 0, wsPushCount: 0, restFallbackCount: 0, diskHitCount: 0, lastTickAt: 0 };
|
||||
this._persistTimer = null;
|
||||
}
|
||||
|
||||
/** 加入关注集合(查询的 code / 持仓 code) */
|
||||
watch(codes) {
|
||||
const list = Array.isArray(codes) ? codes : [];
|
||||
for (const c of list) {
|
||||
if (c) this.watchCodes.add(c);
|
||||
}
|
||||
}
|
||||
|
||||
/** 启动时从磁盘加载行情缓存 + 关注集合 */
|
||||
async warmup() {
|
||||
if (!this.dataStore) return;
|
||||
try {
|
||||
const all = await this.dataStore.loadMarket();
|
||||
let n = 0;
|
||||
for (const [code, snap] of Object.entries(all)) {
|
||||
// 只加载数据到内存缓存,不加入 watchCodes——
|
||||
// watchCodes 应由「持仓 + 前端查询」驱动,不能从磁盘历史认可(否则全市场又进缓存)
|
||||
if (snap) { this.cache.set(code, snap); n++; }
|
||||
}
|
||||
if (n > 0) this.logger?.info?.('[one-divine-lot] MarketDataHub 从磁盘加载行情: ' + n + ' 条');
|
||||
} catch (e) {
|
||||
this.logger?.debug?.('[one-divine-lot] MarketDataHub 行情预热失败: ' + (e?.message ?? e));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 code 列表查询缓存行情;miss 依次查磁盘、REST 补拉(保证返回尽可能全)
|
||||
*/
|
||||
async getByCodes(codes, { settings, dataSource } = {}) {
|
||||
const list = Array.isArray(codes) ? codes : [];
|
||||
// 查询的 code 加入关注集合
|
||||
this.watch(list);
|
||||
const out = {};
|
||||
const miss = [];
|
||||
for (const c of list) {
|
||||
const v = this.cache.get(c);
|
||||
if (v) { out[c] = v; continue; }
|
||||
// 磁盘兜底(持久化缓存)
|
||||
if (this.dataStore) {
|
||||
const dv = await this.dataStore.getMarketQuote(c);
|
||||
if (dv) {
|
||||
this.cache.set(c, dv);
|
||||
out[c] = dv;
|
||||
this.stats.diskHitCount++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
miss.push(c);
|
||||
}
|
||||
// miss 补拉(REST /data/tick)
|
||||
if (miss.length > 0) {
|
||||
await this._fetchAndIngest(miss, { settings, dataSource });
|
||||
for (const c of miss) {
|
||||
const v = this.cache.get(c);
|
||||
if (v) out[c] = v;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 批量写入缓存(MarketFeed 调用:WS 推送 / REST 定时刷新)——只保留关注集合内 */
|
||||
ingest(data) {
|
||||
if (!data) return;
|
||||
let dirty = false;
|
||||
for (const [code, snap] of Object.entries(data)) {
|
||||
// 只缓存关注集合内的 code(防止全市场推送膨胀)
|
||||
if (!this.watchCodes.has(code)) continue;
|
||||
if (snap) {
|
||||
this.cache.set(code, { ...this.cache.get(code), ...snap });
|
||||
dirty = true;
|
||||
}
|
||||
}
|
||||
if (dirty) this._schedulePersist();
|
||||
}
|
||||
|
||||
/** 批量写入并统计(WS 推送专用) */
|
||||
ingestWs(data) {
|
||||
this.ingest(data);
|
||||
this.stats.totalTicks++;
|
||||
this.stats.wsPushCount++;
|
||||
this.stats.lastTickAt = Date.now();
|
||||
}
|
||||
|
||||
/** 缓存大小(诊断用) */
|
||||
get size() {
|
||||
return this.cache.size;
|
||||
}
|
||||
|
||||
/** 定期写盘(防抖) */
|
||||
_schedulePersist() {
|
||||
if (!this.dataStore) return;
|
||||
if (this._persistTimer) clearTimeout(this._persistTimer);
|
||||
this._persistTimer = setTimeout(() => {
|
||||
this._persistTimer = null;
|
||||
const quotes = {};
|
||||
// 只写关注集合内的 code(防止 store.market.json 膨胀)
|
||||
for (const code of this.watchCodes) {
|
||||
const snap = this.cache.get(code);
|
||||
if (snap) quotes[code] = snap;
|
||||
}
|
||||
this.dataStore.setMarketQuotes(quotes).catch((e) => {
|
||||
this.logger?.debug?.('[one-divine-lot] 行情写盘失败: ' + (e?.message ?? e));
|
||||
});
|
||||
}, PERSIST_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
/** REST 拉取并写入缓存(miss 补拉 + 定时刷新共用) */
|
||||
async _fetchAndIngest(codes, { settings, dataSource } = {}) {
|
||||
if (!Array.isArray(codes) || codes.length === 0) return;
|
||||
try {
|
||||
const baseUrl = resolveActiveBaseUrl(settings, dataSource);
|
||||
const r = await fetch(baseUrl + '/data/tick?codes=' + encodeURIComponent(codes.join(',')), {
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
const j = await r.json().catch(() => ({}));
|
||||
if (j.ok && j.data) {
|
||||
this.ingest(j.data);
|
||||
this.stats.restFallbackCount++;
|
||||
}
|
||||
} catch (e) {
|
||||
this.logger?.debug?.('[one-divine-lot] MarketDataHub REST 拉取失败: ' + (e?.message ?? e));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* MarketFeed —— 行情数据获取(R-005,2026-08-31 从 market-cache.js 拆分)
|
||||
*
|
||||
* 职责(单一):从 QMT Bridge 获取行情数据,写入 MarketDataHub。
|
||||
* - WS 订阅连接:ws://<激活host>:8610/ws,收全市场推送 → hub.ingestWs();
|
||||
* - REST 定时刷新:每 5s 对 hub 中已有 code 拉 /data/tick 刷新(WS 不可靠时的兜底);
|
||||
* - 启动主动拉取(prime):服务启动即拉一次持仓股票的盘口,保证缓存有最新价
|
||||
* (2026-09-01 老师指出:服务端启动就该自动拿最新盘口,前端任何时候打开都有价,不依赖前端轮询触发);
|
||||
* - 断线指数退避重连;setBaseUrl 热切换(激活配置变化时重建连接)。
|
||||
*
|
||||
* 与 MarketDataHub 的分工:
|
||||
* - MarketFeed 只管「取数据」,不直接对外提供查询;
|
||||
* - 数据写入走 hub.ingest() / hub.ingestWs(),查询走 hub.getByCodes()。
|
||||
*/
|
||||
|
||||
import { resolveActiveBaseUrl } from '../api/common.js';
|
||||
|
||||
const RECONNECT_BASE_MS = 1000;
|
||||
const RECONNECT_MAX_MS = 15000;
|
||||
const SNAPSHOT_REFRESH_MS = 5000; // REST 定时刷新间隔
|
||||
const REQUEST_TIMEOUT_MS = 10000;
|
||||
|
||||
/** 从 baseUrl 解析 ws 地址 */
|
||||
function buildWsUrl(baseUrl) {
|
||||
if (!baseUrl) return '';
|
||||
try {
|
||||
const u = new URL(String(baseUrl).replace(/\/+$/, ''));
|
||||
return 'ws://' + u.host + '/ws';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export class MarketFeed {
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {object} opts.hub MarketDataHub 实例(缓存服务,写入目标)
|
||||
* @param {object} opts.runtime { settings, dataSource } —— 解析激活 baseUrl、取持仓 code 用
|
||||
* @param {object} [opts.logger]
|
||||
*/
|
||||
constructor({ hub, runtime, logger } = {}) {
|
||||
this.hub = hub;
|
||||
this.runtime = runtime;
|
||||
this.logger = logger;
|
||||
this.ws = null;
|
||||
this.reconnectTimer = null;
|
||||
this.reconnectAttempt = 0;
|
||||
this.refreshTimer = null;
|
||||
this.baseUrl = '';
|
||||
this.mounted = true;
|
||||
}
|
||||
|
||||
/** 启动:主动拉持仓盘口 + 连接 WS + 启动 REST 定时刷新 */
|
||||
start(baseUrl) {
|
||||
this.baseUrl = String(baseUrl ?? '').replace(/\/+$/, '');
|
||||
if (!this.baseUrl) {
|
||||
this.logger?.warn?.('[one-divine-lot] MarketFeed 无 baseUrl,不启动');
|
||||
return;
|
||||
}
|
||||
this.mounted = true;
|
||||
// 启动主动拉一次持仓盘口(服务端预热,前端打开即有价)
|
||||
this._primePositions();
|
||||
this._connect();
|
||||
this._startRefresh();
|
||||
}
|
||||
|
||||
/** 热切换:激活配置变化时调用 */
|
||||
setBaseUrl(baseUrl) {
|
||||
const next = String(baseUrl ?? '').replace(/\/+$/, '');
|
||||
if (next === this.baseUrl) return;
|
||||
this.baseUrl = next;
|
||||
if (!this.mounted) return;
|
||||
this._teardownWs();
|
||||
this._connect();
|
||||
// 切换后也主动拉一次(新地址的盘口)
|
||||
this._primePositions();
|
||||
}
|
||||
|
||||
/** 停止(插件释放时) */
|
||||
stop() {
|
||||
this.mounted = false;
|
||||
this._teardownWs();
|
||||
if (this.refreshTimer) {
|
||||
clearInterval(this.refreshTimer);
|
||||
this.refreshTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动/切换时主动拉持仓股票的盘口(REST /data/tick)写入缓存。
|
||||
* 保证服务端启动即有最新价,前端任何时候打开页面都能从缓存拿到价格(不依赖前端轮询触发)。
|
||||
*/
|
||||
async _primePositions() {
|
||||
if (!this.mounted || !this.baseUrl) return;
|
||||
try {
|
||||
// 从数据源拿当前持仓 code(QMT 真实持仓)
|
||||
const positions = await this.runtime.dataSource.getPositions();
|
||||
const codes = (positions || []).map((p) => p.code).filter(Boolean);
|
||||
if (codes.length === 0) {
|
||||
this.logger?.debug?.('[one-divine-lot] MarketFeed 无持仓可预热');
|
||||
return;
|
||||
}
|
||||
// 持仓 code 加入关注集合(ingest 只保留这些)
|
||||
this.hub.watch(codes);
|
||||
const baseUrl = resolveActiveBaseUrl(this.runtime.settings, this.runtime.dataSource);
|
||||
for (let i = 0; i < codes.length; i += 50) {
|
||||
const batch = codes.slice(i, i + 50);
|
||||
const res = await fetch(baseUrl + '/data/tick?codes=' + encodeURIComponent(batch.join(',')), {
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
const j = await res.json().catch(() => ({}));
|
||||
if (j.ok && j.data) {
|
||||
this.hub.ingest(j.data);
|
||||
this.logger?.info?.('[one-divine-lot] MarketFeed 启动预热盘口: ' + Object.keys(j.data).length + ' 条');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
this.logger?.debug?.('[one-divine-lot] MarketFeed 启动预热失败: ' + (e?.message ?? e));
|
||||
}
|
||||
}
|
||||
|
||||
/** REST 定时刷新:对 hub 已有 code 拉 /data/tick 补数据 */
|
||||
_startRefresh() {
|
||||
if (this.refreshTimer) clearInterval(this.refreshTimer);
|
||||
const run = async () => {
|
||||
if (!this.mounted || !this.baseUrl) return;
|
||||
const knownCodes = [...this.hub.cache.keys()];
|
||||
if (knownCodes.length === 0) return;
|
||||
try {
|
||||
const baseUrl = resolveActiveBaseUrl(this.runtime.settings, this.runtime.dataSource);
|
||||
for (let i = 0; i < knownCodes.length; i += 50) {
|
||||
const batch = knownCodes.slice(i, i + 50);
|
||||
const res = await fetch(baseUrl + '/data/tick?codes=' + encodeURIComponent(batch.join(',')), {
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
const j = await res.json().catch(() => ({}));
|
||||
if (j.ok && j.data) {
|
||||
this.hub.ingest(j.data);
|
||||
if (this.hub.stats) this.hub.stats.restFallbackCount++; // 轮询也计入统计
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
this.logger?.debug?.('[one-divine-lot] MarketFeed REST 定时刷新失败: ' + (e?.message ?? e));
|
||||
}
|
||||
};
|
||||
run();
|
||||
this.refreshTimer = setInterval(run, SNAPSHOT_REFRESH_MS);
|
||||
}
|
||||
|
||||
_teardownWs() {
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
if (this.ws) {
|
||||
try { this.ws.onclose = null; this.ws.close(); } catch { /* ignore */ }
|
||||
this.ws = null;
|
||||
}
|
||||
}
|
||||
|
||||
_connect() {
|
||||
if (!this.mounted || !this.baseUrl) return;
|
||||
const wsUrl = buildWsUrl(this.baseUrl);
|
||||
if (!wsUrl) {
|
||||
this.logger?.warn?.('[one-divine-lot] MarketFeed ws 地址无效: ' + this.baseUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger?.info?.('[one-divine-lot] MarketFeed 连接: ' + wsUrl);
|
||||
const ws = new WebSocket(wsUrl);
|
||||
this.ws = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
this.reconnectAttempt = 0;
|
||||
this.logger?.info?.('[one-divine-lot] MarketFeed WS 已连接: ' + wsUrl);
|
||||
};
|
||||
|
||||
ws.onmessage = (ev) => {
|
||||
let msg = null;
|
||||
try { msg = typeof ev.data === 'string' ? JSON.parse(ev.data) : null; } catch { msg = null; }
|
||||
if (!msg || msg.type !== 'whole' || !msg.data) return;
|
||||
this.hub.ingestWs(msg.data);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (!this.mounted) return;
|
||||
this.logger?.warn?.('[one-divine-lot] MarketFeed WS 断开,重连中...');
|
||||
const attempt = this.reconnectAttempt++;
|
||||
const delay = Math.min(RECONNECT_BASE_MS * Math.pow(2, attempt), RECONNECT_MAX_MS);
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
if (this.mounted && this.baseUrl) this._connect();
|
||||
}, delay);
|
||||
};
|
||||
|
||||
ws.onerror = () => { /* onclose 触发重连 */ };
|
||||
}
|
||||
}
|
||||
@@ -8,15 +8,19 @@
|
||||
* - Q4 不做统计(无市值/盈亏/占比聚合);
|
||||
* - Q5 UI = 策略 tab 内编辑(添加默认 0 起填 + 指定数量移出)。
|
||||
*
|
||||
* 数据存储(S4 storage):{ [code]: { [strategyId]: shares } }
|
||||
* 全部持仓来自数据源(QMT),份额为本地维护元数据。
|
||||
* 2026-08-31 R-006:存储改为 DataStore(策略为中心,JSON data store schema)。
|
||||
* - 数据集存 store.json:strategies[].dataset = [{code, shares}]
|
||||
* - 对外方法签名保持不变(addToStrategy / removeFromStrategy / getSummary 等),
|
||||
* 上层(api)无感知。
|
||||
*/
|
||||
|
||||
import { DataStore } from './DataStore.js';
|
||||
|
||||
export class PositionManager {
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {import('../data-source/types.js').DataSource} opts.dataSource 数据源(QMT REST)
|
||||
* @param {import('../storage.js').AllocationStorage} opts.storage 分仓存储
|
||||
* @param {import('./data-source-types.js').DataSource} opts.dataSource 数据源(QMT REST)
|
||||
* @param {DataStore} opts.storage 数据集存储(R-006 DataStore)
|
||||
*/
|
||||
constructor({ dataSource, storage }) {
|
||||
this.dataSource = dataSource;
|
||||
@@ -40,20 +44,35 @@ export class PositionManager {
|
||||
return pos ? pos.volume : 0;
|
||||
}
|
||||
|
||||
/** 读取全部份额分配(code 为中心,兼容旧调用方){ code: { strategyId: shares } } */
|
||||
async _getAllAsCodeMap() {
|
||||
const datasets = await this.storage.getAllDatasets();
|
||||
const map = {};
|
||||
for (const { strategyId, dataset } of datasets) {
|
||||
for (const item of dataset) {
|
||||
if (!map[item.code]) map[item.code] = {};
|
||||
map[item.code][strategyId] = item.shares;
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/** 某只票的份额分配 { strategyId: shares } */
|
||||
async getShares(code) {
|
||||
return this.storage.get(code);
|
||||
const map = await this._getAllAsCodeMap();
|
||||
return { ...(map[code] ?? {}) };
|
||||
}
|
||||
|
||||
/** 某策略下的持仓(份额 > 0 的标的) */
|
||||
async getStrategyPositions(strategyId) {
|
||||
const [positions, allocations] = await Promise.all([
|
||||
const [positions, dataset] = await Promise.all([
|
||||
this.getAllPositions(),
|
||||
this.storage.getAll(),
|
||||
this.storage.getDataset(strategyId),
|
||||
]);
|
||||
const shareMap = new Map(dataset.map((d) => [d.code, d.shares]));
|
||||
return positions
|
||||
.map((p) => {
|
||||
const shares = allocations[p.code]?.[strategyId] ?? 0;
|
||||
const shares = shareMap.get(p.code) ?? 0;
|
||||
return shares > 0 ? { ...p, shares } : null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
@@ -61,13 +80,13 @@ export class PositionManager {
|
||||
|
||||
/** 未分配持仓(总持仓 - 已分配 > 0 的标的) */
|
||||
async getUnallocatedPositions() {
|
||||
const [positions, allocations] = await Promise.all([
|
||||
const [positions, codeMap] = await Promise.all([
|
||||
this.getAllPositions(),
|
||||
this.storage.getAll(),
|
||||
this._getAllAsCodeMap(),
|
||||
]);
|
||||
return positions
|
||||
.map((p) => {
|
||||
const alloc = allocations[p.code] ?? {};
|
||||
const alloc = codeMap[p.code] ?? {};
|
||||
const allocated = Object.values(alloc).reduce((s, n) => s + n, 0);
|
||||
const unallocated = p.volume - allocated;
|
||||
return unallocated > 0 ? { ...p, shares: alloc, unallocated } : null;
|
||||
@@ -77,9 +96,6 @@ export class PositionManager {
|
||||
|
||||
/**
|
||||
* 添加到策略(Q5:份额从 0 起填,每次只确定分配到当前策略的份额)
|
||||
* @param {string} code 证券代码
|
||||
* @param {string} strategyId 策略 id
|
||||
* @param {number} shares 要添加到该策略的股数(> 0)
|
||||
*/
|
||||
async addToStrategy(code, strategyId, shares) {
|
||||
const total = await this.getTotalVolume(code);
|
||||
@@ -97,15 +113,12 @@ export class PositionManager {
|
||||
), { code: 'share-limit' });
|
||||
}
|
||||
const next = { ...current, [strategyId]: num };
|
||||
await this.storage.set(code, next);
|
||||
await this._setShares(code, next);
|
||||
return this.getShares(code);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从策略移出(Q5:指定数量移出)
|
||||
* @param {string} code 证券代码
|
||||
* @param {string} strategyId 策略 id
|
||||
* @param {number} shares 要移出的股数(> 0,且 ≤ 该策略当前份额)
|
||||
*/
|
||||
async removeFromStrategy(code, strategyId, shares) {
|
||||
const current = await this.getShares(code);
|
||||
@@ -126,25 +139,43 @@ export class PositionManager {
|
||||
} else {
|
||||
delete next[strategyId];
|
||||
}
|
||||
await this.storage.set(code, next);
|
||||
await this._setShares(code, next);
|
||||
return this.getShares(code);
|
||||
}
|
||||
|
||||
/**
|
||||
* 分仓摘要(Q4:仅份额,无市值/盈亏统计)
|
||||
* @returns {{ positions: Array, strategies: Object<string, number>, allocatedTotal: number, unallocated: Array }}
|
||||
*/
|
||||
/** 写入某票份额(code 为中心 → 更新各策略 dataset) */
|
||||
async _setShares(code, shares) {
|
||||
const datasets = await this.storage.getAllDatasets();
|
||||
// 当前各策略 dataset(保留不含该 code 的部分,更新含该 code 的部分)
|
||||
const strategyIds = [...new Set([...datasets.map((d) => d.strategyId), ...Object.keys(shares)])];
|
||||
for (const sid of strategyIds) {
|
||||
const existing = (datasets.find((d) => d.strategyId === sid)?.dataset ?? []).filter((x) => x.code !== code);
|
||||
const n = shares[sid];
|
||||
const nextItems = n > 0 ? [...existing, { code, shares: n }] : existing;
|
||||
await this.storage.setDataset(sid, nextItems);
|
||||
}
|
||||
}
|
||||
|
||||
/** 分仓摘要(Q4:仅份额,无市值/盈亏统计) */
|
||||
async getSummary() {
|
||||
const [positions, allocations] = await Promise.all([
|
||||
const [positions, datasets] = await Promise.all([
|
||||
this.getAllPositions(),
|
||||
this.storage.getAll(),
|
||||
this.storage.getAllDatasets(),
|
||||
]);
|
||||
|
||||
// 构建 code → { strategyId: shares }
|
||||
const codeMap = {};
|
||||
for (const { strategyId, dataset } of datasets) {
|
||||
for (const item of dataset) {
|
||||
if (!codeMap[item.code]) codeMap[item.code] = {};
|
||||
codeMap[item.code][strategyId] = item.shares;
|
||||
}
|
||||
}
|
||||
|
||||
// 每个策略的份额汇总
|
||||
const strategies = {};
|
||||
// 每只票:{ totalVolume, shares: {sid: n}, allocated, unallocated }
|
||||
const positionsDetail = positions.map((p) => {
|
||||
const alloc = allocations[p.code] ?? {};
|
||||
const alloc = codeMap[p.code] ?? {};
|
||||
const allocated = Object.values(alloc).reduce((s, n) => s + n, 0);
|
||||
const unallocated = p.volume - allocated;
|
||||
for (const [sid, n] of Object.entries(alloc)) {
|
||||
@@ -162,17 +193,14 @@ export class PositionManager {
|
||||
|
||||
return {
|
||||
positions: positionsDetail,
|
||||
strategies, // { strategyId: 总份额 }
|
||||
strategies,
|
||||
allocatedTotal: Object.values(strategies).reduce((s, n) => s + n, 0),
|
||||
unallocatedPositions: positionsDetail.filter((p) => p.unallocated > 0),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 全部移入(迭代 02 O6):把某只票的全部未分配份额移入指定策略
|
||||
* 未分配 = 总持仓 - 已分配;一次 add(shares = 未分配数)
|
||||
* @param {string} code 证券代码
|
||||
* @param {string} strategyId 策略 id
|
||||
* @returns {Promise<Object<string, number>>} 更新后的份额分配
|
||||
*/
|
||||
async moveAllUnallocatedToStrategy(code, strategyId) {
|
||||
const total = await this.getTotalVolume(code);
|
||||
@@ -184,17 +212,17 @@ export class PositionManager {
|
||||
throw Object.assign(new Error(`标的 ${code} 无未分配份额`), { code: 'no-unallocated' });
|
||||
}
|
||||
const next = { ...current, [strategyId]: (current[strategyId] ?? 0) + unallocated };
|
||||
await this.storage.set(code, next);
|
||||
await this._setShares(code, next);
|
||||
return this.getShares(code);
|
||||
}
|
||||
|
||||
/**
|
||||
* 一键清零(迭代 02 O6):清空某策略下所有股票的份额(份额回未分配)
|
||||
* @param {string} strategyId 策略 id
|
||||
* 一键清零(迭代 02 O6):清空某策略下所有股票的份额
|
||||
* @returns {Promise<string[]>} 受影响标的代码列表
|
||||
*/
|
||||
async clearStrategyShares(strategyId) {
|
||||
return this.storage.removeStrategyShares(strategyId);
|
||||
const before = await this.storage.getDataset(strategyId);
|
||||
await this.storage.removeDataset(strategyId);
|
||||
return before.map((d) => d.code);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* 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 : [],
|
||||
};
|
||||
}
|
||||
|
||||
/** 委托字段映射:m_ 原始字段 → 语义字段(R-007 2.1/2.2) */
|
||||
mapOrder(o) {
|
||||
const direction = o.m_nOffsetFlag === 48 ? 'buy' : o.m_nOffsetFlag === 49 ? 'sell' : '';
|
||||
return {
|
||||
orderId: o.m_strOrderSysID ?? '',
|
||||
code: o.m_strInstrumentID ?? '',
|
||||
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),
|
||||
insertDate: o.m_strInsertDate ?? '',
|
||||
insertTime: o.m_strInsertTime ?? '',
|
||||
cancelInfo: o.m_strCancelInfo ?? '',
|
||||
errorMsg: o.m_strErrorMsg ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算单笔成交的费用(2026-09-01 老师定规则)
|
||||
* - 佣金:成交额 × (佣金费率万分之m_dCommission),不足 5 元按 5 元(账号没免五)
|
||||
* - 印花税:卖出单边,成交额 × 万分之5(2023-08-28 起标准)
|
||||
* - 过户费:双向,成交额 × 万分之0.1(2022-04-29 起标准,无最低限额)
|
||||
* @param {number} amount 成交额
|
||||
* @param {number} commissionRateWan 佣金费率(万分之,来自 m_dCommission)
|
||||
* @param {'buy'|'sell'|''} direction 方向
|
||||
* @returns {{ commission: number, stampTax: number, transferFee: number, total: number }}
|
||||
*/
|
||||
calcTradeFees(amount, commissionRateWan, direction) {
|
||||
const a = Math.max(0, +(amount ?? 0));
|
||||
// 佣金:万分之X,最低 5 元(没免五)
|
||||
let commission = a * (commissionRateWan / 10000);
|
||||
if (commission < 5 && commission > 0) commission = 5;
|
||||
// 印花税:卖出单边,万分之5
|
||||
const stampTax = direction === 'sell' ? a * 0.0005 : 0;
|
||||
// 过户费:双向,万分之0.1
|
||||
const transferFee = a * 0.00001;
|
||||
return {
|
||||
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' : '';
|
||||
const commissionRateWan = +(t.m_dCommission ?? t.m_dComssion ?? 0); // 拼写变体兜底
|
||||
const amount = +(t.m_dTradeAmount ?? 0);
|
||||
const fees = this.calcTradeFees(amount, commissionRateWan, direction);
|
||||
return {
|
||||
tradeId: t.m_strTradeID ?? '',
|
||||
orderId: t.m_strOrderSysID ?? '', // 关联委托
|
||||
code: t.m_strInstrumentID ?? '',
|
||||
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,
|
||||
commissionRateWan, // 佣金费率(万分之)
|
||||
commission: fees.commission, // 佣金金额(最低5元)
|
||||
stampTax: fees.stampTax, // 印花税(卖出)
|
||||
transferFee: fees.transferFee, // 过户费
|
||||
feeTotal: fees.total, // 总费用
|
||||
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)),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* QmtHealthMonitor —— QMT 连接健康检查(2026-09-01 老师定)
|
||||
*
|
||||
* 机制(服务端定时 + 缓存):
|
||||
* - DSH 服务端每 5 分钟探测一次激活 QMT Bridge /health,结果缓存在内存;
|
||||
* - 前端打开页面时读缓存(秒回,不等实时探测);
|
||||
* - 激活配置切换时立即补探一次(热切换联动);
|
||||
* - 前端点击/悬停可触发即时探测(on-demand),刷新缓存。
|
||||
*
|
||||
* 职责(单一):维护 QMT 健康状态缓存。
|
||||
*/
|
||||
|
||||
import { resolveActiveBaseUrl } from '../api/common.js';
|
||||
|
||||
const HEALTH_TIMEOUT_MS = 5000;
|
||||
const HEALTH_INTERVAL_MS = 5 * 60 * 1000; // 5 分钟定时探测
|
||||
|
||||
export class QmtHealthMonitor {
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {object} opts.runtime { settings, dataSource } —— 解析激活 baseUrl
|
||||
* @param {object} [opts.logger]
|
||||
*/
|
||||
constructor({ runtime, logger } = {}) {
|
||||
this.runtime = runtime;
|
||||
this.logger = logger;
|
||||
this.cache = null; // { baseUrl, healthy, latencyMs, httpStatus, healthError, checkedAt }
|
||||
this.timer = null;
|
||||
this.mounted = true;
|
||||
this._inFlight = null; // 防止并发探测
|
||||
}
|
||||
|
||||
/** 启动:立即探测一次 + 每 5 分钟定时 */
|
||||
start() {
|
||||
this.mounted = true;
|
||||
this.probe(); // 启动即探测
|
||||
this.timer = setInterval(() => this.probe(), HEALTH_INTERVAL_MS);
|
||||
this.logger?.info?.('[one-divine-lot] QmtHealthMonitor 已启动(5 分钟定时探测)');
|
||||
}
|
||||
|
||||
/** 停止(插件释放时) */
|
||||
stop() {
|
||||
this.mounted = false;
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 读取缓存健康状态(前端读这个,秒回) */
|
||||
getStatus() {
|
||||
return this.cache
|
||||
? { ...this.cache, fromCache: true }
|
||||
: { healthy: null, baseUrl: '', latencyMs: 0, healthError: null, checkedAt: 0, fromCache: false };
|
||||
}
|
||||
|
||||
/** 立即探测(启动/定时/前端点击/配置切换共用),结果写缓存 */
|
||||
async probe() {
|
||||
if (!this.mounted) return this.cache;
|
||||
// 防并发:同一时刻只探测一次
|
||||
if (this._inFlight) return this._inFlight;
|
||||
const baseUrl = resolveActiveBaseUrl(this.runtime.settings, this.runtime.dataSource);
|
||||
const startedAt = Date.now();
|
||||
this._inFlight = (async () => {
|
||||
let result;
|
||||
try {
|
||||
const res = await fetch(baseUrl + '/health', {
|
||||
signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS),
|
||||
});
|
||||
result = {
|
||||
baseUrl,
|
||||
healthy: res.ok,
|
||||
latencyMs: Date.now() - startedAt,
|
||||
httpStatus: res.status,
|
||||
healthError: null,
|
||||
checkedAt: Date.now(),
|
||||
};
|
||||
} catch (e) {
|
||||
result = {
|
||||
baseUrl,
|
||||
healthy: false,
|
||||
latencyMs: Date.now() - startedAt,
|
||||
httpStatus: null,
|
||||
healthError: e?.message ?? String(e),
|
||||
checkedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
this.cache = result;
|
||||
return result;
|
||||
})();
|
||||
try {
|
||||
return await this._inFlight;
|
||||
} finally {
|
||||
this._inFlight = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,26 @@
|
||||
* @property {number} positionProfit 持仓盈亏(账户级)
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} Instrument 统一合约/证券基本信息模型
|
||||
* 来源:GET /data/instrument(QMT ContextInfo.get_instrument_detail)
|
||||
* 语义化字段优先,回退 QMT 原始字段(如 FloatVolumn/TotalVolumn 拼写)
|
||||
* @property {string} code 完整证券代码(含交易所后缀,如 600519.SH)
|
||||
* @property {string} instrumentId 合约代码(不带后缀,如 600519)
|
||||
* @property {string} name 证券名称
|
||||
* @property {string} exchange 交易所(SH/SZ)
|
||||
* @property {number} openDate 上市日期 YYYYMMDD
|
||||
* @property {number} preClose 昨收盘价
|
||||
* @property {number} upStopPrice 涨停价
|
||||
* @property {number} downStopPrice 跌停价
|
||||
* @property {number} floatVolume 流通股本(股)
|
||||
* @property {number} totalVolume 总股本(股)
|
||||
* @property {number} priceTick 最小价格变动单位
|
||||
* @property {number} volumeMultiple 合约乘数(股票=1)
|
||||
* @property {number} instrumentStatus 合约状态(0=正常等)
|
||||
* @property {number} tradingDay 交易日 YYYYMMDD
|
||||
*/
|
||||
|
||||
/**
|
||||
* 数据源统一接口:一个数据源适配器必须实现这些方法
|
||||
* @typedef {Object} DataSource
|
||||
@@ -1,98 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/** 基础 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));
|
||||
}
|
||||
|
||||
/** 持仓字段映射:语义字段优先,回退 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)),
|
||||
};
|
||||
}
|
||||
}
|
||||
+45
-16
@@ -13,11 +13,14 @@
|
||||
* 设计约束:技术约束-001/003、产品约束-001/002/003
|
||||
*/
|
||||
|
||||
import { QmtBridgeRestDataSource } from './data-source/qmt-bridge-rest.js';
|
||||
import { registerSettings } from './settings.js';
|
||||
import { AllocationStorage } from './storage.js';
|
||||
import { PositionManager } from './position/manager.js';
|
||||
import { registerApi } from './api.js';
|
||||
import { QmtBridgeRestDataSource } from './component/QmtBridgeRestDataSource.js';
|
||||
import { registerSettings, resolveStartupConnection } from './settings.js';
|
||||
import { DataStore } from './component/DataStore.js';
|
||||
import { PositionManager } from './component/PositionManager.js';
|
||||
import { registerApi } from './api/index.js';
|
||||
import { MarketDataHub } from './component/MarketDataHub.js';
|
||||
import { MarketFeed } from './component/MarketFeed.js';
|
||||
import { QmtHealthMonitor } from './component/QmtHealthMonitor.js';
|
||||
|
||||
const name = 'one-divine-lot';
|
||||
|
||||
@@ -27,25 +30,49 @@ const inject = ['settings', 'webServer'];
|
||||
async function apply(ctx, config) {
|
||||
const logger = ctx.logger;
|
||||
|
||||
// S2: 数据源(REST 直连 QMT Bridge)
|
||||
const dataSource = new QmtBridgeRestDataSource({
|
||||
baseUrl: config?.qmtBaseUrl,
|
||||
timeoutMs: config?.qmtTimeoutMs,
|
||||
});
|
||||
|
||||
// S3: 设置管理(策略=标签 + 显示开关)
|
||||
// S3: 设置管理(策略=标签 + 显示开关 + QMT 连接配置)
|
||||
const settings = registerSettings(ctx, config);
|
||||
|
||||
// S4: 分仓数据本地存储
|
||||
const storage = new AllocationStorage({
|
||||
// S2: 数据源(REST 直连 QMT Bridge)
|
||||
// R-004(技术约束-008):启动激活地址 = 默认配置 → 上次激活 → 第一条;列表为空回退 cordis qmtBaseUrl(Q4 不迁移)
|
||||
const settingsScope = settings;
|
||||
let startup;
|
||||
try {
|
||||
startup = await resolveStartupConnection(settingsScope, config);
|
||||
} catch (e) {
|
||||
logger.warn('[one-divine-lot] 启动连接解析失败,回退 cordis qmtBaseUrl: ' + e.message);
|
||||
startup = { connection: null, baseUrl: config?.qmtBaseUrl ?? null, source: 'cordis-fallback' };
|
||||
}
|
||||
const dataSource = new QmtBridgeRestDataSource({
|
||||
baseUrl: startup.baseUrl ?? config?.qmtBaseUrl,
|
||||
timeoutMs: config?.qmtTimeoutMs,
|
||||
});
|
||||
logger.info(
|
||||
'[one-divine-lot] QMT 连接激活: ' + (startup.connection ? startup.connection.name + ' (' + startup.connection.baseUrl + ')' : String(startup.baseUrl ?? '(未配置)')) +
|
||||
' [来源: ' + startup.source + ']'
|
||||
);
|
||||
|
||||
// S4: 分仓数据本地存储(R-006:JSON data store,策略为中心)
|
||||
const storage = new DataStore({
|
||||
dataDir: config?.dataDir,
|
||||
});
|
||||
|
||||
// R-005:行情数据(MarketDataHub 缓存 + MarketFeed 获取,页面轮询读取)
|
||||
const marketHub = new MarketDataHub({ logger, dataStore: storage });
|
||||
// 启动时从磁盘加载行情缓存(首屏快速展示,不依赖实盘订阅)
|
||||
marketHub.warmup().catch((e) => logger.warn('[one-divine-lot] 行情预热失败: ' + e.message));
|
||||
const marketFeed = new MarketFeed({ hub: marketHub, runtime: { settings, dataSource }, logger });
|
||||
marketFeed.start(startup.baseUrl ?? config?.qmtBaseUrl);
|
||||
|
||||
// QMT 连接健康检查(服务端定时探测 + 缓存,2026-09-01)
|
||||
const qmtHealthMonitor = new QmtHealthMonitor({ runtime: { settings, dataSource }, logger });
|
||||
qmtHealthMonitor.start();
|
||||
|
||||
// S5: 分仓逻辑(份额分配/查询)
|
||||
const manager = new PositionManager({ dataSource, storage });
|
||||
|
||||
// S6: 服务端 HTTP API
|
||||
registerApi(ctx, { manager, settings });
|
||||
// S6: 服务端 HTTP API(R-004:注入 dataSource 以编排激活热切换)
|
||||
registerApi(ctx, { manager, settings, dataSource, marketHub, marketFeed, qmtHealthMonitor });
|
||||
|
||||
// 启动时可用性检查(日志,不阻塞)
|
||||
dataSource.isAvailable().then((ok) => {
|
||||
@@ -57,6 +84,8 @@ async function apply(ctx, config) {
|
||||
ctx.effect(() => {
|
||||
logger.info('[one-divine-lot] 插件已加载(S1-S6 就绪)');
|
||||
return () => {
|
||||
marketFeed.stop();
|
||||
qmtHealthMonitor.stop();
|
||||
logger.info('[one-divine-lot] 插件已释放');
|
||||
};
|
||||
}, 'one-divine-lot.dispose');
|
||||
|
||||
+161
@@ -37,6 +37,17 @@ export const strategySchema = z.object({
|
||||
tradeRecords: true,
|
||||
watchlist: true,
|
||||
}),
|
||||
// QMT 连接配置(R-004,技术约束-008):list + 激活态 + 默认态
|
||||
qmtConnections: z.object({
|
||||
list: z.array(z.object({
|
||||
id: z.string().required(),
|
||||
name: z.string().required(),
|
||||
baseUrl: z.string().required(),
|
||||
order: z.number().default(0),
|
||||
})).default([]),
|
||||
activeId: z.string().default(''),
|
||||
defaultId: z.string().default(''),
|
||||
}).default({ list: [], activeId: '', defaultId: '' }),
|
||||
});
|
||||
|
||||
/** 默认通用 tab 配置 */
|
||||
@@ -308,3 +319,153 @@ export async function updateTabConfig(scope, tabs) {
|
||||
};
|
||||
await scope.update(next);
|
||||
}
|
||||
|
||||
/**
|
||||
* ============================================================================
|
||||
* QMT 连接配置(R-004 已定稿;产品约束-005、技术约束-008)
|
||||
*
|
||||
* 数据结构:qmtConnections: { list: [{id,name,baseUrl,order}], activeId, defaultId }
|
||||
* - 激活:一次只能激活一个(activeId),激活配置为当前生效连接,切换立即生效(Q2)
|
||||
* - 默认:启动时自动激活默认配置(Q1),重启回到默认;运行中手动激活立即生效
|
||||
* - 启动兜底:列表为空时回退 cordis 注入的 qmtBaseUrl(Q4 不迁移)
|
||||
* - 删除边界(Q6):删激活→自动切默认;删默认→默认转移第一条并激活之;禁删最后一条
|
||||
*/
|
||||
|
||||
/** 归一化并校验 HTTP 地址:trim + 去尾斜杠 + 校验 http(s):// 前缀 */
|
||||
export function normalizeBaseUrl(raw) {
|
||||
const url = String(raw ?? '').trim().replace(/\/+$/, '');
|
||||
if (!/^https?:\/\//i.test(url) || url.length <= 8) {
|
||||
throw Object.assign(new Error('HTTP 地址格式无效(需以 http:// 或 https:// 开头)'), { code: 'qmt-invalid-url' });
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
/** 生成连接配置英文 id(复用策略拼音 slug;兜底前缀 qmt-conn) */
|
||||
export function generateQmtConnectionId(name, existingIds = []) {
|
||||
const base = generateStrategyId(name, []);
|
||||
const slug = base && base !== 'strategy' ? base : 'qmt-conn';
|
||||
return dedupe(slug, existingIds);
|
||||
}
|
||||
|
||||
/** 读取 QMT 连接配置(list 按 order 排序) */
|
||||
export function getQmtConnections(scope) {
|
||||
const value = scope?.get() ?? {};
|
||||
const qc = value.qmtConnections ?? {};
|
||||
const list = Array.isArray(qc.list) ? qc.list : [];
|
||||
return {
|
||||
list: list.slice().sort((a, b) => (a.order ?? 0) - (b.order ?? 0)),
|
||||
activeId: typeof qc.activeId === 'string' ? qc.activeId : '',
|
||||
defaultId: typeof qc.defaultId === 'string' ? qc.defaultId : '',
|
||||
};
|
||||
}
|
||||
|
||||
/** 解析当前生效连接:activeId → defaultId → order 第一条 → null */
|
||||
export function getActiveQmtConnection(state) {
|
||||
const byId = (id) => (id ? state.list.find((c) => c.id === id) ?? null : null);
|
||||
return byId(state.activeId) ?? byId(state.defaultId) ?? state.list[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动激活连接解析(Q1/Q4):默认 → 上次激活 → 第一条;列表为空回退 cordis qmtBaseUrl。
|
||||
* 解析结果与 activeId 不一致时回写(保证运行期状态一致)。
|
||||
* @returns {Promise<{connection: object|null, baseUrl: string|null, source: string}>}
|
||||
*/
|
||||
export async function resolveStartupConnection(scope, config) {
|
||||
const state = getQmtConnections(scope);
|
||||
if (!state.list.length) {
|
||||
const fallback = String(config?.qmtBaseUrl ?? '').trim().replace(/\/+$/, '');
|
||||
return { connection: null, baseUrl: fallback || null, source: fallback ? 'cordis-fallback' : 'none' };
|
||||
}
|
||||
const byId = (id) => (id ? state.list.find((c) => c.id === id) ?? null : null);
|
||||
const defaultConn = byId(state.defaultId);
|
||||
const activeConn = byId(state.activeId);
|
||||
const chosen = defaultConn
|
||||
? { conn: defaultConn, why: 'default' }
|
||||
: activeConn
|
||||
? { conn: activeConn, why: 'last-active' }
|
||||
: { conn: state.list[0], why: 'first' };
|
||||
if (chosen.conn.id !== state.activeId) {
|
||||
await activateQmtConnection(scope, chosen.conn.id);
|
||||
}
|
||||
return { connection: chosen.conn, baseUrl: chosen.conn.baseUrl, source: 'settings:' + chosen.why };
|
||||
}
|
||||
|
||||
/** 新增连接配置 */
|
||||
export async function addQmtConnection(scope, { name, baseUrl } = {}) {
|
||||
const trimmedName = String(name ?? '').trim();
|
||||
if (!trimmedName) throw Object.assign(new Error('连接名称不能为空'), { code: 'qmt-name-required' });
|
||||
const normalizedUrl = normalizeBaseUrl(baseUrl);
|
||||
const state = getQmtConnections(scope);
|
||||
const id = generateQmtConnectionId(trimmedName, state.list.map((c) => c.id));
|
||||
const conn = {
|
||||
id,
|
||||
name: trimmedName,
|
||||
baseUrl: normalizedUrl,
|
||||
order: state.list.reduce((max, c) => Math.max(max, c.order ?? 0), -1) + 1,
|
||||
};
|
||||
const value = scope?.get() ?? {};
|
||||
await scope.update({ ...value, qmtConnections: { ...state, list: [...state.list, conn] } });
|
||||
return conn;
|
||||
}
|
||||
|
||||
/** 编辑连接配置(名称 / 地址);返回更新后的配置对象 */
|
||||
export async function updateQmtConnection(scope, id, patch = {}) {
|
||||
const state = getQmtConnections(scope);
|
||||
const target = state.list.find((c) => c.id === id);
|
||||
if (!target) throw Object.assign(new Error('连接配置不存在: ' + id), { code: 'qmt-connection-not-found' });
|
||||
const nextName = patch.name !== undefined ? String(patch.name).trim() : target.name;
|
||||
if (!nextName) throw Object.assign(new Error('连接名称不能为空'), { code: 'qmt-name-required' });
|
||||
const nextUrl = patch.baseUrl !== undefined ? normalizeBaseUrl(patch.baseUrl) : target.baseUrl;
|
||||
const updated = { ...target, name: nextName, baseUrl: nextUrl };
|
||||
const value = scope?.get() ?? {};
|
||||
await scope.update({
|
||||
...value,
|
||||
qmtConnections: { ...state, list: state.list.map((c) => (c.id === id ? updated : c)) },
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** 删除连接配置(含 Q6 删除边界处理);返回删除后的状态与边界信息 */
|
||||
export async function removeQmtConnection(scope, id) {
|
||||
const state = getQmtConnections(scope);
|
||||
if (!state.list.some((c) => c.id === id)) {
|
||||
throw Object.assign(new Error('连接配置不存在: ' + id), { code: 'qmt-connection-not-found' });
|
||||
}
|
||||
if (state.list.length <= 1) {
|
||||
throw Object.assign(new Error('不能删除最后一个连接配置'), { code: 'qmt-last-connection' });
|
||||
}
|
||||
const removedWasActive = state.activeId === id;
|
||||
const removedWasDefault = state.defaultId === id;
|
||||
const list = state.list.filter((c) => c.id !== id);
|
||||
// 删默认 → 默认标记转移到列表第一条(产品约束-005)
|
||||
let defaultId = removedWasDefault ? (list[0]?.id ?? '') : state.defaultId;
|
||||
// 删激活 → 自动切换到默认(若默认仍有效),否则第一条
|
||||
let activeId = removedWasActive
|
||||
? (defaultId && list.some((c) => c.id === defaultId) ? defaultId : (list[0]?.id ?? ''))
|
||||
: state.activeId;
|
||||
const value = scope?.get() ?? {};
|
||||
await scope.update({ ...value, qmtConnections: { list, activeId, defaultId } });
|
||||
return { list, activeId, defaultId, removedWasActive, removedWasDefault };
|
||||
}
|
||||
|
||||
/** 激活连接配置(写 activeId;热切换由 api 层编排 setBaseUrl) */
|
||||
export async function activateQmtConnection(scope, id) {
|
||||
const state = getQmtConnections(scope);
|
||||
if (!state.list.some((c) => c.id === id)) {
|
||||
throw Object.assign(new Error('连接配置不存在: ' + id), { code: 'qmt-connection-not-found' });
|
||||
}
|
||||
const value = scope?.get() ?? {};
|
||||
await scope.update({ ...value, qmtConnections: { ...state, activeId: id } });
|
||||
return { ...state, activeId: id };
|
||||
}
|
||||
|
||||
/** 设为默认配置(写 defaultId) */
|
||||
export async function setDefaultQmtConnection(scope, id) {
|
||||
const state = getQmtConnections(scope);
|
||||
if (!state.list.some((c) => c.id === id)) {
|
||||
throw Object.assign(new Error('连接配置不存在: ' + id), { code: 'qmt-connection-not-found' });
|
||||
}
|
||||
const value = scope?.get() ?? {};
|
||||
await scope.update({ ...value, qmtConnections: { ...state, defaultId: id } });
|
||||
return { ...state, defaultId: id };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user