feat(R-013+重构): 策略自定义字段需求文档 + 结构归类优化

需求与文档:
- R-013 策略自定义字段配置(定义随策略 configSchema 存 settings、值落
  strategy_holdings.values JSON、类型化文本/数字/布尔/枚举)定稿并进入
  PLAN-012 / 迭代 11(含目标数据模型 + 端到端示例 + 技术方案 + 验收标准)
- R-011(Tab设置)/ R-012(UI主题适配)归档至 已完成/,索引标记已归档
- 沉淀约束: 产品约束-010 / 技术约束-015 / 技术约束-016 / UI约束-005

结构重构(技术约束-016):
- src/component/ 平铺还原为语义分域目录: data-source/ storage/ position/
  market/ trades/(git rename 保留历史)
- 清理死代码: 删除 AllocationStorage.js(0 引用)、DataStore
  setDataset/removeDataset(无调用方)
- 同步更新 src/index.js / scripts/*.mjs / tsdown.config.ts 引用
- typecheck + build + test-r009 回归 14/14 通过
This commit is contained in:
2026-09-02 15:38:51 +08:00
parent f1e7e785a1
commit a4902bb730
31 changed files with 509 additions and 198 deletions
-139
View File
@@ -1,139 +0,0 @@
/**
* 本地存储:分仓分配数据(allocations.json
*
* 设计约束:产品约束-003(分仓数据本地保存并更新管理)
* QMT 只提供全量真实持仓,策略分仓分配是本地维护的元数据。
*
* 文件位置:<dataDir>/allocations.json
* 结构:
* {
* "version": 1,
* "allocations": {
* "600719.SH": { "grid-supermarket": 1000, "manual-t": 800 }
* }
* }
*/
import { promises as fs } from 'node:fs';
import { dirname, join } from 'node:path';
import { homedir } from 'node:os';
const DEFAULT_DATA_DIR = join(homedir(), '.dsh', 'one-divine-lot');
const FILE_NAME = 'allocations.json';
/**
* 测试模式数据目录(防误删真实数据,2026-08-31 老师定):
* - 环境变量 ODL_TEST_DATA_DIR 设置时,存储落到独立测试目录,不影响真实 allocations.json
* - 测试脚本必须显式注入 dataDir 或设置该环境变量,禁止在真实数据上执行写操作。
*/
const TEST_DATA_DIR = process.env.ODL_TEST_DATA_DIR || '';
export class AllocationStorage {
/**
* @param {object} [options]
* @param {string} [options.dataDir] 数据目录(默认 ~/.dsh/one-divine-lot
*/
constructor({ dataDir = TEST_DATA_DIR || DEFAULT_DATA_DIR } = {}) {
this.dataDir = dataDir;
this.filePath = join(dataDir, FILE_NAME);
this.data = { version: 1, allocations: {} };
this.loaded = false;
}
/** 加载数据(首次调用时从磁盘读取) */
async load() {
if (this.loaded) return this.data;
try {
const raw = await fs.readFile(this.filePath, 'utf8');
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object') {
this.data = {
version: parsed.version ?? 1,
allocations: parsed.allocations ?? {},
};
}
} catch (err) {
if (err.code !== 'ENOENT') {
throw new Error(`分仓数据读取失败: ${err.message}`);
}
// 文件不存在:首次使用,用默认空数据
}
this.loaded = true;
return this.data;
}
/** 保存数据到磁盘(原子写:临时文件 + rename) */
async save() {
await fs.mkdir(this.dataDir, { recursive: true });
const tmpPath = `${this.filePath}.${process.pid}.tmp`;
await fs.writeFile(tmpPath, JSON.stringify(this.data, null, 2), 'utf8');
await fs.rename(tmpPath, this.filePath);
}
/** 读取某只股票的分配份额 { strategyId: shares } */
async get(code) {
await this.load();
return { ...(this.data.allocations[code] ?? {}) };
}
/** 获取全部分仓分配 */
async getAll() {
await this.load();
return { ...this.data.allocations };
}
/**
* 设置某只股票的份额分配
* @param {string} code 证券代码
* @param {Object<string, number>} shares 策略份额映射,如 { 'grid-supermarket': 1000 }
*/
async set(code, shares) {
await this.load();
// 过滤掉 0/无效值
const cleaned = {};
for (const [sid, n] of Object.entries(shares ?? {})) {
const num = Number(n);
if (Number.isFinite(num) && num > 0) cleaned[sid] = num;
}
if (Object.keys(cleaned).length > 0) {
this.data.allocations[code] = cleaned;
} else {
delete this.data.allocations[code];
}
await this.save();
return this.get(code);
}
/** 删除某只股票的分配 */
async remove(code) {
await this.load();
delete this.data.allocations[code];
await this.save();
}
/**
* 清空某策略在所有股票下的份额(迭代 02:O3 删除策略联动;O6 一键清零按钮已移除)
* 删除该 strategyId 的所有分配,返回受影响(原本有份额)的股票代码列表
* @param {string} strategyId 策略 id
* @returns {Promise<string[]>} 受影响标的代码列表
*/
async removeStrategyShares(strategyId) {
await this.load();
const affected = [];
for (const [code, shares] of Object.entries(this.data.allocations)) {
if (shares && shares[strategyId] !== undefined) {
affected.push(code);
delete shares[strategyId];
// 该票已无任何分配 → 移除整条记录
if (Object.keys(shares).length === 0) {
delete this.data.allocations[code];
}
}
}
if (affected.length > 0) {
await this.save();
}
return affected;
}
}
+7 -7
View File
@@ -13,15 +13,15 @@
* 设计约束:技术约束-001/003、产品约束-001/002/003
*/
import { QmtBridgeRestDataSource } from './component/QmtBridgeRestDataSource.js';
import { QmtBridgeRestDataSource } from './data-source/QmtBridgeRestDataSource.js';
import { registerSettings, resolveStartupConnection } from './settings.js';
import { DataStore } from './component/DataStore.js';
import { PositionManager } from './component/PositionManager.js';
import { DataStore } from './storage/DataStore.js';
import { PositionManager } from './position/PositionManager.js';
import { registerApi } from './api/index.js';
import { MarketDataHub } from './component/MarketDataHub.js';
import { MarketFeed } from './component/MarketFeed.js';
import { QmtHealthMonitor } from './component/QmtHealthMonitor.js';
import { TradeSync } from './component/TradeSync.js';
import { MarketDataHub } from './market/MarketDataHub.js';
import { MarketFeed } from './market/MarketFeed.js';
import { QmtHealthMonitor } from './data-source/QmtHealthMonitor.js';
import { TradeSync } from './trades/TradeSync.js';
const name = 'one-divine-lot';
@@ -26,7 +26,7 @@ export class MarketDataHub {
/**
* @param {object} opts
* @param {object} [opts.logger]
* @param {import('./DataStore.js').DataStore} [opts.dataStore] 持久化存储可选提供则启用行情落盘
* @param {import('../storage/DataStore.js').DataStore} [opts.dataStore] 持久化存储可选提供则启用行情落盘
*/
constructor({ logger, dataStore } = {}) {
this.logger = logger;
@@ -162,4 +162,4 @@ export class MarketDataHub {
this.logger?.debug?.('[one-divine-lot] MarketDataHub REST 拉取失败: ' + (e?.message ?? e));
}
}
}
}
@@ -20,12 +20,12 @@
* - 对外方法签名与返回结构不变上层 api 无感知
*/
import { DataStore } from './DataStore.js';
import { DataStore } from '../storage/DataStore.js';
export class PositionManager {
/**
* @param {object} opts
* @param {import('./data-source-types.js').DataSource} opts.dataSource 数据源QMT REST
* @param {import('../data-source/data-source-types.js').DataSource} opts.dataSource 数据源QMT REST
* @param {DataStore} opts.storage 数据集存储R-006 DataStore
*/
constructor({ dataSource, storage }) {
@@ -3,16 +3,14 @@
*
* 迭代 06存储引擎从 JSON data store 升级为 SQLitenode:sqlite
* - 数据实际存于 SqliteStorestrategy_holdings / market_quotes_cache
* - 本模块保留对外兼容 APIgetDataset/setDataset/removeDataset/getAllDatasets/getMarketQuotes
* 上层PositionManager / MarketDataHub调用点不变
* - 本模块保留对外兼容 APIgetDataset/getAllDatasets/getMarketQuotes 上层PositionManager / MarketDataHub调用点不变
* - 启动时检测旧 JSON 自动迁移幂等JSON 迁移后废弃备份 .bak
*
* PositionManager 适配单票生命周期openHolding/addShares/reduceShares/closeHolding
* 本模块的整策略 setDataset/removeDataset 将不再被调用保留兼容不删除
* PositionManager 适配单票生命周期旧整策略读写 setDataset/removeDataset 已删除
* 2026-09-02 结构优化调用getDataset/getAllDatasets 仍保留供 PositionManager 使用
*/
import { SqliteStore } from './SqliteStore.js';
export class DataStore {
/**
* @param {object} [options]
@@ -97,28 +95,6 @@ export class DataStore {
return this.sqlite.getCurrentHoldings(strategyId).map((h) => ({ code: h.code, shares: h.shares }));
}
/** 设置某策略数据集(整体替换 —— 兼容旧调用,转换为逐票操作) */
async setDataset(strategyId, dataset) {
await this._ensure();
const items = Array.isArray(dataset) ? dataset.filter((x) => x && x.code && x.shares > 0) : [];
// 简单实现:清空当前持仓再重建(供旧调用方过渡;新代码走生命周期)
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);
}
return this.getDataset(strategyId);
}
/** 删除某策略数据集(兼容旧调用 —— 转历史,不物理删除) */
async removeDataset(strategyId) {
await this._ensure();
for (const h of this.sqlite.getCurrentHoldings(strategyId)) {
this.sqlite.closeHolding(strategyId, h.code);
}
}
/** 全部数据集(策略为中心,兼容旧调用) */
async getAllDatasets() {
await this._ensure();