dsh-hyperliquid 0.1.0 → 0.3.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/NOTICE +3 -0
- package/README.md +23 -9
- package/client.js +4446 -588
- package/docs/research-review-2026-10-16.md +46 -0
- package/index.js +2 -1
- package/lib/chart-drawings.d.ts +27 -0
- package/lib/chart-drawings.js +217 -0
- package/lib/indicators.js +7 -2
- package/lib/moving-average.d.ts +21 -0
- package/lib/moving-average.js +133 -0
- package/lib/remote.js +233 -61
- package/lib/review.d.ts +3 -0
- package/lib/review.js +79 -0
- package/lib/risk-settings.js +20 -0
- package/lib/store.js +124 -19
- package/lib/studies.d.ts +13 -0
- package/lib/studies.js +157 -0
- package/lib/tools.js +128 -22
- package/lib/workspace-settings.d.ts +3 -0
- package/lib/workspace-settings.js +12 -0
- package/package.json +8 -3
- package/skills/hyperliquid-trading/SKILL.md +19 -4
package/lib/remote.js
CHANGED
|
@@ -13,6 +13,12 @@ import { canonicalMarketCoin } from './market-symbol.js'
|
|
|
13
13
|
import { HyperStore } from './store.js'
|
|
14
14
|
import { addressFromPrivateKey, buildL1TypedData, splitSignature } from './signing.js'
|
|
15
15
|
import { TradingGuard } from './trading-guard.js'
|
|
16
|
+
import { normalizeMaLines } from './moving-average.js'
|
|
17
|
+
import { ALERT_KINDS, ICON_NAMES, TEXT_KINDS, drawingGeometry, drawingStyle, isIconLabel, linePriceAt } from './chart-drawings.js'
|
|
18
|
+
import { normalizeRiskSettings } from './risk-settings.js'
|
|
19
|
+
import { INDICATOR_IDS, normalizeIndicatorSettings } from './studies.js'
|
|
20
|
+
import { normalizeResearch } from './workspace-settings.js'
|
|
21
|
+
import { fundingWindow } from './review.js'
|
|
16
22
|
|
|
17
23
|
const TOOL_CALL = Symbol('verified DSH tool call')
|
|
18
24
|
|
|
@@ -81,6 +87,7 @@ const ACCOUNT_MODE_TTL_MS = 30_000
|
|
|
81
87
|
const TRADE_CAPACITY_TTL_MS = 15_000
|
|
82
88
|
const SUB_SWEEP_MS = 30_000
|
|
83
89
|
const SUB_IDLE_MS = 120_000
|
|
90
|
+
const ALERT_CHECK_MS = 2_000
|
|
84
91
|
const BOOK_GROUPINGS = {
|
|
85
92
|
full: null,
|
|
86
93
|
'5': { nSigFigs: 5 },
|
|
@@ -121,6 +128,10 @@ export class HyperRemote extends TypertRemoteService {
|
|
|
121
128
|
if (this.cfg.tradingAddress && !/^0x[0-9a-fA-F]{40}$/.test(this.cfg.tradingAddress)) throw new Error('tradingAddress 必须为 20 字节 EVM 地址')
|
|
122
129
|
this.client = new HyperliquidClient({ network: this.cfg.network, privateKey: this.cfg.privateKey })
|
|
123
130
|
this.store = new HyperStore(this.cfg.dataDir, this.cfg.network)
|
|
131
|
+
this.baseRiskSettings = Object.fromEntries(['allowedCoins', 'maxOrderUsd', 'maxDailyUsd', 'maxPositionUsd', 'maxLeverage'].map((key) => [key, this.cfg[key]]))
|
|
132
|
+
const savedRiskSettings = this.store.getRiskSettings()
|
|
133
|
+
this.riskSettingsOverride = savedRiskSettings != null
|
|
134
|
+
if (savedRiskSettings) Object.assign(this.cfg, normalizeRiskSettings(savedRiskSettings))
|
|
124
135
|
this.guard = new TradingGuard(this.cfg.dataDir, { ...this.cfg, dataDir: this.cfg.dataDir })
|
|
125
136
|
this.paperGuard = new TradingGuard(this.cfg.dataDir, { ...this.cfg, dataDir: this.cfg.dataDir }, { budgetName: this.cfg.network === 'mainnet' ? 'paper-budget-mainnet.json' : 'paper-budget.json' })
|
|
126
137
|
this.marketsCache = null
|
|
@@ -149,6 +160,10 @@ export class HyperRemote extends TypertRemoteService {
|
|
|
149
160
|
}
|
|
150
161
|
}
|
|
151
162
|
this.sweepTimer = setInterval(() => this.sweepSubs(), SUB_SWEEP_MS)
|
|
163
|
+
this.sweepTimer.unref?.()
|
|
164
|
+
this.alertSides = new Map() // drawing id → last side of the mid price (-1 below, 1 above)
|
|
165
|
+
this.alertTimer = setInterval(() => { try { this.checkDrawingAlerts() } catch { /* next tick retries */ } }, ALERT_CHECK_MS)
|
|
166
|
+
this.alertTimer.unref?.()
|
|
152
167
|
}
|
|
153
168
|
|
|
154
169
|
sweepSubs() {
|
|
@@ -443,7 +458,7 @@ export class HyperRemote extends TypertRemoteService {
|
|
|
443
458
|
const sessionId = String(request?.sessionId ?? '')
|
|
444
459
|
if (!this.store.getSession(sessionId)) return { marks: [] }
|
|
445
460
|
const coin = requireCoin(request)
|
|
446
|
-
const interval = requireInterval(request?.interval ?? this.desk.interval)
|
|
461
|
+
const interval = request?.allIntervals === true ? null : requireInterval(request?.interval ?? this.desk.interval)
|
|
447
462
|
return { marks: this.store.listChartMarks(sessionId, coin, interval) }
|
|
448
463
|
}
|
|
449
464
|
|
|
@@ -451,30 +466,100 @@ export class HyperRemote extends TypertRemoteService {
|
|
|
451
466
|
this.requireTradingSession(sessionId)
|
|
452
467
|
const coin = requireCoin(request)
|
|
453
468
|
const interval = requireInterval(request?.interval ?? this.desk.interval)
|
|
454
|
-
const kind = request?.kind
|
|
469
|
+
const kind = request?.kind ?? 'level'
|
|
455
470
|
const role = ['support', 'resistance', 'neutral'].includes(request?.role) ? request.role : 'neutral'
|
|
456
471
|
const label = String(request?.label ?? '').trim()
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
if (kind === '
|
|
460
|
-
|
|
461
|
-
if (!Number.isFinite(price) || price <= 0) throw new Error('水平线 price 必须为正数')
|
|
462
|
-
mark.price = price
|
|
463
|
-
} else {
|
|
464
|
-
const fromTime = Number(request?.fromTime)
|
|
465
|
-
const toTime = Number(request?.toTime)
|
|
466
|
-
const fromPrice = Number(request?.fromPrice)
|
|
467
|
-
const toPrice = Number(request?.toPrice)
|
|
468
|
-
const oldest = Date.now() - 90 * 86_400_000
|
|
469
|
-
const latest = Date.now() + 86_400_000
|
|
470
|
-
if (!(fromTime >= oldest && toTime > fromTime && toTime <= latest && fromPrice > 0 && toPrice > 0 && Number.isFinite(fromPrice) && Number.isFinite(toPrice))) {
|
|
471
|
-
throw new Error('趋势线需要最近 90 天内递增的毫秒时间戳及正数价格')
|
|
472
|
-
}
|
|
473
|
-
Object.assign(mark, { fromTime, toTime, fromPrice, toPrice })
|
|
474
|
-
}
|
|
472
|
+
const maxLabel = TEXT_KINDS.includes(kind) ? 200 : 60
|
|
473
|
+
if (!label || label.length > maxLabel) throw new Error(`图表标注需要 1-${maxLabel} 字${TEXT_KINDS.includes(kind) ? '文本' : '标签'}`)
|
|
474
|
+
if (kind === 'icon' && !isIconLabel(label)) throw new Error(`图标 label 必须为 ${ICON_NAMES.join('/')} 或贴图库中的 emoji:<贴纸>`)
|
|
475
|
+
const mark = { sessionId, coin, interval, kind, role, label, source, ...drawingGeometry(kind, request), ...drawingStyle({ ...request, kind }) }
|
|
475
476
|
return this.store.addChartMark(mark)
|
|
476
477
|
}
|
|
477
478
|
|
|
479
|
+
/** Replace a session chart's MA lines (agent path); the desk reloads on the `chart.ma` event. */
|
|
480
|
+
setSessionMaLines(sessionId, lines) {
|
|
481
|
+
this.requireTradingSession(sessionId)
|
|
482
|
+
const maLines = normalizeMaLines(lines)
|
|
483
|
+
this.store.updateSessionWorkspace(sessionId, { maLines })
|
|
484
|
+
this.store.recordEvent('chart.ma', { sessionId, ids: maLines.map((line) => line.id) })
|
|
485
|
+
return maLines
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** Agent sets what the session chart shows (interval, indicator panes); the desk follows on `chart.view`. */
|
|
489
|
+
setSessionChartView(sessionId, { interval, show = [], hide = [] } = {}) {
|
|
490
|
+
this.requireTradingSession(sessionId)
|
|
491
|
+
const unknown = [...show, ...hide].filter((id) => !INDICATOR_IDS.includes(id))
|
|
492
|
+
if (unknown.length) throw new Error(`未知指标 ${unknown.join(',')},可选:${INDICATOR_IDS.join(', ')}`)
|
|
493
|
+
const current = this.store.getSessionWorkspace(sessionId)
|
|
494
|
+
const patch = { indicators: [...new Set([...(current.indicators ?? []).filter((id) => !hide.includes(id)), ...show])] }
|
|
495
|
+
if (interval != null) patch.interval = requireInterval(interval)
|
|
496
|
+
const next = this.store.updateSessionWorkspace(sessionId, patch)
|
|
497
|
+
if (this.desk?.sessionId === sessionId && patch.interval) this.desk.interval = patch.interval
|
|
498
|
+
this.store.recordEvent('chart.view', { sessionId, interval: next.interval, indicators: next.indicators })
|
|
499
|
+
return next
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** Move, reshape, restyle or relabel one drawing; geometry is re-validated as a whole. */
|
|
503
|
+
editChartMark(id, patch, sessionId) {
|
|
504
|
+
this.requireTradingSession(sessionId)
|
|
505
|
+
const current = this.store.getChartMark(sessionId, String(id ?? ''))
|
|
506
|
+
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) throw new Error('画线修改无效')
|
|
507
|
+
const merged = { ...current, ...patch }
|
|
508
|
+
const geometryKeys = ['price', 'fromTime', 'fromPrice', 'toTime', 'toPrice', 'points']
|
|
509
|
+
const fields = { ...drawingStyle({ ...patch, kind: current.kind }) }
|
|
510
|
+
if (patch?.alert === true) fields.alertTriggeredAt = undefined
|
|
511
|
+
// null clears a style back to its default (undo of a first restyle relies on this).
|
|
512
|
+
for (const key of ['color', 'lineWidth', 'lineStyle', 'rows', 'valueArea']) if (key in patch && patch[key] === null) fields[key] = undefined
|
|
513
|
+
if (geometryKeys.some((key) => key in patch)) Object.assign(fields, drawingGeometry(current.kind, merged))
|
|
514
|
+
if ('label' in patch) {
|
|
515
|
+
const label = String(patch.label ?? '').trim()
|
|
516
|
+
const maxLabel = TEXT_KINDS.includes(current.kind) ? 200 : 60
|
|
517
|
+
if (!label || label.length > maxLabel) throw new Error(`图表标注需要 1-${maxLabel} 字`)
|
|
518
|
+
fields.label = label
|
|
519
|
+
}
|
|
520
|
+
if (current.locked && !('locked' in patch) && Object.keys(fields).length) throw new Error('该画线已锁定,先解锁再修改')
|
|
521
|
+
return this.store.updateChartMark(sessionId, current.id, fields)
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
async updateChartMark(request) {
|
|
525
|
+
return this.editChartMark(request?.id, request?.patch, String(request?.sessionId ?? ''))
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Drawing alerts: when the mid price crosses an alert-enabled line it fires once (the alert
|
|
530
|
+
* switches off, the time is recorded) and the desk gets a `chart.alert` event.
|
|
531
|
+
* @param mids - coin → mid (defaults to the live allMids snapshot).
|
|
532
|
+
*/
|
|
533
|
+
checkDrawingAlerts(now = Date.now(), mids = null) {
|
|
534
|
+
const watched = this.store.allChartMarks?.().filter((mark) => mark.alert && ALERT_KINDS.includes(mark.kind)) ?? []
|
|
535
|
+
for (const id of [...this.alertSides.keys()]) if (!watched.some((mark) => mark.id === id)) this.alertSides.delete(id)
|
|
536
|
+
if (!watched.length) return []
|
|
537
|
+
let book = mids
|
|
538
|
+
if (!book) {
|
|
539
|
+
const key = this.touch(keys.allMids())
|
|
540
|
+
const snap = this.client.snapshot(key)
|
|
541
|
+
if (!snap || now - snap.at > 10_000) return []
|
|
542
|
+
book = snap.data?.mids ?? snap.data
|
|
543
|
+
}
|
|
544
|
+
const fired = []
|
|
545
|
+
for (const mark of watched) {
|
|
546
|
+
const mid = Number(book?.[mark.coin])
|
|
547
|
+
const line = linePriceAt(mark, now)
|
|
548
|
+
if (!Number.isFinite(mid) || line == null || !Number.isFinite(line)) continue
|
|
549
|
+
const side = mid > line ? 1 : mid < line ? -1 : 0
|
|
550
|
+
const before = this.alertSides.get(mark.id)
|
|
551
|
+
if (side !== 0) this.alertSides.set(mark.id, side)
|
|
552
|
+
if (before == null || side === 0 || side === before) continue
|
|
553
|
+
this.alertSides.delete(mark.id)
|
|
554
|
+
const saved = this.store.updateChartMark(mark.sessionId, mark.id, { alert: false, alertTriggeredAt: now })
|
|
555
|
+
const event = { sessionId: mark.shared ? null : mark.sessionId, shared: Boolean(mark.shared), coin: mark.coin, id: mark.id, label: mark.label, kind: mark.kind,
|
|
556
|
+
price: mid, line, direction: side > 0 ? 'up' : 'down', at: now }
|
|
557
|
+
this.store.recordEvent('chart.alert', event)
|
|
558
|
+
fired.push({ ...event, mark: saved })
|
|
559
|
+
}
|
|
560
|
+
return fired
|
|
561
|
+
}
|
|
562
|
+
|
|
478
563
|
removeChartMark(id, sessionId) {
|
|
479
564
|
this.requireTradingSession(sessionId)
|
|
480
565
|
return this.store.removeChartMark(sessionId, String(id ?? ''))
|
|
@@ -505,21 +590,29 @@ export class HyperRemote extends TypertRemoteService {
|
|
|
505
590
|
patch.tapeTab = input.tapeTab
|
|
506
591
|
}
|
|
507
592
|
if ('bottomTab' in input) {
|
|
508
|
-
if (!['positions', 'orders', 'account'].includes(input.bottomTab)) throw new Error('账户页签无效')
|
|
593
|
+
if (!['positions', 'orders', 'account', 'review'].includes(input.bottomTab)) throw new Error('账户页签无效')
|
|
509
594
|
patch.bottomTab = input.bottomTab
|
|
510
595
|
}
|
|
511
596
|
if ('indicators' in input) {
|
|
512
|
-
const allowed =
|
|
597
|
+
const allowed = INDICATOR_IDS
|
|
513
598
|
if (!Array.isArray(input.indicators) || input.indicators.length > allowed.length || input.indicators.some((id) => !allowed.includes(id))) throw new Error('图表指标无效')
|
|
514
599
|
patch.indicators = [...new Set(input.indicators)]
|
|
515
600
|
}
|
|
601
|
+
if ('maLines' in input) patch.maLines = normalizeMaLines(input.maLines)
|
|
602
|
+
if ('indicatorSettings' in input) patch.indicatorSettings = normalizeIndicatorSettings(input.indicatorSettings)
|
|
603
|
+
if ('research' in input) patch.research = normalizeResearch(input.research)
|
|
604
|
+
if ('showDataWindow' in input) { if (typeof input.showDataWindow !== 'boolean') throw Error('数据窗口设置无效'); patch.showDataWindow = input.showDataWindow }
|
|
605
|
+
if ('chartType' in input) {
|
|
606
|
+
if (!['candles', 'bars', 'line', 'area', 'heikinAshi'].includes(input.chartType)) throw new Error('图表类型无效')
|
|
607
|
+
patch.chartType = input.chartType
|
|
608
|
+
}
|
|
516
609
|
if ('priceScale' in input) {
|
|
517
610
|
if (!['normal', 'log', 'percentage'].includes(input.priceScale)) throw new Error('价格刻度无效')
|
|
518
611
|
patch.priceScale = input.priceScale
|
|
519
612
|
}
|
|
520
613
|
if ('chartLayers' in input) {
|
|
521
614
|
const layers = input.chartLayers
|
|
522
|
-
if (!layers || typeof layers !== 'object' || Array.isArray(layers) || Object.keys(layers).some((key) => !['orders', 'fills', 'drawings'].includes(key)) || Object.values(layers).some((value) => typeof value !== 'boolean')) throw new Error('图表图层无效')
|
|
615
|
+
if (!layers || typeof layers !== 'object' || Array.isArray(layers) || Object.keys(layers).some((key) => !['orders', 'fills', 'drawings', 'reasons'].includes(key)) || Object.values(layers).some((value) => typeof value !== 'boolean')) throw new Error('图表图层无效')
|
|
523
616
|
patch.chartLayers = layers
|
|
524
617
|
}
|
|
525
618
|
if ('layout' in input) {
|
|
@@ -556,9 +649,30 @@ export class HyperRemote extends TypertRemoteService {
|
|
|
556
649
|
return result
|
|
557
650
|
}
|
|
558
651
|
|
|
652
|
+
async indicatorTemplates(request) {
|
|
653
|
+
const action = request?.action ?? 'list'
|
|
654
|
+
if (action === 'list') return { templates: this.store.indicatorTemplates() }
|
|
655
|
+
if (action === 'remove') { this.store.removeIndicatorTemplate(String(request?.id ?? '')); return { templates: this.store.indicatorTemplates() } }
|
|
656
|
+
if (action !== 'save') throw Error('指标模板操作无效')
|
|
657
|
+
const name = String(request?.name ?? '').trim()
|
|
658
|
+
if (!name || name.length > 60) throw Error('模板名称需要 1–60 个字符')
|
|
659
|
+
const indicators = request?.indicators
|
|
660
|
+
if (!Array.isArray(indicators) || indicators.length > INDICATOR_IDS.length || indicators.some((id) => !INDICATOR_IDS.includes(id))) throw Error('模板指标无效')
|
|
661
|
+
const id = request?.id
|
|
662
|
+
if (id != null && !this.store.indicatorTemplates().some((row) => row.id === id)) throw Error('指标模板不存在')
|
|
663
|
+
const template = this.store.saveIndicatorTemplate({ id, name, indicators: [...new Set(indicators)], indicatorSettings: normalizeIndicatorSettings(request?.indicatorSettings), maLines: normalizeMaLines(request?.maLines ?? []) })
|
|
664
|
+
return { template, templates: this.store.indicatorTemplates() }
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
async updatePaperCosts(request) {
|
|
668
|
+
if (this.activeTradeOps > 0) throw Error('交易操作仍在进行,请稍后修改模拟成本')
|
|
669
|
+
return { paperCosts: this.store.setPaperCosts(request?.costs) }
|
|
670
|
+
}
|
|
671
|
+
|
|
559
672
|
async drawChartMark(request) {
|
|
560
673
|
const sessionId = String(request?.sessionId ?? '')
|
|
561
|
-
|
|
674
|
+
// Undo re-creates a deleted agent drawing; keep its origin so the chart still labels it as the agent's.
|
|
675
|
+
return this.addChartMark(request, sessionId, request?.restoreSource === 'agent' ? 'agent' : 'user')
|
|
562
676
|
}
|
|
563
677
|
|
|
564
678
|
async eraseChartMark(request) {
|
|
@@ -616,6 +730,31 @@ export class HyperRemote extends TypertRemoteService {
|
|
|
616
730
|
return { mode }
|
|
617
731
|
}
|
|
618
732
|
|
|
733
|
+
/** Persist limits as one atomic override and update both live/paper guards. */
|
|
734
|
+
async updateRiskSettings(request) {
|
|
735
|
+
if (this.activeTradeOps > 0) throw new Error('交易操作仍在进行,暂不能修改限额')
|
|
736
|
+
const limits = normalizeRiskSettings(request?.limits)
|
|
737
|
+
this.store.saveRiskSettings(limits)
|
|
738
|
+
this.applyRiskSettings(limits, true)
|
|
739
|
+
return { riskLimits: { ...limits, usedTodayUsd: this.usedTodayUsd() } }
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
async resetRiskSettings(request) {
|
|
743
|
+
if (this.activeTradeOps > 0) throw new Error('交易操作仍在进行,暂不能修改限额')
|
|
744
|
+
this.store.clearRiskSettings()
|
|
745
|
+
this.applyRiskSettings(this.baseRiskSettings, false)
|
|
746
|
+
return { riskLimits: { ...this.baseRiskSettings, usedTodayUsd: this.usedTodayUsd() } }
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
applyRiskSettings(limits, override) {
|
|
750
|
+
Object.assign(this.cfg, limits)
|
|
751
|
+
Object.assign(this.guard.limits, limits)
|
|
752
|
+
Object.assign(this.paperGuard.limits, limits)
|
|
753
|
+
this.guard.plans.clear()
|
|
754
|
+
this.riskSettingsOverride = override
|
|
755
|
+
this.store.recordEvent('risk.changed')
|
|
756
|
+
}
|
|
757
|
+
|
|
619
758
|
/** Today's (UTC) notional already counted against maxDailyUsd for the active mode. */
|
|
620
759
|
usedTodayUsd() {
|
|
621
760
|
const guard = this.cfg.tradingMode === 'paper' ? this.paperGuard : this.guard
|
|
@@ -628,10 +767,12 @@ export class HyperRemote extends TypertRemoteService {
|
|
|
628
767
|
const address = await this.resolveAddress()
|
|
629
768
|
const wallet = this.cfg.signer === 'web3' ? this.walletService() : null
|
|
630
769
|
return {
|
|
770
|
+
paperCosts: this.store.paperCosts?.() ?? { feeBps: 4.5, slippageBps: 5 },
|
|
631
771
|
network: this.cfg.network,
|
|
632
772
|
tradingMode: this.cfg.tradingMode,
|
|
633
773
|
signer: this.cfg.signer,
|
|
634
774
|
autonomousLive: this.cfg.autonomousLive,
|
|
775
|
+
riskSettingsSource: this.riskSettingsOverride ? 'desk' : 'plugin',
|
|
635
776
|
riskLimits: { allowedCoins: this.cfg.allowedCoins, maxOrderUsd: this.cfg.maxOrderUsd, maxDailyUsd: this.cfg.maxDailyUsd, maxPositionUsd: this.cfg.maxPositionUsd, maxLeverage: this.cfg.maxLeverage,
|
|
636
777
|
// Day budget of the ledger that applies to the current mode (paper and live are separate).
|
|
637
778
|
usedTodayUsd: this.usedTodayUsd() },
|
|
@@ -737,7 +878,7 @@ export class HyperRemote extends TypertRemoteService {
|
|
|
737
878
|
marginUsed: risk.marksConfirmed ? risk.marginUsed : null,
|
|
738
879
|
availableMargin: risk.marksConfirmed ? Math.max(0, risk.equity - risk.marginUsed) : null,
|
|
739
880
|
marksConfirmed: risk.marksConfirmed, positions: risk.positions, fills: paper.fills.slice(0, 50),
|
|
740
|
-
simulation: '
|
|
881
|
+
simulation: '已模拟配置的滑点与手续费;未模拟盘口深度、资金费或强平' }
|
|
741
882
|
}
|
|
742
883
|
const address = await this.resolveAddress()
|
|
743
884
|
if (!address) throw new Error('live 模式需要钱包(config.privateKey 或 signer=web3)')
|
|
@@ -830,34 +971,58 @@ export class HyperRemote extends TypertRemoteService {
|
|
|
830
971
|
}
|
|
831
972
|
|
|
832
973
|
/** Explicitly loaded history: these weighted API calls must not join the 3s account poll. */
|
|
833
|
-
async tradeHistory() {
|
|
834
|
-
|
|
974
|
+
async tradeHistory(request) {
|
|
975
|
+
const client = this.client, mode = this.cfg.tradingMode, network = this.cfg.network
|
|
976
|
+
if (request?.days != null && ![7, 30, 90].includes(request.days)) throw Error('复盘支持最近 7/30/90 天')
|
|
977
|
+
const end = Date.now(), requestedStart = request?.days != null ? end - request.days * 86_400_000 : request?.startTime == null ? null : Number(request.startTime)
|
|
978
|
+
if (requestedStart != null && (!Number.isFinite(requestedStart) || requestedStart < end - 90 * 86_400_000 || requestedStart >= end)) throw Error('复盘时间范围必须在最近 90 天内')
|
|
979
|
+
const start = requestedStart ?? Math.max(0, end - 30 * 86_400_000)
|
|
980
|
+
let result
|
|
981
|
+
if (mode === 'paper') {
|
|
835
982
|
const paper = this.store.paperAccount()
|
|
836
|
-
|
|
983
|
+
result = { mode: 'paper', orders: paper.orders.slice(0, 5000), fills: paper.fills.slice(0, 5000), paperCosts: this.store.paperCosts(), funding: [],
|
|
984
|
+
coverage: { fundingStatus: 'not-simulated', fillsLimited: paper.fills.length >= 200, ordersLimited: paper.orders.length >= 200 } }
|
|
985
|
+
} else {
|
|
986
|
+
const address = await this.resolveAddress()
|
|
987
|
+
if (!address) throw Error('未配置交易主账户地址')
|
|
988
|
+
const [orders, fills] = await Promise.all([client.historicalOrders(address), client.userFills(address)])
|
|
989
|
+
if (!Array.isArray(orders) || !Array.isArray(fills)) throw Error('交易所历史数据响应无效')
|
|
990
|
+
const decisions = this.store.liveDecisions ?? {}
|
|
991
|
+
const rows = orders.slice(0, 2000).map((entry) => {
|
|
992
|
+
const order = entry.order ?? entry, local = this.store.liveOrders?.[order.cloid], decision = decisions[local?.decisionId]
|
|
993
|
+
return { ...order, status: entry.status ?? local?.status ?? 'unknown', statusTimestamp: entry.statusTimestamp,
|
|
994
|
+
referencePx: decision?.preview?.mid ?? null,
|
|
995
|
+
reason: local?.thesis ?? decision?.thesis ?? null, sessionId: local?.sessionId ?? decision?.sessionId ?? null }
|
|
996
|
+
})
|
|
997
|
+
result = { mode: 'live', orders: rows, fills: fills.slice(0, 2000), funding: [],
|
|
998
|
+
coverage: { fundingStatus: 'not-requested', fillsLimited: fills.length >= 2000, ordersLimited: orders.length >= 2000 },
|
|
999
|
+
localOrders: Object.entries(this.store.liveOrders ?? {}).map(([cloid, row]) => ({ cloid, coin: row.coin, status: row.status, at: row.updatedAt ?? row.at, sessionId: row.sessionId })) }
|
|
1000
|
+
if (request?.includeFunding) {
|
|
1001
|
+
try {
|
|
1002
|
+
const funding = await fundingWindow(client, address, start, end)
|
|
1003
|
+
result.funding = funding.rows
|
|
1004
|
+
result.coverage.fundingStatus = funding.status
|
|
1005
|
+
} catch (error) { result.coverage.fundingStatus = 'error'; result.coverage.fundingError = String(error?.message ?? error) }
|
|
1006
|
+
}
|
|
837
1007
|
}
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
const localByCloid = this.store.liveOrders
|
|
845
|
-
const decisions = this.store.liveDecisions
|
|
846
|
-
return { mode: 'live', orders: orders.slice(0, 2000).map((entry) => {
|
|
847
|
-
const order = entry.order ?? entry
|
|
848
|
-
const local = localByCloid[order.cloid]
|
|
849
|
-
return { ...order, status: entry.status ?? local?.status ?? 'unknown', statusTimestamp: entry.statusTimestamp,
|
|
850
|
-
reason: local?.thesis ?? (local?.decisionId ? decisions[local.decisionId]?.thesis ?? null : null) }
|
|
851
|
-
}), fills: fills.slice(0, 2000) }
|
|
1008
|
+
if (requestedStart != null) {
|
|
1009
|
+
result.fills = result.fills.filter((row) => Number(row.time) >= start && Number(row.time) <= end)
|
|
1010
|
+
const ids = new Set(result.fills.filter((row) => row.oid != null).map((row) => `${row.coin}:${row.oid}`))
|
|
1011
|
+
result.orders = result.orders.filter((row) => Number(row.timestamp ?? row.time) >= start || ids.has(`${row.coin}:${row.oid}`))
|
|
1012
|
+
}
|
|
1013
|
+
return { ...result, network, observedAt: Date.now(), coverage: { ...result.coverage, startTime: start, endTime: end, bounded: true } }
|
|
852
1014
|
}
|
|
853
1015
|
|
|
854
1016
|
/** Hyperliquid portfolio samples are display data, not an accounting ledger. */
|
|
855
|
-
async portfolio() {
|
|
1017
|
+
async portfolio(request) {
|
|
856
1018
|
if (this.cfg.tradingMode === 'paper') {
|
|
857
1019
|
const account = await this.account({})
|
|
858
1020
|
const fills = this.store.paperAccount().fills
|
|
859
1021
|
return { mode: 'paper', equity: account.equity, unrealizedPnl: account.uPnl,
|
|
860
|
-
realizedPnl: fills.reduce((sum, fill) => sum + Number(fill.realized ?? 0), 0),
|
|
1022
|
+
realizedPnl: fills.reduce((sum, fill) => sum + Number(fill.realized ?? 0), 0),
|
|
1023
|
+
fees: fills.reduce((sum, fill) => sum + Number(fill.fee ?? 0), 0),
|
|
1024
|
+
netRealizedPnl: fills.some((fill) => fill.fee == null) ? null : fills.reduce((sum, fill) => sum + Number(fill.realized ?? 0) - Number(fill.fee), 0),
|
|
1025
|
+
retainedFills: fills.length, series: {} }
|
|
861
1026
|
}
|
|
862
1027
|
const address = await this.resolveAddress()
|
|
863
1028
|
if (!address) throw new Error('未配置交易主账户地址')
|
|
@@ -1011,9 +1176,11 @@ export class HyperRemote extends TypertRemoteService {
|
|
|
1011
1176
|
if (!sessionId || !this.store.getSession(sessionId)) throw new Error('交易操作必须来自已登记的交易会话')
|
|
1012
1177
|
}
|
|
1013
1178
|
|
|
1014
|
-
async authorizeToolAction(reason, exec) {
|
|
1179
|
+
async authorizeToolAction(reason, exec, english = reason) {
|
|
1015
1180
|
if (!exec?.agent) throw new Error('live 签名仅允许从 DSH agent 工具调用')
|
|
1016
|
-
|
|
1181
|
+
// reason stays the audit text; displayReason only localizes the approval panel.
|
|
1182
|
+
const outcome = await this.approvalGateway().request({ toolName: 'dsh-hyperliquid', callId: exec.callId, agent: exec.agent, signal: exec.signal, reason,
|
|
1183
|
+
displayReason: { en: english, zh: reason } })
|
|
1017
1184
|
if (outcome !== 'allowed-once') throw new Error(`用户未批准 live 操作(${outcome})`)
|
|
1018
1185
|
}
|
|
1019
1186
|
|
|
@@ -1044,29 +1211,32 @@ export class HyperRemote extends TypertRemoteService {
|
|
|
1044
1211
|
if (!Number.isFinite(limit) || limit <= 0) throw new Error('限价单需要有效 px')
|
|
1045
1212
|
if ((isBuy && limit < mid) || (!isBuy && limit > mid)) throw new Error('paper 当前只支持可立即成交的限价单')
|
|
1046
1213
|
}
|
|
1047
|
-
const
|
|
1048
|
-
const
|
|
1214
|
+
const costs = this.store.paperCosts()
|
|
1215
|
+
const slip = request?.slippagePct == null ? null : Number(request.slippagePct)
|
|
1216
|
+
const pct = slip != null && Number.isFinite(slip) ? Math.min(Math.max(slip, 0), 10) / 100 : costs.slippageBps / 10_000
|
|
1049
1217
|
const px = mid * (isBuy ? 1 + pct : 1 - pct)
|
|
1050
1218
|
if (request?.type === 'limit' && (isBuy ? px > Number(request.px) : px < Number(request.px))) throw new Error('paper 模拟滑点后无法满足限价')
|
|
1051
1219
|
const paper = this.store.paperAccount()
|
|
1052
|
-
const risk = paperRiskState(paper, { ...await safeMids(this), [coin]:
|
|
1220
|
+
const risk = paperRiskState(paper, { ...await safeMids(this), [coin]: mid }, true)
|
|
1053
1221
|
const positionSize = Number(paper.positions[coin]?.size ?? 0)
|
|
1054
1222
|
const leverage = Number(paper.positions[coin]?.leverage ?? paper.leverageByCoin?.[coin] ?? 1)
|
|
1055
1223
|
const otherExposureUsd = risk.positions.reduce((sum, p) => sum + (p.coin === coin ? 0 : Math.abs(p.size) * p.markPx), 0)
|
|
1056
1224
|
const guard = this.paperGuard ??= new TradingGuard(this.cfg.dataDir, { ...this.cfg, dataDir: this.cfg.dataDir }, { budgetName: this.cfg.network === 'mainnet' ? 'paper-budget-mainnet.json' : 'paper-budget.json' })
|
|
1057
1225
|
const checked = guard.validate({ coin, isBuy, sz, price: px, positionSize, otherExposureUsd, accountValue: risk.equity, leverage, reduceOnly })
|
|
1058
|
-
const currentMargin = Math.abs(positionSize) *
|
|
1059
|
-
const projectedMargin = risk.marginUsed - currentMargin + Math.abs(checked.nextSize) *
|
|
1060
|
-
|
|
1226
|
+
const currentMargin = Math.abs(positionSize) * mid / leverage
|
|
1227
|
+
const projectedMargin = risk.marginUsed - currentMargin + Math.abs(checked.nextSize) * mid / leverage
|
|
1228
|
+
const projectedEquity = risk.equity - checked.notionalUsd * costs.feeBps / 10_000 - sz * (isBuy ? px - mid : mid - px)
|
|
1229
|
+
if (!checked.riskReducing && projectedMargin > projectedEquity + 1e-8) throw new Error('模拟账户可用保证金不足')
|
|
1061
1230
|
const budgetTicket = guard.reserve(checked.notionalUsd, { riskReducing: checked.riskReducing })
|
|
1062
1231
|
let fill
|
|
1063
|
-
|
|
1232
|
+
const sessionId = request?.[TOOL_CALL]?.sessionId ?? null
|
|
1233
|
+
try { fill = this.store.paperFill({ coin, isBuy, sz, px, reason: request?.reason ?? 'chat', sessionId, feeBps: costs.feeBps, referencePx: mid, slippageBps: pct * 10_000 }) }
|
|
1064
1234
|
catch (err) { guard.release(budgetTicket); throw err }
|
|
1065
|
-
this.store.paperOrderRecord({ coin, isBuy, sz, px: request?.type === 'limit' ? Number(request.px) : px,
|
|
1235
|
+
this.store.paperOrderRecord({ oid: fill.oid, referencePx: mid, coin, isBuy, sz, px: request?.type === 'limit' ? Number(request.px) : px,
|
|
1066
1236
|
filledPx: px, type: request?.type === 'limit' ? 'limit' : 'market', reduceOnly,
|
|
1067
|
-
reason: request?.reason ?? '' })
|
|
1068
|
-
return { mode: 'paper', fill, marginUsed: projectedMargin, availableMargin: Math.max(0,
|
|
1069
|
-
simulation: '
|
|
1237
|
+
reason: request?.reason ?? '', sessionId })
|
|
1238
|
+
return { mode: 'paper', fill, marginUsed: projectedMargin, availableMargin: Math.max(0, projectedEquity - projectedMargin),
|
|
1239
|
+
simulation: '已模拟配置的滑点与手续费;未模拟盘口深度、资金费或强平' }
|
|
1070
1240
|
}
|
|
1071
1241
|
// live
|
|
1072
1242
|
const trusted = request?.[TOOL_CALL]
|
|
@@ -1077,7 +1247,8 @@ export class HyperRemote extends TypertRemoteService {
|
|
|
1077
1247
|
const fresh = await this.checkLiveOrder(orderSpec)
|
|
1078
1248
|
if (Math.abs(fresh.mid / preview.mid - 1) > 0.01) throw new Error('价格已偏离预检超过 1%,请重新决策')
|
|
1079
1249
|
if (!this.cfg.autonomousLive) {
|
|
1080
|
-
await this.authorizeToolAction(`Hyperliquid ${this.cfg.network} ${coin} ${isBuy ? '买入' : '卖出'} ${sz},预估名义价值 ${fresh.notionalUsd.toFixed(2)} USD。是否允许本次交易?`, trusted.exec
|
|
1250
|
+
await this.authorizeToolAction(`Hyperliquid ${this.cfg.network} ${coin} ${isBuy ? '买入' : '卖出'} ${sz},预估名义价值 ${fresh.notionalUsd.toFixed(2)} USD。是否允许本次交易?`, trusted.exec,
|
|
1251
|
+
`Hyperliquid ${this.cfg.network}: ${isBuy ? 'buy' : 'sell'} ${sz} ${coin}, estimated notional ${fresh.notionalUsd.toFixed(2)} USD. Allow this trade?`)
|
|
1081
1252
|
}
|
|
1082
1253
|
const { universe } = await this.markets()
|
|
1083
1254
|
const idx = universe.findIndex((u) => u.name === coin)
|
|
@@ -1156,7 +1327,7 @@ export class HyperRemote extends TypertRemoteService {
|
|
|
1156
1327
|
if (this.cfg.tradingMode === 'paper') throw new Error('paper 模式没有挂单')
|
|
1157
1328
|
const exec = request?.[TOOL_CALL]?.exec
|
|
1158
1329
|
if (!exec?.agent) throw new Error('live 撤单仅允许从 DSH agent 工具调用')
|
|
1159
|
-
if (!this.cfg.autonomousLive || !this.cfg.allowedCoins.includes(coin)) await this.authorizeToolAction(`Hyperliquid ${this.cfg.network} 撤销 ${coin} 订单 oid=${oid}?`, exec)
|
|
1330
|
+
if (!this.cfg.autonomousLive || !this.cfg.allowedCoins.includes(coin)) await this.authorizeToolAction(`Hyperliquid ${this.cfg.network} 撤销 ${coin} 订单 oid=${oid}?`, exec, `Hyperliquid ${this.cfg.network}: cancel ${coin} order oid=${oid}?`)
|
|
1160
1331
|
const { universe } = await this.markets()
|
|
1161
1332
|
const idx = universe.findIndex((u) => u.name === coin)
|
|
1162
1333
|
if (idx < 0) throw new Error(`未知合约 ${coin}`)
|
|
@@ -1194,7 +1365,7 @@ export class HyperRemote extends TypertRemoteService {
|
|
|
1194
1365
|
if (!this.cfg.allowedCoins.includes(coin)) throw new Error(`交易限额:${coin} 不在允许交易币对内(allowedCoins)`)
|
|
1195
1366
|
const exec = request?.[TOOL_CALL]?.exec
|
|
1196
1367
|
if (!exec?.agent) throw new Error('live 杠杆变更仅允许从 DSH agent 工具调用')
|
|
1197
|
-
if (!this.cfg.autonomousLive) await this.authorizeToolAction(`Hyperliquid ${this.cfg.network} 将 ${coin} 杠杆设为 ${leverage}x?`, exec)
|
|
1368
|
+
if (!this.cfg.autonomousLive) await this.authorizeToolAction(`Hyperliquid ${this.cfg.network} 将 ${coin} 杠杆设为 ${leverage}x?`, exec, `Hyperliquid ${this.cfg.network}: set ${coin} leverage to ${leverage}x?`)
|
|
1198
1369
|
const res = await this.submitAction({ type: 'updateLeverage', asset: idx, isCross, leverage }, exec)
|
|
1199
1370
|
assertExchangeAccepted(res)
|
|
1200
1371
|
this.store.recordEvent('live.leverage', { coin, leverage, isCross })
|
|
@@ -1218,6 +1389,7 @@ export class HyperRemote extends TypertRemoteService {
|
|
|
1218
1389
|
/** Stop the WS loop on teardown. */
|
|
1219
1390
|
stopWs() {
|
|
1220
1391
|
clearInterval(this.sweepTimer)
|
|
1392
|
+
clearInterval(this.alertTimer)
|
|
1221
1393
|
this.client.close()
|
|
1222
1394
|
}
|
|
1223
1395
|
}
|
|
@@ -1385,7 +1557,7 @@ function paperRiskState(paper, mids, strict = false) {
|
|
|
1385
1557
|
|
|
1386
1558
|
// Remote markers: every method the web client may call.
|
|
1387
1559
|
for (const m of [
|
|
1388
|
-
'status', 'switchNetwork', 'switchTradingMode', 'markets', 'marketCatalog', 'marketDetail', 'chartMarks', 'drawChartMark', 'eraseChartMark', 'sessionWorkspace', 'updateSessionWorkspace', 'snapshot', 'candles', 'fundingHistory', 'account', 'fills', 'tradeHistory', 'portfolio',
|
|
1560
|
+
'indicatorTemplates', 'updatePaperCosts', 'status', 'switchNetwork', 'switchTradingMode', 'updateRiskSettings', 'resetRiskSettings', 'markets', 'marketCatalog', 'marketDetail', 'chartMarks', 'drawChartMark', 'updateChartMark', 'eraseChartMark', 'sessionWorkspace', 'updateSessionWorkspace', 'snapshot', 'candles', 'fundingHistory', 'account', 'fills', 'tradeHistory', 'portfolio',
|
|
1389
1561
|
'sessions', 'registerSession', 'removeSession', 'bindCoin', 'deskState',
|
|
1390
1562
|
'eventsSince',
|
|
1391
1563
|
]) {
|
package/lib/review.d.ts
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export interface ReviewRecord { id: string; coin: string; oid: string | number | null; time: number | null; px: number | null; size: number | null; isBuy: boolean | null; gross: number | null; fee: number | null; feeToken: string; net: number | null; reference: number | null; slippageBps: number | null; closedSize: number | null; reason: string; sessionId: string | null; flags: string[]; source: string }
|
|
2
|
+
export declare function fundingWindow(client: any, address: string, startTime: number, endTime: number): Promise<{rows: any[]; status: string; pages: number}>
|
|
3
|
+
export declare function reviewLedger(payload: any, coin?: string): { records: ReviewRecord[]; orders: any[]; duplicates: number; fees: Record<string, number>; gross: number | null; tradingNet: number | null; funding: number | null; net: number | null; closeCount: number; missingReasons: number; unmatched: number; gaps: number; unsupported: number }
|
package/lib/review.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/** Evidence over a bounded history window; missing fields remain unknown. No balance inference. */
|
|
2
|
+
const numeric = (value) => value == null || value === '' || !Number.isFinite(Number(value)) ? null : Number(value)
|
|
3
|
+
const key = (row) => row?.oid == null || !row.coin ? null : `${row.coin}:${String(row.oid)}`
|
|
4
|
+
const core = (coin) => typeof coin === 'string' && coin.trim().length > 0 && !coin.startsWith('#') && !coin.includes(':') && !coin.startsWith('@') && !coin.includes('/')
|
|
5
|
+
export async function fundingWindow(client, address, startTime, endTime) {
|
|
6
|
+
const rows = [], seen = new Set(); let cursor = startTime, partial = false
|
|
7
|
+
for (let page = 0; page < 12; page++) {
|
|
8
|
+
const result = await client.info('userFunding', { user: address, startTime: cursor, endTime })
|
|
9
|
+
if (!Array.isArray(result)) throw Error('资金费历史响应无效')
|
|
10
|
+
const valid = result.filter((row) => Number.isFinite(Number(row.time)) && Number(row.time) >= cursor && Number(row.time) <= endTime && row.delta?.type === 'funding')
|
|
11
|
+
if (valid.length !== result.length) partial = true
|
|
12
|
+
for (const row of valid) {
|
|
13
|
+
const id = `${row.time}:${row.hash ?? ''}:${row.delta.coin}:${row.delta.usdc}`
|
|
14
|
+
if (!seen.has(id)) { seen.add(id); rows.push(row) }
|
|
15
|
+
}
|
|
16
|
+
if (result.length < 500) return { rows, status: partial ? 'partial' : 'complete', pages: page + 1 }
|
|
17
|
+
const last = Math.max(...valid.map((row) => Number(row.time)))
|
|
18
|
+
if (!Number.isFinite(last) || last <= cursor) return { rows, status: 'partial', pages: page + 1 }
|
|
19
|
+
// Inclusive boundary: repeat the final timestamp and deduplicate, avoiding lost peers.
|
|
20
|
+
cursor = last
|
|
21
|
+
}
|
|
22
|
+
return { rows, status: 'partial', pages: 12 }
|
|
23
|
+
}
|
|
24
|
+
export function reviewLedger(payload, coin = '') {
|
|
25
|
+
const orders = (payload.orders ?? []).filter((row) => !coin || row.coin === coin)
|
|
26
|
+
const allFills = (payload.fills ?? []).filter((row) => !coin || row.coin === coin)
|
|
27
|
+
const byOrder = new Map(orders.filter((row) => key(row)).map((row) => [key(row), row]))
|
|
28
|
+
const seen = new Set(), records = []; let duplicates = 0
|
|
29
|
+
for (const [index, fill] of allFills.entries()) {
|
|
30
|
+
const id = fill.tid != null ? `${fill.coin}:${fill.tid}` : payload.mode === 'paper' && fill.oid ? `${fill.coin}:${fill.oid}` : `unidentified:${index}`
|
|
31
|
+
if (seen.has(id)) { duplicates++; continue } seen.add(id)
|
|
32
|
+
const order = byOrder.get(key(fill)), pnl = numeric(fill.closedPnl ?? fill.realized), fee = numeric(fill.fee)
|
|
33
|
+
const feeToken = String(fill.feeToken ?? '').trim()
|
|
34
|
+
const px = numeric(fill.px), size = numeric(fill.sz), buy = typeof fill.isBuy === 'boolean' ? fill.isBuy : fill.side === 'B' ? true : fill.side === 'A' ? false : null
|
|
35
|
+
const reference = numeric(fill.referencePx ?? order?.referencePx)
|
|
36
|
+
const reason = fill.reason || order?.reason || ''
|
|
37
|
+
const closed = numeric(fill.closedSize) ?? (buy != null && numeric(fill.startPosition) != null && Math.sign(Number(fill.startPosition)) !== (buy ? 1 : -1) ? Math.min(Math.abs(Number(fill.startPosition)), size ?? 0) : /^Close/.test(fill.dir ?? '') ? size : null)
|
|
38
|
+
const eligible = core(fill.coin) && feeToken === 'USDC'
|
|
39
|
+
const net = eligible && pnl != null && fee != null ? pnl - fee : null
|
|
40
|
+
const flags = []
|
|
41
|
+
if (!order) flags.push('unmatched-order')
|
|
42
|
+
if (px == null || px <= 0) flags.push('missing-price')
|
|
43
|
+
if (size == null || size <= 0) flags.push('missing-size')
|
|
44
|
+
if (numeric(fill.time) == null) flags.push('missing-time')
|
|
45
|
+
if (buy == null) flags.push('missing-side')
|
|
46
|
+
if (!reason || /^(session:|tool$|chat$|order$)/.test(reason)) flags.push('missing-reason')
|
|
47
|
+
if (fee == null || !feeToken) flags.push('missing-fee')
|
|
48
|
+
if (pnl == null) flags.push('missing-pnl')
|
|
49
|
+
if (fill.tid == null && payload.mode !== 'paper') flags.push('missing-trade-id')
|
|
50
|
+
records.push({ id, coin: fill.coin, oid: fill.oid ?? null, time: numeric(fill.time), px, size, isBuy: buy, gross: pnl, fee, feeToken, net,
|
|
51
|
+
reference, slippageBps: reference && reference > 0 && px && buy != null ? (buy ? px - reference : reference - px) / reference * 10_000 : null,
|
|
52
|
+
closedSize: closed, reason: flags.includes('missing-reason') ? '' : reason, sessionId: fill.sessionId ?? order?.sessionId ?? null, flags, source: payload.mode })
|
|
53
|
+
}
|
|
54
|
+
const reconciled = orders.map((order) => {
|
|
55
|
+
const fills = records.filter((row) => key(row) && key(row) === key(order))
|
|
56
|
+
const filled = fills.reduce((sum, row) => sum + (row.size ?? 0), 0)
|
|
57
|
+
const expected = numeric(order.origSz ?? order.sz), tolerance = Math.max(1e-8, (expected ?? 0) * 1e-6)
|
|
58
|
+
const status = fills.some((row) => row.flags.includes('missing-size')) ? 'missing-size' : /filled/i.test(order.status ?? '') && expected != null && Math.abs(filled - expected) > tolerance ? 'size-gap'
|
|
59
|
+
: expected != null && filled > expected + tolerance ? 'excess-fill' : fills.length ? 'matched' : 'no-fill-in-window'
|
|
60
|
+
return { coin: order.coin, oid: order.oid ?? null, status, filled, expected, exchangeStatus: order.status ?? 'unknown', reason: order.reason ?? '' }
|
|
61
|
+
})
|
|
62
|
+
const supported = records.filter((row) => core(row.coin))
|
|
63
|
+
const fees = {}; for (const row of records) if (row.fee != null && row.feeToken) fees[row.feeToken] = (fees[row.feeToken] ?? 0) + row.fee
|
|
64
|
+
const supportedScope = !coin || core(coin)
|
|
65
|
+
const gross = supportedScope ? supported.reduce((sum, row) => sum + (row.gross ?? 0), 0) : null
|
|
66
|
+
const tradingNet = !supportedScope || supported.some((row) => row.net == null) ? null : supported.reduce((sum, row) => sum + row.net, 0)
|
|
67
|
+
const fundingRows = (payload.funding ?? []).filter((row) => core(row.delta?.coin) && (!coin || row.delta.coin === coin))
|
|
68
|
+
const funding = supportedScope && payload.coverage?.fundingStatus === 'complete' && fundingRows.every((row) => numeric(row.delta.usdc) != null)
|
|
69
|
+
? fundingRows.reduce((sum, row) => sum + Number(row.delta.usdc), 0) : null
|
|
70
|
+
const closes = supported.filter((row) => row.closedSize > 0)
|
|
71
|
+
return { records, orders: reconciled, duplicates, fees, gross, tradingNet, funding,
|
|
72
|
+
net: tradingNet != null && funding != null ? tradingNet + funding : null,
|
|
73
|
+
closeCount: closes.length,
|
|
74
|
+
missingReasons: records.filter((row) => row.flags.includes('missing-reason')).length,
|
|
75
|
+
unmatched: records.filter((row) => row.flags.includes('unmatched-order')).length,
|
|
76
|
+
gaps: reconciled.filter((row) => ['size-gap', 'excess-fill'].includes(row.status)).length,
|
|
77
|
+
unsupported: records.length - supported.length,
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Validate the user-editable trading limits before they reach either guard. */
|
|
2
|
+
export function normalizeRiskSettings(input) {
|
|
3
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('交易设置无效')
|
|
4
|
+
const allowed = ['allowedCoins', 'maxOrderUsd', 'maxDailyUsd', 'maxPositionUsd', 'maxLeverage']
|
|
5
|
+
if (Object.keys(input).some((key) => !allowed.includes(key)) || allowed.some((key) => !(key in input))) throw new Error('交易设置字段无效')
|
|
6
|
+
const coins = typeof input.allowedCoins === 'string' ? input.allowedCoins.split(',') : input.allowedCoins
|
|
7
|
+
if (!Array.isArray(coins)) throw new Error('允许交易的币对无效')
|
|
8
|
+
const allowedCoins = [...new Set(coins.map((coin) => String(coin).trim()).filter(Boolean))]
|
|
9
|
+
if (!allowedCoins.length || allowedCoins.length > 100 || allowedCoins.some((coin) => coin.length > 32 || !/^[A-Za-z0-9:_-]+$/.test(coin))) {
|
|
10
|
+
throw new Error('允许交易的币对无效')
|
|
11
|
+
}
|
|
12
|
+
const values = {}
|
|
13
|
+
for (const key of allowed.slice(1)) {
|
|
14
|
+
const value = Number(input[key])
|
|
15
|
+
if (!Number.isFinite(value) || value <= 0 || value > 1_000_000_000) throw new Error(`${key} 必须为正数`)
|
|
16
|
+
values[key] = value
|
|
17
|
+
}
|
|
18
|
+
if (!Number.isInteger(values.maxLeverage) || values.maxLeverage > 50) throw new Error('maxLeverage 必须为 1–50 的整数')
|
|
19
|
+
return { allowedCoins, ...values }
|
|
20
|
+
}
|