This commit is contained in:
2026-08-29 16:20:33 +08:00
commit cba31428dc
52 changed files with 6553 additions and 0 deletions
+157
View File
@@ -0,0 +1,157 @@
/**
* 服务端 HTTP API:通过 webServer 注册 /odl/api/* 路由
*
* 为什么不用 RPCconnection.rpc.intercept('/api')):
* DSH 的 /api 通道只能有一个 interceptorapi-gateway 已占用),
* 再 intercept 会抛 "already has an interceptor" 导致插件 apply 失败。
* 因此改用 webServer 自开路由(与 dsh-better-sidebar 的 /sidebar/api 同模式)。
*
* 端点(POST /odl/api/<method>body = { args: {...} }):
* positions → 全量持仓
* strategies → 策略列表(含 visible)
* strategies/update → 更新策略列表(整表)
* strategies/add → 新增策略 { name }O3
* strategies/remove → 删除策略 { strategyId },联动清份额(O3
* strategies/move → 排序 { strategyId, dir: up|down }O3
* strategy-positions → 某策略持仓 { strategyId }
* unallocated → 未分配持仓
* summary → 分仓摘要
* add-shares → 添加份额 { code, strategyId, shares }
* move-all-shares → 全部移入 { code, strategyId }O6
* remove-shares → 移出份额 { code, strategyId, shares }
* remove-all-shares → 一键清零 { strategyId }O6
*/
import { getStrategies, updateStrategies, addStrategy, removeStrategy, moveStrategy, getTabConfig, updateTabConfig } from './settings.js';
const API_PREFIX = '/odl/api/';
/** 响应 JSON */
function writeJson(res, status, data) {
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(data));
}
/** 读取 JSON body */
async function readJsonBody(req) {
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
const raw = Buffer.concat(chunks).toString('utf8');
if (!raw) return {};
try {
return JSON.parse(raw);
} catch {
return {};
}
}
/** 端点方法表 */
const METHODS = new Set([
'positions',
'strategies',
'strategies/update',
'strategies/add',
'strategies/remove',
'strategies/move',
'strategy-positions',
'unallocated',
'summary',
'add-shares',
'move-all-shares',
'remove-shares',
'remove-all-shares',
'tabs',
'tabs/update',
]);
/**
* 注册 HTTP API
* @param {import('@deepseek-ai/cordis').Context} ctx
* @param {object} runtime { manager, settings }
*/
export function registerApi(ctx, { manager, settings }) {
ctx.effect(() => ctx.webServer.register({
kind: 'prefix',
path: '/odl/api',
handler: async (req, res) => {
if (req.method !== 'POST') {
writeJson(res, 405, { ok: false, error: { code: 'method-error', message: 'method not allowed' } });
return;
}
const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname;
const method = pathname.startsWith(API_PREFIX) ? pathname.slice(API_PREFIX.length) : void 0;
if (!method || !METHODS.has(method)) {
writeJson(res, 404, { ok: false, error: { code: 'not-found', message: 'unknown method: ' + method } });
return;
}
try {
const body = await readJsonBody(req);
const args = body?.args ?? {};
let value;
switch (method) {
case 'positions':
value = await manager.getAllPositions();
break;
case 'strategies':
value = getStrategies(settings);
break;
case 'strategies/update':
await updateStrategies(settings, args.strategies);
value = getStrategies(settings);
break;
case 'strategies/add':
value = await addStrategy(settings, args.name);
break;
case 'strategies/remove':
// 删除策略 + 联动清空该策略下所有份额(份额回未分配)
await removeStrategy(settings, args.strategyId);
await manager.clearStrategyShares(args.strategyId);
value = getStrategies(settings);
break;
case 'strategies/move':
value = await moveStrategy(settings, args.strategyId, args.dir);
break;
case 'tabs':
value = getTabConfig(settings);
break;
case 'tabs/update':
await updateTabConfig(settings, args.tabs);
value = getTabConfig(settings);
break;
case 'strategy-positions':
value = await manager.getStrategyPositions(args.strategyId);
break;
case 'unallocated':
value = await manager.getUnallocatedPositions();
break;
case 'summary':
value = await manager.getSummary();
break;
case 'add-shares':
value = await manager.addToStrategy(args.code, args.strategyId, args.shares);
break;
case 'move-all-shares':
value = await manager.moveAllUnallocatedToStrategy(args.code, args.strategyId);
break;
case 'remove-shares':
value = await manager.removeFromStrategy(args.code, args.strategyId, args.shares);
break;
case 'remove-all-shares':
value = await manager.clearStrategyShares(args.strategyId);
break;
default:
writeJson(res, 404, { ok: false, error: { code: 'not-found', message: 'unknown method' } });
return;
}
writeJson(res, 200, { ok: true, value });
} catch (error) {
writeJson(res, 500, {
ok: false,
error: { code: error?.code ?? 'internal', message: error?.message ?? String(error), details: {} },
});
}
},
}), 'one-divine-lot.api');
ctx.logger?.info('[one-divine-lot] HTTP API 已注册 (/odl/api/*)');
}
+139
View File
@@ -0,0 +1,139 @@
/**
* 神之一手(one_divine_lot)客户端插件
*
* 注册:
* - conversation.view tabs:全部持仓 / 交易记录 / 关注列表(通用,按 tabConfig 显隐)+ 策略 tabs(按策略配置)
* - settings.section:神之一手设置菜单(通用设置 + 策略分组)
*
* 迭代 02
* - O2 策略 tab 动态化:策略配置驱动注册
* - 设置 tab 化扩展:三个通用 tab(全部持仓/交易记录/关注列表)由 tabConfig 控制显隐
* - 刷新页面 = 插件重载 = 按最新配置重新注册(无事件机制)
*/
import { createElement } from 'react';
import { AllPositionsTab } from './views/AllPositionsTab.jsx';
import { StrategyTab } from './views/StrategyTab.jsx';
import { PlaceholderTab } from './views/PlaceholderTab.jsx';
import { SettingsSection } from './views/SettingsSection.jsx';
import { ConnectionProvider } from './views/connection.jsx';
export const inject = ['slots', 'connection'];
async function fetchStrategies() {
try {
const res = await fetch('/odl/api/strategies', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
const data = await res.json();
return data && data.ok ? data.value : [];
} catch (e) {
return [];
}
}
async function fetchTabConfig() {
try {
const res = await fetch('/odl/api/tabs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
const data = await res.json();
if (data && data.ok) {
return {
allPositions: data.value.allPositions !== false,
tradeRecords: data.value.tradeRecords !== false,
watchlist: data.value.watchlist !== false,
};
}
} catch (e) { /* ignore */ }
return { allPositions: true, tradeRecords: true, watchlist: true };
}
function strategyTabId(strategyId) {
return 'odl-strategy-' + strategyId;
}
const GENERAL_TABS = [
{ key: 'allPositions', id: 'odl-all-positions', label: '全部持仓', order: 10,
render: (withConnection) => withConnection((props) => createElement(AllPositionsTab, props)) },
{ key: 'tradeRecords', id: 'odl-trade-records', label: '交易记录', order: 11,
render: (withConnection) => withConnection((props) => createElement(PlaceholderTab, { ...props, title: '交易记录' })) },
{ key: 'watchlist', id: 'odl-watchlist', label: '关注列表', order: 12,
render: (withConnection) => withConnection((props) => createElement(PlaceholderTab, { ...props, title: '关注列表' })) },
];
export function apply(ctx) {
const connection = ctx.get('connection');
if (!connection) {
ctx.logger?.warn?.('[one-divine-lot] connection 服务不可用');
}
const withConnection = (render) => (props) =>
createElement(ConnectionProvider, { connection }, render(props));
ctx.slots.register({
name: 'settings.section',
id: 'one-divine-lot',
order: 50,
label: () => '神之一手',
}, withConnection((props) => createElement(SettingsSection, props)));
let tabDisposers = [];
let disposed = false;
const disposeAllTabs = () => {
for (const d of tabDisposers) {
try { d(); } catch (e) { /* ignore */ }
}
tabDisposers = [];
};
const registerGeneralTabs = (tabConfig) => {
for (const tab of GENERAL_TABS) {
if (tabConfig[tab.key] === false) continue;
const dispose = ctx.slots.register({
name: 'conversation.view',
id: tab.id,
order: tab.order,
label: () => tab.label,
}, tab.render(withConnection));
tabDisposers.push(dispose);
}
};
const registerStrategyTabs = (strategies) => {
const visible = strategies.filter((s) => s.visible !== false);
visible.forEach((s, idx) => {
const dispose = ctx.slots.register({
name: 'conversation.view',
id: strategyTabId(s.id),
order: 13 + idx,
label: () => s.name,
}, withConnection((props) => createElement(StrategyTab, { ...props, strategyId: s.id, strategyName: s.name })));
tabDisposers.push(dispose);
});
return visible.length;
};
const registerAllTabs = async () => {
const [strategies, tabConfig] = await Promise.all([fetchStrategies(), fetchTabConfig()]);
if (disposed) return;
disposeAllTabs();
registerGeneralTabs(tabConfig);
const n = registerStrategyTabs(strategies);
ctx.logger?.info?.('[one-divine-lot] 已注册通用 tabs + ' + n + ' 个策略 tab');
};
registerAllTabs();
ctx.effect(() => {
return () => {
disposed = true;
disposeAllTabs();
};
}, 'one-divine-lot.client');
}
+67
View File
@@ -0,0 +1,67 @@
/**
* 全部持仓 tab 组件
* 显示全量真实持仓(QMT),标注每只票的份额分配
*/
import { useState, useEffect, useCallback } from 'react';
import { useRpc } from './connection.jsx';
import { LoadState } from './LoadState.jsx';
export function AllPositionsTab(props) {
const [positions, setPositions] = useState(null);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
const call = useRpc();
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await call('one-divine-lot/summary');
if (res && res.ok) setPositions(res.value);
else setError((res && res.error && res.error.message) || '加载失败');
} catch (e) {
setError(e.message);
} finally {
setLoading(false);
}
}, [call]);
useEffect(() => {
load();
}, [load]);
return (
<div style={{ padding: 16, fontFamily: 'system-ui' }}>
<h3 style={{ margin: '0 0 12px' }}>全部持仓{positions ? positions.positions.length : 0} </h3>
<LoadState loading={loading} error={error} onRetry={load} autoRetry={2}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ textAlign: 'left', borderBottom: '2px solid #ddd' }}>
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>代码</th>
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>名称</th>
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>总持仓</th>
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>已分配</th>
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>未分配</th>
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>分配详情</th>
</tr>
</thead>
<tbody>
{(positions?.positions ?? []).map((p) => (
<tr key={p.code} style={{ borderBottom: '1px solid #eee' }}>
<td style={{ padding: '8px 6px', lineHeight: '24px' }}>{p.code}</td>
<td style={{ padding: '8px 6px', lineHeight: '24px' }}>{p.name}</td>
<td style={{ padding: '8px 6px', lineHeight: '24px' }}>{p.totalVolume}</td>
<td style={{ padding: '8px 6px', lineHeight: '24px' }}>{p.allocated}</td>
<td style={{ padding: '8px 6px', lineHeight: '24px', color: p.unallocated > 0 ? '#e65100' : '#2e7d32' }}>{p.unallocated}</td>
<td style={{ padding: '8px 6px', lineHeight: '24px', fontSize: 12, color: '#555' }}>
{Object.entries(p.shares).map(([sid, n]) => sid + ': ' + n).join(', ') || '—'}
</td>
</tr>
))}
</tbody>
</table>
</LoadState>
</div>
);
}
+71
View File
@@ -0,0 +1,71 @@
/**
* 三态加载组件(迭代 02 O5:数据加载失败兜底)
*
* 状态:
* - loading:显示「加载中...」
* - error:显示失败原因 + 重试按钮(自动重试 2 次后进入手动重试态)
* - success:渲染 children
*
* 用法:
* <LoadState loading={loading} error={error} onRetry={load} autoRetry={2}>
* <Table ... />
* </LoadState>
*/
import { useState, useRef, useEffect, useCallback } from 'react';
const RETRY_INTERVAL_MS = 2000;
export function LoadState({ loading, error, onRetry, autoRetry = 2, children }) {
const [autoRetriesLeft, setAutoRetriesLeft] = useState(autoRetry);
const [manualError, setManualError] = useState(null);
const prevErrorRef = useRef(null);
const timerRef = useRef(null);
// 检测 error 变化 → 触发自动重试
useEffect(() => {
const errChanged = error !== prevErrorRef.current;
prevErrorRef.current = error;
if (error && errChanged && autoRetriesLeft > 0 && !loading) {
setAutoRetriesLeft((n) => n - 1);
timerRef.current = setTimeout(() => {
onRetry?.();
}, RETRY_INTERVAL_MS);
}
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [error, autoRetriesLeft, loading, onRetry]);
// loading 时重置自动重试计数(新加载开始)
useEffect(() => {
if (loading) setAutoRetriesLeft(autoRetry);
}, [loading, autoRetry]);
const handleManualRetry = useCallback(() => {
setManualError(null);
setAutoRetriesLeft(autoRetry);
onRetry?.();
}, [onRetry, autoRetry]);
if (loading) {
return <div style={{ padding: 16, color: '#666' }}>加载中...</div>;
}
const errMsg = error || manualError;
if (errMsg) {
return (
<div style={{ padding: 16, color: '#c62828' }}>
<div style={{ marginBottom: 8 }}>数据加载失败{errMsg}</div>
<button
onClick={handleManualRetry}
style={{ padding: '6px 16px', cursor: 'pointer', border: '1px solid #c62828', borderRadius: 4, background: '#fff', color: '#c62828' }}
>
重试
</button>
</div>
);
}
return children;
}
+13
View File
@@ -0,0 +1,13 @@
/**
* 占位 tab 组件(迭代 02 设置 tab 化扩展)
* 交易记录 / 关注列表 当前为空占位,功能后续迭代实现
*/
export function PlaceholderTab({ title }) {
return (
<div style={{ padding: 40, textAlign: 'center', fontFamily: 'system-ui', color: '#999' }}>
<div style={{ fontSize: 16, marginBottom: 8 }}>{title}</div>
<div style={{ fontSize: 13 }}>功能开发中敬请期待</div>
</div>
);
}
+397
View File
@@ -0,0 +1,397 @@
/**
* 神之一手设置 section(迭代 02:设置 tab 化扩展)
*
* 结构:
* - 两个子 tab:通用设置 / 策略分组
* - 通用设置:三个 switch(全部持仓 / 交易记录 / 关注列表),控制对应会话 tab 显隐,切换后提示「刷新页面后生效」
* - 策略分组:策略 CRUD(新增/重命名/删除确认/排序/显隐)+ 就地提示
*/
import { useState, useEffect, useCallback } from 'react';
import { useRpc } from './connection.jsx';
import { ToastProvider, useToast } from './Toast.jsx';
/** 确认弹窗 */
function ConfirmDialog({ title, message, onConfirm, onCancel }) {
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' }}>
确认
</button>
</div>
</div>
</div>
);
}
/** switch 组件 */
function Switch({ checked, onChange, disabled }) {
return (
<button
onClick={() => !disabled && onChange(!checked)}
disabled={disabled}
style={{
width: 44, height: 24, borderRadius: 12, border: 'none', cursor: disabled ? 'not-allowed' : 'pointer',
background: checked ? '#2e7d32' : '#9e9e9e', position: 'relative', padding: 0, transition: 'background 0.2s',
opacity: disabled ? 0.6 : 1,
}}
>
<span style={{
position: 'absolute', top: 2, left: checked ? 22 : 2, width: 20, height: 20,
borderRadius: '50%', background: '#fff', transition: 'left 0.2s',
}} />
</button>
);
}
/** 通用设置子 tab */
function GeneralSettings({ onChanged }) {
const [tabConfig, setTabConfig] = useState(null);
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
const call = useRpc();
const toast = useToast();
const load = useCallback(async () => {
try {
const res = await call('one-divine-lot/tabs', {});
if (res && res.ok) setTabConfig(res.value);
else setError((res && res.error && res.error.message) || '加载失败');
} catch (e) {
setError(e.message);
}
}, [call]);
useEffect(() => { load(); }, [load]);
const toggle = async (key, label) => {
if (!tabConfig) return;
setSaving(true);
try {
const next = { ...tabConfig, [key]: !tabConfig[key] };
const res = await call('one-divine-lot/tabs/update', { args: { tabs: next } });
if (res && res.ok) {
setTabConfig(next);
toast.success(`${label}」已${next[key] ? '显示' : '隐藏'}`);
onChanged?.(label, next[key]);
} else {
toast.error((res && res.error && res.error.message) || '保存失败');
}
} catch (e) {
toast.error(e.message);
} finally {
setSaving(false);
}
};
const items = [
{ key: 'allPositions', label: '全部持仓', desc: '会话窗中的「全部持仓」标签页' },
{ key: 'tradeRecords', label: '交易记录', desc: '会话窗中的「交易记录」标签页(开发中)' },
{ key: 'watchlist', label: '关注列表', desc: '会话窗中的「关注列表」标签页(开发中)' },
];
return (
<div>
{error && <div style={{ color: '#c62828', marginBottom: 12 }}>{error}</div>}
{!tabConfig && !error && <div style={{ color: '#666' }}>加载中...</div>}
{tabConfig && items.map((item) => (
<div key={item.key} style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '12px 0', borderBottom: '1px solid #eee',
}}>
<div>
<div style={{ fontSize: 14, fontWeight: 500 }}>{item.label}</div>
<div style={{ fontSize: 12, color: '#888', marginTop: 2 }}>{item.desc}</div>
</div>
<Switch checked={tabConfig[item.key] !== false} onChange={() => toggle(item.key, item.label)} disabled={saving} />
</div>
))}
</div>
);
}
/** 策略分组子 tab(原策略 CRUD) */
function StrategyGroupSettings() {
const [strategies, setStrategies] = useState(null);
const [error, setError] = useState(null);
const [saving, setSaving] = useState(false);
const [newName, setNewName] = useState('');
const [editingId, setEditingId] = useState(null);
const [editingName, setEditingName] = useState('');
const [confirmDelete, setConfirmDelete] = useState(null);
const [changedMsg, setChangedMsg] = useState(null);
const call = useRpc();
const toast = useToast();
const load = useCallback(async () => {
try {
const res = await call('one-divine-lot/strategies', {});
if (res && res.ok) setStrategies(res.value);
else setError((res && res.error && res.error.message) || '加载失败');
} catch (e) {
setError(e.message);
}
}, [call]);
useEffect(() => { load(); }, [load]);
const handleChanged = useCallback((msg) => {
setChangedMsg(msg);
toast.success(msg + '(刷新页面后生效)');
load();
}, [load, toast]);
const handleAdd = async () => {
const name = newName.trim();
if (!name) { toast.error('请输入策略名称'); return; }
setSaving(true);
try {
const res = await call('one-divine-lot/strategies/add', { args: { name } });
if (res && res.ok) {
setNewName('');
handleChanged('策略「' + name + '」已添加');
} else {
toast.error((res && res.error && res.error.message) || '添加失败');
}
} catch (e) {
toast.error(e.message);
} finally {
setSaving(false);
}
};
const handleRename = async (id) => {
const name = editingName.trim();
if (!name) { toast.error('名称不能为空'); return; }
setSaving(true);
try {
const res = await call('one-divine-lot/strategies/update', {
args: { strategies: strategies.map((s) => (s.id === id ? { ...s, name } : s)) },
});
if (res && res.ok) {
setEditingId(null);
handleChanged('策略已重命名');
} else {
toast.error((res && res.error && res.error.message) || '重命名失败');
}
} catch (e) {
toast.error(e.message);
} finally {
setSaving(false);
}
};
const handleDelete = async (id) => {
setSaving(true);
try {
const res = await call('one-divine-lot/strategies/remove', { args: { strategyId: id } });
if (res && res.ok) {
setConfirmDelete(null);
handleChanged('策略已删除,份额已回到未分配');
} else {
toast.error((res && res.error && res.error.message) || '删除失败');
setConfirmDelete(null);
}
} catch (e) {
toast.error(e.message);
setConfirmDelete(null);
} finally {
setSaving(false);
}
};
const handleMove = async (id, dir) => {
setSaving(true);
try {
const res = await call('one-divine-lot/strategies/move', { args: { strategyId: id, dir } });
if (res && res.ok) {
await load();
} else {
toast.error((res && res.error && res.error.message) || '排序失败');
}
} catch (e) {
toast.error(e.message);
} finally {
setSaving(false);
}
};
const toggleVisible = async (id) => {
if (!strategies) return;
setSaving(true);
try {
const next = strategies.map((s) => (s.id === id ? { ...s, visible: !s.visible } : s));
const res = await call('one-divine-lot/strategies/update', { args: { strategies: next } });
if (res && res.ok) {
setStrategies(next);
handleChanged('显示设置已更新');
} else {
toast.error((res && res.error && res.error.message) || '保存失败');
}
} catch (e) {
toast.error(e.message);
} finally {
setSaving(false);
}
};
return (
<div>
{changedMsg && (
<div style={{
padding: '10px 14px', marginBottom: 12, borderRadius: 6,
background: '#e3f2fd', color: '#1565c0', fontSize: 13, border: '1px solid #90caf9',
}}>
{changedMsg} <strong>刷新页面后生效</strong>
</div>
)}
{error && <div style={{ color: '#c62828', marginBottom: 12 }}>{error}</div>}
<div style={{ display: 'flex', gap: 8, marginBottom: 16, alignItems: 'center' }}>
<input
type="text"
placeholder="新策略名称(如:长线持有)"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
style={{ flex: 1, padding: '6px 10px', border: '1px solid #ccc', borderRadius: 4 }}
/>
<button
onClick={handleAdd}
disabled={saving}
style={{ padding: '6px 16px', cursor: 'pointer', border: 'none', borderRadius: 4, background: '#2e7d32', color: '#fff' }}
>
+ 新增策略
</button>
</div>
{!strategies && !error && <div style={{ color: '#666' }}>加载中...</div>}
{strategies && (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
<thead>
<tr style={{ textAlign: 'left', borderBottom: '2px solid #ddd' }}>
<th style={{ padding: '8px 6px' }}>排序</th>
<th style={{ padding: '8px 6px' }}>策略</th>
<th style={{ padding: '8px 6px' }}>ID</th>
<th style={{ padding: '8px 6px' }}>显示</th>
<th style={{ padding: '8px 6px' }}>操作</th>
</tr>
</thead>
<tbody>
{strategies.map((s, idx) => (
<tr key={s.id} style={{ borderBottom: '1px solid #eee' }}>
<td style={{ padding: '8px 6px', whiteSpace: 'nowrap' }}>
<button
onClick={() => handleMove(s.id, 'up')}
disabled={saving || idx === 0}
style={{ padding: '2px 6px', cursor: idx === 0 ? 'not-allowed' : 'pointer', border: '1px solid #ccc', borderRadius: 3, background: '#fff', marginRight: 4 }}
></button>
<button
onClick={() => handleMove(s.id, 'down')}
disabled={saving || idx === strategies.length - 1}
style={{ padding: '2px 6px', cursor: idx === strategies.length - 1 ? 'not-allowed' : 'pointer', border: '1px solid #ccc', borderRadius: 3, background: '#fff' }}
></button>
</td>
<td style={{ padding: '8px 6px' }}>
{editingId === s.id ? (
<span>
<input
type="text"
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleRename(s.id)}
style={{ padding: '4px 6px', border: '1px solid #ccc', borderRadius: 3, marginRight: 6 }}
/>
<button onClick={() => handleRename(s.id)} disabled={saving} style={{ padding: '2px 8px', cursor: 'pointer' }}>保存</button>
<button onClick={() => setEditingId(null)} style={{ padding: '2px 8px', cursor: 'pointer', marginLeft: 4 }}>取消</button>
</span>
) : (
s.name
)}
</td>
<td style={{ padding: '8px 6px', color: '#666', fontSize: 12 }}>{s.id}</td>
<td style={{ padding: '8px 6px' }}>
<Switch checked={s.visible !== false} onChange={() => toggleVisible(s.id)} disabled={saving} />
</td>
<td style={{ padding: '8px 6px', whiteSpace: 'nowrap' }}>
<button
onClick={() => { setEditingId(s.id); setEditingName(s.name); }}
style={{ padding: '2px 8px', cursor: 'pointer', border: '1px solid #ccc', borderRadius: 3, background: '#fff', marginRight: 4 }}
>重命名</button>
<button
onClick={() => setConfirmDelete({ id: s.id, name: s.name })}
style={{ padding: '2px 8px', cursor: 'pointer', border: '1px solid #c62828', borderRadius: 3, background: '#fff', color: '#c62828' }}
>删除</button>
</td>
</tr>
))}
</tbody>
</table>
)}
{confirmDelete && (
<ConfirmDialog
title={'删除策略「' + confirmDelete.name + '」'}
message="该策略下已分配的份额将回到「未分配」,数据不会丢失。确定删除?"
onConfirm={() => handleDelete(confirmDelete.id)}
onCancel={() => setConfirmDelete(null)}
/>
)}
</div>
);
}
/** 设置 section 主组件:两个子 tab */
function SettingsSectionInner(props) {
const [activeTab, setActiveTab] = useState('general'); // general | strategies
return (
<div style={{ padding: 20, fontFamily: 'system-ui' }}>
<h2 style={{ margin: '0 0 16px' }}>神之一手</h2>
{/* 子 tab 导航 */}
<div style={{ display: 'flex', gap: 4, borderBottom: '1px solid #ddd', marginBottom: 16 }}>
{[
{ key: 'general', label: '通用设置' },
{ key: 'strategies', label: '策略分组' },
].map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
style={{
padding: '8px 16px', cursor: 'pointer', border: 'none', borderBottom: activeTab === tab.key ? '2px solid #2e7d32' : '2px solid transparent',
background: 'none', fontSize: 14, color: activeTab === tab.key ? '#2e7d32' : '#666', fontWeight: activeTab === tab.key ? 600 : 400,
}}
>
{tab.label}
</button>
))}
</div>
{activeTab === 'general' && <GeneralSettings onChanged={(label, visible) => {
// 通用设置变更 → 就地提示(由 GeneralSettings 内 toast 提示,这里可额外提示)
}} />}
{activeTab === 'strategies' && <StrategyGroupSettings />}
</div>
);
}
/** 导出组件(包 ToastProvider */
export function SettingsSection(props) {
return (
<ToastProvider>
<SettingsSectionInner {...props} />
</ToastProvider>
);
}
+364
View File
@@ -0,0 +1,364 @@
/**
* 策略持仓 tab 组件(迭代 02)
*
* 功能:
* - 显示某策略下的持仓份额(O2:策略名/份额由外部传入,支持动态策略)
* - 添加(从全部持仓选标的+填份额,保留)
* - 移出(指定数量,保留)
* - 全部移入(O6:行内按钮,该票全部未分配份额移入当前策略)
* - 一键清零(O6:顶部按钮,弹窗确认后清空当前策略全部份额)
* - 操作反馈:Toast 轻提示
* - 加载失败兜底:LoadState 三态
*
* 添加持仓区域为响应式布局(内联 <style> + 媒体查询):
* - 宽屏:三元素一行(选择标的 | 份额 | 确认按钮)
* - 中屏(<720px):确认按钮换行到第二行,右对齐
* - 窄屏(<480px):每元素一行,两端对齐
*/
import { useState, useEffect, useCallback } from 'react';
import { useRpc } from './connection.jsx';
import { LoadState } from './LoadState.jsx';
import { ToastProvider, useToast } from './Toast.jsx';
/** 添加持仓区域响应式样式(内联注入,含媒体查询) */
const ADD_FORM_CSS = `
.odl-add-form {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
padding: 12px;
border: 1px solid #ddd;
border-radius: 6px;
margin-bottom: 12px;
background: #fafafa;
}
.odl-add-form .odl-field {
flex: 1 1 260px;
min-width: 0;
display: flex;
align-items: center;
gap: 6px;
margin: 0;
}
.odl-add-form .odl-field select {
flex: 1;
min-width: 0;
padding: 4px;
}
.odl-add-form .odl-shares {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 6px;
margin: 0;
}
.odl-add-form .odl-shares input {
width: 90px;
padding: 4px;
}
.odl-add-form .odl-submit {
flex: 0 0 auto;
margin-left: auto;
padding: 6px 16px;
cursor: pointer;
background: #2e7d32;
color: #fff;
border: none;
border-radius: 4px;
}
@media (max-width: 720px) {
.odl-add-form .odl-field {
flex-basis: calc(100% - 150px);
}
.odl-add-form .odl-submit {
margin-left: auto;
}
}
@media (max-width: 480px) {
.odl-add-form .odl-field,
.odl-add-form .odl-shares {
flex: 1 1 100%;
justify-content: space-between;
}
.odl-add-form .odl-submit {
flex: 1 1 100%;
margin-left: 0;
width: 100%;
}
}
`;
/** 注入样式到文档(只注入一次) */
let styleInjected = false;
function ensureAddFormCss() {
if (styleInjected || typeof document === 'undefined') return;
const el = document.createElement('style');
el.textContent = ADD_FORM_CSS;
document.head.appendChild(el);
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);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
const [showAdd, setShowAdd] = useState(false);
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 load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [strategyRes, allRes] = await Promise.all([
call('one-divine-lot/strategy-positions', { args: { strategyId } }),
call('one-divine-lot/unallocated', {}),
]);
if (strategyRes && strategyRes.ok) setPositions(strategyRes.value);
if (allRes && allRes.ok) setAllPositions(allRes.value);
if ((strategyRes && !strategyRes.ok) || (allRes && !allRes.ok)) {
setError((strategyRes?.error?.message) || (allRes?.error?.message) || '加载失败');
}
} catch (e) {
setError(e.message);
} finally {
setLoading(false);
}
}, [call, strategyId]);
useEffect(() => {
ensureAddFormCss();
load();
}, [load]);
const handleAdd = async () => {
if (!selectedCode || !addShares) return;
const res = await call('one-divine-lot/add-shares', {
args: { code: selectedCode, strategyId, shares: Number(addShares) },
});
if (res && res.ok) {
setShowAdd(false);
setSelectedCode('');
setAddShares('');
toast.success('已添加份额');
load();
} else {
toast.error((res && res.error && res.error.message) || '添加失败');
}
};
const handleRemove = async (code) => {
if (!removeTarget || !removeTarget.shares) return;
const res = await call('one-divine-lot/remove-shares', {
args: { code, strategyId, shares: Number(removeTarget.shares) },
});
if (res && res.ok) {
setRemoveTarget(null);
toast.success('已移出份额');
load();
} else {
toast.error((res && res.error && res.error.message) || '移出失败');
}
};
/** 全部移入(O6 单票级):该票全部未分配份额移入当前策略 */
const handleMoveAllIn = async (code) => {
const res = await call('one-divine-lot/move-all-shares', {
args: { code, strategyId },
});
if (res && res.ok) {
toast.success('未分配份额已全部移入');
load();
} else {
toast.error((res && res.error && res.error.message) || '全部移入失败');
}
};
/** 一键清零(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 (
<div style={{ padding: 16, fontFamily: 'system-ui' }}>
<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' }}
>
{showAdd ? '取消' : '+ 添加持仓'}
</button>
</div>
</div>
<LoadState loading={loading} error={error} onRetry={load} autoRetry={2}>
{showAdd && (
<div className="odl-add-form">
<label className="odl-field">
选择标的未分配持仓
<select value={selectedCode} onChange={(e) => setSelectedCode(e.target.value)}>
<option value="">请选择</option>
{(allPositions || []).map((p) => (
<option key={p.code} value={p.code}>
{p.code} {p.name}未分配 {p.unallocated}
</option>
))}
</select>
</label>
<label className="odl-shares">
份额股数
<input
type="number"
min="0"
step={100}
value={addShares}
onChange={(e) => setAddShares(e.target.value)}
/>
</label>
<button className="odl-submit" onClick={handleAdd}>
确认添加
</button>
</div>
)}
{positions && positions.length === 0 ? (
<div style={{ padding: 24, textAlign: 'center', color: '#999' }}>
暂无持仓点击+ 添加持仓从全部持仓中添加
</div>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ textAlign: 'left', borderBottom: '2px solid #ddd' }}>
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>代码</th>
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>名称</th>
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>策略份额</th>
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>总持仓</th>
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>操作</th>
</tr>
</thead>
<tbody>
{(positions ?? []).map((p) => (
<tr key={p.code} style={{ borderBottom: '1px solid #eee' }}>
<td style={{ padding: '8px 6px' }}>{p.code}</td>
<td style={{ padding: '8px 6px' }}>{p.name}</td>
<td style={{ padding: '8px 6px', fontWeight: 'bold' }}>{p.shares}</td>
<td style={{ padding: '8px 6px' }}>{p.volume}</td>
<td style={{ padding: '8px 6px', whiteSpace: 'nowrap' }}>
{removeTarget && removeTarget.code === p.code ? (
<span>
<input
type="number"
min="1"
step={100}
value={removeTarget.shares}
onChange={(e) => setRemoveTarget({ code: p.code, shares: e.target.value })}
style={{ padding: 2, width: 70, marginRight: 6 }}
/>
<button onClick={() => handleRemove(p.code)} style={{ padding: '2px 8px', cursor: 'pointer' }}>
确认
</button>
<button onClick={() => setRemoveTarget(null)} style={{ padding: '2px 8px', cursor: 'pointer', marginLeft: 4 }}>
取消
</button>
</span>
) : (
<span>
<button
onClick={() => handleMoveAllIn(p.code)}
style={{ padding: '2px 8px', cursor: 'pointer', border: '1px solid #2e7d32', borderRadius: 3, background: '#fff', color: '#2e7d32', marginRight: 4 }}
title="将该票全部未分配份额移入本策略"
>
全部移入
</button>
<button
onClick={() => setRemoveTarget({ code: p.code, shares: p.shares })}
style={{ padding: '2px 8px', cursor: 'pointer' }}
>
移出
</button>
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</LoadState>
{confirmClear && (
<ConfirmDialog
title={`一键清零「${name}`}
message="将清空该策略下所有持仓的份额分配,全部回到「未分配」。此操作不可撤销,确定继续?"
onConfirm={handleClearAll}
onCancel={() => setConfirmClear(false)}
confirmText="确认清零"
/>
)}
</div>
);
}
/** 导出组件(包 ToastProvider */
export function StrategyTab(props) {
return (
<ToastProvider>
<StrategyTabInner {...props} />
</ToastProvider>
);
}
+88
View File
@@ -0,0 +1,88 @@
/**
* 轻提示组件(迭代 02 O6/O3:操作反馈)
* 全局 Toast 容器 + useToast hook
*
* 用法:
* const toast = useToast();
* toast.success('保存成功');
* toast.error('操作失败: xxx');
* <ToastHost /> // 挂载一次在根组件
*/
import { useState, useCallback, createContext, useContext } from 'react';
const ToastContext = createContext(null);
let toastIdSeq = 0;
export function ToastHost() {
const [toasts, setToasts] = useState([]);
const remove = useCallback((id) => {
setToasts((list) => list.filter((t) => t.id !== id));
}, []);
const push = useCallback((type, message) => {
const id = ++toastIdSeq;
setToasts((list) => [...list, { id, type, message }]);
setTimeout(() => remove(id), 3000);
}, [remove]);
return (
<div style={{
position: 'fixed',
top: 16,
right: 16,
zIndex: 9999,
display: 'flex',
flexDirection: 'column',
gap: 8,
}}>
{toasts.map((t) => (
<div
key={t.id}
style={{
padding: '10px 16px',
borderRadius: 6,
color: '#fff',
background: t.type === 'success' ? '#2e7d32' : '#c62828',
boxShadow: '0 2px 8px rgba(0,0,0,0.2)',
fontSize: 13,
maxWidth: 360,
}}
>
{t.message}
</div>
))}
</div>
);
}
export function ToastProvider({ children }) {
const [toasts, setToasts] = useState([]);
const remove = useCallback((id) => {
setToasts((list) => list.filter((t) => t.id !== id));
}, []);
const push = useCallback((type, message) => {
const id = ++toastIdSeq;
setToasts((list) => [...list, { id, type, message }]);
setTimeout(() => remove(id), 3000);
}, [remove]);
const toast = {
success: (m) => push('success', m),
error: (m) => push('error', m),
};
return (
<ToastContext.Provider value={toast}>
{children}
<ToastHost />
</ToastContext.Provider>
);
}
/** 获取 toast 方法(需在 ToastProvider 内) */
export function useToast() {
return useContext(ToastContext);
}
+42
View File
@@ -0,0 +1,42 @@
/**
* 客户端 API 调用 hook
* 通过 fetch 调用服务端插件 HTTP API/odl/api/<method>
*/
import { useContext, createContext, useCallback } from 'react';
const ConnectionContext = createContext(null);
export function ConnectionProvider({ connection, children }) {
return (
<ConnectionContext.Provider value={connection}>
{children}
</ConnectionContext.Provider>
);
}
/** 标准化 endpoint:剥离 one-divine-lot/ 前缀(服务端 /odl/api/ 已隐含 namespace */
function normalizeEndpoint(endpoint) {
if (typeof endpoint === 'string' && endpoint.startsWith('one-divine-lot/')) {
return endpoint.slice('one-divine-lot/'.length);
}
return endpoint;
}
/** 获取 API 调用函数(fetch /odl/api)—— useCallback 稳定引用,避免每次渲染新建导致 effect 循环 */
export function useRpc() {
return useCallback(async (endpoint, payload = {}) => {
try {
const method = normalizeEndpoint(endpoint);
const res = await fetch('/odl/api/' + method, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await res.json();
return data;
} catch (e) {
return { ok: false, error: { message: 'API 调用异常: ' + (e?.message ?? String(e)) } };
}
}, []);
}
+98
View File
@@ -0,0 +1,98 @@
/**
* QMT Bridge REST 数据源适配器
*
* 设计约束:
* - 技术约束-001:统一数据源抽象(业务层不感知具体数据源)
* - 技术约束-003:直连 QMT Bridge RESTful 接口(不经过 MCP
*
* 服务地址:http://192.168.3.43:8610(可通过 config 覆盖)
*/
const DEFAULT_BASE_URL = 'http://192.168.3.43:8610';
const REQUEST_TIMEOUT_MS = 15000;
export class QmtBridgeRestDataSource {
constructor({ baseUrl = DEFAULT_BASE_URL, timeoutMs = REQUEST_TIMEOUT_MS } = {}) {
this.name = 'qmt-bridge-rest';
this.baseUrl = baseUrl.replace(/\/$/, '');
this.timeoutMs = timeoutMs;
}
/** 基础 HTTP 请求(fetch + 超时) */
async request(path, { method = 'GET', body } = {}) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
try {
const res = await fetch(`${this.baseUrl}${path}`, {
method,
headers: body ? { 'Content-Type': 'application/json' } : undefined,
body: body ? JSON.stringify(body) : undefined,
signal: controller.signal,
});
if (!res.ok) {
throw new Error(`QMT Bridge REST ${method} ${path} 失败: HTTP ${res.status}`);
}
return await res.json();
} catch (err) {
if (err.name === 'AbortError') {
throw new Error(`QMT Bridge REST 请求超时: ${path} (${this.timeoutMs}ms)`);
}
throw err;
} finally {
clearTimeout(timer);
}
}
/** 可用性检查 */
async isAvailable() {
try {
const data = await this.request('/health');
return data?.status === 'ok';
} catch {
return false;
}
}
/** 资金摘要(/trade/asset */
async getAsset() {
const res = await this.request('/trade/asset');
const a = Array.isArray(res?.data) ? res.data[0] : res?.data;
if (!a) throw new Error('QMT 资金数据为空');
return {
accountId: a.m_strAccountID ?? '',
status: a.m_strStatus ?? '',
tradingDate: a.m_strTradingDate ?? '',
totalAsset: a.m_dAssetBalance ?? 0,
available: a.m_dAvailable ?? 0,
stockValue: a.m_dStockValue ?? 0,
balance: a.m_dBalance ?? 0,
positionProfit: a.m_dPositionProfit ?? 0,
};
}
/** 全量持仓(/trade/positions */
async getPositions() {
const res = await this.request('/trade/positions');
const data = res?.data;
const list = Array.isArray(data) ? data : data?.positions;
if (!Array.isArray(list)) return [];
return list.map((p) => this.mapPosition(p));
}
/** 持仓字段映射:语义字段优先,回退 m_ 原始字段 */
mapPosition(p) {
return {
code: p.stock_code ?? p.m_strInstrumentID ?? '',
name: p.stock_name ?? p.m_strInstrumentName ?? '',
exchange: p.m_strExchangeName ?? '',
volume: p.volume ?? p.m_nVolume ?? 0,
available: p.available ?? p.m_nCanUseVolume ?? 0,
frozenVolume: p.frozen_volume ?? p.m_nFrozenVolume ?? 0,
avgPrice: +(p.avg_price ?? p.m_dAvgOpenPrice ?? 0),
price: +(p.price ?? p.m_dLastPrice ?? 0),
marketValue: +(p.market_value ?? p.m_dInstrumentValue ?? 0),
profit: +(p.profit ?? p.m_dFloatProfit ?? 0),
profitPct: +(p.profit_pct ?? (p.m_dProfitRate != null ? p.m_dProfitRate * 100 : 0)),
};
}
}
+40
View File
@@ -0,0 +1,40 @@
/**
* 统一数据源抽象(设计约束:技术约束-001)
* 业务层只依赖这些接口,不感知具体数据源实现(当前:QMT Bridge REST
*/
/**
* @typedef {Object} Position 统一持仓模型
* @property {string} code 证券代码(含交易所后缀,如 600719.SH)
* @property {string} name 证券名称
* @property {string} exchange 交易所名称
* @property {number} volume 总持仓数量
* @property {number} available 可用数量
* @property {number} frozenVolume 冻结数量
* @property {number} avgPrice 成本均价
* @property {number} price 最新价
* @property {number} marketValue 市值
* @property {number} profit 浮动盈亏
* @property {number} profitPct 盈亏比例(%)
*/
/**
* @typedef {Object} AssetSummary 统一资金模型
* @property {string} accountId 账户
* @property {string} status 登录状态
* @property {string} tradingDate 交易日 YYYYMMDD
* @property {number} totalAsset 总资产
* @property {number} available 可用资金
* @property {number} stockValue 股票市值
* @property {number} balance 账户余额
* @property {number} positionProfit 持仓盈亏(账户级)
*/
/**
* 数据源统一接口:一个数据源适配器必须实现这些方法
* @typedef {Object} DataSource
* @property {string} name 数据源名称
* @property {() => Promise<AssetSummary>} getAsset 资金摘要
* @property {() => Promise<Position[]>} getPositions 全量持仓
* @property {() => Promise<boolean>} isAvailable 可用性检查
*/
+65
View File
@@ -0,0 +1,65 @@
/**
* 神之一手(one_divine_lotDSH 插件入口
*
* 迭代 01:分仓管理工具
* - S2 数据源直连 QMT Bridge REST(技术约束-003
* - S3 设置管理(策略=标签 + 显示开关)
* - S4 分仓数据本地存储
* - S5 分仓逻辑(份额分配/查询)
* - S6 服务端 HTTP APIwebServer /odl/api/*
* - S7 客户端 tab 展示(客户端插件)
*
* 依据:docs/02-计划/计划-分仓管理工具.md、docs/04-迭代记录/01-分仓管理工具/
* 设计约束:技术约束-001/003、产品约束-001/002/003
*/
import { QmtBridgeRestDataSource } from './data-source/qmt-bridge-rest.js';
import { registerSettings } from './settings.js';
import { AllocationStorage } from './storage.js';
import { PositionManager } from './position/manager.js';
import { registerApi } from './api.js';
const name = 'one-divine-lot';
// 依赖服务:settingsS3)、webServerS6 api
const inject = ['settings', 'webServer'];
async function apply(ctx, config) {
const logger = ctx.logger;
// S2: 数据源(REST 直连 QMT Bridge
const dataSource = new QmtBridgeRestDataSource({
baseUrl: config?.qmtBaseUrl,
timeoutMs: config?.qmtTimeoutMs,
});
// S3: 设置管理(策略=标签 + 显示开关)
const settings = registerSettings(ctx, config);
// S4: 分仓数据本地存储
const storage = new AllocationStorage({
dataDir: config?.dataDir,
});
// S5: 分仓逻辑(份额分配/查询)
const manager = new PositionManager({ dataSource, storage });
// S6: 服务端 HTTP API
registerApi(ctx, { manager, settings });
// 启动时可用性检查(日志,不阻塞)
dataSource.isAvailable().then((ok) => {
logger.info(`[one-divine-lot] QMT Bridge REST 可用: ${ok}`);
}).catch((e) => {
logger.warn(`[one-divine-lot] QMT Bridge REST 检查失败: ${e.message}`);
});
ctx.effect(() => {
logger.info('[one-divine-lot] 插件已加载(S1-S6 就绪)');
return () => {
logger.info('[one-divine-lot] 插件已释放');
};
}, 'one-divine-lot.dispose');
}
export { name, inject, apply };
+200
View File
@@ -0,0 +1,200 @@
/**
* 分仓逻辑:持仓 ↔ 策略份额分配
*
* 设计约束:产品约束-001(全量持仓)、产品约束-002/003(标签体系)
* 2026-08-27 S5 讨论定稿:
* - Q1 按股数分配;Q2 允许未分配余额(份额之和 ≤ 总持仓);
* - Q3 策略仅为人为分组(不实现策略管理逻辑);
* - Q4 不做统计(无市值/盈亏/占比聚合);
* - Q5 UI = 策略 tab 内编辑(添加默认 0 起填 + 指定数量移出)。
*
* 数据存储(S4 storage):{ [code]: { [strategyId]: shares } }
* 全部持仓来自数据源(QMT),份额为本地维护元数据。
*/
export class PositionManager {
/**
* @param {object} opts
* @param {import('../data-source/types.js').DataSource} opts.dataSource 数据源(QMT REST
* @param {import('../storage.js').AllocationStorage} opts.storage 分仓存储
*/
constructor({ dataSource, storage }) {
this.dataSource = dataSource;
this.storage = storage;
}
/** 全量持仓(QMT 真实数据) */
async getAllPositions() {
return this.dataSource.getPositions();
}
/** 单只持仓 */
async getPosition(code) {
const positions = await this.getAllPositions();
return positions.find((p) => p.code === code) ?? null;
}
/** 总持仓量(某只票) */
async getTotalVolume(code) {
const pos = await this.getPosition(code);
return pos ? pos.volume : 0;
}
/** 某只票的份额分配 { strategyId: shares } */
async getShares(code) {
return this.storage.get(code);
}
/** 某策略下的持仓(份额 > 0 的标的) */
async getStrategyPositions(strategyId) {
const [positions, allocations] = await Promise.all([
this.getAllPositions(),
this.storage.getAll(),
]);
return positions
.map((p) => {
const shares = allocations[p.code]?.[strategyId] ?? 0;
return shares > 0 ? { ...p, shares } : null;
})
.filter(Boolean);
}
/** 未分配持仓(总持仓 - 已分配 > 0 的标的) */
async getUnallocatedPositions() {
const [positions, allocations] = await Promise.all([
this.getAllPositions(),
this.storage.getAll(),
]);
return positions
.map((p) => {
const alloc = allocations[p.code] ?? {};
const allocated = Object.values(alloc).reduce((s, n) => s + n, 0);
const unallocated = p.volume - allocated;
return unallocated > 0 ? { ...p, shares: alloc, unallocated } : null;
})
.filter(Boolean);
}
/**
* 添加到策略(Q5:份额从 0 起填,每次只确定分配到当前策略的份额)
* @param {string} code 证券代码
* @param {string} strategyId 策略 id
* @param {number} shares 要添加到该策略的股数(> 0)
*/
async addToStrategy(code, strategyId, shares) {
const total = await this.getTotalVolume(code);
if (total <= 0) throw new Error(`标的 ${code} 无持仓`);
const num = Number(shares);
if (!Number.isFinite(num) || num <= 0) {
throw new Error('份额必须为正数');
}
// 校验:当前已分配 + 新增 ≤ 总持仓
const current = await this.getShares(code);
const currentAllocated = Object.values(current).reduce((s, n) => s + n, 0);
if (currentAllocated + num > total) {
throw Object.assign(new Error(
`份额超限: ${code} 总持仓 ${total},已分配 ${currentAllocated},添加 ${num} 超出`
), { code: 'share-limit' });
}
const next = { ...current, [strategyId]: num };
await this.storage.set(code, next);
return this.getShares(code);
}
/**
* 从策略移出(Q5:指定数量移出)
* @param {string} code 证券代码
* @param {string} strategyId 策略 id
* @param {number} shares 要移出的股数(> 0,且 ≤ 该策略当前份额)
*/
async removeFromStrategy(code, strategyId, shares) {
const current = await this.getShares(code);
const currentShares = current[strategyId] ?? 0;
const num = Number(shares);
if (!Number.isFinite(num) || num <= 0) {
throw new Error('移出份额必须为正数');
}
if (num > currentShares) {
throw Object.assign(new Error(
`移出超限: ${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.storage.set(code, next);
return this.getShares(code);
}
/**
* 分仓摘要(Q4:仅份额,无市值/盈亏统计)
* @returns {{ positions: Array, strategies: Object<string, number>, allocatedTotal: number, unallocated: Array }}
*/
async getSummary() {
const [positions, allocations] = await Promise.all([
this.getAllPositions(),
this.storage.getAll(),
]);
// 每个策略的份额汇总
const strategies = {};
// 每只票:{ totalVolume, shares: {sid: n}, allocated, unallocated }
const positionsDetail = positions.map((p) => {
const alloc = allocations[p.code] ?? {};
const allocated = Object.values(alloc).reduce((s, n) => s + n, 0);
const unallocated = p.volume - allocated;
for (const [sid, n] of Object.entries(alloc)) {
strategies[sid] = (strategies[sid] ?? 0) + n;
}
return {
code: p.code,
name: p.name,
totalVolume: p.volume,
shares: alloc,
allocated,
unallocated,
};
});
return {
positions: positionsDetail,
strategies, // { strategyId: 总份额 }
allocatedTotal: Object.values(strategies).reduce((s, n) => s + n, 0),
unallocatedPositions: positionsDetail.filter((p) => p.unallocated > 0),
};
}
/**
* 全部移入(迭代 02 O6):把某只票的全部未分配份额移入指定策略
* 未分配 = 总持仓 - 已分配;一次 add(shares = 未分配数)
* @param {string} code 证券代码
* @param {string} strategyId 策略 id
* @returns {Promise<Object<string, number>>} 更新后的份额分配
*/
async moveAllUnallocatedToStrategy(code, strategyId) {
const total = await this.getTotalVolume(code);
if (total <= 0) throw new Error(`标的 ${code} 无持仓`);
const current = await this.getShares(code);
const currentAllocated = Object.values(current).reduce((s, n) => s + n, 0);
const unallocated = total - currentAllocated;
if (unallocated <= 0) {
throw Object.assign(new Error(`标的 ${code} 无未分配份额`), { code: 'no-unallocated' });
}
const next = { ...current, [strategyId]: (current[strategyId] ?? 0) + unallocated };
await this.storage.set(code, next);
return this.getShares(code);
}
/**
* 一键清零(迭代 02 O6):清空某策略下所有股票的份额(份额回未分配)
* @param {string} strategyId 策略 id
* @returns {Promise<string[]>} 受影响标的代码列表
*/
async clearStrategyShares(strategyId) {
return this.storage.removeStrategyShares(strategyId);
}
}
+310
View File
@@ -0,0 +1,310 @@
/**
* 设置管理:策略(标签)配置
*
* 设计约束:产品约束-002/003/004(标签体系、标签自定义 + 显示开关、删除份额回未分配)
* 通过 ctx.settings.register('one-divine-lot', schema) 注册策略配置 namespace。
*
* 数据结构:
* strategies: [{ id, name, visible, order }]
* 预置:网格超市、手动做T(R-003 O3-5:可删除,彻底自由)
*
* 迭代 02 新增(R-003 O3 策略 CRUD):
* - generateStrategyId(name):中文/任意名 → 英文 slug id
* - addStrategy / renameStrategy / moveStrategy / removeStrategy
* - removeStrategy 的份额清理由上层(api.js 编排 storage)完成
*/
import z from '@deepseek-ai/schemastery';
/** 策略设置 schemaschemastery 定义,settings.register 要求 schema 对象) */
export const strategySchema = z.object({
strategies: z.array(z.object({
id: z.string().required(),
name: z.string().required(),
visible: z.boolean().default(true),
order: z.number().default(0),
})).default([
{ id: 'grid-supermarket', name: '网格超市', visible: true, order: 0 },
{ id: 'manual-t', name: '手动做T', visible: true, order: 1 },
]),
// 通用 tab 显隐配置(设置 tab 化扩展,2026-08-28
tabs: z.object({
allPositions: z.boolean().default(true), // 全部持仓
tradeRecords: z.boolean().default(true), // 交易记录(未来 tab,本期空占位)
watchlist: z.boolean().default(true), // 关注列表(未来 tab,本期空占位)
}).default({
allPositions: true,
tradeRecords: true,
watchlist: true,
}),
});
/** 默认通用 tab 配置 */
export const DEFAULT_TABS = {
allPositions: true,
tradeRecords: true,
watchlist: true,
};
/** 默认策略(首次注册时的初始值) */
export const DEFAULT_STRATEGIES = [
{ id: 'grid-supermarket', name: '网格超市', visible: true, order: 0 },
{ id: 'manual-t', name: '手动做T', visible: true, order: 1 },
];
/** 简易拼音映射表(常用汉字 → 拼音,用于 slug 生成兜底) */
const PINYIN_MAP = {
// 完整短语映射(优先级最高)
'网格超市': 'grid-supermarket',
'手动做T': 'manual-t',
'长线持有': 'long-term-hold',
'短线交易': 'short-term-trade',
'波段操作': 'swing-trade',
'价值投资': 'value-invest',
'趋势跟踪': 'trend-follow',
'新股申购': 'ipo-subscribe',
'打板': 'board',
'低吸': 'dip-buy',
'高抛': 'high-sell',
'做T': 't',
// 词映射
'长线': 'long-term',
'短线': 'short-term',
'持有': 'hold',
'波段': 'swing',
'价值': 'value',
'网格': 'grid',
'策略': 'strategy',
'趋势': 'trend',
'新股': 'new-stock',
'打新': 'ipo',
// 单字映射
'超': 'super',
'市': 'market',
'场': 'market',
'手': 'manual',
'动': 'action',
'长': 'long',
'短': 'short',
'持': 'hold',
'波': 'swing',
'值': 'value',
'格': 'grid',
'略': 'strategy',
'趋': 'trend',
'势': 'trend',
};
/**
* 生成策略英文 idslug
* 规则:常见中文策略名映射拼音 → 去空格/转小写/连字符 → 非 ASCII 字符用序号兜底
* @param {string} name 策略名称
* @param {string[]} existingIds 已存在的 id(用于去重)
* @returns {string} 唯一英文 id
*/
export function generateStrategyId(name, existingIds = []) {
const raw = String(name ?? '').trim();
if (!raw) return '';
// 1. 整名映射(常见策略名直接映射,优先级最高)
const whole = PINYIN_MAP[raw];
if (whole) return dedupe(whole, existingIds);
// 2. 最长子串贪婪匹配(把名字切成已映射的词/字片段)
let slug = '';
let i = 0;
while (i < raw.length) {
let matched = false;
for (let len = Math.min(4, raw.length - i); len >= 1; len--) {
const seg = raw.slice(i, i + len);
const p = PINYIN_MAP[seg];
if (p) {
slug += p + '-';
i += len;
matched = true;
break;
}
}
if (matched) continue;
const ch = raw[i];
if (/[a-zA-Z0-9]/.test(ch)) {
slug += ch.toLowerCase() + '-';
}
i++;
}
let base = slug.replace(/-+$/, '').replace(/^-+/, '') || 'strategy';
// 3. 规范化(去空格、连字符规整、移除非法字符)
base = base
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-]/g, '')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
|| 'strategy';
return dedupe(base, existingIds);
}
/** 去重:若 id 已存在,追加序号 */
function dedupe(base, existingIds) {
let candidate = base;
let i = 2;
while (existingIds.includes(candidate)) {
candidate = `${base}-${i}`;
i++;
}
return candidate;
}
/**
* 注册策略设置 namespace,返回 SettingsScope
* @param {import('@deepseek-ai/cordis').Context} ctx
*/
export function registerSettings(ctx, config) {
const ns = config.settingsNamespace ?? 'one-divine-lot';
const scope = ctx.settings.register(ns, strategySchema, {
// 组合 base(如需从 composition 注入默认值)
base: config.strategies ? { strategies: config.strategies } : undefined,
});
ctx.logger?.info(`[one-divine-lot] 设置已注册: ${ns}`);
return scope;
}
/**
* 读取策略列表(含 visible 过滤)
* @param {ReturnType<typeof registerSettings>} scope
*/
export function getStrategies(scope) {
const value = scope?.get() ?? {};
const list = Array.isArray(value.strategies) ? value.strategies : DEFAULT_STRATEGIES;
// 统一按 order 排序(设置页 / tab 栏 / 逻辑层共用同一顺序)
return list.slice().sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
}
/**
* 获取可见策略列表(visible === true,按 order 排序)
*/
export function getVisibleStrategies(scope) {
return getStrategies(scope)
.filter((s) => s.visible !== false)
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
}
/**
* 更新策略(整表更新)
* @param {ReturnType<typeof registerSettings>} scope
* @param {Array} strategies 完整策略列表
*/
export async function updateStrategies(scope, strategies) {
await scope.update({ strategies });
}
/**
* 新增策略(O3
* @param {ReturnType<typeof registerSettings>} scope
* @param {string} name 策略名称
* @returns {Promise<object>} 新策略对象
*/
export async function addStrategy(scope, name) {
const list = getStrategies(scope);
const id = generateStrategyId(name, list.map((s) => s.id));
const strategy = {
id,
name: String(name ?? '').trim(),
visible: true,
order: list.reduce((max, s) => Math.max(max, s.order ?? 0), -1) + 1,
};
await updateStrategies(scope, [...list, strategy]);
return strategy;
}
/**
* 重命名策略(O3;分配数据挂 id 不挂名称,改名不影响)
* @param {ReturnType<typeof registerSettings>} scope
* @param {string} id 策略 id
* @param {string} name 新名称
* @returns {Promise<boolean>} 是否成功
*/
export async function renameStrategy(scope, id, name) {
const list = getStrategies(scope);
const target = list.find((s) => s.id === id);
if (!target) throw Object.assign(new Error(`策略不存在: ${id}`), { code: 'strategy-not-found' });
const next = list.map((s) => (s.id === id ? { ...s, name: String(name ?? '').trim() || s.name } : s));
await updateStrategies(scope, next);
return true;
}
/**
* 移动策略(排序,上移/下移)
* @param {ReturnType<typeof registerSettings>} scope
* @param {string} id 策略 id
* @param {'up'|'down'} dir 方向
* @returns {Promise<boolean>} 是否成功
*/
export async function moveStrategy(scope, id, dir) {
const list = getStrategies(scope)
.slice()
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
const idx = list.findIndex((s) => s.id === id);
if (idx < 0) throw Object.assign(new Error(`策略不存在: ${id}`), { code: 'strategy-not-found' });
const swapIdx = dir === 'up' ? idx - 1 : idx + 1;
if (swapIdx < 0 || swapIdx >= list.length) return false; // 已在边界
const a = list[idx];
const b = list[swapIdx];
const orderA = a.order ?? 0;
const orderB = b.order ?? 0;
const next = list.map((s) => {
if (s.id === a.id) return { ...s, order: orderB };
if (s.id === b.id) return { ...s, order: orderA };
return s;
});
await updateStrategies(scope, next);
return true;
}
/**
* 删除策略(O3;份额清理由上层编排 storage)
* @param {ReturnType<typeof registerSettings>} scope
* @param {string} id 策略 id
* @returns {Promise<boolean>} 是否成功
*/
export async function removeStrategy(scope, id) {
const list = getStrategies(scope);
const target = list.find((s) => s.id === id);
if (!target) throw Object.assign(new Error(`策略不存在: ${id}`), { code: 'strategy-not-found' });
await updateStrategies(scope, list.filter((s) => s.id !== id));
return true;
}
/**
* 读取通用 tab 显隐配置(设置 tab 化扩展)
* @param {ReturnType<typeof registerSettings>} scope
* @returns {{ allPositions: boolean, tradeRecords: boolean, watchlist: boolean }}
*/
export function getTabConfig(scope) {
const value = scope?.get() ?? {};
const tabs = value.tabs ?? {};
return {
allPositions: tabs.allPositions !== false,
tradeRecords: tabs.tradeRecords !== false,
watchlist: tabs.watchlist !== false,
};
}
/**
* 更新通用 tab 显隐配置(整表更新)
* @param {ReturnType<typeof registerSettings>} scope
* @param {object} tabs { allPositions?, tradeRecords?, watchlist? }
*/
export async function updateTabConfig(scope, tabs) {
const current = scope?.get() ?? {};
const next = {
...current,
tabs: {
allPositions: tabs.allPositions !== false,
tradeRecords: tabs.tradeRecords !== false,
watchlist: tabs.watchlist !== false,
},
};
await scope.update(next);
}
+132
View File
@@ -0,0 +1,132 @@
/**
* 本地存储:分仓分配数据(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';
export class AllocationStorage {
/**
* @param {object} [options]
* @param {string} [options.dataDir] 数据目录(默认 ~/.dsh/one-divine-lot
*/
constructor({ dataDir = 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;
}
}