迭代13: 盘口内存快照(QuoteSync/QuoteHub)+ 同步指示灯 + 行情列
- QuoteSync/QuoteHub 替换 MarketFeed/MarketDataHub(方案A 删除重写): 5s REST 同步 watch 集合 + 涨停/跌停经 /data/instrument 按交易日内存缓存 + 读穿透走适配层 getTicks(修正旧 hub 绕适配层的层级破洞) - WS 数据通路移除(从无生效结论;ingest 留 source 标签回归口子) - market_quotes_cache 表退役 DROP(幂等);价格单一入口 = QuoteHub - 数据同步指示灯组: QMT连接|持仓数据|行情数据(绿/黄/灰,点击即同步/即时探测) + sync-status 端点(吸收 market-stats)+ sync-now;修复 runtime 漏传 positionSync 致持灯恒灰 - 策略持仓表行情列: 涨停价/跌停价/今开/最高(COLUMN_META defaultVisible=false,纯价格) - 回归 test-quote-sync.mjs 27 项(含 DROP 幂等真实 SQLite 验证) - 文档链: R-015 + PLAN-014 + 迭代三件套 + 技术约束-010/012变更、018新增 + 产品约束-012 需求: R-015(老师五拍板: 替换/WS移除/纯内存DROP/watch现状/指示灯)
This commit is contained in:
+2
-2
@@ -59,8 +59,8 @@ const HANDLERS = [
|
||||
* @param {import('@deepseek-ai/cordis').Context} ctx
|
||||
* @param {object} runtime { manager, settings, dataSource, marketCache }
|
||||
*/
|
||||
export function registerApi(ctx, { manager, settings, dataSource, marketHub, marketFeed, qmtHealthMonitor, storage, tradeSync }) {
|
||||
const runtime = { ctx, manager, settings, dataSource, marketHub, marketFeed, qmtHealthMonitor, storage, tradeSync };
|
||||
export function registerApi(ctx, { manager, settings, dataSource, marketHub, marketFeed, qmtHealthMonitor, storage, tradeSync, positionSync }) {
|
||||
const runtime = { ctx, manager, settings, dataSource, marketHub, marketFeed, qmtHealthMonitor, storage, tradeSync, positionSync };
|
||||
|
||||
ctx.effect(() => ctx.webServer.register({
|
||||
kind: 'prefix',
|
||||
|
||||
+68
-19
@@ -1,12 +1,11 @@
|
||||
/**
|
||||
* 服务端 API:行情域(2026-08-31 拆分;2026-09-01 精简)
|
||||
* 服务端 API:行情域(2026-08-31 拆分;2026-09-02 R-015/迭代 13 改造)
|
||||
*
|
||||
* 端点:
|
||||
* market-snapshot → 按 code 查询行情缓存(miss 自动补拉在 MarketDataHub 内部,此处只做编排)
|
||||
* qmt-health → QMT 连接健康检查(读服务端缓存;refresh=true 触发即时探测)
|
||||
*
|
||||
* 2026-09-01:QMT health 改为「服务端定时探测(5 分钟)+ 缓存」机制(QmtHealthMonitor),
|
||||
* 前端打开读缓存秒回,点击/悬停传 refresh=true 触发即时探测。
|
||||
* market-snapshot → 按 code 查盘口内存快照(含涨停/跌停;miss 读穿透)
|
||||
* qmt-health → QMT 连接健康检查(服务端缓存 + refresh 即时探测,2026-09-01)
|
||||
* sync-status → 数据同步指示灯状态(持仓/盘口/QMT 三域,吸收原临时诊断 market-stats)
|
||||
* sync-now → 手动触发某域立即同步(指示灯点击)
|
||||
*/
|
||||
|
||||
import { resolveActiveBaseUrl } from './common.js';
|
||||
@@ -15,21 +14,45 @@ import { resolveActiveBaseUrl } from './common.js';
|
||||
export const MARKET_METHODS = new Set([
|
||||
'market-snapshot',
|
||||
'qmt-health',
|
||||
'market-stats', // 临时诊断:WS/REST 通道统计
|
||||
'sync-status',
|
||||
'sync-now',
|
||||
]);
|
||||
|
||||
/** 盘口快照对外投影:UI 消费字段 + updatedAt(价龄);涨停/跌停来自 QuoteHub 合约缓存 */
|
||||
function projectQuote(hub, code, snap) {
|
||||
const inst = hub.instruments.get(code) ?? {};
|
||||
return {
|
||||
code,
|
||||
lastPrice: snap?.lastPrice ?? null,
|
||||
lastClose: snap?.lastClose ?? null,
|
||||
open: snap?.open ?? null,
|
||||
high: snap?.high ?? null,
|
||||
low: snap?.low ?? null,
|
||||
volume: snap?.volume ?? null,
|
||||
amount: snap?.amount ?? null,
|
||||
upStopPrice: inst.upStopPrice ?? null,
|
||||
downStopPrice: inst.downStopPrice ?? null,
|
||||
updatedAt: snap?.updatedAt ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理行情端点
|
||||
* @param {string} method
|
||||
* @param {object} args
|
||||
* @param {object} runtime { settings, dataSource, marketHub, marketFeed, qmtHealthMonitor }
|
||||
* @param {object} runtime { settings, dataSource, marketHub, marketFeed(=QuoteSync), qmtHealthMonitor, positionSync }
|
||||
*/
|
||||
export async function handleMarket(method, args, { ctx, settings, dataSource, marketHub, marketFeed, qmtHealthMonitor }) {
|
||||
export async function handleMarket(method, args, { settings, dataSource, marketHub, marketFeed: marketSync, qmtHealthMonitor, positionSync }) {
|
||||
switch (method) {
|
||||
case 'market-snapshot': {
|
||||
const codes = Array.isArray(args.codes) ? args.codes : [];
|
||||
if (!marketHub) return {};
|
||||
return await marketHub.getByCodes(codes, { settings, dataSource });
|
||||
const quotes = await marketHub.getQuotes(codes, { settings, dataSource });
|
||||
const out = {};
|
||||
for (const [code, snap] of Object.entries(quotes)) {
|
||||
out[code] = projectQuote(marketHub, code, snap);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
case 'qmt-health': {
|
||||
// QMT 连接健康检查(服务端缓存 + 即时探测,2026-09-01)
|
||||
@@ -37,20 +60,46 @@ export async function handleMarket(method, args, { ctx, settings, dataSource, ma
|
||||
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(),
|
||||
case 'sync-status': {
|
||||
// 数据同步指示灯(R-015 产品约束-012):绿=新鲜 / 黄=失败中陈旧 / 灰=从未;阈值 = 3×周期
|
||||
const state = (status) => {
|
||||
if (!status || !status.syncedAt) return 'never';
|
||||
return Date.now() - status.syncedAt <= status.periodMs * 3 ? 'fresh' : 'stale';
|
||||
};
|
||||
return {
|
||||
serverTime: Date.now(),
|
||||
position: positionSync
|
||||
? {
|
||||
syncedAt: positionSync.getSyncedAt(),
|
||||
periodMs: 10000,
|
||||
state: state({ syncedAt: positionSync.getSyncedAt(), periodMs: 10000 }),
|
||||
snapshotSize: positionSync.getSnapshot().length,
|
||||
failCount: positionSync.stats.failCount,
|
||||
lastError: positionSync.stats.lastError,
|
||||
}
|
||||
: null,
|
||||
quote: marketSync
|
||||
? { ...marketSync.getStatus(), state: state(marketSync.getStatus()) }
|
||||
: null,
|
||||
qmt: qmtHealthMonitor ? qmtHealthMonitor.getStatus() : null,
|
||||
};
|
||||
}
|
||||
case 'sync-now': {
|
||||
// 指示灯点击:立即触发对应域同步(不等下个周期)
|
||||
const domain = args.domain;
|
||||
if (domain === 'position' && positionSync) {
|
||||
const r = await positionSync.syncNow();
|
||||
return { ok: r !== null, domain, result: r };
|
||||
}
|
||||
if (domain === 'quote' && marketSync) {
|
||||
const r = await marketSync.syncNow();
|
||||
return { ok: r !== null, domain, result: r };
|
||||
}
|
||||
throw Object.assign(new Error('unknown sync domain: ' + domain), { code: 'bad-request' });
|
||||
}
|
||||
default:
|
||||
throw Object.assign(new Error('unknown market method: ' + method), { code: 'not-found' });
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* (技术约束-008:按请求读取 baseUrl,改字段即生效)
|
||||
*
|
||||
* 2026-08-31:移除 subscribe-ws / unsubscribe-ws / tick-snapshot(架构演进后无人调用,
|
||||
* 订阅与快照统一由 MarketFeed/MarketDataHub 内部处理,不再暴露为 HTTP 端点)
|
||||
* 订阅与快照统一由 QuoteSync/QuoteHub 内部处理,不再暴露为 HTTP 端点(R-015 迭代 13)
|
||||
*/
|
||||
|
||||
import {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* MarketDataProvider —— 行情数据提供者(R-005 服务端中转架构,2026-08-31)
|
||||
*
|
||||
* 架构(服务端中转,老师确认):
|
||||
* - DSH 服务端做「一次 WS 订阅 + QMT 桥整合」,维护一份实时行情缓存(MarketDataHub + 持久化);
|
||||
* - DSH 服务端维护盘口内存快照(R-015:QuoteSync + QuoteHub,纯内存不落库,WS 已移除);
|
||||
* - 前端**统一轮询** DSH 接口(/odl/api/market-snapshot,按 code 查询),不做前端直连;
|
||||
* - 轮询间隔 5s(老师确认)。
|
||||
*
|
||||
@@ -134,7 +134,6 @@ export function MarketDataProvider({ children }) {
|
||||
status,
|
||||
getPrice: useCallback((code) => prices[code] ?? null, [prices]),
|
||||
registerCodes,
|
||||
wsInfo: null,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* - 挂载 slot:conversation.session.header.actions(与 DSH 内置 PTC 模式标签并排)
|
||||
* - 形态:
|
||||
* - chip「QMT: <激活配置名> ▾」:点开下拉列出全部配置(激活项勾选),仅切换激活
|
||||
* - chip 右侧独立「QMT 健康」控件:小圆点(绿=健康 / 红=异常),展示 QMT 连接状态
|
||||
* - chip 右侧同步指示灯组(2026-09-03 调整):QMT连接(绿/红/灰,点击即时探测)|持仓数据|行情数据(绿/黄/灰,点击立即同步)
|
||||
* - 数据:qmt-health 端点(服务端代理 GET 激活配置 /health)
|
||||
*
|
||||
* 2026-09-01:移除全盘订阅状态标记(暂缓,重开筛选做);QMT health 检查独立保留。
|
||||
@@ -13,6 +13,7 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useRpc } from './connection.jsx';
|
||||
import { ToastProvider, useToast } from './Toast.jsx';
|
||||
import { SyncIndicators } from './SyncIndicators.jsx';
|
||||
|
||||
/** 下拉菜单项 */
|
||||
function MenuItem({ item, selected, onSelect }) {
|
||||
@@ -37,69 +38,6 @@ function MenuItem({ item, selected, onSelect }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 ? 'var(--dsw-alias-label-tertiary, #bbb)' : (health.healthy ? 'var(--dsw-alias-state-success-primary, #2e7d32)' : 'var(--dsw-alias-state-error-primary, #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: health === null ? 'none' : '0 0 4px ' + color,
|
||||
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);
|
||||
@@ -174,8 +112,8 @@ function ChipInner() {
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{label}</span>
|
||||
<span style={{ fontSize: 10, flex: 'none' }}>▾</span>
|
||||
</button>
|
||||
{/* QMT 健康状态控件(下拉框右侧,绿灯=健康/红灯=异常) */}
|
||||
<QmtHealthIndicator />
|
||||
{/* 数据同步指示灯组(R-015 迭代 13:QMT连接/持仓数据/行情数据 三灯,点击即操作) */}
|
||||
<SyncIndicators />
|
||||
{open && (
|
||||
<div
|
||||
role="menu"
|
||||
|
||||
@@ -384,6 +384,15 @@ function StrategyTabInner({ strategyId, strategyName }) {
|
||||
return <span>{fmtPrice(p.lastTradePrice)}</span>;
|
||||
case 'shares':
|
||||
return <span style={{ fontWeight: 'bold' }}>{p.shares}</span>;
|
||||
// R-015/迭代 13:行情列(盘口快照 + 合约信息;纯价格展示,缺数据 —)
|
||||
case 'upStopPrice':
|
||||
return <span>{fmtPrice(getPrice(p.code)?.upStopPrice)}</span>;
|
||||
case 'downStopPrice':
|
||||
return <span>{fmtPrice(getPrice(p.code)?.downStopPrice)}</span>;
|
||||
case 'open':
|
||||
return <span>{fmtPrice(getPrice(p.code)?.open)}</span>;
|
||||
case 'high':
|
||||
return <span>{fmtPrice(getPrice(p.code)?.high)}</span>;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* SyncIndicators —— 数据同步指示灯组(R-015 / 迭代 13;2026-09-03 老师调整:三灯带全称标签)
|
||||
*
|
||||
* 位置:会话头部(QmtConnectionChip 内,QMT 下拉框右侧)。
|
||||
* 形态:QMT连接(灯)|持仓数据(灯)|行情数据(灯)——统一圆点 + 全称标签。
|
||||
*
|
||||
* 状态(产品约束-012):
|
||||
* - QMT 连接:绿=健康 / 红=异常 / 灰=未知(qmt-health 缓存)
|
||||
* - 持仓数据:绿=fresh(syncedAt 30s 内)/ 黄=stale / 灰=never(PositionSync 10s 周期)
|
||||
* - 行情数据:绿=fresh(15s 内)/ 黄=stale / 灰=never(QuoteSync 5s 周期)
|
||||
* 悬停:状态 + 同步时间 + 快照量 + 失败次数 + 错误摘要。
|
||||
* 点击(持仓/行情):立即触发该域 syncNow;点击 QMT:即时探测 health。
|
||||
*
|
||||
* 数据:sync-status(10s 轮询,读服务端内存)+ qmt-health refresh。
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRpc } from './connection.jsx';
|
||||
|
||||
const POLL_MS = 10000;
|
||||
|
||||
/** 统一灯:label + color + title + onClick */
|
||||
function IndicatorDot({ label, color, glow, title, onClick, dimmed }) {
|
||||
return (
|
||||
<span
|
||||
role="button"
|
||||
aria-label={label + '状态'}
|
||||
title={title}
|
||||
onClick={onClick}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4, cursor: 'pointer',
|
||||
opacity: dimmed ? 0.5 : 1, transition: 'opacity .2s', flex: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 12, color: 'var(--dsw-alias-label-secondary, #666)', lineHeight: 1 }}>{label}</span>
|
||||
<span style={{
|
||||
display: 'inline-block', width: 10, height: 10, borderRadius: '50%', background: color,
|
||||
boxShadow: glow ? '0 0 4px ' + color : 'none',
|
||||
}} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const STATE_TEXT = { fresh: '同步正常', stale: '同步失败中(页面显示上次数据)', never: '尚未同步' };
|
||||
const STATE_COLOR = { fresh: 'var(--dsw-alias-state-success-primary, #2e7d32)', stale: 'var(--dsw-alias-state-warn-primary, #f9a825)', never: 'var(--dsw-alias-label-tertiary, #bbb)' };
|
||||
|
||||
export function SyncIndicators() {
|
||||
const [status, setStatus] = useState(null);
|
||||
const [syncingDomain, setSyncingDomain] = useState(null);
|
||||
const [healthRefreshing, setHealthRefreshing] = useState(false);
|
||||
const call = useRpc();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const res = await call('one-divine-lot/sync-status', {});
|
||||
if (res && res.ok) setStatus(res.value);
|
||||
// 失败保留上次状态
|
||||
} catch { /* 静默 */ }
|
||||
}, [call]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const timer = setInterval(load, POLL_MS);
|
||||
return () => clearInterval(timer);
|
||||
}, [load]);
|
||||
|
||||
const syncNow = useCallback(async (domain) => {
|
||||
if (syncingDomain) return;
|
||||
setSyncingDomain(domain);
|
||||
try {
|
||||
await call('one-divine-lot/sync-now', { args: { domain } });
|
||||
} catch { /* 静默 */ } finally {
|
||||
setSyncingDomain(null);
|
||||
load();
|
||||
}
|
||||
}, [call, syncingDomain, load]);
|
||||
|
||||
const refreshHealth = useCallback(async () => {
|
||||
if (healthRefreshing) return;
|
||||
setHealthRefreshing(true);
|
||||
try {
|
||||
await call('one-divine-lot/qmt-health', { args: { refresh: true } });
|
||||
} catch { /* 静默 */ } finally {
|
||||
setHealthRefreshing(false);
|
||||
load();
|
||||
}
|
||||
}, [call, healthRefreshing, load]);
|
||||
|
||||
if (!status) return null;
|
||||
|
||||
// QMT 连接灯:绿=健康 / 红=异常 / 灰=未知
|
||||
const qmt = status.qmt;
|
||||
const qmtColor = qmt?.healthy === true
|
||||
? 'var(--dsw-alias-state-success-primary, #2e7d32)'
|
||||
: qmt?.healthy === false
|
||||
? 'var(--dsw-alias-state-error-primary, #d32f2f)'
|
||||
: 'var(--dsw-alias-label-tertiary, #bbb)';
|
||||
const qmtTitle = qmt?.healthy === true
|
||||
? 'QMT 连接:健康(' + (qmt.latencyMs ?? '-') + 'ms,检测于 ' + new Date(qmt.checkedAt ?? Date.now()).toLocaleTimeString() + ',点击重新检测)'
|
||||
: qmt?.healthy === false
|
||||
? 'QMT 连接:异常' + (qmt.healthError ? ':' + String(qmt.healthError).slice(0, 40) : '') + '(点击重新检测)'
|
||||
: 'QMT 连接:未知(点击重新检测)';
|
||||
|
||||
// 同步灯 title 组装
|
||||
const syncTitle = (label, s) => {
|
||||
if (!s) return label + ':未启用';
|
||||
const lines = [
|
||||
label + ':' + (STATE_TEXT[s.state] ?? s.state),
|
||||
s.syncedAt ? '同步于 ' + new Date(s.syncedAt).toLocaleTimeString() + '(' + Math.round((s.periodMs ?? 0) / 1000) + 's 周期)' : null,
|
||||
s.snapshotSize != null ? '快照 ' + s.snapshotSize + ' 条' : null,
|
||||
s.failCount ? '失败 ' + s.failCount + ' 次' + (s.lastError ? ':' + String(s.lastError).slice(0, 40) : '') : null,
|
||||
'点击立即同步',
|
||||
];
|
||||
return lines.filter(Boolean).join(' | ');
|
||||
};
|
||||
|
||||
const sep = <span style={{ color: 'var(--dsw-alias-border-l2, #ddd)', fontSize: 12, flex: 'none' }}>|</span>;
|
||||
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, flex: 'none' }}>
|
||||
<IndicatorDot label="QMT连接" color={qmtColor} glow={qmt?.healthy === true} title={qmtTitle} onClick={refreshHealth} dimmed={healthRefreshing} />
|
||||
{sep}
|
||||
<IndicatorDot
|
||||
label="持仓数据"
|
||||
color={STATE_COLOR[status.position?.state ?? 'never']}
|
||||
glow={status.position?.state === 'fresh'}
|
||||
title={syncTitle('持仓数据', status.position)}
|
||||
onClick={() => syncNow('position')}
|
||||
dimmed={syncingDomain === 'position'}
|
||||
/>
|
||||
{sep}
|
||||
<IndicatorDot
|
||||
label="行情数据"
|
||||
color={STATE_COLOR[status.quote?.state ?? 'never']}
|
||||
glow={status.quote?.state === 'fresh'}
|
||||
title={syncTitle('行情数据', status.quote)}
|
||||
onClick={() => syncNow('quote')}
|
||||
dimmed={syncingDomain === 'quote'}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -146,6 +146,28 @@ export class QmtBridgeRestDataSource {
|
||||
return list.map((t) => this.mapTrade(t));
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量盘口快照(GET /data/tick?codes=a,b,c,R-015 迭代 13 首次走适配层通道)
|
||||
* 返回 { [code]: snapshot };snapshot 为 QMT tick 语义字段(lastPrice/lastClose/open/high/low/volume/amount + time/timetag),
|
||||
* 统一附加 updatedAt = Date.now()(服务端收到时刻,价龄判断基准)。
|
||||
* @param {string[]} codes 完整代码列表(含后缀)
|
||||
* @returns {Promise<Object<string, object>>}
|
||||
*/
|
||||
async getTicks(codes) {
|
||||
const list = (Array.isArray(codes) ? codes : []).filter(Boolean);
|
||||
if (list.length === 0) return {};
|
||||
const res = await this.request('/data/tick?codes=' + encodeURIComponent(list.join(',')));
|
||||
const data = res?.data;
|
||||
if (!data || typeof data !== 'object') return {};
|
||||
const now = Date.now();
|
||||
const out = {};
|
||||
for (const [code, snap] of Object.entries(data)) {
|
||||
if (!snap) continue;
|
||||
out[code] = { ...snap, updatedAt: now };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 交易日历(GET /data/calendar/trading_dates,R-007 日期导航)
|
||||
* @param {object} [opts] { start, end } YYYYMMDD
|
||||
|
||||
+16
-9
@@ -17,9 +17,10 @@ import { QmtBridgeRestDataSource } from './data-source/QmtBridgeRestDataSource.j
|
||||
import { registerSettings, resolveStartupConnection, getStrategies } from './settings.js';
|
||||
import { DataStore } from './storage/DataStore.js';
|
||||
import { PositionManager } from './position/PositionManager.js';
|
||||
import { PositionSync } from './position/PositionSync.js';
|
||||
import { registerApi } from './api/index.js';
|
||||
import { MarketDataHub } from './market/MarketDataHub.js';
|
||||
import { MarketFeed } from './market/MarketFeed.js';
|
||||
import { QuoteHub } from './market/QuoteHub.js';
|
||||
import { QuoteSync } from './market/QuoteSync.js';
|
||||
import { QmtHealthMonitor } from './data-source/QmtHealthMonitor.js';
|
||||
import { TradeSync } from './trades/TradeSync.js';
|
||||
|
||||
@@ -58,22 +59,27 @@ async function apply(ctx, config) {
|
||||
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 });
|
||||
// R-015/迭代 13:盘口内存快照(QuoteSync 取数 + QuoteHub 存查;纯内存不落库,价格单一入口)
|
||||
const marketHub = new QuoteHub({ logger });
|
||||
const marketFeed = new QuoteSync({ 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();
|
||||
|
||||
// 持仓内存快照同步(2026-09-02 优化:10s 定时全量同步,以快照为准;失败保留上次快照)
|
||||
// 幽灵持仓自动清仓:QMT 连续 3 轮(约 30s)消失的 code,本地当前持仓自动转历史(带账户身份守卫防误清)
|
||||
const positionSync = new PositionSync({ runtime: { dataSource, storage }, logger });
|
||||
positionSync.start();
|
||||
|
||||
// S5: 分仓逻辑(份额分配/查询)
|
||||
// R-013:注入策略自定义字段定义读取回调(updateHoldingValues 校验用;settings.getStrategies 已归一化 configSchema)
|
||||
// 2026-09-02:注入 positionSync —— getAllPositions 改读内存快照(读穿透兜底),不再每次请求穿透 QMT
|
||||
const manager = new PositionManager({
|
||||
dataSource,
|
||||
storage,
|
||||
positionSync,
|
||||
getStrategySchema: (strategyId) => getStrategies(settings).find((s) => s.id === strategyId)?.configSchema ?? [],
|
||||
});
|
||||
|
||||
@@ -82,7 +88,7 @@ async function apply(ctx, config) {
|
||||
tradeSync.start();
|
||||
|
||||
// S6: 服务端 HTTP API(R-004:注入 dataSource 以编排激活热切换;R-009:注入 storage 供 trades/history)
|
||||
registerApi(ctx, { manager, settings, dataSource, marketHub, marketFeed, qmtHealthMonitor, storage, tradeSync });
|
||||
registerApi(ctx, { manager, settings, dataSource, marketHub, marketFeed, qmtHealthMonitor, storage, tradeSync, positionSync });
|
||||
|
||||
// 启动时可用性检查(日志,不阻塞)
|
||||
dataSource.isAvailable().then((ok) => {
|
||||
@@ -94,8 +100,9 @@ async function apply(ctx, config) {
|
||||
ctx.effect(() => {
|
||||
logger.info('[one-divine-lot] 插件已加载(S1-S6 就绪)');
|
||||
return () => {
|
||||
marketFeed.stop();
|
||||
marketFeed.stop(); // 停止盘口定时同步(迭代 13:QuoteSync;内存快照保留供收尾读取)
|
||||
qmtHealthMonitor.stop();
|
||||
positionSync.stop(); // 停止持仓快照定时同步(2026-09-02;内存快照保留供收尾读取)
|
||||
tradeSync.stop(); // 停止交易记录定时同步(迭代 07)
|
||||
storage.close(); // 关闭 SQLite 连接(迭代 06)
|
||||
logger.info('[one-divine-lot] 插件已释放');
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
/**
|
||||
* 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('../storage/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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
/**
|
||||
* 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 触发重连 */ };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* QuoteHub —— 盘口内存快照存储与查询(R-015 / 迭代 13,2026-09-02)
|
||||
*
|
||||
* 职责(单一):盘口数据的「存 + 查」。取数由 QuoteSync 负责;本类只消费 QuoteSync.ingest /
|
||||
* dataSource 读穿透,不再自己 fetch REST(修正旧 MarketDataHub 绕过适配层的层级破洞)。
|
||||
*
|
||||
* 2026-09-02 R-015(老师拍板):
|
||||
* - 纯内存,不落库:market_quotes_cache 表退役(DROP),「重启首屏有价」由 QuoteSync 启动 prime +
|
||||
* 读穿透兜底保证(秒级有价);替换 MarketDataHub(三级命中改两级:内存 → 读穿透);
|
||||
* - 价格单一入口:getQuote / getQuotes;涨停/跌停(/data/instrument 按交易日缓存)经合约信息缓存提供;
|
||||
* - watchCodes 关注集合维持现状:只进不出、无上限(WS 移除后膨胀仅轻微浪费;切回旧 tab 价格秒显);
|
||||
* - ingest 入口来源无关(source 标签:rest;WS 将来回归时加 source 即可,存储查询不动);
|
||||
*
|
||||
* 修复继承:旧 MarketDataHub.warmup 依赖 DataStore.loadMarket()(return this 残迹)导致磁盘预热从未生效——
|
||||
* 本类无磁盘层,bug 不复存在。
|
||||
*/
|
||||
|
||||
export class QuoteHub {
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {object} [opts.logger]
|
||||
*/
|
||||
constructor({ logger } = {}) {
|
||||
this.logger = logger;
|
||||
/** 盘口内存快照:code → snapshot(全量 tick 字段 + updatedAt;只读约定) */
|
||||
this.quotes = new Map();
|
||||
/** 关注集合:定时刷新/prime 的 code 范围(只进不出,老师拍板维持现状) */
|
||||
this.watchCodes = new Set();
|
||||
/** 合约静态信息缓存:code → { upStopPrice, downStopPrice, preClose, tradingDay }(交易日内有效) */
|
||||
this.instruments = new Map();
|
||||
this.stats = { syncCount: 0, failCount: 0, lastError: '', lastSyncedAt: 0, restCount: 0, watchSize: 0 };
|
||||
}
|
||||
|
||||
/** 加入关注集合(QuoteSync prime / api 查询共用) */
|
||||
watch(codes) {
|
||||
const list = Array.isArray(codes) ? codes : [];
|
||||
for (const c of list) {
|
||||
if (c) this.watchCodes.add(c);
|
||||
}
|
||||
this.stats.watchSize = this.watchCodes.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一写入入口(来源无关;QuoteSync 调用)。
|
||||
* @param {Object<string, object>} data code → snapshot
|
||||
* @param {object} [opts] { source: 'rest' }(来源标签;WS 回归时新增来源即可)
|
||||
*/
|
||||
ingest(data, { source = 'rest' } = {}) {
|
||||
void source;
|
||||
if (!data || typeof data !== 'object') return;
|
||||
for (const [code, snap] of Object.entries(data)) {
|
||||
// watch 过滤保留(防止未来全市场来源膨胀);读穿透路径先 watch 再 ingest,不受影响
|
||||
if (!this.watchCodes.has(code)) continue;
|
||||
if (snap) this.quotes.set(code, { ...this.quotes.get(code), ...snap });
|
||||
}
|
||||
}
|
||||
|
||||
/** 同步成功标记(QuoteSync 每轮成功后调用,供指示灯) */
|
||||
markSynced() {
|
||||
this.stats.syncCount++;
|
||||
this.stats.lastSyncedAt = Date.now();
|
||||
}
|
||||
|
||||
/** 同步失败标记 */
|
||||
markFailed(err) {
|
||||
this.stats.failCount++;
|
||||
this.stats.lastError = err?.message ?? String(err ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量查询:内存命中;miss 读穿透(dataSource.getTicks 补拉并回填)。
|
||||
* 与旧三级命中(内存→磁盘→REST)对齐为两级(磁盘层退役)。
|
||||
* @param {string[]} codes
|
||||
* @param {object} [opts] { dataSource }(读穿透用)
|
||||
* @returns {Promise<Object<string, object>>}
|
||||
*/
|
||||
async getQuotes(codes, { dataSource } = {}) {
|
||||
const list = Array.isArray(codes) ? codes : [];
|
||||
this.watch(list); // 查询的 code 加入关注集合(旧行为保留)
|
||||
const out = {};
|
||||
const miss = [];
|
||||
for (const c of list) {
|
||||
const v = this.quotes.get(c);
|
||||
if (v) { out[c] = v; continue; }
|
||||
miss.push(c);
|
||||
}
|
||||
if (miss.length > 0 && dataSource?.getTicks) {
|
||||
try {
|
||||
const backfill = await dataSource.getTicks(miss);
|
||||
this.ingest(backfill, { source: 'rest' });
|
||||
this.stats.restCount++;
|
||||
} catch (e) {
|
||||
this.logger?.debug?.('[one-divine-lot] QuoteHub 读穿透失败: ' + (e?.message ?? e));
|
||||
}
|
||||
for (const c of miss) {
|
||||
const v = this.quotes.get(c);
|
||||
if (v) out[c] = v;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 单码便捷查询(前端 getPrice 消费形态) */
|
||||
async getQuote(code, opts) {
|
||||
if (!code) return null;
|
||||
const out = await this.getQuotes([code], opts);
|
||||
return out[code] ?? null;
|
||||
}
|
||||
|
||||
// ===== 合约静态信息(涨停/跌停;交易日缓存)=====
|
||||
|
||||
/** 合约缓存是否可用(存在且交易日一致) */
|
||||
_instrumentFresh(code, tradingDay) {
|
||||
const hit = this.instruments.get(code);
|
||||
return hit && hit.tradingDay === tradingDay;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取合约静态信息(涨停/跌停/昨收);缓存失效(无缓存/换交易日)时经 fetcher 拉取。
|
||||
* @param {string} code
|
||||
* @param {string|number} tradingDay YYYYMMDD(日切失效键)
|
||||
* @param {(code: string) => Promise<object>} fetcher dataSource.getInstrument 包装
|
||||
* @returns {Promise<{upStopPrice:number, downStopPrice:number, preClose:number}|null>}
|
||||
*/
|
||||
async getInstrumentCached(code, tradingDay, fetcher) {
|
||||
if (!code || !fetcher) return null;
|
||||
if (this._instrumentFresh(code, tradingDay)) {
|
||||
const { upStopPrice, downStopPrice, preClose } = this.instruments.get(code);
|
||||
return { upStopPrice, downStopPrice, preClose };
|
||||
}
|
||||
try {
|
||||
const inst = await fetcher(code);
|
||||
if (!inst) return null;
|
||||
this.instruments.set(code, {
|
||||
upStopPrice: inst.upStopPrice ?? 0,
|
||||
downStopPrice: inst.downStopPrice ?? 0,
|
||||
preClose: inst.preClose ?? 0,
|
||||
tradingDay: inst.tradingDay || tradingDay,
|
||||
});
|
||||
const { upStopPrice, downStopPrice, preClose } = this.instruments.get(code);
|
||||
return { upStopPrice, downStopPrice, preClose };
|
||||
} catch (e) {
|
||||
this.logger?.debug?.('[one-divine-lot] QuoteHub 合约信息拉取失败: ' + code + ' ' + (e?.message ?? e));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 热切换/日切时清空合约缓存 */
|
||||
clearInstruments() {
|
||||
this.instruments.clear();
|
||||
}
|
||||
|
||||
/** 快照规模(诊断) */
|
||||
get size() {
|
||||
return this.quotes.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* 兼容旧 MarketDataHub.getByCodes 签名(api/market.js 消费)。
|
||||
* @deprecated 用 getQuotes
|
||||
*/
|
||||
async getByCodes(codes, opts = {}) {
|
||||
return this.getQuotes(codes, opts);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* QuoteSync —— 盘口数据定时同步(R-015 / 迭代 13,2026-09-02)
|
||||
*
|
||||
* 职责(单一):把 QMT 盘口数据同步进 QuoteHub 内存快照。与 PositionSync(10s/持仓)、
|
||||
* TradeSync(60s/交易)三域同构:启动预热 + 定时 + 失败容忍(保留上次价)+ 手动 syncNow。
|
||||
*
|
||||
* 老师拍板(R-015):
|
||||
* - WS 数据通路移除(从无生效结论、全市场推送被 watch 过滤成本高、REST 5s 已覆盖);
|
||||
* ingest 带 source 标签,将来 WS 回归 = 加一个来源,Hub/查询零改动;
|
||||
* - 同步失败保留上次价(内存不动),恢复后 5s 内自动追上;
|
||||
* - 涨停/跌停走 /data/instrument,按交易日缓存(当天不变,不跟 5s 周期;日切/热切换失效重拉)。
|
||||
*/
|
||||
|
||||
import { resolveActiveBaseUrl } from '../api/common.js';
|
||||
|
||||
const SYNC_INTERVAL_MS = 5 * 1000; // 同步间隔(与旧行情轮询一致)
|
||||
const TICK_BATCH = 50; // tick 批量(沿用旧 MarketFeed 分批)
|
||||
|
||||
export class QuoteSync {
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {object} opts.hub QuoteHub 实例(写入目标)
|
||||
* @param {object} opts.runtime { settings, dataSource }
|
||||
* @param {object} [opts.logger]
|
||||
* @param {number} [opts.intervalMs] 同步间隔(测试可调小)
|
||||
*/
|
||||
constructor({ hub, runtime, logger, intervalMs = SYNC_INTERVAL_MS } = {}) {
|
||||
this.hub = hub;
|
||||
this.runtime = runtime;
|
||||
this.logger = logger;
|
||||
this.intervalMs = intervalMs;
|
||||
this.timer = null;
|
||||
this.mounted = false;
|
||||
this.syncing = false;
|
||||
this._tradingDay = "";
|
||||
this.stats = { primeCount: 0, syncCount: 0, failCount: 0, lastError: "", lastSyncedAt: 0 };
|
||||
}
|
||||
|
||||
/** 启动:主动拉持仓盘口(prime)+ 定时刷新(REST;WS 已移除) */
|
||||
start() {
|
||||
if (this.mounted) return;
|
||||
this.mounted = true;
|
||||
this._primePositions();
|
||||
this.timer = setInterval(() => { this.syncNow().catch(() => {}); }, this.intervalMs);
|
||||
this.logger?.info?.("[one-divine-lot] QuoteSync 启动(5s REST 同步;WS 通路已移除 R-015)");
|
||||
}
|
||||
|
||||
/** 停止(插件释放时);内存快照保留 */
|
||||
stop() {
|
||||
this.mounted = false;
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 热切换:激活配置变化时调用(api/qmt-connections 编排)——清合约缓存 + 立即 prime */
|
||||
setBaseUrl() {
|
||||
if (!this.mounted) return;
|
||||
this.hub.clearInstruments(); // 新环境合约信息全部失效
|
||||
this._primePositions();
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动/热切换时主动拉持仓股票的盘口,保证服务端启动即有最新价,前端打开页面即有价。
|
||||
*/
|
||||
async _primePositions() {
|
||||
if (!this.mounted) return;
|
||||
try {
|
||||
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] QuoteSync 无持仓可预热");
|
||||
return;
|
||||
}
|
||||
this.hub.watch(codes);
|
||||
const ticks = await this.runtime.dataSource.getTicks(codes);
|
||||
this.hub.ingest(ticks, { source: "rest" });
|
||||
this.hub.markSynced();
|
||||
this.stats.primeCount++;
|
||||
this.stats.lastSyncedAt = Date.now();
|
||||
this.logger?.info?.("[one-divine-lot] QuoteSync 启动预热盘口: " + Object.keys(ticks).length + " 条");
|
||||
} catch (e) {
|
||||
this.hub.markFailed(e);
|
||||
this.stats.failCount++;
|
||||
this.stats.lastError = e?.message ?? String(e);
|
||||
this.logger?.debug?.("[one-divine-lot] QuoteSync 启动预热失败: " + (e?.message ?? e));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步一轮:watch 集合分批拉 tick → hub.ingest;watch 中新 code 懒拉合约信息(涨停/跌停)。
|
||||
* 任何失败只记统计,不动内存(读方继续消费上次价)。
|
||||
*/
|
||||
async syncNow() {
|
||||
// mounted 不拦手动同步(测试/指示灯点击在 stop 后仍可用);syncing 只防重入(迭代 12 同款教训)
|
||||
if (this.syncing) return null;
|
||||
const { dataSource } = this.runtime;
|
||||
if (!dataSource) return null;
|
||||
this.syncing = true;
|
||||
try {
|
||||
const codes = [...this.hub.watchCodes];
|
||||
if (codes.length > 0) {
|
||||
let ok = 0;
|
||||
for (let i = 0; i < codes.length; i += TICK_BATCH) {
|
||||
const batch = codes.slice(i, i + TICK_BATCH);
|
||||
const ticks = await dataSource.getTicks(batch);
|
||||
this.hub.ingest(ticks, { source: "rest" });
|
||||
ok += Object.keys(ticks).length;
|
||||
}
|
||||
this.hub.markSynced();
|
||||
this.stats.syncCount++;
|
||||
this.stats.lastSyncedAt = Date.now();
|
||||
await this._syncInstruments(codes);
|
||||
return { quotes: ok, watch: codes.length };
|
||||
}
|
||||
// watch 空:不空转(watch 由 prime 登记,正常场景恒非空)
|
||||
return { quotes: 0, watch: 0 };
|
||||
} catch (e) {
|
||||
this.hub.markFailed(e);
|
||||
this.stats.failCount++;
|
||||
this.stats.lastError = e?.message ?? String(e);
|
||||
this.logger?.debug?.("[one-divine-lot] QuoteSync 同步失败(保留旧价): " + this.stats.lastError);
|
||||
return null;
|
||||
} finally {
|
||||
this.syncing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 合约信息懒拉:watch 集合中无缓存(或交易日变化)的 code 拉 /data/instrument。
|
||||
* 交易日取 asset.tradingDate(拿不到则沿用上次值兜底)。
|
||||
*/
|
||||
async _syncInstruments(codes) {
|
||||
const { dataSource } = this.runtime;
|
||||
try {
|
||||
const asset = await dataSource.getAsset();
|
||||
if (asset?.tradingDate) {
|
||||
if (this._tradingDay && asset.tradingDate !== this._tradingDay) {
|
||||
this.hub.clearInstruments(); // 日切:全部失效重拉
|
||||
}
|
||||
this._tradingDay = asset.tradingDate;
|
||||
}
|
||||
} catch { /* getAsset 失败:沿用上次交易日兜底 */ }
|
||||
const fetcher = (code) => dataSource.getInstrument(code);
|
||||
for (const code of codes) {
|
||||
if (this.hub._instrumentFresh(code, this._tradingDay)) continue;
|
||||
await this.hub.getInstrumentCached(code, this._tradingDay, fetcher); // 失败静默,下轮重试
|
||||
}
|
||||
}
|
||||
|
||||
/** 指示灯状态读点 */
|
||||
getStatus() {
|
||||
return {
|
||||
syncedAt: this.stats.lastSyncedAt,
|
||||
periodMs: this.intervalMs,
|
||||
failCount: this.stats.failCount,
|
||||
lastError: this.stats.lastError,
|
||||
snapshotSize: this.hub.size,
|
||||
watchSize: this.hub.watchCodes.size,
|
||||
};
|
||||
}
|
||||
}
|
||||
+9
-3
@@ -49,6 +49,11 @@ export const COLUMN_META = [
|
||||
{ key: 'avgPrice', label: '成本价', fixed: false },
|
||||
{ key: 'lastTradePrice', label: '最后一笔成交价', fixed: false },
|
||||
{ key: 'shares', label: '策略份额', fixed: false },
|
||||
// R-015/迭代 13:行情列(盘口快照 + 合约信息;defaultVisible=false 默认隐藏,列设置勾选开启)
|
||||
{ key: 'upStopPrice', label: '涨停价', fixed: false, defaultVisible: false },
|
||||
{ key: 'downStopPrice', label: '跌停价', fixed: false, defaultVisible: false },
|
||||
{ key: 'open', label: '今开', fixed: false, defaultVisible: false },
|
||||
{ key: 'high', label: '最高', fixed: false, defaultVisible: false },
|
||||
];
|
||||
|
||||
/** 策略设置 schema(schemastery 定义,settings.register 要求 schema 对象) */
|
||||
@@ -438,8 +443,8 @@ export function normalizeStrategyColumns(scope, strategyId) {
|
||||
const configSchema = Array.isArray(strategy?.configSchema) ? strategy.configSchema : [];
|
||||
const raw = (value.strategyColumns && value.strategyColumns[strategyId]) || [];
|
||||
|
||||
// 1. 默认全列(基础列全显默认序 + 字段列全显 configSchema 序)
|
||||
const baseCols = COLUMN_META.map((c) => ({ key: c.key, label: c.label, fixed: false, kind: 'base' }));
|
||||
// 1. 默认全列(基础列默认序 + 字段列 configSchema 序;显隐缺省跟随 defaultVisible,R-015 行情列默认隐藏)
|
||||
const baseCols = COLUMN_META.map((c) => ({ key: c.key, label: c.label, fixed: false, kind: 'base', defaultVisible: c.defaultVisible !== false }));
|
||||
const fieldCols = configSchema.map((f) => ({ key: f.key, label: f.label || f.key, fixed: false, kind: 'field' }));
|
||||
const defaults = [...baseCols, ...fieldCols];
|
||||
|
||||
@@ -461,7 +466,8 @@ export function normalizeStrategyColumns(scope, strategyId) {
|
||||
}
|
||||
for (const d of defaults) {
|
||||
if (!placed.has(d.key)) {
|
||||
ordered.push({ ...d, visible: visibleMap.has(d.key) ? visibleMap.get(d.key) : true });
|
||||
const dv = visibleMap.has(d.key) ? visibleMap.get(d.key) : d.defaultVisible !== false;
|
||||
ordered.push({ ...d, visible: dv });
|
||||
}
|
||||
}
|
||||
return ordered;
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* DataStore —— 数据存储门面(R-006 JSON → R-008 SQLite 迁移过渡)
|
||||
*
|
||||
* 迭代 06:存储引擎从 JSON data store 升级为 SQLite(node:sqlite)。
|
||||
* - 数据实际存于 SqliteStore(strategy_holdings / market_quotes_cache 表);
|
||||
* - 本模块保留对外兼容 API(getDataset/getAllDatasets/getMarketQuotes 等),上层(PositionManager / MarketDataHub)调用点不变;
|
||||
* - 数据实际存于 SqliteStore(strategy_holdings / trade_orders / trade_fills 表;market_quotes_cache 已退役 DROP,R-015);
|
||||
* - 本模块保留对外兼容 API(getDataset/getAllDatasets 等),上层(PositionManager / QuoteSync)调用点不变;
|
||||
* - 启动时检测旧 JSON → 自动迁移(幂等),JSON 迁移后废弃(备份 .bak)。
|
||||
*
|
||||
* 注:PositionManager 适配单票生命周期后,旧整策略读写 setDataset/removeDataset 已删除
|
||||
@@ -19,7 +19,6 @@ export class DataStore {
|
||||
constructor({ dataDir } = {}) {
|
||||
this.sqlite = new SqliteStore({ dataDir });
|
||||
this.loaded = false;
|
||||
this.marketLoaded = false;
|
||||
}
|
||||
|
||||
/** 确保初始化 + 自动迁移(幂等) */
|
||||
@@ -42,13 +41,6 @@ export class DataStore {
|
||||
return { version: 1, strategies: this.getAllDatasetsSync() };
|
||||
}
|
||||
|
||||
/** 加载行情(兼容旧调用) */
|
||||
async loadMarket() {
|
||||
await this._ensure();
|
||||
this.marketLoaded = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
// ===== 持仓生命周期(新 API,PositionManager 适配后使用)=====
|
||||
|
||||
/** 建仓 */
|
||||
@@ -122,26 +114,6 @@ export class DataStore {
|
||||
return [...map.entries()].map(([strategyId, dataset]) => ({ strategyId, dataset }));
|
||||
}
|
||||
|
||||
// ===== 行情(委托 SqliteStore)=====
|
||||
|
||||
/** 单码行情 */
|
||||
async getMarketQuote(code) {
|
||||
await this._ensure();
|
||||
return this.sqlite.getMarketQuote(code);
|
||||
}
|
||||
|
||||
/** 批量行情 */
|
||||
async getMarketQuotes(codes) {
|
||||
await this._ensure();
|
||||
return this.sqlite.getMarketQuotes(codes);
|
||||
}
|
||||
|
||||
/** 写行情(整体替换防膨胀) */
|
||||
async setMarketQuotes(quotes) {
|
||||
await this._ensure();
|
||||
return this.sqlite.setMarketQuotes(quotes);
|
||||
}
|
||||
|
||||
// ===== 交易记录(R-009 迭代 07,委托 SqliteStore)=====
|
||||
|
||||
/** UPSERT 委托(幂等) */
|
||||
|
||||
@@ -6,14 +6,13 @@
|
||||
*
|
||||
* 表结构(技术实现方案):
|
||||
* - strategy_holdings 策略持仓生命周期表(一笔 = 一次「建仓→清仓」,历史保留)
|
||||
* - market_quotes_cache 行情快照缓存(code 维度一行一码,只存 UI 消费的 lastPrice/lastClose)
|
||||
* - trade_orders 交易委托(R-009:委托主行,order_id 主键,UPSERT 幂等,零冗余)
|
||||
* - trade_fills 交易成交(R-009:成交明细,trade_id 主键,order_id 关联委托)
|
||||
* - (market_quotes_cache 已退役 DROP,R-015/迭代 13:行情改内存快照 QuoteHub,价格单一入口)
|
||||
*
|
||||
* 方法集(替代 DataStore 原 getDataset/setDataset/removeDataset/getAllDatasets 整体读写):
|
||||
* 持仓生命周期:openHolding / addShares / reduceShares / closeHolding
|
||||
* 持仓查询 :getCurrentHoldings / getHoldingHistory
|
||||
* 行情读写 :getMarketQuote / getMarketQuotes / setMarketQuotes
|
||||
* 生命周期 :init / migrateJson / close
|
||||
*/
|
||||
|
||||
@@ -33,6 +32,7 @@ const MARKET_FILE = 'store.market.json';
|
||||
const LEGACY_FILE = 'allocations.json';
|
||||
|
||||
const SCHEMA_SQL = `
|
||||
DROP TABLE IF EXISTS market_quotes_cache; -- R-015:存量库清理(幂等;行情改内存快照)
|
||||
CREATE TABLE IF NOT EXISTS strategy_holdings (
|
||||
holding_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
strategy_id TEXT NOT NULL,
|
||||
@@ -43,12 +43,7 @@ CREATE TABLE IF NOT EXISTS strategy_holdings (
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_active_holding
|
||||
ON strategy_holdings (strategy_id, code) WHERE closed_at IS NULL;
|
||||
CREATE TABLE IF NOT EXISTS market_quotes_cache (
|
||||
code TEXT PRIMARY KEY,
|
||||
last_price REAL NOT NULL,
|
||||
last_close REAL NOT NULL,
|
||||
updated_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
-- market_quotes_cache 已退役(R-015/迭代 13:行情改内存快照 QuoteHub,价格单一入口不再落库)
|
||||
-- 交易委托(R-009 迭代 07:委托主行;order_id 唯一,UPSERT 幂等;零冗余 strategy_id/holding_id)
|
||||
CREATE TABLE IF NOT EXISTS trade_orders (
|
||||
order_id TEXT PRIMARY KEY,
|
||||
@@ -223,28 +218,26 @@ export class SqliteStore {
|
||||
}
|
||||
}
|
||||
|
||||
/** 判断库是否为空(两表均无数据) */
|
||||
/** 判断库是否为空(R-015:行情表退役,只看 strategy_holdings) */
|
||||
isEmpty() {
|
||||
this.init();
|
||||
const h = this.db.prepare('SELECT COUNT(*) AS n FROM strategy_holdings').get();
|
||||
const m = this.db.prepare('SELECT COUNT(*) AS n FROM market_quotes_cache').get();
|
||||
return (h?.n ?? 0) === 0 && (m?.n ?? 0) === 0;
|
||||
return (h?.n ?? 0) === 0;
|
||||
}
|
||||
|
||||
// ===== 迁移(D6:一次性脚本 + 启动自动迁移,幂等)=====
|
||||
|
||||
/**
|
||||
* 旧 JSON → SQLite 迁移(迁移前备份 JSON 为 *.bak;写库失败不删原文件,幂等)
|
||||
* @returns {Promise<{holdings: number, quotes: number}>} 迁移的持仓行数 / 行情行数
|
||||
* @returns {Promise<{holdings: number}>} 迁移的持仓行数
|
||||
*/
|
||||
async migrateJson() {
|
||||
this.init();
|
||||
if (!this.isEmpty()) {
|
||||
return { holdings: 0, quotes: 0, skipped: true }; // 已有数据,跳过
|
||||
return { holdings: 0, skipped: true }; // 已有数据,跳过
|
||||
}
|
||||
|
||||
let holdingsCount = 0;
|
||||
let quotesCount = 0;
|
||||
const now = Date.now();
|
||||
|
||||
// 1. store.json → strategy_holdings(策略为中心平铺)
|
||||
@@ -274,30 +267,6 @@ export class SqliteStore {
|
||||
// ENOENT = 无 store.json,正常
|
||||
}
|
||||
|
||||
// 2. store.market.json → market_quotes_cache(抽取 lastPrice/lastClose)
|
||||
try {
|
||||
const raw = await fsp.readFile(this.marketPath, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
const quotes = parsed?.quotes ?? {};
|
||||
const upsert = this.db.prepare(
|
||||
'INSERT INTO market_quotes_cache (code, last_price, last_close, updated_at) VALUES (?,?,?,?) ON CONFLICT(code) DO UPDATE SET last_price=excluded.last_price, last_close=excluded.last_close, updated_at=excluded.updated_at'
|
||||
);
|
||||
this.db.exec('BEGIN');
|
||||
for (const [code, snap] of Object.entries(quotes)) {
|
||||
if (!snap) continue;
|
||||
const lastPrice = Number(snap.lastPrice ?? snap.last_price ?? 0);
|
||||
const lastClose = Number(snap.lastClose ?? snap.last_close ?? 0);
|
||||
upsert.run(code, lastPrice, lastClose, Number(snap.time ?? snap.updated_at ?? now));
|
||||
quotesCount++;
|
||||
}
|
||||
this.db.exec('COMMIT');
|
||||
await this._backupJson(this.marketPath);
|
||||
} catch (err) {
|
||||
if (err.code !== 'ENOENT') {
|
||||
console.warn('[one-divine-lot] store.market.json 迁移失败: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 旧 allocations.json(若仍存在,R-006 遗留迁移源,code 为中心)→ strategy_holdings
|
||||
try {
|
||||
const raw = await fsp.readFile(this.legacyPath, 'utf8');
|
||||
@@ -329,7 +298,7 @@ export class SqliteStore {
|
||||
}
|
||||
}
|
||||
|
||||
return { holdings: holdingsCount, quotes: quotesCount };
|
||||
return { holdings: holdingsCount };
|
||||
}
|
||||
|
||||
/** 备份 JSON 文件为 .bak(D7:迁移前自动备份) */
|
||||
@@ -433,60 +402,6 @@ export class SqliteStore {
|
||||
|
||||
|
||||
|
||||
// ===== 行情读写(market_quotes_cache)=====
|
||||
|
||||
/** 单码行情 */
|
||||
getMarketQuote(code) {
|
||||
this.init();
|
||||
const row = this.db.prepare(
|
||||
'SELECT code, last_price, last_close, updated_at FROM market_quotes_cache WHERE code=?'
|
||||
).get(code);
|
||||
return row ? this._mapQuote(row) : undefined;
|
||||
}
|
||||
|
||||
/** 批量行情 */
|
||||
getMarketQuotes(codes) {
|
||||
this.init();
|
||||
if (!codes || codes.length === 0) return {};
|
||||
const placeholders = codes.map(() => '?').join(',');
|
||||
const rows = this.db.prepare(
|
||||
'SELECT code, last_price, last_close, updated_at FROM market_quotes_cache WHERE code IN (' + placeholders + ')'
|
||||
).all(...codes);
|
||||
const out = {};
|
||||
for (const row of rows) out[row.code] = this._mapQuote(row);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 写入行情(UPSERT;整体集合替换 —— 清除不在集合中的旧键,防膨胀) */
|
||||
setMarketQuotes(quotes) {
|
||||
this.init();
|
||||
const upsert = this.db.prepare(
|
||||
'INSERT INTO market_quotes_cache (code, last_price, last_close, updated_at) VALUES (?,?,?,?) ON CONFLICT(code) DO UPDATE SET last_price=excluded.last_price, last_close=excluded.last_close, updated_at=excluded.updated_at'
|
||||
);
|
||||
const now = Date.now();
|
||||
this.db.exec('BEGIN');
|
||||
try {
|
||||
for (const [code, snap] of Object.entries(quotes || {})) {
|
||||
if (!snap) continue;
|
||||
upsert.run(code, Number(snap.lastPrice ?? snap.last_price ?? 0), Number(snap.lastClose ?? snap.last_close ?? 0), Number(snap.time ?? now));
|
||||
}
|
||||
this.db.exec('COMMIT');
|
||||
} catch (err) {
|
||||
this.db.exec('ROLLBACK');
|
||||
throw err;
|
||||
}
|
||||
return this.getMarketQuotes(Object.keys(quotes || {}));
|
||||
}
|
||||
|
||||
_mapQuote(row) {
|
||||
return {
|
||||
code: row.code,
|
||||
lastPrice: row.last_price,
|
||||
lastClose: row.last_close,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
// ===== 交易记录(trade_orders / trade_fills,R-009 迭代 07)=====
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user