@363045841yyt/klinechart-ai-runtime 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.
Files changed (55) hide show
  1. package/README.md +130 -0
  2. package/dist/createWithMcp.d.ts +9 -0
  3. package/dist/createWithMcp.d.ts.map +1 -0
  4. package/dist/createWithMcp.js +15 -0
  5. package/dist/createWithMcp.js.map +1 -0
  6. package/dist/describeControllers.d.ts +34 -0
  7. package/dist/describeControllers.d.ts.map +1 -0
  8. package/dist/describeControllers.js +104 -0
  9. package/dist/describeControllers.js.map +1 -0
  10. package/dist/executeTool.d.ts +12 -0
  11. package/dist/executeTool.d.ts.map +1 -0
  12. package/dist/executeTool.js +63 -0
  13. package/dist/executeTool.js.map +1 -0
  14. package/dist/index.d.ts +8 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +7 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/mcpServer.d.ts +35 -0
  19. package/dist/mcpServer.d.ts.map +1 -0
  20. package/dist/mcpServer.js +189 -0
  21. package/dist/mcpServer.js.map +1 -0
  22. package/dist/serialization.d.ts +28 -0
  23. package/dist/serialization.d.ts.map +1 -0
  24. package/dist/serialization.js +53 -0
  25. package/dist/serialization.js.map +1 -0
  26. package/dist/sessionRegistry.d.ts +19 -0
  27. package/dist/sessionRegistry.d.ts.map +1 -0
  28. package/dist/sessionRegistry.js +41 -0
  29. package/dist/sessionRegistry.js.map +1 -0
  30. package/dist/toolSchemas.d.ts +14 -0
  31. package/dist/toolSchemas.d.ts.map +1 -0
  32. package/dist/toolSchemas.js +216 -0
  33. package/dist/toolSchemas.js.map +1 -0
  34. package/dist/types.d.ts +75 -0
  35. package/dist/types.d.ts.map +1 -0
  36. package/dist/types.js +2 -0
  37. package/dist/types.js.map +1 -0
  38. package/package.json +63 -0
  39. package/src/__tests__/chartBridge.integration.test.ts +100 -0
  40. package/src/__tests__/describeControllers.test.ts +163 -0
  41. package/src/__tests__/executeTool.test.ts +187 -0
  42. package/src/__tests__/mcpServer.integration.test.ts +155 -0
  43. package/src/__tests__/mcpServer.test.ts +30 -0
  44. package/src/__tests__/serialization.test.ts +116 -0
  45. package/src/__tests__/sessionRegistry.test.ts +139 -0
  46. package/src/__tests__/toolSchemas.test.ts +149 -0
  47. package/src/createWithMcp.ts +28 -0
  48. package/src/describeControllers.ts +166 -0
  49. package/src/executeTool.ts +92 -0
  50. package/src/index.ts +38 -0
  51. package/src/mcpServer.ts +268 -0
  52. package/src/serialization.ts +88 -0
  53. package/src/sessionRegistry.ts +61 -0
  54. package/src/toolSchemas.ts +235 -0
  55. package/src/types.ts +52 -0
