feat(存储): JSON data store 升级 SQLite(迭代 06,R-008)
- 新增 SqliteStore(node:sqlite 封装):strategy_holdings 持仓生命周期表 + market_quotes_cache 行情缓存表 - DataStore 改造为门面委托 SqliteStore,保留兼容 API,启动检测自动迁移(幂等) - PositionManager 适配单票生命周期(openHolding/addShares/reduceShares/closeHolding),清仓转历史保留 - 新增一次性迁移脚本 scripts/migrate-json-to-sqlite.mjs + pnpm migrate 命令 - 修复 addToStrategy 语义 Bug(新增量 vs 绝对目标量),真实 API CRUD 回归通过 - 移除策略 tab 一键清零按钮及 remove-all-shares 端点(clearStrategyShares 保留供删除策略联动) - 插件 dispose 补充 storage.close() 关闭 SQLite 连接
This commit is contained in:
+2
-1
@@ -47,7 +47,8 @@
|
||||
"scripts": {
|
||||
"build": "rm -rf lib && tsc -p tsconfig.build.json && tsdown && node scripts/wrap-client.mjs",
|
||||
"bundle": "tsdown",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"migrate": "node scripts/migrate-json-to-sqlite.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-client-runtime": "0.1.1-rc.2",
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* 一次性迁移脚本:旧 JSON data store → SQLite(R-008 / 迭代 06,D6)
|
||||
*
|
||||
* 用途:
|
||||
* - 独立执行(手动 / CI),逻辑与 DataStore 启动自动迁移共用(都调用 SqliteStore.migrateJson)
|
||||
* - 手动/CI 环境下可显式触发迁移,无需等插件启动
|
||||
*
|
||||
* 用法:
|
||||
* node scripts/migrate-json-to-sqlite.mjs # 默认数据目录 ~/.dsh/one-divine-lot
|
||||
* ODL_TEST_DATA_DIR=/tmp/xxx node scripts/migrate-json-to-sqlite.mjs # 指定数据目录(测试隔离,技术约束-011)
|
||||
*
|
||||
* 行为(与启动自动迁移一致,幂等):
|
||||
* 1. 检测 SQLite 是否为空(strategy_holdings / market_quotes_cache 均无数据)——非空则跳过;
|
||||
* 2. store.json → strategy_holdings(策略为中心平铺,created_at=迁移时间戳);
|
||||
* 3. store.market.json → market_quotes_cache(抽取 lastPrice/lastClose 两列);
|
||||
* 4. 旧 allocations.json(若存在)→ strategy_holdings(code 为中心聚合,跳过重复);
|
||||
* 5. 迁移前备份 JSON 为 *.bak;迁移失败不破坏原文件。
|
||||
*/
|
||||
|
||||
import { SqliteStore } from '../lib/component/SqliteStore.js';
|
||||
|
||||
async function main() {
|
||||
const store = new SqliteStore({}); // dataDir 默认 ~/.dsh/one-divine-lot,或 ODL_TEST_DATA_DIR
|
||||
try {
|
||||
if (!store.isEmpty()) {
|
||||
console.log('[migrate] SQLite 已有数据,跳过迁移(幂等)');
|
||||
return;
|
||||
}
|
||||
const result = await store.migrateJson();
|
||||
console.log('[migrate] 迁移完成:', JSON.stringify(result));
|
||||
if (result.holdings === 0 && result.quotes === 0) {
|
||||
console.log('[migrate] 无数据可迁移(store.json / store.market.json / allocations.json 均不存在或为空)');
|
||||
} else {
|
||||
console.log('[migrate] 旧 JSON 已备份为 *.bak,可安全删除或保留');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[migrate] 迁移失败:', err.message);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
store.close();
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -23,7 +23,6 @@
|
||||
* 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/订阅代理
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* 端点:
|
||||
* strategies / strategies/update / strategies/add / strategies/remove / strategies/move
|
||||
* strategy-positions / unallocated / summary
|
||||
* add-shares / move-all-shares / remove-shares / remove-all-shares
|
||||
* add-shares / move-all-shares / remove-shares
|
||||
* tabs / tabs/update
|
||||
*/
|
||||
|
||||
@@ -26,7 +26,6 @@ export const STRATEGY_METHODS = new Set([
|
||||
'add-shares',
|
||||
'move-all-shares',
|
||||
'remove-shares',
|
||||
'remove-all-shares',
|
||||
'tabs',
|
||||
'tabs/update',
|
||||
]);
|
||||
@@ -70,8 +69,6 @@ export async function handleStrategy(method, args, { manager, settings }) {
|
||||
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' });
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
* - 添加(从全部持仓选标的+填份额,保留)
|
||||
* - 移出(指定数量,保留)
|
||||
* - 全部移入(O6:行内按钮,该票全部未分配份额移入当前策略)
|
||||
* - 一键清零(O6:顶部按钮,弹窗确认后清空当前策略全部份额)
|
||||
* - 操作反馈:Toast 轻提示
|
||||
* - 加载失败兜底:LoadState 三态
|
||||
*
|
||||
@@ -102,29 +101,6 @@ function ensureAddFormCss() {
|
||||
styleInjected = true;
|
||||
}
|
||||
|
||||
/** 确认弹窗(复用 SettingsSection 的样式,独立实现避免耦合) */
|
||||
function ConfirmDialog({ title, message, onConfirm, onCancel, confirmText = '确认' }) {
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 10000,
|
||||
}}>
|
||||
<div style={{ background: '#fff', padding: 24, borderRadius: 8, maxWidth: 420, width: '90%' }}>
|
||||
<h3 style={{ margin: '0 0 12px', fontSize: 16 }}>{title}</h3>
|
||||
<div style={{ fontSize: 14, color: '#555', marginBottom: 20 }}>{message}</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 10 }}>
|
||||
<button onClick={onCancel} style={{ padding: '6px 16px', cursor: 'pointer', border: '1px solid #ccc', borderRadius: 4, background: '#fff' }}>
|
||||
取消
|
||||
</button>
|
||||
<button onClick={onConfirm} style={{ padding: '6px 16px', cursor: 'pointer', border: 'none', borderRadius: 4, background: '#c62828', color: '#fff' }}>
|
||||
{confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StrategyTabInner({ strategyId, strategyName }) {
|
||||
const [positions, setPositions] = useState(null);
|
||||
const [allPositions, setAllPositions] = useState(null);
|
||||
@@ -134,7 +110,6 @@ function StrategyTabInner({ strategyId, strategyName }) {
|
||||
const [selectedCode, setSelectedCode] = useState('');
|
||||
const [addShares, setAddShares] = useState('');
|
||||
const [removeTarget, setRemoveTarget] = useState(null); // { code, shares }
|
||||
const [confirmClear, setConfirmClear] = useState(false); // 一键清零确认
|
||||
const call = useRpc();
|
||||
const toast = useToast();
|
||||
const { getPrice, registerCodes } = useMarket();
|
||||
@@ -212,22 +187,6 @@ function StrategyTabInner({ strategyId, strategyName }) {
|
||||
}
|
||||
};
|
||||
|
||||
/** 一键清零(O6 策略级):清空当前策略全部份额 */
|
||||
const handleClearAll = async () => {
|
||||
const res = await call('one-divine-lot/remove-all-shares', {
|
||||
args: { strategyId },
|
||||
});
|
||||
if (res && res.ok) {
|
||||
setConfirmClear(false);
|
||||
const affected = Array.isArray(res.value) ? res.value.length : 0;
|
||||
toast.success(`已清空策略份额(${affected} 只标的回到未分配)`);
|
||||
load();
|
||||
} else {
|
||||
toast.error((res && res.error && res.error.message) || '清空失败');
|
||||
setConfirmClear(false);
|
||||
}
|
||||
};
|
||||
|
||||
const name = strategyName || (strategyId === 'grid-supermarket' ? '网格策略持仓' : strategyId === 'manual-t' ? '做T持仓' : strategyId);
|
||||
|
||||
return (
|
||||
@@ -235,14 +194,6 @@ function StrategyTabInner({ strategyId, strategyName }) {
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12, flexWrap: 'wrap', gap: 8 }}>
|
||||
<h3 style={{ margin: 0 }}>{name}({(positions ?? []).length} 只)</h3>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
{positions && positions.length > 0 && (
|
||||
<button
|
||||
onClick={() => setConfirmClear(true)}
|
||||
style={{ padding: '6px 12px', cursor: 'pointer', border: '1px solid #c62828', borderRadius: 4, background: '#fff', color: '#c62828' }}
|
||||
>
|
||||
一键清零
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowAdd(!showAdd)}
|
||||
style={{ padding: '6px 12px', cursor: 'pointer', border: '1px solid #ccc', borderRadius: 4, background: '#f5f5f5' }}
|
||||
@@ -351,16 +302,6 @@ function StrategyTabInner({ strategyId, strategyName }) {
|
||||
</table>
|
||||
)}
|
||||
</LoadState>
|
||||
|
||||
{confirmClear && (
|
||||
<ConfirmDialog
|
||||
title={`一键清零「${name}」`}
|
||||
message="将清空该策略下所有持仓的份额分配,全部回到「未分配」。此操作不可撤销,确定继续?"
|
||||
onConfirm={handleClearAll}
|
||||
onCancel={() => setConfirmClear(false)}
|
||||
confirmText="确认清零"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ export class AllocationStorage {
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空某策略在所有股票下的份额(迭代 02:O3 删除策略联动 / O6 一键清零)
|
||||
* 清空某策略在所有股票下的份额(迭代 02:O3 删除策略联动;O6 一键清零按钮已移除)
|
||||
* 删除该 strategyId 的所有分配,返回受影响(原本有份额)的股票代码列表
|
||||
* @param {string} strategyId 策略 id
|
||||
* @returns {Promise<string[]>} 受影响标的代码列表
|
||||
|
||||
+113
-208
@@ -1,258 +1,163 @@
|
||||
/**
|
||||
* DataStore —— 策略数据集 JSON data store(R-006,2026-08-31 老师定)
|
||||
* DataStore —— 数据存储门面(R-006 JSON → R-008 SQLite 迁移过渡)
|
||||
*
|
||||
* 架构(定稿结论):
|
||||
* - 策略定义仍留 DSH settings(设置页交互不变);
|
||||
* - 份额分配(数据集)标准化为 JSON data store:
|
||||
* - store.schema.json:数据集 schema(类 JSON Schema,描述 dataset 字段);
|
||||
* - store.json:实际数据(策略为中心:strategies[].dataset = [{code, shares}]);
|
||||
* - 旧 allocations.json 启动时自动迁移(保留 .bak 备份);
|
||||
* - 删除策略时由 API 层联动删对应 dataset。
|
||||
* 迭代 06:存储引擎从 JSON data store 升级为 SQLite(node:sqlite)。
|
||||
* - 数据实际存于 SqliteStore(strategy_holdings / market_quotes_cache 表);
|
||||
* - 本模块保留对外兼容 API(getDataset/setDataset/removeDataset/getAllDatasets/getMarketQuotes 等),
|
||||
* 上层(PositionManager / MarketDataHub)调用点不变;
|
||||
* - 启动时检测旧 JSON → 自动迁移(幂等),JSON 迁移后废弃(备份 .bak)。
|
||||
*
|
||||
* 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 # 行情快照缓存(原子写)
|
||||
* 注:PositionManager 适配单票生命周期(openHolding/addShares/reduceShares/closeHolding)后,
|
||||
* 本模块的整策略 setDataset/removeDataset 将不再被调用(保留兼容,不删除)。
|
||||
*/
|
||||
|
||||
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)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
import { SqliteStore } from './SqliteStore.js';
|
||||
|
||||
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
|
||||
constructor({ dataDir } = {}) {
|
||||
this.sqlite = new SqliteStore({ dataDir });
|
||||
this.loaded = false;
|
||||
this.marketLoaded = false;
|
||||
}
|
||||
|
||||
/** 确保目录存在 */
|
||||
async _ensureDir() {
|
||||
await fs.mkdir(this.dataDir, { recursive: true });
|
||||
/** 确保初始化 + 自动迁移(幂等) */
|
||||
async _ensure() {
|
||||
if (this.loaded) return;
|
||||
this.sqlite.init();
|
||||
// 迁移:SQLite 空且旧 JSON 存在 → 自动迁移
|
||||
if (this.sqlite.isEmpty()) {
|
||||
const result = await this.sqlite.migrateJson();
|
||||
if (result.holdings > 0 || result.quotes > 0) {
|
||||
console.log(`[one-divine-lot] SQLite 自动迁移完成: 持仓 ${result.holdings} 行, 行情 ${result.quotes} 行`);
|
||||
}
|
||||
|
||||
/** 写入 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);
|
||||
/** 加载份额数据(兼容旧调用;实际数据在 SQLite) */
|
||||
async load() {
|
||||
await this._ensure();
|
||||
return { version: 1, strategies: this.getAllDatasetsSync() };
|
||||
}
|
||||
|
||||
/** 迁移: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 = {};
|
||||
}
|
||||
await this._ensure();
|
||||
this.marketLoaded = true;
|
||||
return this.market;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** 读取行情快照(code → snapshot),无则 undefined */
|
||||
async getMarketQuote(code) {
|
||||
await this.loadMarket();
|
||||
return this.market[code];
|
||||
// ===== 持仓生命周期(新 API,PositionManager 适配后使用)=====
|
||||
|
||||
/** 建仓 */
|
||||
async openHolding(strategyId, code, shares) {
|
||||
await this._ensure();
|
||||
return this.sqlite.openHolding(strategyId, code, shares);
|
||||
}
|
||||
|
||||
/** 批量读取行情快照 */
|
||||
async getMarketQuotes(codes) {
|
||||
await this.loadMarket();
|
||||
const out = {};
|
||||
for (const c of codes) {
|
||||
if (this.market[c]) out[c] = this.market[c];
|
||||
}
|
||||
return out;
|
||||
/** 加仓 */
|
||||
async addShares(strategyId, code, shares) {
|
||||
await this._ensure();
|
||||
return this.sqlite.addShares(strategyId, code, shares);
|
||||
}
|
||||
|
||||
/** 写入行情快照(整体替换,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;
|
||||
/** 减仓 */
|
||||
async reduceShares(strategyId, code, shares) {
|
||||
await this._ensure();
|
||||
return this.sqlite.reduceShares(strategyId, code, shares);
|
||||
}
|
||||
|
||||
/** 保存行情快照(原子写 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);
|
||||
/** 清仓(转历史) */
|
||||
async closeHolding(strategyId, code) {
|
||||
await this._ensure();
|
||||
return this.sqlite.closeHolding(strategyId, code);
|
||||
}
|
||||
|
||||
// ===== 份额分配 CRUD(store.json)=====
|
||||
/** 当前持仓 */
|
||||
async getCurrentHoldings(strategyId) {
|
||||
await this._ensure();
|
||||
return this.sqlite.getCurrentHoldings(strategyId);
|
||||
}
|
||||
|
||||
/** 读取某策略的数据集([{code, shares}]) */
|
||||
/** 历史持仓 */
|
||||
async getHoldingHistory(code) {
|
||||
await this._ensure();
|
||||
return this.sqlite.getHoldingHistory(code);
|
||||
}
|
||||
|
||||
// ===== 兼容 API(旧调用;PositionManager 迁移后不再使用 setDataset/removeDataset)=====
|
||||
|
||||
/** 读取某策略数据集([{code, shares}],兼容旧调用) */
|
||||
async getDataset(strategyId) {
|
||||
await this.load();
|
||||
const s = this.data.strategies.find((x) => x.strategyId === strategyId);
|
||||
return s ? [...s.dataset] : [];
|
||||
await this._ensure();
|
||||
return this.sqlite.getCurrentHoldings(strategyId).map((h) => ({ code: h.code, shares: h.shares }));
|
||||
}
|
||||
|
||||
/** 设置某策略的数据集(整体替换) */
|
||||
/** 设置某策略数据集(整体替换 —— 兼容旧调用,转换为逐票操作) */
|
||||
async setDataset(strategyId, dataset) {
|
||||
await this.load();
|
||||
await this._ensure();
|
||||
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 });
|
||||
// 简单实现:清空当前持仓再重建(供旧调用方过渡;新代码走生命周期)
|
||||
for (const h of this.sqlite.getCurrentHoldings(strategyId)) {
|
||||
this.sqlite.closeHolding(strategyId, h.code);
|
||||
}
|
||||
for (const item of items) {
|
||||
this.sqlite.openHolding(strategyId, item.code, item.shares);
|
||||
}
|
||||
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();
|
||||
await this._ensure();
|
||||
for (const h of this.sqlite.getCurrentHoldings(strategyId)) {
|
||||
this.sqlite.closeHolding(strategyId, h.code);
|
||||
}
|
||||
}
|
||||
|
||||
/** 全部数据集(策略为中心,供 summary/strategy-positions 使用) */
|
||||
/** 全部数据集(策略为中心,兼容旧调用) */
|
||||
async getAllDatasets() {
|
||||
await this.load();
|
||||
return this.data.strategies.map((s) => ({ strategyId: s.strategyId, dataset: [...s.dataset] }));
|
||||
await this._ensure();
|
||||
return this.getAllDatasetsSync();
|
||||
}
|
||||
|
||||
/** 同步版全部数据集(load 内部用) */
|
||||
getAllDatasetsSync() {
|
||||
const holdings = this.sqlite.getCurrentHoldings();
|
||||
const map = new Map();
|
||||
for (const h of holdings) {
|
||||
if (!map.has(h.strategyId)) map.set(h.strategyId, []);
|
||||
map.get(h.strategyId).push({ code: h.code, shares: h.shares });
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
/** 关闭(插件释放时) */
|
||||
close() {
|
||||
this.sqlite.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,12 @@
|
||||
* - 数据集存 store.json:strategies[].dataset = [{code, shares}]
|
||||
* - 对外方法签名保持不变(addToStrategy / removeFromStrategy / getSummary 等),
|
||||
* 上层(api)无感知。
|
||||
*
|
||||
* 2026-09-01 迭代 06(R-008):存储升级 SQLite(DataStore 委托 SqliteStore)。
|
||||
* - 写入从「整策略 dataset 重写」(_setShares)改为「单票持仓生命周期」:
|
||||
* openHolding(建仓)/ addShares(加仓)/ reduceShares(减仓)/ closeHolding(清仓转历史);
|
||||
* - 清仓/清零/删策略后数据转历史保留(closed_at),供交易记录/复盘关联(R-007 铺路);
|
||||
* - 对外方法签名与返回结构不变,上层 api 无感知。
|
||||
*/
|
||||
|
||||
import { DataStore } from './DataStore.js';
|
||||
@@ -112,8 +118,9 @@ export class PositionManager {
|
||||
`份额超限: ${code} 总持仓 ${total},已分配 ${currentAllocated},添加 ${num} 超出`
|
||||
), { code: 'share-limit' });
|
||||
}
|
||||
const next = { ...current, [strategyId]: num };
|
||||
await this._setShares(code, next);
|
||||
// 单票生命周期:目标份额 = 当前该策略份额 + 新增 num
|
||||
const currentShares = current[strategyId] ?? 0;
|
||||
await this._applySharesForStrategy(code, strategyId, currentShares + num);
|
||||
return this.getShares(code);
|
||||
}
|
||||
|
||||
@@ -132,28 +139,39 @@ export class PositionManager {
|
||||
`移出超限: ${code} 策略 ${strategyId} 当前 ${currentShares},移出 ${num} 超出`
|
||||
), { code: 'share-exceed' });
|
||||
}
|
||||
const next = { ...current };
|
||||
const remain = currentShares - num;
|
||||
if (remain > 0) {
|
||||
next[strategyId] = remain;
|
||||
} else {
|
||||
delete next[strategyId];
|
||||
}
|
||||
await this._setShares(code, next);
|
||||
// 单票生命周期:目标份额 = remain(>0 → reduceShares;=0 → closeHolding)
|
||||
await this._applySharesForStrategy(code, strategyId, remain);
|
||||
return this.getShares(code);
|
||||
}
|
||||
|
||||
/** 写入某票份额(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);
|
||||
/**
|
||||
* 写入某票在指定策略的份额(单票生命周期,迭代 06)
|
||||
* @param {number} target 该策略该 code 的【绝对目标份额】(非增量)
|
||||
* - target > 0:无当前持仓 → openHolding(建仓);有 → addShares(加仓)/ reduceShares(减仓)
|
||||
* - target = 0:closeHolding(清仓转历史)
|
||||
* - 不物理删除,历史保留
|
||||
*/
|
||||
async _applySharesForStrategy(code, strategyId, target) {
|
||||
const num = Number(target);
|
||||
if (!Number.isFinite(num) || num < 0) throw new Error('份额必须为非负数');
|
||||
if (num === 0) {
|
||||
await this.storage.closeHolding(strategyId, code);
|
||||
return;
|
||||
}
|
||||
// 当前该策略该 code 是否有持仓
|
||||
const holdings = await this.storage.getCurrentHoldings(strategyId);
|
||||
const active = holdings.find((h) => h.code === code);
|
||||
if (!active) {
|
||||
await this.storage.openHolding(strategyId, code, num);
|
||||
} else if (num > active.shares) {
|
||||
await this.storage.addShares(strategyId, code, num - active.shares);
|
||||
} else if (num < active.shares) {
|
||||
// 减仓(num=0 已在上面处理为 closeHolding,此处 num>0 直接 reduce)
|
||||
const delta = active.shares - num;
|
||||
await this.storage.reduceShares(strategyId, code, delta);
|
||||
}
|
||||
// num === active.shares:无变化
|
||||
}
|
||||
|
||||
/** 分仓摘要(Q4:仅份额,无市值/盈亏统计) */
|
||||
@@ -211,18 +229,21 @@ export class PositionManager {
|
||||
if (unallocated <= 0) {
|
||||
throw Object.assign(new Error(`标的 ${code} 无未分配份额`), { code: 'no-unallocated' });
|
||||
}
|
||||
const next = { ...current, [strategyId]: (current[strategyId] ?? 0) + unallocated };
|
||||
await this._setShares(code, next);
|
||||
// 单票生命周期:该策略份额 += 未分配(无持仓→openHolding(unallocated),有→addShares)
|
||||
await this._applySharesForStrategy(code, strategyId, (current[strategyId] ?? 0) + unallocated);
|
||||
return this.getShares(code);
|
||||
}
|
||||
|
||||
/**
|
||||
* 一键清零(迭代 02 O6):清空某策略下所有股票的份额
|
||||
* 清空某策略下所有股票的份额(删除策略时联动;迭代 02 O6 的「一键清零」按钮已移除 2026-09-01)
|
||||
* @returns {Promise<string[]>} 受影响标的代码列表
|
||||
*/
|
||||
async clearStrategyShares(strategyId) {
|
||||
const before = await this.storage.getDataset(strategyId);
|
||||
await this.storage.removeDataset(strategyId);
|
||||
return before.map((d) => d.code);
|
||||
// 批量 closeHolding:当前持仓全部转历史(不物理删除,历史保留供交易记录关联)
|
||||
const before = await this.storage.getCurrentHoldings(strategyId);
|
||||
for (const h of before) {
|
||||
await this.storage.closeHolding(strategyId, h.code);
|
||||
}
|
||||
return before.map((h) => h.code);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
/**
|
||||
* SqliteStore —— SQLite 存储封装(R-008 / 迭代 06)
|
||||
*
|
||||
* 技术选型(R-008 D2):node:sqlite(Node ≥22.5 内置 DatabaseSync,同步 API,零依赖分发)
|
||||
* - experimental 风险由本模块隔离:上层(DataStore/PositionManager)只依赖本模块方法,未来可切换驱动。
|
||||
*
|
||||
* 表结构(技术实现方案):
|
||||
* - strategy_holdings 策略持仓生命周期表(一笔 = 一次「建仓→清仓」,历史保留)
|
||||
* - market_quotes_cache 行情快照缓存(code 维度一行一码,只存 UI 消费的 lastPrice/lastClose)
|
||||
*
|
||||
* 方法集(替代 DataStore 原 getDataset/setDataset/removeDataset/getAllDatasets 整体读写):
|
||||
* 持仓生命周期:openHolding / addShares / reduceShares / closeHolding
|
||||
* 持仓查询 :getCurrentHoldings / getHoldingHistory
|
||||
* 行情读写 :getMarketQuote / getMarketQuotes / setMarketQuotes
|
||||
* 生命周期 :init / migrateJson / close
|
||||
*/
|
||||
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { promises as fsp } from 'node:fs';
|
||||
import { mkdirSync } 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 DB_FILE = 'store.db';
|
||||
const SCHEMA_FILE = 'store.schema.json';
|
||||
const DATA_FILE = 'store.json';
|
||||
const MARKET_FILE = 'store.market.json';
|
||||
const LEGACY_FILE = 'allocations.json';
|
||||
|
||||
const SCHEMA_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS strategy_holdings (
|
||||
holding_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
strategy_id TEXT NOT NULL,
|
||||
code TEXT NOT NULL,
|
||||
shares REAL NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
closed_at INTEGER
|
||||
);
|
||||
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
|
||||
);
|
||||
`;
|
||||
|
||||
export class SqliteStore {
|
||||
/**
|
||||
* @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.dbPath = join(dataDir, DB_FILE);
|
||||
this.dataPath = join(dataDir, DATA_FILE);
|
||||
this.marketPath = join(dataDir, MARKET_FILE);
|
||||
this.legacyPath = join(dataDir, LEGACY_FILE);
|
||||
this.schemaPath = join(dataDir, SCHEMA_FILE);
|
||||
this.db = null;
|
||||
}
|
||||
|
||||
// ===== 生命周期 =====
|
||||
|
||||
/** 打开数据库(建库建表),幂等 */
|
||||
init() {
|
||||
if (this.db) return this.db;
|
||||
// 确保目录存在
|
||||
mkdirSync(this.dataDir, { recursive: true });
|
||||
this.db = new DatabaseSync(this.dbPath);
|
||||
this.db.exec(SCHEMA_SQL);
|
||||
return this.db;
|
||||
}
|
||||
|
||||
/** 关闭连接 */
|
||||
close() {
|
||||
if (this.db) {
|
||||
try { this.db.close(); } catch { /* ignore */ }
|
||||
this.db = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 判断库是否为空(两表均无数据) */
|
||||
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;
|
||||
}
|
||||
|
||||
// ===== 迁移(D6:一次性脚本 + 启动自动迁移,幂等)=====
|
||||
|
||||
/**
|
||||
* 旧 JSON → SQLite 迁移(迁移前备份 JSON 为 *.bak;写库失败不删原文件,幂等)
|
||||
* @returns {Promise<{holdings: number, quotes: number}>} 迁移的持仓行数 / 行情行数
|
||||
*/
|
||||
async migrateJson() {
|
||||
this.init();
|
||||
if (!this.isEmpty()) {
|
||||
return { holdings: 0, quotes: 0, skipped: true }; // 已有数据,跳过
|
||||
}
|
||||
|
||||
let holdingsCount = 0;
|
||||
let quotesCount = 0;
|
||||
const now = Date.now();
|
||||
|
||||
// 1. store.json → strategy_holdings(策略为中心平铺)
|
||||
try {
|
||||
const raw = await fsp.readFile(this.dataPath, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
const strategies = Array.isArray(parsed?.strategies) ? parsed.strategies : [];
|
||||
const insert = this.db.prepare(
|
||||
'INSERT INTO strategy_holdings (strategy_id, code, shares, created_at, closed_at) VALUES (?,?,?,?,NULL)'
|
||||
);
|
||||
this.db.exec('BEGIN');
|
||||
for (const s of strategies) {
|
||||
for (const item of s?.dataset ?? []) {
|
||||
if (item && item.code && item.shares > 0) {
|
||||
insert.run(s.strategyId, item.code, item.shares, now);
|
||||
holdingsCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
this.db.exec('COMMIT');
|
||||
await this._backupJson(this.dataPath);
|
||||
} catch (err) {
|
||||
if (err.code !== 'ENOENT') {
|
||||
// 迁移失败不破坏原 JSON(先读后写,写库失败不删原文件)
|
||||
console.warn('[one-divine-lot] store.json 迁移失败: ' + err.message);
|
||||
}
|
||||
// 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');
|
||||
const parsed = JSON.parse(raw);
|
||||
const alloc = parsed?.allocations ?? {};
|
||||
const insert = this.db.prepare(
|
||||
'INSERT INTO strategy_holdings (strategy_id, code, shares, created_at, closed_at) VALUES (?,?,?,?,NULL)'
|
||||
);
|
||||
this.db.exec('BEGIN');
|
||||
for (const [code, sharesMap] of Object.entries(alloc)) {
|
||||
for (const [strategyId, n] of Object.entries(sharesMap ?? {})) {
|
||||
if (n > 0) {
|
||||
// 跳过已在 store.json 迁移过的 code(避免重复)
|
||||
const exists = this.db.prepare(
|
||||
'SELECT 1 FROM strategy_holdings WHERE strategy_id=? AND code=? AND closed_at IS NULL'
|
||||
).get(strategyId, code);
|
||||
if (!exists) {
|
||||
insert.run(strategyId, code, n, now);
|
||||
holdingsCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
this.db.exec('COMMIT');
|
||||
await this._backupJson(this.legacyPath);
|
||||
} catch (err) {
|
||||
if (err.code !== 'ENOENT') {
|
||||
console.warn('[one-divine-lot] allocations.json 迁移失败: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
return { holdings: holdingsCount, quotes: quotesCount };
|
||||
}
|
||||
|
||||
/** 备份 JSON 文件为 .bak(D7:迁移前自动备份) */
|
||||
async _backupJson(filePath) {
|
||||
try {
|
||||
await fsp.access(filePath);
|
||||
await fsp.rename(filePath, filePath + '.bak');
|
||||
} catch { /* 已备份或不存在 */ }
|
||||
}
|
||||
|
||||
// ===== 持仓生命周期 =====
|
||||
|
||||
/** 建仓:创建一笔新持仓(该策略该 code 无当前持仓时) */
|
||||
openHolding(strategyId, code, shares) {
|
||||
this.init();
|
||||
const now = Date.now();
|
||||
const r = this.db.prepare(
|
||||
'INSERT INTO strategy_holdings (strategy_id, code, shares, created_at, closed_at) VALUES (?,?,?,?,NULL)'
|
||||
).run(strategyId, code, Number(shares), now);
|
||||
return { holdingId: Number(r.lastInsertRowid), strategyId, code, shares: Number(shares), createdAt: now, closedAt: null };
|
||||
}
|
||||
|
||||
/** 加仓:当前持仓份额累加 */
|
||||
addShares(strategyId, code, shares) {
|
||||
this.init();
|
||||
const r = this.db.prepare(
|
||||
'UPDATE strategy_holdings SET shares = shares + ? WHERE strategy_id=? AND code=? AND closed_at IS NULL'
|
||||
).run(Number(shares), strategyId, code);
|
||||
if (r.changes === 0) {
|
||||
// 无当前持仓(被关过),退化为建仓
|
||||
return this.openHolding(strategyId, code, shares);
|
||||
}
|
||||
return this._getActive(strategyId, code);
|
||||
}
|
||||
|
||||
/** 减仓:当前持仓份额减少(需先确认减后 > 0,调用方校验) */
|
||||
reduceShares(strategyId, code, shares) {
|
||||
this.init();
|
||||
this.db.prepare(
|
||||
'UPDATE strategy_holdings SET shares = shares - ? WHERE strategy_id=? AND code=? AND closed_at IS NULL'
|
||||
).run(Number(shares), strategyId, code);
|
||||
return this._getActive(strategyId, code);
|
||||
}
|
||||
|
||||
/** 清仓:份额归零,closed_at=now 转历史(不物理删除) */
|
||||
closeHolding(strategyId, code) {
|
||||
this.init();
|
||||
const now = Date.now();
|
||||
this.db.prepare(
|
||||
'UPDATE strategy_holdings SET shares = 0, closed_at = ? WHERE strategy_id=? AND code=? AND closed_at IS NULL'
|
||||
).run(now, strategyId, code);
|
||||
return { strategyId, code, closedAt: now };
|
||||
}
|
||||
|
||||
/** 读取某策略某 code 的当前持仓(可能为 null) */
|
||||
_getActive(strategyId, code) {
|
||||
this.init();
|
||||
const row = this.db.prepare(
|
||||
'SELECT holding_id, strategy_id, code, shares, created_at, closed_at FROM strategy_holdings WHERE strategy_id=? AND code=? AND closed_at IS NULL'
|
||||
).get(strategyId, code);
|
||||
return row ? this._mapHolding(row) : null;
|
||||
}
|
||||
|
||||
/** 查询当前持仓(closed_at IS NULL;可加 strategy_id 过滤) */
|
||||
getCurrentHoldings(strategyId) {
|
||||
this.init();
|
||||
if (strategyId) {
|
||||
return this.db.prepare(
|
||||
'SELECT holding_id, strategy_id, code, shares, created_at, closed_at FROM strategy_holdings WHERE strategy_id=? AND closed_at IS NULL ORDER BY holding_id'
|
||||
).all(strategyId).map(this._mapHolding);
|
||||
}
|
||||
return this.db.prepare(
|
||||
'SELECT holding_id, strategy_id, code, shares, created_at, closed_at FROM strategy_holdings WHERE closed_at IS NULL ORDER BY strategy_id, holding_id'
|
||||
).all().map(this._mapHolding);
|
||||
}
|
||||
|
||||
/** 查询历史(含当前 + 已清仓;可加 code 过滤) */
|
||||
getHoldingHistory(code) {
|
||||
this.init();
|
||||
if (code) {
|
||||
return this.db.prepare(
|
||||
'SELECT holding_id, strategy_id, code, shares, created_at, closed_at FROM strategy_holdings WHERE code=? ORDER BY holding_id'
|
||||
).all(code).map(this._mapHolding);
|
||||
}
|
||||
return this.db.prepare(
|
||||
'SELECT holding_id, strategy_id, code, shares, created_at, closed_at FROM strategy_holdings ORDER BY holding_id'
|
||||
).all().map(this._mapHolding);
|
||||
}
|
||||
|
||||
_mapHolding(row) {
|
||||
return {
|
||||
holdingId: row.holding_id,
|
||||
strategyId: row.strategy_id,
|
||||
code: row.code,
|
||||
shares: row.shares,
|
||||
createdAt: row.created_at,
|
||||
closedAt: row.closed_at,
|
||||
};
|
||||
}
|
||||
|
||||
// ===== 行情读写(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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,7 @@ async function apply(ctx, config) {
|
||||
return () => {
|
||||
marketFeed.stop();
|
||||
qmtHealthMonitor.stop();
|
||||
storage.close(); // 关闭 SQLite 连接(迭代 06)
|
||||
logger.info('[one-divine-lot] 插件已释放');
|
||||
};
|
||||
}, 'one-divine-lot.dispose');
|
||||
|
||||
Reference in New Issue
Block a user