diff --git a/package.json b/package.json
index f0025e2..e9933f1 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/scripts/migrate-json-to-sqlite.mjs b/scripts/migrate-json-to-sqlite.mjs
new file mode 100644
index 0000000..9f390c8
--- /dev/null
+++ b/scripts/migrate-json-to-sqlite.mjs
@@ -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();
diff --git a/src/api/index.js b/src/api/index.js
index 3f8a887..f6b4486 100644
--- a/src/api/index.js
+++ b/src/api/index.js
@@ -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/订阅代理
diff --git a/src/api/strategies.js b/src/api/strategies.js
index 4954ab7..bda355f 100644
--- a/src/api/strategies.js
+++ b/src/api/strategies.js
@@ -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' });
}
diff --git a/src/client/views/StrategyTab.jsx b/src/client/views/StrategyTab.jsx
index beab39d..d6f3ac5 100644
--- a/src/client/views/StrategyTab.jsx
+++ b/src/client/views/StrategyTab.jsx
@@ -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 (
-
-
-
{title}
-
{message}
-
-
-
-
-
-
- );
-}
-
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 }) {
{name}({(positions ?? []).length} 只)
- {positions && positions.length > 0 && (
-
- )}
);
}
diff --git a/src/component/AllocationStorage.js b/src/component/AllocationStorage.js
index b40f38b..78d7854 100644
--- a/src/component/AllocationStorage.js
+++ b/src/component/AllocationStorage.js
@@ -113,7 +113,7 @@ export class AllocationStorage {
}
/**
- * 清空某策略在所有股票下的份额(迭代 02:O3 删除策略联动 / O6 一键清零)
+ * 清空某策略在所有股票下的份额(迭代 02:O3 删除策略联动;O6 一键清零按钮已移除)
* 删除该 strategyId 的所有分配,返回受影响(原本有份额)的股票代码列表
* @param {string} strategyId 策略 id
* @returns {Promise
} 受影响标的代码列表
diff --git a/src/component/DataStore.js b/src/component/DataStore.js
index c6c7770..1567b25 100644
--- a/src/component/DataStore.js
+++ b/src/component/DataStore.js
@@ -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 });
- }
-
- /** 写入 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 : [],
- };
+ /** 确保初始化 + 自动迁移(幂等) */
+ 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} 行`);
}
- } 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();
}
}
diff --git a/src/component/PositionManager.js b/src/component/PositionManager.js
index edc5c8f..899e436 100644
--- a/src/component/PositionManager.js
+++ b/src/component/PositionManager.js
@@ -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} 受影响标的代码列表
*/
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);
}
}
diff --git a/src/component/SqliteStore.js b/src/component/SqliteStore.js
new file mode 100644
index 0000000..8cfa265
--- /dev/null
+++ b/src/component/SqliteStore.js
@@ -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,
+ };
+ }
+}
diff --git a/src/index.js b/src/index.js
index f11e5ea..c690d63 100644
--- a/src/index.js
+++ b/src/index.js
@@ -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');