@@ -0,0 +1,116 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ serialize,
4
+ deserialize,
5
+ ChartSerializationError,
6
+ } from '../serialization'
7
+
8
+ describe('serialize', () => {
9
+ it('produces a SerializedChartState with schemaVersion 1 and a parseable timestamp', () => {
10
+ const out = serialize({
11
+ label: 'Test setup',
12
+ viewport: { zoomLevel: 5, visibleFrom: 0, visibleTo: 100 },
13
+ })
14
+ expect(out.schemaVersion).toBe(1)
15
+ expect(out.label).toBe('Test setup')
16
+ expect(Date.parse(out.snapshotTakenAt)).not.toBeNaN()
17
+ expect(out.controllers.viewport?.zoomLevel).toBe(5)
18
+ })
19
+
20
+ it('omits absent controllers from the output', () => {
21
+ const out = serialize({})
22
+ expect(out.controllers.viewport).toBeUndefined()
23
+ expect(out.controllers.theme).toBeUndefined()
24
+ })
25
+
26
+ it('includes all controller blocks when provided', () => {
27
+ const out = serialize({
28
+ viewport: { zoomLevel: 1, visibleFrom: 0, visibleTo: 10 },
29
+ theme: 'dark',
30
+ indicators: [{ definitionId: 'MA', params: { period: 20 } }],
31
+ alerts: [
32
+ {
33
+ id: 'a1',
34
+ name: 'BTC 100k',
35
+ predicate: {
36
+ kind: 'price-cross',
37
+ price: 100_000,
38
+ direction: 'up',
39
+ },
40
+ oneShot: true,
41
+ },
42
+ ],
43
+ })
44
+ expect(out.controllers.viewport).toBeDefined()
45
+ expect(out.controllers.theme).toBe('dark')
46
+ expect(out.controllers.indicators).toHaveLength(1)
47
+ expect(out.controllers.alerts).toHaveLength(1)
48
+ })
49
+ })
50
+
51
+ describe('deserialize', () => {
52
+ it('round-trips through JSON', () => {
53
+ const original = serialize({ label: 'a', theme: 'dark' })
54
+ const back = deserialize(JSON.stringify(original))
55
+ expect(back.schemaVersion).toBe(1)
56
+ expect(back.label).toBe('a')
57
+ expect(back.controllers.theme).toBe('dark')
58
+ })
59
+
60
+ it('throws ChartSerializationError on invalid JSON', () => {
61
+ expect(() => deserialize('not json')).toThrowError(ChartSerializationError)
62
+ })
63
+
64
+ it('throws on non-object root', () => {
65
+ expect(() => deserialize('"a string"')).toThrowError(
66
+ /NOT_OBJECT|root must be/,
67
+ )
68
+ })
69
+
70
+ it('throws on wrong schemaVersion', () => {
71
+ const bad = JSON.stringify({
72
+ schemaVersion: 2,
73
+ snapshotTakenAt: new Date().toISOString(),
74
+ controllers: {},
75
+ })
76
+ expect(() => deserialize(bad)).toThrowError(/schemaVersion/)
77
+ })
78
+
79
+ it('throws on missing/invalid snapshotTakenAt', () => {
80
+ const bad = JSON.stringify({
81
+ schemaVersion: 1,
82
+ controllers: {},
83
+ })
84
+ expect(() => deserialize(bad)).toThrowError(/INVALID_TIMESTAMP|ISO/)
85
+ })
86
+
87
+ it('throws when controllers is missing', () => {
88
+ const bad = JSON.stringify({
89
+ schemaVersion: 1,
90
+ snapshotTakenAt: new Date().toISOString(),
91
+ })
92
+ expect(() => deserialize(bad)).toThrowError(
93
+ /MISSING_CONTROLLERS|controllers/,
94
+ )
95
+ })
96
+
97
+ it('NEVER eval()s — payload is data-only', () => {
98
+ const evil = JSON.stringify({
99
+ schemaVersion: 1,
100
+ snapshotTakenAt: new Date().toISOString(),
101
+ controllers: {
102
+ alerts: [
103
+ {
104
+ id: 'a',
105
+ name: 'a',
106
+ predicate: { evil: 'process.exit(1)' },
107
+ oneShot: true,
108
+ },
109
+ ],
110
+ },
111
+ })
112
+ const back = deserialize(evil)
113
+ const a = back.controllers.alerts?.[0]
114
+ expect(a?.predicate).toEqual({ evil: 'process.exit(1)' })
115
+ })
116
+ })
@@ -0,0 +1,139 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { SessionRegistry, type SessionHandle } from '../sessionRegistry'
3
+ import type { ToolCall, ToolResult } from '../executeTool'
4
+
5
+ function createMockHandle(sessionId: string): SessionHandle {
6
+ return {
7
+ sessionId,
8
+ executeTool: async (call: ToolCall): Promise<ToolResult> => ({
9
+ success: true,
10
+ data: { handled: call.name },
11
+ }),
12
+ }
13
+ }
14
+
15
+ describe('SessionRegistry', () => {
16
+ it('registers and retrieves a session', () => {
17
+ const registry = new SessionRegistry()
18
+ const handle = createMockHandle('s1')
19
+ registry.register('s1', handle)
20
+ expect(registry.get('s1')).toBe(handle)
21
+ expect(registry.has('s1')).toBe(true)
22
+ })
23
+
24
+ it('unregisters a session', () => {
25
+ const registry = new SessionRegistry()
26
+ registry.register('s1', createMockHandle('s1'))
27
+ registry.unregister('s1')
28
+ expect(registry.get('s1')).toBeUndefined()
29
+ expect(registry.has('s1')).toBe(false)
30
+ })
31
+
32
+ it('returns active session ids', () => {
33
+ const registry = new SessionRegistry()
34
+ registry.register('s1', createMockHandle('s1'))
35
+ registry.register('s2', createMockHandle('s2'))
36
+ expect(registry.getActiveSessionIds()).toEqual(['s1', 's2'])
37
+ })
38
+
39
+ it('returns empty array when no sessions', () => {
40
+ const registry = new SessionRegistry()
41
+ expect(registry.getActiveSessionIds()).toEqual([])
42
+ })
43
+
44
+ it('handles duplicate registration gracefully', () => {
45
+ const registry = new SessionRegistry()
46
+ const oldHandle = createMockHandle('s1')
47
+ const newHandle = createMockHandle('s1')
48
+ registry.register('s1', oldHandle)
49
+ registry.register('s1', newHandle)
50
+ expect(registry.get('s1')).toBe(newHandle)
51
+ })
52
+ })
53
+
54
+ describe('SessionRegistry - state management', () => {
55
+ it('initializes with empty state on register', () => {
56
+ const registry = new SessionRegistry()
57
+ registry.register('s1', createMockHandle('s1'))
58
+ expect(registry.getState('s1')).toEqual({})
59
+ })
60
+
61
+ it('updates state for a session', () => {
62
+ const registry = new SessionRegistry()
63
+ registry.register('s1', createMockHandle('s1'))
64
+ registry.updateState('s1', {
65
+ volumeProfile: {
66
+ controllerId: 'volumeProfile',
67
+ summary: 'VP ready',
68
+ facts: { ready: true },
69
+ },
70
+ })
71
+ expect(registry.getState('s1')?.volumeProfile?.summary).toBe('VP ready')
72
+ })
73
+
74
+ it('merges state on multiple updates', () => {
75
+ const registry = new SessionRegistry()
76
+ registry.register('s1', createMockHandle('s1'))
77
+ registry.updateState('s1', {
78
+ vp: { controllerId: 'vp', summary: 'VP', facts: {} },
79
+ })
80
+ registry.updateState('s1', {
81
+ alerts: { controllerId: 'alerts', summary: 'Alerts', facts: {} },
82
+ })
83
+ const state = registry.getState('s1')!
84
+ expect(state.vp?.summary).toBe('VP')
85
+ expect(state.alerts?.summary).toBe('Alerts')
86
+ })
87
+
88
+ it('clears state on unregister', () => {
89
+ const registry = new SessionRegistry()
90
+ registry.register('s1', createMockHandle('s1'))
91
+ registry.updateState('s1', {
92
+ vp: { controllerId: 'vp', summary: 'VP', facts: {} },
93
+ })
94
+ registry.unregister('s1')
95
+ expect(registry.getState('s1')).toBeUndefined()
96
+ })
97
+ })
98
+
99
+ describe('SessionRegistry - summary generation', () => {
100
+ it('returns placeholder when no controllers described', () => {
101
+ const registry = new SessionRegistry()
102
+ registry.register('s1', createMockHandle('s1'))
103
+ expect(registry.getSummary('s1')).toBe('No controllers described.')
104
+ })
105
+
106
+ it('builds summary from controller descriptions', () => {
107
+ const registry = new SessionRegistry()
108
+ registry.register('s1', createMockHandle('s1'))
109
+ registry.updateState('s1', {
110
+ vp: {
111
+ controllerId: 'volumeProfile',
112
+ summary: 'POC at 50000, VA 49000-51000',
113
+ facts: { poc: 50000 },
114
+ },
115
+ })
116
+ const summary = registry.getSummary('s1')
117
+ expect(summary).toContain('volumeProfile')
118
+ expect(summary).toContain('POC at 50000')
119
+ })
120
+
121
+ it('joins multiple controller summaries', () => {
122
+ const registry = new SessionRegistry()
123
+ registry.register('s1', createMockHandle('s1'))
124
+ registry.updateState('s1', {
125
+ vp: { controllerId: 'vp', summary: 'VP ready', facts: {} },
126
+ alerts: { controllerId: 'alerts', summary: '3 rules', facts: {} },
127
+ })
128
+ const summary = registry.getSummary('s1')
129
+ expect(summary).toContain('VP ready')
130
+ expect(summary).toContain('3 rules')
131
+ })
132
+
133
+ it('returns "No controllers described" when state is empty object', () => {
134
+ const registry = new SessionRegistry()
135
+ registry.register('s1', createMockHandle('s1'))
136
+ registry.updateState('s1', {})
137
+ expect(registry.getSummary('s1')).toBe('No controllers described.')
138
+ })
139
+ })
@@ -0,0 +1,149 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ ALL_TOOLS,
4
+ TOOL_GROUPS,
5
+ CHART_NAVIGATION_TOOLS,
6
+ INDICATOR_TOOLS,
7
+ ALERT_TOOLS,
8
+ REPLAY_TOOLS,
9
+ findTool,
10
+ } from '../toolSchemas'
11
+
12
+ describe('ALL_TOOLS structural invariants', () => {
13
+ it('every tool has a unique name', () => {
14
+ const names = ALL_TOOLS.map((t) => t.name)
15
+ expect(new Set(names).size).toBe(names.length)
16
+ })
17
+
18
+ it('every tool name follows controller.method convention', () => {
19
+ for (const tool of ALL_TOOLS) {
20
+ expect(tool.name).toMatch(/^[a-z][a-zA-Z]+\.[a-zA-Z]+$/)
21
+ }
22
+ })
23
+
24
+ it('every tool has a non-trivial description (>= 30 chars)', () => {
25
+ for (const tool of ALL_TOOLS) {
26
+ expect(tool.description.length).toBeGreaterThanOrEqual(30)
27
+ }
28
+ })
29
+
30
+ it('every tool inputSchema is type: object with properties', () => {
31
+ for (const tool of ALL_TOOLS) {
32
+ expect(tool.inputSchema.type).toBe('object')
33
+ if (tool.inputSchema.type === 'object') {
34
+ expect(typeof tool.inputSchema.properties).toBe('object')
35
+ }
36
+ }
37
+ })
38
+
39
+ it('safety is one of the three legal values', () => {
40
+ const legal = new Set(['readonly', 'mutates-state', 'destroys-state'])
41
+ for (const tool of ALL_TOOLS) {
42
+ expect(legal.has(tool.safety)).toBe(true)
43
+ }
44
+ })
45
+ })
46
+
47
+ describe('TOOL_GROUPS coverage', () => {
48
+ it('ALL_TOOLS is the union of every group', () => {
49
+ const union = [
50
+ ...TOOL_GROUPS.navigation,
51
+ ...TOOL_GROUPS.indicators,
52
+ ...TOOL_GROUPS.alerts,
53
+ ...TOOL_GROUPS.replay,
54
+ ]
55
+ expect(union.length).toBe(ALL_TOOLS.length)
56
+ })
57
+
58
+ it('each group has at least one tool', () => {
59
+ for (const [name, group] of Object.entries(TOOL_GROUPS)) {
60
+ expect(group.length).toBeGreaterThan(0)
61
+ void name
62
+ }
63
+ })
64
+
65
+ it('CHART_NAVIGATION_TOOLS includes zoomToLevel + setTheme', () => {
66
+ const names = CHART_NAVIGATION_TOOLS.map((t) => t.name)
67
+ expect(names).toContain('chart.zoomToLevel')
68
+ expect(names).toContain('chart.setTheme')
69
+ })
70
+
71
+ it('INDICATOR_TOOLS includes add/remove/updateParams', () => {
72
+ const names = INDICATOR_TOOLS.map((t) => t.name)
73
+ expect(names).toEqual(
74
+ expect.arrayContaining([
75
+ 'indicators.add',
76
+ 'indicators.remove',
77
+ 'indicators.updateParams',
78
+ ]),
79
+ )
80
+ })
81
+
82
+ it('ALERT_TOOLS includes the two add-variants + remove', () => {
83
+ const names = ALERT_TOOLS.map((t) => t.name)
84
+ expect(names).toContain('alerts.addPriceCross')
85
+ expect(names).toContain('alerts.addIndicatorCross')
86
+ expect(names).toContain('alerts.remove')
87
+ })
88
+
89
+ it('REPLAY_TOOLS includes seek/play/pause/setSpeed', () => {
90
+ const names = REPLAY_TOOLS.map((t) => t.name)
91
+ expect(names).toEqual(
92
+ expect.arrayContaining([
93
+ 'replay.seekTo',
94
+ 'replay.play',
95
+ 'replay.pause',
96
+ 'replay.setSpeed',
97
+ ]),
98
+ )
99
+ })
100
+ })
101
+
102
+ describe('findTool', () => {
103
+ it('returns the tool when name matches', () => {
104
+ const t = findTool('chart.zoomToLevel')
105
+ expect(t).not.toBeNull()
106
+ expect(t?.name).toBe('chart.zoomToLevel')
107
+ })
108
+
109
+ it('returns null for unknown name', () => {
110
+ expect(findTool('chart.nonexistent')).toBeNull()
111
+ expect(findTool('')).toBeNull()
112
+ })
113
+ })
114
+
115
+ describe('inputSchema correctness — spot checks', () => {
116
+ it('chart.zoomToLevel requires `level` and clamps to 1-20', () => {
117
+ const t = findTool('chart.zoomToLevel')!
118
+ expect(t.inputSchema.type).toBe('object')
119
+ if (t.inputSchema.type === 'object') {
120
+ expect(t.inputSchema.required).toContain('level')
121
+ const level = t.inputSchema.properties.level
122
+ expect(level?.type).toBe('integer')
123
+ if (level?.type === 'integer') {
124
+ expect(level.minimum).toBe(1)
125
+ expect(level.maximum).toBe(20)
126
+ }
127
+ }
128
+ })
129
+
130
+ it('chart.setTheme uses an enum to constrain values', () => {
131
+ const t = findTool('chart.setTheme')!
132
+ if (t.inputSchema.type === 'object') {
133
+ const theme = t.inputSchema.properties.theme
134
+ expect(theme?.type).toBe('string')
135
+ if (theme?.type === 'string') {
136
+ expect(theme.enum).toEqual(['light', 'dark'])
137
+ }
138
+ }
139
+ })
140
+
141
+ it('alerts.addPriceCross required fields enforce id+name+price+direction+oneShot', () => {
142
+ const t = findTool('alerts.addPriceCross')!
143
+ if (t.inputSchema.type === 'object') {
144
+ expect(t.inputSchema.required).toEqual(
145
+ expect.arrayContaining(['id', 'name', 'price', 'direction', 'oneShot']),
146
+ )
147
+ }
148
+ })
149
+ })
@@ -0,0 +1,28 @@
1
+ import {
2
+ createChartController,
3
+ type ChartMountOptions,
4
+ type ChartController,
5
+ } from '@363045841yyt/klinechart-core'
6
+ import { executeTool } from './executeTool'
7
+
8
+ export interface CreateChartWithMcpOptions extends ChartMountOptions {
9
+ mcp: {
10
+ wsUrl?: string
11
+ autoReconnect?: boolean
12
+ }
13
+ }
14
+
15
+ export function createChartControllerWithMcp(
16
+ opts: CreateChartWithMcpOptions,
17
+ ): ChartController {
18
+ let ctrl: ChartController
19
+ ctrl = createChartController({
20
+ ...opts,
21
+ mcp: {
22
+ wsUrl: opts.mcp.wsUrl,
23
+ autoReconnect: opts.mcp.autoReconnect,
24
+ onToolCall: (call) => executeTool(ctrl, call),
25
+ },
26
+ })
27
+ return ctrl
28
+ }
@@ -0,0 +1,166 @@
1
+ import type { ControllerDescription } from './types'
2
+
3
+ export interface VolumeProfileSnapshot {
4
+ poc: number
5
+ vah: number
6
+ val: number
7
+ totalVolume: number
8
+ vaVolume: number
9
+ }
10
+
11
+ export function describeVolumeProfileState(
12
+ state: VolumeProfileSnapshot | null,
13
+ ): ControllerDescription {
14
+ if (state === null) {
15
+ return {
16
+ controllerId: 'volumeProfile',
17
+ summary:
18
+ 'Volume Profile has not been computed yet — no bars have been ingested.',
19
+ facts: { ready: false },
20
+ }
21
+ }
22
+
23
+ const vaPercent =
24
+ state.totalVolume > 0 ? (state.vaVolume / state.totalVolume) * 100 : 0
25
+ const vaSpan = state.vah - state.val
26
+
27
+ return {
28
+ controllerId: 'volumeProfile',
29
+ summary:
30
+ `Volume Profile shows the Point of Control at ${state.poc.toFixed(2)} — ` +
31
+ `the price level with the highest traded volume. The Value Area runs from ` +
32
+ `${state.val.toFixed(2)} (VAL) to ${state.vah.toFixed(2)} (VAH), spanning ` +
33
+ `${vaSpan.toFixed(2)} and containing ${vaPercent.toFixed(1)}% of total volume.`,
34
+ facts: {
35
+ poc: state.poc,
36
+ vah: state.vah,
37
+ val: state.val,
38
+ vaSpan: Number(vaSpan.toFixed(8)),
39
+ vaPercent: Number(vaPercent.toFixed(2)),
40
+ totalVolume: state.totalVolume,
41
+ },
42
+ }
43
+ }
44
+
45
+ export interface AnchoredVwapSeriesSnapshot {
46
+ label: string
47
+ barIndex: number
48
+ vwap: number
49
+ upper1: number
50
+ lower1: number
51
+ upper2: number
52
+ lower2: number
53
+ }
54
+
55
+ export function describeAnchoredVwap(
56
+ activeAnchors: ReadonlyArray<AnchoredVwapSeriesSnapshot>,
57
+ latestPrice: number | null,
58
+ ): ControllerDescription {
59
+ if (activeAnchors.length === 0) {
60
+ return {
61
+ controllerId: 'anchoredVwap',
62
+ summary: 'No Anchored VWAP series are active.',
63
+ facts: { count: 0 },
64
+ }
65
+ }
66
+
67
+ const lines: string[] = []
68
+ for (const a of activeAnchors) {
69
+ const rel =
70
+ latestPrice === null
71
+ ? ''
72
+ : latestPrice > a.upper1
73
+ ? ' (price above 1\u03c3 upper band \u2014 overextended)'
74
+ : latestPrice < a.lower1
75
+ ? ' (price below 1\u03c3 lower band \u2014 overextended)'
76
+ : ''
77
+ lines.push(
78
+ `"${a.label}" at ${a.vwap.toFixed(2)}, \u00b11\u03c3 [${a.lower1.toFixed(2)}, ${a.upper1.toFixed(2)}]${rel}`,
79
+ )
80
+ }
81
+
82
+ return {
83
+ controllerId: 'anchoredVwap',
84
+ summary:
85
+ `${activeAnchors.length} Anchored VWAP series active. ` +
86
+ lines.join('; ') +
87
+ '.',
88
+ facts: {
89
+ count: activeAnchors.length,
90
+ anchors: lines.join(' | '),
91
+ },
92
+ }
93
+ }
94
+
95
+ export interface FootprintLatestBarSnapshot {
96
+ barIndex: number
97
+ delta: number
98
+ totalVolume: number
99
+ imbalanceCount: number
100
+ maxImbalanceRatio: number
101
+ }
102
+
103
+ export function describeFootprintLatestBar(
104
+ bar: FootprintLatestBarSnapshot | null,
105
+ cumulativeDelta: number,
106
+ ): ControllerDescription {
107
+ if (bar === null) {
108
+ return {
109
+ controllerId: 'footprint',
110
+ summary: 'Footprint controller has no bars yet.',
111
+ facts: { ready: false },
112
+ }
113
+ }
114
+
115
+ const tone =
116
+ bar.delta > 0
117
+ ? 'buy-dominated'
118
+ : bar.delta < 0
119
+ ? 'sell-dominated'
120
+ : 'balanced'
121
+
122
+ const imbalance =
123
+ bar.imbalanceCount > 0
124
+ ? `${bar.imbalanceCount} diagonal imbalance${bar.imbalanceCount === 1 ? '' : 's'} ` +
125
+ `(max ratio ${bar.maxImbalanceRatio.toFixed(1)}\u00d7)`
126
+ : 'no imbalances flagged'
127
+
128
+ return {
129
+ controllerId: 'footprint',
130
+ summary:
131
+ `Latest footprint bar #${bar.barIndex} is ${tone} with delta ${bar.delta.toFixed(0)} ` +
132
+ `against ${bar.totalVolume.toFixed(0)} total volume. ${imbalance}. ` +
133
+ `Cumulative delta across visible bars: ${cumulativeDelta.toFixed(0)}.`,
134
+ facts: {
135
+ barIndex: bar.barIndex,
136
+ delta: bar.delta,
137
+ tone,
138
+ totalVolume: bar.totalVolume,
139
+ imbalanceCount: bar.imbalanceCount,
140
+ maxImbalanceRatio: Number(bar.maxImbalanceRatio.toFixed(2)),
141
+ cumulativeDelta,
142
+ },
143
+ }
144
+ }
145
+
146
+ export interface AlertSnapshot {
147
+ rulesEnabled: number
148
+ rulesTotal: number
149
+ recentEventsCount: number
150
+ }
151
+
152
+ export function describeAlerts(state: AlertSnapshot): ControllerDescription {
153
+ return {
154
+ controllerId: 'alerts',
155
+ summary:
156
+ state.rulesTotal === 0
157
+ ? 'No alert rules configured.'
158
+ : `${state.rulesEnabled} of ${state.rulesTotal} alert rules are enabled. ` +
159
+ `${state.recentEventsCount} recent events buffered.`,
160
+ facts: {
161
+ rulesEnabled: state.rulesEnabled,
162
+ rulesTotal: state.rulesTotal,
163
+ recentEventsCount: state.recentEventsCount,
164
+ },
165
+ }
166
+ }
@@ -0,0 +1,92 @@
1
+ import type { ChartController } from '@363045841yyt/klinechart-core'
2
+ import { findTool } from './toolSchemas'
3
+
4
+ export interface ToolCall {
5
+ name: string
6
+ input: Record<string, unknown>
7
+ }
8
+
9
+ export interface ToolResult {
10
+ success: boolean
11
+ error?: string
12
+ data?: unknown
13
+ }
14
+
15
+ export function executeTool(
16
+ chart: ChartController,
17
+ call: ToolCall,
18
+ ): ToolResult {
19
+ const schema = findTool(call.name)
20
+ if (!schema) {
21
+ return { success: false, error: `Unknown tool: ${call.name}` }
22
+ }
23
+
24
+ switch (call.name) {
25
+ case 'chart.zoomToLevel': {
26
+ const { level, anchorX } = call.input as {
27
+ level: number
28
+ anchorX?: number
29
+ }
30
+ chart.zoomToLevel(level, anchorX)
31
+ return { success: true }
32
+ }
33
+
34
+ case 'chart.setTheme': {
35
+ const { theme } = call.input as { theme: 'light' | 'dark' }
36
+ chart.setTheme(theme)
37
+ return { success: true }
38
+ }
39
+
40
+ case 'indicators.add': {
41
+ const { definitionId } = call.input as { definitionId: string }
42
+ const def = chart.catalog.find((d) => d.id === definitionId)
43
+ const role = def?.role ?? 'main'
44
+ const instanceId = chart.addIndicator(definitionId, role)
45
+ return { success: true, data: { instanceId } }
46
+ }
47
+
48
+ case 'indicators.remove': {
49
+ const { instanceId } = call.input as { instanceId: string }
50
+ const ok = chart.removeIndicator(instanceId)
51
+ return ok
52
+ ? { success: true }
53
+ : { success: false, error: `Indicator ${instanceId} not found` }
54
+ }
55
+
56
+ case 'indicators.updateParams': {
57
+ const { instanceId, params } = call.input as {
58
+ instanceId: string
59
+ params: Record<string, unknown>
60
+ }
61
+ const ok = chart.updateIndicatorParams(instanceId, params)
62
+ return ok
63
+ ? { success: true }
64
+ : { success: false, error: `Indicator ${instanceId} not found` }
65
+ }
66
+
67
+ // Alerts controller does not exist on main yet — placeholder
68
+ case 'alerts.addPriceCross':
69
+ case 'alerts.addIndicatorCross':
70
+ case 'alerts.remove': {
71
+ return {
72
+ success: false,
73
+ error: `"${call.name}" is not implemented — alerts controller is not available`,
74
+ }
75
+ }
76
+
77
+ // Replay controller does not exist on main yet — placeholder
78
+ case 'replay.seekTo':
79
+ case 'replay.play':
80
+ case 'replay.pause':
81
+ case 'replay.setSpeed': {
82
+ return {
83
+ success: false,
84
+ error: `"${call.name}" is not implemented — replay controller is not available`,
85
+ }
86
+ }
87
+
88
+ default: {
89
+ return { success: false, error: `No handler registered for ${call.name}` }
90
+ }
91
+ }
92
+ }