dsh-wealth 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nickszy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,51 @@
1
- # Temporary Holding Version
1
+ # dsh-wealth(资产账本)
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ DeepSeek Harness(DSH)桌面端插件:多账户、多资产、多币种的持仓与交易流水管理。
4
+
5
+ ## 安装
6
+ 在 DSH 桌面端插件页「添加插件」输入名称即可(npm 包名):
7
+
8
+ ```
9
+ dsh-wealth
10
+ ```
11
+
12
+ 或命令行(profile 里):
13
+
14
+ ```
15
+ dsh plugin --profile desktop add dsh-wealth
16
+ ```
17
+
18
+ 安装后重启客户端,左侧导航出现「资产」。
19
+
20
+ > npm: <https://www.npmjs.com/package/dsh-wealth> · GitHub: <https://github.com/Nickszy/dsh-wealth>
21
+
22
+ ## 功能
23
+ - 左侧导航「资产」入口,点开是完整资产页
24
+ - 账户管理:券商/银行/基金/手写账本,每个账户可设币种
25
+ - 持仓:手动录入或 CSV 导入,支持手填现价与成本
26
+ - 流水:买入/卖出记账,自动更新持仓数量与成本
27
+ - 多币种:CNY/HKD/USD…,汇率可手改,统一折算到本位币
28
+ - 对话维护:`资产管家` Bot 通过 `cli.mjs` 读写同一份账本
29
+
30
+ ## 数据位置
31
+ `%USERPROFILE%\.dsh\data\dsh-portfolio\ledger.json`(本机文件,自动 `.bak` 备份,不上云)
32
+
33
+ ## 给 AI 用(cli.mjs)
34
+ ```
35
+ node cli.mjs state
36
+ node cli.mjs summary
37
+ node cli.mjs account.upsert name=华泰证券 type=broker currency=CNY
38
+ node cli.mjs trade.add accountId=ACC_xxx symbol=600519 side=buy qty=100 price=1600
39
+ node cli.mjs help
40
+ ```
41
+ 操作列表见 `node cli.mjs help`。PowerShell 下建议用 `key=value` 形式(JSON 里的双引号会被吞)。
42
+
43
+ ## 结构
44
+ - `index.js` — 宿主半身:`/portfolio/api/*` 路由(state / op)
45
+ - `store.mjs` — JSON 存储与操作分发(原子写、.bak 备份、多币种折算)
46
+ - `cli.mjs` — 命令行读写入口(给 AI)
47
+ - `client.js` — 前端页面(左侧「资产」入口 + 总览/账户/持仓/流水/录入)
48
+ - `cordis.patch.yml` — bundle 注册补丁
49
+
50
+ ## License
51
+ MIT
package/cli.mjs ADDED
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env node
2
+ // dsh-portfolio CLI — how the AI (资产管家) reads and writes the ledger.
3
+ // Usage:
4
+ // node cli.mjs state
5
+ // node cli.mjs summary
6
+ // node cli.mjs '<op-json>' e.g. '{"op":"trade.add","accountId":"...","symbol":"600519","side":"buy","qty":100,"price":1600}'
7
+ // node cli.mjs help
8
+ // Every command prints one JSON object to stdout. Nothing is interactive.
9
+ import { load, save, applyOp, summarize, OpError, DATA_FILE, DATA_DIR } from './store.mjs'
10
+
11
+ function out(obj, code = 0) {
12
+ process.stdout.write(JSON.stringify(obj, null, 2) + '\n')
13
+ process.exitCode = code
14
+ }
15
+
16
+ function help() {
17
+ out({
18
+ ok: true,
19
+ dataFile: DATA_FILE,
20
+ dataDir: DATA_DIR,
21
+ usage: [
22
+ 'node cli.mjs state # full ledger',
23
+ 'node cli.mjs summary # valued totals in base currency',
24
+ 'node cli.mjs <op> key=value key=value ... # run one operation',
25
+ 'node cli.mjs \'{"op":"...",...}\' # or pass one JSON object',
26
+ '',
27
+ 'examples:',
28
+ ' node cli.mjs account.upsert name=华泰证券 type=broker currency=CNY',
29
+ ' node cli.mjs trade.add accountId=ACC_xxx symbol=600519 side=buy qty=100 price=1600',
30
+ ' node cli.mjs holding.upsert accountId=ACC_xxx symbol=00700 market=HK qty=200 avgCost=380',
31
+ ' node cli.mjs price.set instrumentId=INS_xxx price=1650',
32
+ ' node cli.mjs fx.set currency=HKD rate=0.92',
33
+ ],
34
+ ops: {
35
+ 'account.upsert': '{op, account:{id?,name,type?,currency?,note?}}',
36
+ 'account.remove': '{op, id}',
37
+ 'instrument.upsert': '{op, instrument:{id?,symbol,name?,market?,assetClass?,currency?}}',
38
+ 'holding.upsert': '{op, holding:{id?,accountId,symbol|instrumentId,market?,qty,avgCost,currency?,manualPrice?,note?}}',
39
+ 'holding.remove': '{op, id}',
40
+ 'trade.add': '{op, accountId, symbol|instrumentId, market?, side:"buy"|"sell", qty, price, fee?, currency?, date?, note?}',
41
+ 'trade.remove': '{op, id}',
42
+ 'price.set': '{op, holdingId?|instrumentId?, price}',
43
+ 'fx.set': '{op, currency, rate} # 1 unit of currency = ? base',
44
+ 'base.set': '{op, currency}',
45
+ 'import.holdings': '{op, rows:[holding...]}',
46
+ 'import.trades': '{op, rows:[trade...]}',
47
+ },
48
+ })
49
+ }
50
+
51
+ function parseValue(v) {
52
+ if (/^-?\d+(\.\d+)?$/.test(v)) return Number(v)
53
+ if (v === 'true') return true
54
+ if (v === 'false') return false
55
+ return v
56
+ }
57
+
58
+ // Accepts: op <name> k=v k=v ... OR a single JSON argument '{...}'
59
+ function parseRequest(args) {
60
+ if (args[0] && args[0].trim().startsWith('{')) {
61
+ const req = JSON.parse(args.join(' '))
62
+ const op = req.op || req.operation
63
+ const payload = { ...req }; delete payload.op; delete payload.operation
64
+ return { op, payload }
65
+ }
66
+ const [op, ...rest] = args
67
+ const payload = {}
68
+ for (const token of rest) {
69
+ const eq = token.indexOf('=')
70
+ if (eq > 0) payload[token.slice(0, eq)] = parseValue(token.slice(eq + 1))
71
+ else if (token.startsWith('-')) { /* ignore stray flags */ }
72
+ }
73
+ return { op, payload }
74
+ }
75
+
76
+ const arg = process.argv.slice(2)
77
+ if (arg.length === 0 || arg[0] === 'help' || arg[0] === '--help' || arg[0] === '-h') { help() }
78
+ else if (arg[0] === 'state') {
79
+ const db = load()
80
+ out({ ok: true, ledger: db, summary: summarize(db) })
81
+ } else if (arg[0] === 'summary') {
82
+ out({ ok: true, summary: summarize(load()) })
83
+ } else {
84
+ let req
85
+ try { req = parseRequest(arg) } catch (e) { out({ ok: false, error: { code: 'bad-json', message: '参数解析失败: ' + e.message } }, 2); process.exit() }
86
+ const { op, payload } = req
87
+ if (!op) { out({ ok: false, error: { code: 'bad-request', message: '缺少 op' } }, 2); process.exit() }
88
+ const db = load()
89
+ try {
90
+ const result = applyOp(db, op, payload)
91
+ save(db)
92
+ out({ ok: true, op, result, summary: summarize(db) })
93
+ } catch (error) {
94
+ const code = error instanceof OpError ? error.code : 'internal'
95
+ out({ ok: false, op, error: { code, message: error.message } }, 1)
96
+ }
97
+ }
package/client.js ADDED
@@ -0,0 +1,379 @@
1
+ window.__ModuleLoader__.load({
2
+ id: 'dsh-wealth',
3
+ factory(require) {
4
+ const React = require('react')
5
+ const h = React.createElement
6
+
7
+ // ---------- palette ----------
8
+ function useIsDark() {
9
+ const [dark, setDark] = React.useState(() => {
10
+ try { return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches } catch (_) { return true }
11
+ })
12
+ React.useEffect(() => {
13
+ let mq
14
+ try {
15
+ mq = window.matchMedia('(prefers-color-scheme: dark)')
16
+ const on = (e) => setDark(e.matches)
17
+ mq.addEventListener ? mq.addEventListener('change', on) : mq.addListener(on)
18
+ return () => { mq.removeEventListener ? mq.removeEventListener('change', on) : mq.removeListener(on) }
19
+ } catch (_) { /* ignore */ }
20
+ }, [])
21
+ return dark
22
+ }
23
+ function palette(dark) {
24
+ return dark
25
+ ? { bg: '#0f1115', panel: '#161a21', card: '#1d222b', border: '#2b323d', text: '#e6e8ee', sub: '#9aa3b2', accent: '#5b8cff', green: '#3ddc84', red: '#ff6161', input: '#12161d' }
26
+ : { bg: '#f6f7f9', panel: '#ffffff', card: '#ffffff', border: '#e3e6eb', text: '#1b1f27', sub: '#6b7480', accent: '#2f6fed', green: '#1f9d55', red: '#d64545', input: '#ffffff' }
27
+ }
28
+
29
+ // ---------- data access ----------
30
+ async function getState() {
31
+ const r = await fetch('/portfolio/api/state', { headers: { accept: 'application/json' } })
32
+ return r.json()
33
+ }
34
+ async function runOp(op, payload) {
35
+ const r = await fetch('/portfolio/api/op', {
36
+ method: 'POST', headers: { 'content-type': 'application/json' },
37
+ body: JSON.stringify({ op, ...(payload || {}) }),
38
+ })
39
+ return r.json()
40
+ }
41
+
42
+ function fmtMoney(n, cur) {
43
+ if (n === null || n === undefined || Number.isNaN(n)) return '—'
44
+ const v = Math.abs(n) >= 10000 ? Math.round(n).toLocaleString('zh-CN') : (Math.round(n * 100) / 100).toLocaleString('zh-CN')
45
+ return (cur ? '' : '') + v + (cur ? ' ' + cur : '')
46
+ }
47
+ function fmtPct(n) { if (n === null || !Number.isFinite(n)) return '—'; return (n >= 0 ? '+' : '') + (Math.round(n * 100) / 100) + '%' }
48
+
49
+ const T = {
50
+ th: (c, t) => h('th', { style: { textAlign: 'left', padding: '8px 10px', fontSize: 12, color: t.sub, fontWeight: 500, borderBottom: '1px solid ' + t.border, whiteSpace: 'nowrap' } }, c),
51
+ td: (c, t, extra) => h('td', { style: Object.assign({ padding: '8px 10px', fontSize: 13, color: t.text, borderBottom: '1px solid ' + t.border, verticalAlign: 'middle' }, extra || {}) }, c),
52
+ btn: (t, active) => ({ padding: '6px 12px', fontSize: 13, borderRadius: 7, cursor: 'pointer', border: '1px solid ' + (active ? t.accent : t.border), background: active ? t.accent : t.card, color: active ? '#fff' : t.text }),
53
+ input: (t) => ({ padding: '7px 9px', fontSize: 13, borderRadius: 7, border: '1px solid ' + t.border, background: t.input, color: t.text, outline: 'none', width: '100%', boxSizing: 'border-box' }),
54
+ card: (t) => ({ background: t.card, border: '1px solid ' + t.border, borderRadius: 10, padding: 14 }),
55
+ label: (t) => ({ fontSize: 12, color: t.sub, marginBottom: 4, display: 'block' }),
56
+ }
57
+
58
+ // ---------- main page ----------
59
+ function PortfolioPage() {
60
+ const dark = useIsDark(); const t = palette(dark)
61
+ const [tab, setTab] = React.useState('overview')
62
+ const [state, setState] = React.useState(null)
63
+ const [err, setErr] = React.useState('')
64
+ const [busy, setBusy] = React.useState(false)
65
+
66
+ const reload = React.useCallback(async () => {
67
+ try { const s = await getState(); if (s.ok) { setState(s); setErr('') } else setErr(s.error?.message || '读取失败') }
68
+ catch (e) { setErr('无法连接插件服务:' + e.message) }
69
+ }, [])
70
+ React.useEffect(() => { reload() }, [reload])
71
+
72
+ const call = async (op, payload) => {
73
+ setBusy(true)
74
+ try { const r = await runOp(op, payload); if (r.ok) { setState({ ok: true, ledger: r.ledger, summary: r.summary }); setErr('') } else setErr(r.error?.message || '操作失败'); return r }
75
+ catch (e) { setErr('操作失败:' + e.message); return { ok: false } }
76
+ finally { setBusy(false) }
77
+ }
78
+
79
+ const tabs = [['overview', '总览'], ['accounts', '账户'], ['holdings', '持仓'], ['trades', '流水'], ['input', '录入/导入']]
80
+ return h('div', { style: { height: '100%', overflow: 'auto', background: t.bg, color: t.text, fontFamily: 'system-ui,-apple-system,"Segoe UI",Roboto,sans-serif' } },
81
+ h('div', { style: { maxWidth: 1080, margin: '0 auto', padding: '18px 20px 40px' } },
82
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 } },
83
+ h('div', { style: { fontSize: 18, fontWeight: 650 } }, '资产账本'),
84
+ h('div', { style: { fontSize: 12, color: t.sub } }, state?.summary ? ('· 本位币 ' + state.summary.baseCurrency) : ''),
85
+ h('div', { style: { flex: 1 } }),
86
+ h('button', { style: T.btn(t, false), onClick: reload, disabled: busy }, '刷新'),
87
+ ),
88
+ h('div', { style: { display: 'flex', gap: 8, marginBottom: 14, flexWrap: 'wrap' } },
89
+ tabs.map(([id, name]) => h('button', { key: id, style: T.btn(t, tab === id), onClick: () => setTab(id) }, name)),
90
+ ),
91
+ err ? h('div', { role: 'alert', style: { ...T.card(t), borderColor: t.red, color: t.red, marginBottom: 12, fontSize: 13 } }, err) : null,
92
+ !state ? h('div', { style: { color: t.sub, fontSize: 13 } }, '加载中…') :
93
+ tab === 'overview' ? h(Overview, { t, state, call, busy }) :
94
+ tab === 'accounts' ? h(Accounts, { t, state, call, busy }) :
95
+ tab === 'holdings' ? h(Holdings, { t, state, call, busy }) :
96
+ tab === 'trades' ? h(Trades, { t, state, call, busy }) :
97
+ h(Input, { t, state, call, busy }),
98
+ h('div', { style: { marginTop: 22, fontSize: 11, color: t.sub, lineHeight: 1.7 } },
99
+ '数据全部保存在本机;行情取不到时可直接手填现价/成本。要用对话记账,打开左侧「资产管家」会话直接说,例如「把华泰账户买 200 股贵州茅台,价格 1600 记上」。'),
100
+ ),
101
+ )
102
+ }
103
+
104
+ function Overview({ t, state, call, busy }) {
105
+ const s = state.summary, db = state.ledger
106
+ const accMap = new Map(db.accounts.map((a) => [a.id, a]))
107
+ const cur = db.baseCurrency
108
+ const [baseSel, setBaseSel] = React.useState(cur)
109
+ React.useEffect(() => { setBaseSel(cur) }, [cur])
110
+ const [fxEdit, setFxEdit] = React.useState({ cur: '', rate: '' })
111
+ return h('div', null,
112
+ h('div', { style: { display: 'grid', gridTemplateColumns: 'repeat(3,minmax(0,1fr))', gap: 12, marginBottom: 14 } },
113
+ h('div', { style: T.card(t) }, h('div', { style: T.label(t) }, '总市值'), h('div', { style: { fontSize: 22, fontWeight: 650 } }, fmtMoney(s.totalValueBase, s.baseCurrency))),
114
+ h('div', { style: T.card(t) }, h('div', { style: T.label(t) }, '总成本'), h('div', { style: { fontSize: 22, fontWeight: 650 } }, fmtMoney(s.totalCostBase, s.baseCurrency))),
115
+ h('div', { style: T.card(t) }, h('div', { style: T.label(t) }, '浮动盈亏'), h('div', { style: { fontSize: 22, fontWeight: 650, color: s.totalPnlBase >= 0 ? t.green : t.red } }, fmtMoney(s.totalPnlBase, s.baseCurrency))),
116
+ ),
117
+ h('div', { style: { ...T.card(t), marginBottom: 14 } },
118
+ h('div', { style: { display: 'flex', gap: 12, alignItems: 'flex-end', flexWrap: 'wrap' } },
119
+ h('div', null, h('label', { style: T.label(t) }, '本位币'), h('select', { value: baseSel, onChange: (e) => setBaseSel(e.target.value), style: { ...T.input(t), width: 120 } },
120
+ [...new Set(['CNY', 'HKD', 'USD', ...Object.keys(db.fx)])].map((c) => h('option', { key: c, value: c }, c)))),
121
+ h('button', { style: T.btn(t, false), disabled: busy, onClick: () => call('base.set', { currency: baseSel }) }, '设为本位币'),
122
+ h('div', { style: { width: 1, height: 28, background: t.border } }),
123
+ h('div', null, h('label', { style: T.label(t) }, '币种'), h('input', { value: fxEdit.cur, onChange: (e) => setFxEdit({ ...fxEdit, cur: e.target.value.toUpperCase() }), placeholder: 'HKD', style: { ...T.input(t), width: 90 } })),
124
+ h('div', null, h('label', { style: T.label(t) }, '汇率(1单位=?' + cur + ')'), h('input', { value: fxEdit.rate, onChange: (e) => setFxEdit({ ...fxEdit, rate: e.target.value }), placeholder: '0.92', style: { ...T.input(t), width: 110 } })),
125
+ h('button', { style: T.btn(t, false), disabled: busy, onClick: async () => { await call('fx.set', { currency: fxEdit.cur, rate: Number(fxEdit.rate) }); setFxEdit({ cur: '', rate: '' }) } }, '保存汇率'),
126
+ ),
127
+ h('div', { style: { marginTop: 10, fontSize: 12, color: t.sub } }, '当前汇率:' + Object.entries(db.fx).map(([c, r]) => c + '→' + cur + ' ' + r).join(' ')),
128
+ ),
129
+ h('div', { style: T.card(t) },
130
+ h('div', { style: { fontSize: 13, fontWeight: 600, marginBottom: 8 } }, '各账户'),
131
+ s.accounts.length === 0 ? h('div', { style: { color: t.sub, fontSize: 13 } }, '还没有账户,去「账户」页添加。') :
132
+ h('table', { style: { width: '100%', borderCollapse: 'collapse' } },
133
+ h('thead', null, h('tr', null, T.th('账户', t), T.th('持仓数', t), T.th('市值', t), T.th('成本', t), T.th('盈亏', t))),
134
+ h('tbody', null, s.accounts.map((a) => h('tr', { key: a.accountId },
135
+ T.td((accMap.get(a.accountId)?.name || a.accountId) + (accMap.get(a.accountId) ? ' · ' + accMap.get(a.accountId).currency : ''), t),
136
+ T.td(String(a.holdings), t),
137
+ T.td(fmtMoney(a.valueBase, cur), t),
138
+ T.td(fmtMoney(a.costBase, cur), t),
139
+ T.td(fmtMoney(a.pnlBase, cur), t, { color: a.pnlBase >= 0 ? t.green : t.red }),
140
+ ))),
141
+ ),
142
+ ),
143
+ )
144
+ }
145
+
146
+ function Accounts({ t, state, call, busy }) {
147
+ const db = state.ledger
148
+ const [form, setForm] = React.useState({ name: '', type: 'broker', currency: 'CNY', note: '' })
149
+ const [editId, setEditId] = React.useState(null)
150
+ const submit = async () => {
151
+ if (!form.name.trim()) return
152
+ await call('account.upsert', { account: { id: editId || undefined, ...form } })
153
+ setForm({ name: '', type: 'broker', currency: 'CNY', note: '' }); setEditId(null)
154
+ }
155
+ return h('div', null,
156
+ h('div', { style: { ...T.card(t), marginBottom: 14 } },
157
+ h('div', { style: { display: 'grid', gridTemplateColumns: '1.4fr 1fr 0.8fr 1.4fr auto', gap: 10, alignItems: 'end' } },
158
+ h('div', null, h('label', { style: T.label(t) }, '账户名'), h('input', { value: form.name, onChange: (e) => setForm({ ...form, name: e.target.value }), placeholder: '如 华泰证券', style: T.input(t) })),
159
+ h('div', null, h('label', { style: T.label(t) }, '类型'), h('select', { value: form.type, onChange: (e) => setForm({ ...form, type: e.target.value }), style: T.input(t) }, ['broker', 'bank', 'fund', 'hand'].map((x) => h('option', { key: x, value: x }, x)))),
160
+ h('div', null, h('label', { style: T.label(t) }, '币种'), h('input', { value: form.currency, onChange: (e) => setForm({ ...form, currency: e.target.value.toUpperCase() }), style: T.input(t) })),
161
+ h('div', null, h('label', { style: T.label(t) }, '备注'), h('input', { value: form.note, onChange: (e) => setForm({ ...form, note: e.target.value }), style: T.input(t) })),
162
+ h('button', { style: T.btn(t, true), disabled: busy, onClick: submit }, editId ? '保存' : '添加账户'),
163
+ ),
164
+ ),
165
+ h('div', { style: T.card(t) },
166
+ h('table', { style: { width: '100%', borderCollapse: 'collapse' } },
167
+ h('thead', null, h('tr', null, T.th('账户', t), T.th('类型', t), T.th('币种', t), T.th('备注', t), T.th('操作', t))),
168
+ h('tbody', null, db.accounts.length ? db.accounts.map((a) => h('tr', { key: a.id },
169
+ T.td(a.name, t), T.td(a.type, t), T.td(a.currency, t), T.td(a.note || '—', t),
170
+ T.td(h('span', null,
171
+ h('button', { style: { ...T.btn(t, false), padding: '3px 8px', marginRight: 6 }, onClick: () => { setForm({ name: a.name, type: a.type, currency: a.currency, note: a.note || '' }); setEditId(a.id) } }, '编辑'),
172
+ h('button', { style: { ...T.btn(t, false), padding: '3px 8px', borderColor: t.red, color: t.red }, onClick: () => { if (window.confirm('删除账户「' + a.name + '」及其全部持仓和流水?')) call('account.remove', { id: a.id }) } }, '删除'),
173
+ ), t),
174
+ )) : h('tr', null, T.td('还没有账户', t, { color: t.sub }))),
175
+ ),
176
+ ),
177
+ )
178
+ }
179
+
180
+ function Holdings({ t, state, call, busy }) {
181
+ const db = state.ledger
182
+ const accMap = new Map(db.accounts.map((a) => [a.id, a]))
183
+ const insMap = new Map(db.instruments.map((i) => [i.id, i]))
184
+ const [form, setForm] = React.useState({ accountId: db.accounts[0]?.id || '', symbol: '', name: '', market: 'CN', qty: '', avgCost: '', currency: '', manualPrice: '' })
185
+ const submit = async () => {
186
+ if (!form.accountId || !form.symbol.trim()) return
187
+ await call('holding.upsert', { holding: { ...form, qty: Number(form.qty) || 0, avgCost: Number(form.avgCost) || 0, manualPrice: form.manualPrice === '' ? null : Number(form.manualPrice) } })
188
+ setForm({ ...form, symbol: '', name: '', qty: '', avgCost: '', manualPrice: '' })
189
+ }
190
+ return h('div', null,
191
+ h('div', { style: { ...T.card(t), marginBottom: 14 } },
192
+ h('div', { style: { display: 'grid', gridTemplateColumns: '1fr 0.9fr 1fr 0.6fr 0.7fr 0.7fr 0.7fr 0.8fr auto', gap: 8, alignItems: 'end' } },
193
+ h('div', null, h('label', { style: T.label(t) }, '账户'), h('select', { value: form.accountId, onChange: (e) => setForm({ ...form, accountId: e.target.value }), style: T.input(t) }, db.accounts.map((a) => h('option', { key: a.id, value: a.id }, a.name)))),
194
+ h('div', null, h('label', { style: T.label(t) }, '代码'), h('input', { value: form.symbol, onChange: (e) => setForm({ ...form, symbol: e.target.value.trim() }), placeholder: '600519', style: T.input(t) })),
195
+ h('div', null, h('label', { style: T.label(t) }, '名称'), h('input', { value: form.name, onChange: (e) => setForm({ ...form, name: e.target.value }), placeholder: '贵州茅台', style: T.input(t) })),
196
+ h('div', null, h('label', { style: T.label(t) }, '市场'), h('select', { value: form.market, onChange: (e) => setForm({ ...form, market: e.target.value }), style: T.input(t) }, ['CN', 'HK', 'US', 'FUND'].map((x) => h('option', { key: x, value: x }, x)))),
197
+ h('div', null, h('label', { style: T.label(t) }, '数量'), h('input', { value: form.qty, onChange: (e) => setForm({ ...form, qty: e.target.value }), style: T.input(t) })),
198
+ h('div', null, h('label', { style: T.label(t) }, '成本'), h('input', { value: form.avgCost, onChange: (e) => setForm({ ...form, avgCost: e.target.value }), style: T.input(t) })),
199
+ h('div', null, h('label', { style: T.label(t) }, '现价(手填)'), h('input', { value: form.manualPrice, onChange: (e) => setForm({ ...form, manualPrice: e.target.value }), style: T.input(t) })),
200
+ h('div', null, h('label', { style: T.label(t) }, '币种'), h('input', { value: form.currency, onChange: (e) => setForm({ ...form, currency: e.target.value.toUpperCase() }), placeholder: '自动', style: T.input(t) })),
201
+ h('button', { style: T.btn(t, true), disabled: busy, onClick: submit }, '添加/更新'),
202
+ ),
203
+ ),
204
+ h('div', { style: T.card(t) },
205
+ h('table', { style: { width: '100%', borderCollapse: 'collapse' } },
206
+ h('thead', null, h('tr', null, T.th('标的', t), T.th('账户', t), T.th('数量', t), T.th('成本', t), T.th('现价', t), T.th('币种', t), T.th('市值(本位币)', t), T.th('操作', t))),
207
+ h('tbody', null, db.holdings.length ? db.holdings.map((ho) => {
208
+ const ins = insMap.get(ho.instrumentId); const acc = accMap.get(ho.accountId)
209
+ const price = ho.manualPrice ?? ho.avgCost ?? 0
210
+ const rate = db.fx[ho.currency] ?? 1
211
+ const mv = ho.qty * price * rate
212
+ return h('tr', { key: ho.id },
213
+ T.td((ins ? ins.name + ' ' + ins.symbol : ho.instrumentId), t),
214
+ T.td(acc ? acc.name : ho.accountId, t),
215
+ T.td(String(ho.qty), t),
216
+ T.td(fmtMoney(ho.avgCost), t),
217
+ T.td(h('input', { defaultValue: ho.manualPrice ?? '', placeholder: '手填', onBlur: (e) => { const v = e.target.value; if (v !== '' && String(ho.manualPrice ?? '') !== v) call('price.set', { holdingId: ho.id, price: Number(v) }) }, style: { ...T.input(t), width: 78 } }), t),
218
+ T.td(ho.currency, t),
219
+ T.td(fmtMoney(mv, db.baseCurrency), t),
220
+ T.td(h('button', { style: { ...T.btn(t, false), padding: '3px 8px', borderColor: t.red, color: t.red }, onClick: () => { if (window.confirm('删除该持仓?')) call('holding.remove', { id: ho.id }) } }, '删除'), t),
221
+ )
222
+ }) : h('tr', null, T.td('还没有持仓', t, { color: t.sub }))),
223
+ ),
224
+ ),
225
+ )
226
+ }
227
+
228
+ function Trades({ t, state, call, busy }) {
229
+ const db = state.ledger
230
+ const accMap = new Map(db.accounts.map((a) => [a.id, a]))
231
+ const insMap = new Map(db.instruments.map((i) => [i.id, i]))
232
+ const [form, setForm] = React.useState({ accountId: db.accounts[0]?.id || '', symbol: '', side: 'buy', qty: '', price: '', fee: '', date: new Date().toISOString().slice(0, 10), currency: '' })
233
+ const submit = async () => {
234
+ if (!form.accountId || !form.symbol.trim()) return
235
+ await call('trade.add', { trade: { ...form, qty: Number(form.qty) || 0, price: Number(form.price) || 0, fee: Number(form.fee) || 0 } })
236
+ setForm({ ...form, symbol: '', qty: '', price: '', fee: '' })
237
+ }
238
+ return h('div', null,
239
+ h('div', { style: { ...T.card(t), marginBottom: 14 } },
240
+ h('div', { style: { display: 'grid', gridTemplateColumns: '1fr 0.8fr 0.7fr 0.6fr 0.7fr 0.6fr 0.9fr 0.7fr auto', gap: 8, alignItems: 'end' } },
241
+ h('div', null, h('label', { style: T.label(t) }, '账户'), h('select', { value: form.accountId, onChange: (e) => setForm({ ...form, accountId: e.target.value }), style: T.input(t) }, db.accounts.map((a) => h('option', { key: a.id, value: a.id }, a.name)))),
242
+ h('div', null, h('label', { style: T.label(t) }, '代码'), h('input', { value: form.symbol, onChange: (e) => setForm({ ...form, symbol: e.target.value.trim() }), style: T.input(t) })),
243
+ h('div', null, h('label', { style: T.label(t) }, '方向'), h('select', { value: form.side, onChange: (e) => setForm({ ...form, side: e.target.value }), style: T.input(t) }, [['buy', '买入'], ['sell', '卖出']].map(([v, n]) => h('option', { key: v, value: v }, n)))),
244
+ h('div', null, h('label', { style: T.label(t) }, '数量'), h('input', { value: form.qty, onChange: (e) => setForm({ ...form, qty: e.target.value }), style: T.input(t) })),
245
+ h('div', null, h('label', { style: T.label(t) }, '价格'), h('input', { value: form.price, onChange: (e) => setForm({ ...form, price: e.target.value }), style: T.input(t) })),
246
+ h('div', null, h('label', { style: T.label(t) }, '费用'), h('input', { value: form.fee, onChange: (e) => setForm({ ...form, fee: e.target.value }), style: T.input(t) })),
247
+ h('div', null, h('label', { style: T.label(t) }, '日期'), h('input', { type: 'date', value: form.date, onChange: (e) => setForm({ ...form, date: e.target.value }), style: T.input(t) })),
248
+ h('div', null, h('label', { style: T.label(t) }, '币种'), h('input', { value: form.currency, onChange: (e) => setForm({ ...form, currency: e.target.value.toUpperCase() }), placeholder: '自动', style: T.input(t) })),
249
+ h('button', { style: T.btn(t, true), disabled: busy, onClick: submit }, '记一笔'),
250
+ ),
251
+ ),
252
+ h('div', { style: T.card(t) },
253
+ h('table', { style: { width: '100%', borderCollapse: 'collapse' } },
254
+ h('thead', null, h('tr', null, T.th('日期', t), T.th('账户', t), T.th('标的', t), T.th('方向', t), T.th('数量', t), T.th('价格', t), T.th('金额', t), T.th('操作', t))),
255
+ h('tbody', null, db.trades.length ? [...db.trades].reverse().map((tr) => {
256
+ const ins = insMap.get(tr.instrumentId); const acc = accMap.get(tr.accountId)
257
+ return h('tr', { key: tr.id },
258
+ T.td(tr.date, t), T.td(acc ? acc.name : tr.accountId, t), T.td(ins ? ins.name + ' ' + ins.symbol : tr.instrumentId, t),
259
+ T.td(tr.side === 'buy' ? '买入' : '卖出', t, { color: tr.side === 'buy' ? t.red : t.green }),
260
+ T.td(String(tr.qty), t), T.td(fmtMoney(tr.price), t), T.td(fmtMoney(tr.qty * tr.price), t),
261
+ T.td(h('button', { style: { ...T.btn(t, false), padding: '3px 8px', borderColor: t.red, color: t.red }, onClick: () => { if (window.confirm('删除这条流水?(不会自动回算持仓)')) call('trade.remove', { id: tr.id }) } }, '删除'), t),
262
+ )
263
+ }) : h('tr', null, T.td('还没有流水', t, { color: t.sub }))),
264
+ ),
265
+ ),
266
+ )
267
+ }
268
+
269
+ function Input({ t, state, call, busy }) {
270
+ const db = state.ledger
271
+ const [mode, setMode] = React.useState('holdings')
272
+ const [text, setText] = React.useState('')
273
+ const [msg, setMsg] = React.useState('')
274
+ const sample = mode === 'holdings'
275
+ ? '账户名,代码,名称,市场,数量,成本,币种\n华泰证券,600519,贵州茅台,CN,100,1600,CNY\n华泰证券,00700,腾讯控股,HK,200,380,HKD'
276
+ : '账户名,代码,方向,数量,价格,费用,日期,币种\n华泰证券,600519,buy,100,1600,5,2026-11-06,CNY'
277
+ const parse = (txt) => {
278
+ const lines = txt.split(/\r?\n/).map((l) => l.trim()).filter(Boolean)
279
+ const rows = []
280
+ for (let i = 0; i < lines.length; i++) {
281
+ const cells = lines[i].split(',').map((c) => c.trim())
282
+ if (i === 0 && /账户|代码|方向|数量|成本/.test(cells.join(''))) continue // header
283
+ rows.push(cells)
284
+ }
285
+ return rows
286
+ }
287
+ const doImport = async () => {
288
+ setMsg('')
289
+ const rows = parse(text)
290
+ if (!rows.length) { setMsg('没有可导入的数据行'); return }
291
+ const accByName = new Map(db.accounts.map((a) => [a.name, a]))
292
+ const payload = []
293
+ for (const c of rows) {
294
+ const acc = accByName.get(c[0]); if (!acc) { setMsg('账户不存在:' + c[0] + '(请先在「账户」页添加同名账户)'); return }
295
+ if (mode === 'holdings') payload.push({ accountId: acc.id, symbol: c[1], name: c[2], market: c[3] || 'CN', qty: Number(c[4]) || 0, avgCost: Number(c[5]) || 0, currency: c[6] || '' })
296
+ else payload.push({ accountId: acc.id, symbol: c[1], side: c[2] || 'buy', qty: Number(c[3]) || 0, price: Number(c[4]) || 0, fee: Number(c[5]) || 0, date: c[6] || undefined, currency: c[7] || '' })
297
+ }
298
+ const r = await call(mode === 'holdings' ? 'import.holdings' : 'import.trades', { rows: payload })
299
+ if (r.ok) { setMsg('已导入 ' + (r.result?.imported ?? payload.length) + ' 条'); setText('') }
300
+ }
301
+ return h('div', null,
302
+ h('div', { style: { ...T.card(t), marginBottom: 14 } },
303
+ h('div', { style: { display: 'flex', gap: 8, marginBottom: 10 } },
304
+ h('button', { style: T.btn(t, mode === 'holdings'), onClick: () => setMode('holdings') }, '导入持仓'),
305
+ h('button', { style: T.btn(t, mode === 'trades'), onClick: () => setMode('trades') }, '导入流水'),
306
+ ),
307
+ h('div', { style: { fontSize: 12, color: t.sub, marginBottom: 6 } }, '按 CSV 粘贴(第一行表头可留)。列顺序:'),
308
+ h('div', { style: { fontSize: 12, color: t.text, marginBottom: 8, fontFamily: 'monospace', whiteSpace: 'pre-wrap' } }, sample.split('\n')[0]),
309
+ h('textarea', { value: text, onChange: (e) => setText(e.target.value), placeholder: sample, rows: 8, style: { ...T.input(t), fontFamily: 'monospace', fontSize: 12, resize: 'vertical' } }),
310
+ h('div', { style: { marginTop: 10, display: 'flex', gap: 10, alignItems: 'center' } },
311
+ h('button', { style: T.btn(t, true), disabled: busy, onClick: doImport }, '开始导入'),
312
+ h('button', { style: T.btn(t, false), onClick: () => setText(sample) }, '填入示例'),
313
+ msg ? h('span', { style: { fontSize: 12, color: t.accent } }, msg) : null,
314
+ ),
315
+ ),
316
+ )
317
+ }
318
+
319
+ // ---------- sidebar panel takeover (same pattern as market-clock) ----------
320
+ let ctx = null
321
+ const PANEL = 'dsh-wealth'
322
+ let shellDispose = null
323
+ let panelSession = null
324
+
325
+ function closePanel() {
326
+ const d = shellDispose; shellDispose = null; panelSession = null
327
+ if (d) { try { d() } catch (_) {} }
328
+ }
329
+ function openPanel() {
330
+ const layout = ctx && ctx.get && ctx.get('layout')
331
+ if (!layout || typeof layout.selectPanel !== 'function') throw new Error('布局服务不可用,无法打开资产页。')
332
+ if (!shellDispose) {
333
+ const sidebar = ctx.get('sidebarRight')
334
+ panelSession = (sidebar && sidebar.mounted && sidebar.mounted.getSnapshot && sidebar.mounted.getSnapshot()) || null
335
+ shellDispose = ctx.slots.register({ name: 'main.conversation', priority: -10 }, NativePortfolio)
336
+ }
337
+ try { layout.selectPanel(null) } catch (e) { closePanel(); throw e }
338
+ }
339
+
340
+ function PortfolioIcon({ size = 20 }) {
341
+ return h('svg', { width: size, height: size, viewBox: '0 0 24 24', fill: 'none', 'aria-hidden': true },
342
+ h('path', { d: 'M4 19V5 M4 19h16', stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round' }),
343
+ h('path', { d: 'M7 15l3-4 3 2 4-6', stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round', strokeLinejoin: 'round' }),
344
+ h('circle', { cx: 17, cy: 7, r: 1.4, fill: 'currentColor' }),
345
+ )
346
+ }
347
+
348
+ function PortfolioPanelEntry() {
349
+ const [error, setError] = React.useState('')
350
+ React.useEffect(() => { try { openPanel() } catch (e) { setError(e.message || '打开失败') } }, [])
351
+ return h('div', { role: 'status', style: { padding: 24 } }, error || '正在打开资产账本…')
352
+ }
353
+
354
+ function NativePortfolio({ sessionId }) {
355
+ const mounted = React.useRef(false)
356
+ React.useEffect(() => {
357
+ mounted.current = true
358
+ const owned = shellDispose
359
+ return () => { mounted.current = false; queueMicrotask(() => { if (!mounted.current && shellDispose === owned) closePanel() }) }
360
+ }, [])
361
+ React.useEffect(() => { if (panelSession && sessionId !== panelSession) closePanel() }, [sessionId])
362
+ return h(PortfolioPage)
363
+ }
364
+
365
+ return {
366
+ inject: ['slots'],
367
+ apply(c) {
368
+ ctx = c
369
+ c.effect(() => c.slots.inject('sidebar.panellist', () => c.slots.register({
370
+ name: 'sidebar.panellist', id: PANEL, order: 30, label: '资产',
371
+ }, PortfolioIcon)), 'dsh-portfolio: nav')
372
+ c.effect(() => c.slots.inject('main', () => c.slots.register({
373
+ name: 'main', key: PANEL,
374
+ }, PortfolioPanelEntry)), 'dsh-portfolio: panel entry')
375
+ c.effect(() => () => closePanel(), 'dsh-portfolio: restore shell')
376
+ },
377
+ }
378
+ },
379
+ })
@@ -0,0 +1,4 @@
1
+ - insert:
2
+ - id: dsh-wealth
3
+ name: dsh-wealth
4
+ config: {}
package/icon.svg ADDED
@@ -0,0 +1,5 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none">
2
+ <path d="M4 19V5 M4 19h16" stroke="#5b8cff" stroke-width="1.8" stroke-linecap="round"/>
3
+ <path d="M7 15l3-4 3 2 4-6" stroke="#3ddc84" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
4
+ <circle cx="17" cy="7" r="1.5" fill="#3ddc84"/>
5
+ </svg>
package/index.js ADDED
@@ -0,0 +1,98 @@
1
+ /**
2
+ * dsh-portfolio — host half.
3
+ * Serves the local ledger over `/portfolio/api/*` so the plugin page (client.js)
4
+ * can read and edit it, and so any local script can POST operations.
5
+ * The AI (资产管家 / any agent) edits the same store through cli.mjs.
6
+ */
7
+ import { load, save, applyOp, summarize, OpError } from './store.mjs'
8
+
9
+ export const name = 'dsh-wealth'
10
+ export const inject = ['webServer']
11
+
12
+ function readBody(req, limit = 5_000_000) {
13
+ return new Promise((resolve, reject) => {
14
+ let size = 0
15
+ const chunks = []
16
+ req.on('data', (c) => {
17
+ size += c.length
18
+ if (size > limit) { reject(new Error('body too large')); req.destroy(); return }
19
+ chunks.push(c)
20
+ })
21
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
22
+ req.on('error', reject)
23
+ })
24
+ }
25
+
26
+ function send(res, status, obj) {
27
+ const body = Buffer.from(JSON.stringify(obj))
28
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'content-length': String(body.length), 'cache-control': 'no-store' })
29
+ res.end(body)
30
+ }
31
+
32
+ function isLoopback(hostname) {
33
+ if (hostname === 'localhost' || hostname === '[::1]' || hostname === '::1') return true
34
+ return /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname)
35
+ }
36
+
37
+ function trusted(req, ctx) {
38
+ const raw = req.headers.host
39
+ if (!raw) return false
40
+ let hostUrl
41
+ try { hostUrl = new URL('http://' + raw) } catch { return false }
42
+ if (req.headers['sec-fetch-site'] === 'cross-site') return false
43
+ const origin = req.headers.origin
44
+ if (origin) {
45
+ try { if (new URL(origin).host !== hostUrl.host) return false } catch { return false }
46
+ }
47
+ if (isLoopback(hostUrl.hostname)) return true
48
+ const trustedHosts = ctx.webStartup?.trustedHosts
49
+ if (Array.isArray(trustedHosts)) {
50
+ return trustedHosts.some((entry) => {
51
+ try { const u = new URL(/^https?:\/\//.test(entry) ? entry : 'https://' + entry); return u.hostname === hostUrl.hostname } catch { return false }
52
+ })
53
+ }
54
+ return false
55
+ }
56
+
57
+ export function apply(ctx) {
58
+ ctx.effect(() => ctx.webServer.register({
59
+ kind: 'prefix',
60
+ path: '/portfolio',
61
+ handler: async (req, res) => {
62
+ if (!trusted(req, ctx)) { res.writeHead(403); res.end('forbidden'); return }
63
+ let url
64
+ try { url = new URL(req.url || '/', 'http://dsh.internal') } catch { res.writeHead(400); res.end(); return }
65
+ const path = url.pathname.replace(/^\/portfolio\/?/, '')
66
+ try {
67
+ if (path === 'api/state' || path === 'api/summary') {
68
+ if (req.method !== 'GET') { res.writeHead(405); res.end(); return }
69
+ const db = load()
70
+ send(res, 200, { ok: true, ledger: db, summary: summarize(db) })
71
+ return
72
+ }
73
+ if (path === 'api/op') {
74
+ if (req.method !== 'POST') { res.writeHead(405); res.end(); return }
75
+ const text = await readBody(req)
76
+ let req2
77
+ try { req2 = JSON.parse(text) } catch { send(res, 400, { ok: false, error: { code: 'bad-json', message: '请求体不是合法 JSON' } }); return }
78
+ const op = req2.op
79
+ if (!op) { send(res, 400, { ok: false, error: { code: 'bad-request', message: '缺少 op' } }); return }
80
+ const payload = { ...req2 }; delete payload.op
81
+ const db = load()
82
+ try {
83
+ const result = applyOp(db, op, payload)
84
+ save(db)
85
+ send(res, 200, { ok: true, op, result, ledger: db, summary: summarize(db) })
86
+ } catch (error) {
87
+ const code = error instanceof OpError ? error.code : 'internal'
88
+ send(res, code === 'not-found' ? 404 : code === 'bad-request' ? 400 : 500, { ok: false, op, error: { code, message: error.message } })
89
+ }
90
+ return
91
+ }
92
+ res.writeHead(404); res.end('not found')
93
+ } catch (error) {
94
+ try { send(res, 500, { ok: false, error: { code: 'internal', message: String(error && error.message || error) } }) } catch { res.destroy() }
95
+ }
96
+ },
97
+ }), 'dsh-portfolio: /portfolio routes')
98
+ }
package/package.json CHANGED
@@ -1,6 +1,31 @@
1
1
  {
2
2
  "name": "dsh-wealth",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "资产账本:多账户、多资产、多币种持仓与交易流水管理,支持对话维护与本机持久化 | Multi-account, multi-asset, multi-currency portfolio & trade ledger for DeepSeek Harness, with chat-based maintenance.",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "icon": "./icon.svg",
8
+ "exports": {
9
+ ".": "./index.js",
10
+ "./client": "./client.js",
11
+ "./package.json": "./package.json"
12
+ },
13
+ "files": ["index.js", "client.js", "store.mjs", "cli.mjs", "cordis.patch.yml", "icon.svg", "README.md", "LICENSE"],
14
+ "keywords": ["dsh", "dsh-plugin", "deepseek-harness", "portfolio", "ledger", "assets", "投资", "持仓", "记账"],
15
+ "license": "MIT",
16
+ "repository": { "type": "git", "url": "git+https://github.com/Nickszy/dsh-wealth.git" },
17
+ "homepage": "https://github.com/Nickszy/dsh-wealth#readme",
18
+ "bugs": { "url": "https://github.com/Nickszy/dsh-wealth/issues" },
19
+ "dsh": {
20
+ "bundle": { "patch": "./cordis.patch.yml" },
21
+ "client": {
22
+ "platform": "web",
23
+ "inject": [
24
+ "@deepseek-ai/dsh-client-ui-layout",
25
+ "@deepseek-ai/dsh-client-ui-conversation",
26
+ "@deepseek-ai/dsh-client-ui-sidebar-right"
27
+ ]
28
+ }
29
+ },
30
+ "engines": { "node": ">=20" }
31
+ }
package/store.mjs ADDED
@@ -0,0 +1,298 @@
1
+ // dsh-portfolio — shared data store (used by host plugin index.js and by cli.mjs)
2
+ // Plain JSON file, atomic writes, keeps a .bak of the previous version.
3
+ import { mkdirSync, readFileSync, writeFileSync, renameSync, existsSync, copyFileSync } from 'node:fs'
4
+ import { join } from 'node:path'
5
+ import { homedir } from 'node:os'
6
+ import { randomUUID } from 'node:crypto'
7
+
8
+ export const DATA_DIR = process.env.DSH_PORTFOLIO_DIR || join(homedir(), '.dsh', 'data', 'dsh-portfolio')
9
+ export const DATA_FILE = join(DATA_DIR, 'ledger.json')
10
+
11
+ const EMPTY = () => ({
12
+ version: 1,
13
+ baseCurrency: 'CNY',
14
+ fx: { CNY: 1, HKD: 0.92, USD: 7.15 },
15
+ accounts: [],
16
+ instruments: [],
17
+ holdings: [],
18
+ trades: [],
19
+ updatedAt: null,
20
+ })
21
+
22
+ function ensureDir() { mkdirSync(DATA_DIR, { recursive: true }) }
23
+
24
+ export function load() {
25
+ ensureDir()
26
+ if (!existsSync(DATA_FILE)) return EMPTY()
27
+ try {
28
+ const db = JSON.parse(readFileSync(DATA_FILE, 'utf8'))
29
+ return { ...EMPTY(), ...db }
30
+ } catch (error) {
31
+ // keep the broken file for inspection, start clean
32
+ try { copyFileSync(DATA_FILE, DATA_FILE + '.corrupt-' + Date.now()) } catch { /* ignore */ }
33
+ return EMPTY()
34
+ }
35
+ }
36
+
37
+ export function save(db) {
38
+ ensureDir()
39
+ db.updatedAt = new Date().toISOString()
40
+ const tmp = DATA_FILE + '.tmp'
41
+ writeFileSync(tmp, JSON.stringify(db, null, 2), 'utf8')
42
+ try { if (existsSync(DATA_FILE)) copyFileSync(DATA_FILE, DATA_FILE + '.bak') } catch { /* ignore */ }
43
+ renameSync(tmp, DATA_FILE)
44
+ return db
45
+ }
46
+
47
+ const uid = (prefix) => prefix + '_' + randomUUID().slice(0, 8).toUpperCase()
48
+ const toNum = (v) => { if (v === null || v === undefined || v === '') return null; const n = Number(v); return Number.isFinite(n) ? n : null }
49
+ const nowISO = () => new Date().toISOString()
50
+
51
+ function normCurrency(c, fallback) {
52
+ const v = typeof c === 'string' && c.trim() !== '' ? c.trim().toUpperCase() : fallback
53
+ return v || 'CNY'
54
+ }
55
+
56
+ function findAccount(db, id) { return db.accounts.find((a) => a.id === id) }
57
+ function findInstrument(db, id) { return db.instruments.find((i) => i.id === id) }
58
+ function findHolding(db, accountId, instrumentId) {
59
+ return db.holdings.find((h) => h.accountId === accountId && h.instrumentId === instrumentId)
60
+ }
61
+
62
+ // Resolve an instrument by id, or by symbol+market (creating it if allowed).
63
+ function resolveInstrument(db, ref, { create = true } = {}) {
64
+ if (!ref) throw new OpError('bad-request', '缺少标的')
65
+ if (ref.instrumentId) {
66
+ const got = findInstrument(db, ref.instrumentId)
67
+ if (!got) throw new OpError('not-found', '标的不存在: ' + ref.instrumentId)
68
+ return got
69
+ }
70
+ const symbol = String(ref.symbol || '').trim()
71
+ if (!symbol) throw new OpError('bad-request', '缺少标的代码 symbol')
72
+ const market = String(ref.market || '').trim().toUpperCase()
73
+ const existing = db.instruments.find((i) => i.symbol === symbol && (market === '' || i.market === market))
74
+ if (existing) return existing
75
+ if (!create) throw new OpError('not-found', '标的不存在: ' + symbol)
76
+ const instrument = {
77
+ id: uid('INS'),
78
+ symbol,
79
+ name: String(ref.name || symbol).trim(),
80
+ market: market || 'CN',
81
+ assetClass: String(ref.assetClass || guessAssetClass(symbol, ref.assetClass)).trim(),
82
+ currency: normCurrency(ref.currency, defaultCurrencyOfMarket(market)),
83
+ note: String(ref.note || ''),
84
+ createdAt: nowISO(),
85
+ }
86
+ db.instruments.push(instrument)
87
+ return instrument
88
+ }
89
+
90
+ function guessAssetClass(symbol, given) {
91
+ if (given) return given
92
+ if (/^\d{6}$/.test(symbol)) return 'stock'
93
+ if (/^[A-Z]{1,5}$/.test(symbol)) return 'stock'
94
+ return 'fund'
95
+ }
96
+ function defaultCurrencyOfMarket(market) {
97
+ if (market === 'HK') return 'HKD'
98
+ if (market === 'US') return 'USD'
99
+ return 'CNY'
100
+ }
101
+
102
+ // Apply a trade to the matching holding (buy = increase qty & weighted avg cost; sell = decrease qty).
103
+ function applyTradeToHolding(db, trade) {
104
+ let holding = findHolding(db, trade.accountId, trade.instrumentId)
105
+ const qty = toNum(trade.qty) || 0
106
+ const price = toNum(trade.price) || 0
107
+ if (!holding) {
108
+ holding = {
109
+ id: uid('POS'), accountId: trade.accountId, instrumentId: trade.instrumentId,
110
+ qty: 0, avgCost: 0, currency: trade.currency, manualPrice: null, note: '', updatedAt: nowISO(),
111
+ }
112
+ db.holdings.push(holding)
113
+ }
114
+ if (trade.side === 'buy') {
115
+ const totalQty = holding.qty + qty
116
+ if (totalQty > 0) holding.avgCost = ((holding.qty * holding.avgCost) + (qty * price)) / totalQty
117
+ holding.qty = totalQty
118
+ } else if (trade.side === 'sell') {
119
+ holding.qty = Math.max(0, holding.qty - qty)
120
+ if (holding.qty === 0) holding.avgCost = 0
121
+ }
122
+ holding.updatedAt = nowISO()
123
+ return holding
124
+ }
125
+
126
+ export class OpError extends Error {
127
+ constructor(code, message) { super(message); this.code = code }
128
+ }
129
+
130
+ // Single dispatcher. Each op mutates `db` and returns a plain result object.
131
+ export function applyOp(db, op, payload = {}) {
132
+ switch (op) {
133
+ case 'state':
134
+ return { ledger: db }
135
+
136
+ case 'account.upsert': {
137
+ const p = payload.account || payload
138
+ if (!p.name) throw new OpError('bad-request', '账户需要 name')
139
+ let acc = p.id ? findAccount(db, p.id) : null
140
+ if (acc) {
141
+ acc.name = String(p.name)
142
+ acc.type = String(p.type || acc.type || 'broker')
143
+ acc.currency = normCurrency(p.currency, acc.currency)
144
+ if (p.note !== undefined) acc.note = String(p.note)
145
+ } else {
146
+ acc = { id: uid('ACC'), name: String(p.name), type: String(p.type || 'broker'), currency: normCurrency(p.currency, 'CNY'), note: String(p.note || ''), archived: false, createdAt: nowISO() }
147
+ db.accounts.push(acc)
148
+ }
149
+ return { account: acc }
150
+ }
151
+
152
+ case 'account.remove': {
153
+ const id = payload.id
154
+ if (!findAccount(db, id)) throw new OpError('not-found', '账户不存在: ' + id)
155
+ db.accounts = db.accounts.filter((a) => a.id !== id)
156
+ db.holdings = db.holdings.filter((h) => h.accountId !== id)
157
+ db.trades = db.trades.filter((t) => t.accountId !== id)
158
+ return { removed: id }
159
+ }
160
+
161
+ case 'instrument.upsert': {
162
+ const p = payload.instrument || payload
163
+ const instrument = resolveInstrument(db, { ...p, instrumentId: p.id }, { create: true })
164
+ return { instrument }
165
+ }
166
+
167
+ case 'holding.upsert': {
168
+ const p = payload.holding || payload
169
+ if (!p.accountId) throw new OpError('bad-request', '缺少 accountId')
170
+ if (!findAccount(db, p.accountId)) throw new OpError('not-found', '账户不存在: ' + p.accountId)
171
+ const instrument = resolveInstrument(db, p, { create: true })
172
+ let holding = p.id ? db.holdings.find((h) => h.id === p.id) : findHolding(db, p.accountId, instrument.id)
173
+ if (holding) {
174
+ if (p.qty !== undefined) holding.qty = toNum(p.qty) || 0
175
+ if (p.avgCost !== undefined) holding.avgCost = toNum(p.avgCost) || 0
176
+ if (p.manualPrice !== undefined) holding.manualPrice = toNum(p.manualPrice)
177
+ if (p.currency !== undefined) holding.currency = normCurrency(p.currency, holding.currency)
178
+ if (p.note !== undefined) holding.note = String(p.note)
179
+ holding.updatedAt = nowISO()
180
+ } else {
181
+ holding = {
182
+ id: uid('POS'), accountId: p.accountId, instrumentId: instrument.id,
183
+ qty: toNum(p.qty) || 0, avgCost: toNum(p.avgCost) || 0,
184
+ currency: normCurrency(p.currency, instrument.currency),
185
+ manualPrice: toNum(p.manualPrice), note: String(p.note || ''), updatedAt: nowISO(),
186
+ }
187
+ db.holdings.push(holding)
188
+ }
189
+ return { holding }
190
+ }
191
+
192
+ case 'holding.remove': {
193
+ const id = payload.id
194
+ if (!db.holdings.some((h) => h.id === id)) throw new OpError('not-found', '持仓不存在: ' + id)
195
+ db.holdings = db.holdings.filter((h) => h.id !== id)
196
+ return { removed: id }
197
+ }
198
+
199
+ case 'trade.add': {
200
+ const p = payload.trade || payload
201
+ if (!p.accountId) throw new OpError('bad-request', '缺少 accountId')
202
+ if (!findAccount(db, p.accountId)) throw new OpError('not-found', '账户不存在: ' + p.accountId)
203
+ const instrument = resolveInstrument(db, p, { create: true })
204
+ const side = (p.side === 'sell' ? 'sell' : 'buy')
205
+ const trade = {
206
+ id: uid('TRD'), accountId: p.accountId, instrumentId: instrument.id, side,
207
+ qty: toNum(p.qty) || 0, price: toNum(p.price) || 0, fee: toNum(p.fee) || 0,
208
+ currency: normCurrency(p.currency, instrument.currency),
209
+ date: String(p.date || new Date().toISOString().slice(0, 10)),
210
+ note: String(p.note || ''), createdAt: nowISO(),
211
+ }
212
+ db.trades.push(trade)
213
+ const holding = applyTradeToHolding(db, trade)
214
+ return { trade, holding }
215
+ }
216
+
217
+ case 'trade.remove': {
218
+ const id = payload.id
219
+ if (!db.trades.some((t) => t.id === id)) throw new OpError('not-found', '流水不存在: ' + id)
220
+ db.trades = db.trades.filter((t) => t.id !== id)
221
+ return { removed: id, note: '已删除流水;持仓数量未自动回算,请手动核对' }
222
+ }
223
+
224
+ case 'price.set': {
225
+ const p = payload
226
+ if (p.holdingId) {
227
+ const h = db.holdings.find((x) => x.id === p.holdingId)
228
+ if (!h) throw new OpError('not-found', '持仓不存在: ' + p.holdingId)
229
+ h.manualPrice = toNum(p.price); h.updatedAt = nowISO()
230
+ return { holding: h }
231
+ }
232
+ if (p.instrumentId) {
233
+ let n = 0
234
+ for (const h of db.holdings) if (h.instrumentId === p.instrumentId) { h.manualPrice = toNum(p.price); h.updatedAt = nowISO(); n++ }
235
+ if (n === 0) throw new OpError('not-found', '该标的没有持仓')
236
+ return { updated: n }
237
+ }
238
+ throw new OpError('bad-request', 'price.set 需要 holdingId 或 instrumentId')
239
+ }
240
+
241
+ case 'fx.set': {
242
+ const cur = normCurrency(payload.currency, null)
243
+ if (!cur) throw new OpError('bad-request', '缺少 currency')
244
+ const rate = toNum(payload.rate)
245
+ if (rate === null || rate <= 0) throw new OpError('bad-request', 'rate 必须是正数(1 单位该币种 = ? 本位币)')
246
+ db.fx[cur] = rate
247
+ return { fx: db.fx }
248
+ }
249
+
250
+ case 'base.set': {
251
+ const cur = normCurrency(payload.currency, null)
252
+ if (!cur) throw new OpError('bad-request', '缺少 currency')
253
+ db.baseCurrency = cur
254
+ return { baseCurrency: cur }
255
+ }
256
+
257
+ case 'import.holdings': {
258
+ const rows = Array.isArray(payload.rows) ? payload.rows : []
259
+ const out = []
260
+ for (const row of rows) out.push(applyOp(db, 'holding.upsert', { holding: row }).holding)
261
+ return { imported: out.length, holdings: out }
262
+ }
263
+
264
+ case 'import.trades': {
265
+ const rows = Array.isArray(payload.rows) ? payload.rows : []
266
+ const out = []
267
+ for (const row of rows) out.push(applyOp(db, 'trade.add', { trade: row }).trade)
268
+ return { imported: out.length, trades: out }
269
+ }
270
+
271
+ default:
272
+ throw new OpError('bad-request', '未知操作 op: ' + op)
273
+ }
274
+ }
275
+
276
+ // Valuation helper shared by CLI: convert every holding to base currency.
277
+ export function summarize(db) {
278
+ const base = db.baseCurrency || 'CNY'
279
+ const fx = { ...db.fx, [base]: 1 }
280
+ const byAccount = new Map()
281
+ let totalBase = 0
282
+ for (const h of db.holdings) {
283
+ const ins = db.instruments.find((i) => i.id === h.instrumentId)
284
+ if (!ins) continue
285
+ const price = (h.manualPrice ?? h.avgCost ?? 0)
286
+ const cur = h.currency || ins.currency || base
287
+ const rate = fx[cur] ?? 1
288
+ const valueBase = h.qty * price * rate
289
+ const costBase = h.qty * (h.avgCost || 0) * rate
290
+ totalBase += valueBase
291
+ if (!byAccount.has(h.accountId)) byAccount.set(h.accountId, { accountId: h.accountId, valueBase: 0, costBase: 0, holdings: 0 })
292
+ const agg = byAccount.get(h.accountId)
293
+ agg.valueBase += valueBase; agg.costBase += costBase; agg.holdings += 1
294
+ }
295
+ const round = (n) => Math.round(n * 100) / 100
296
+ const accounts = [...byAccount.values()].map((a) => ({ ...a, valueBase: round(a.valueBase), costBase: round(a.costBase), pnlBase: round(a.valueBase - a.costBase) }))
297
+ return { baseCurrency: base, fx, totalValueBase: round(totalBase), totalCostBase: round(accounts.reduce((s, a) => s + a.costBase, 0)), totalPnlBase: round(accounts.reduce((s, a) => s + a.pnlBase, 0)), accounts }
298
+ }