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');