@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.
- package/README.md +130 -0
- package/dist/createWithMcp.d.ts +9 -0
- package/dist/createWithMcp.d.ts.map +1 -0
- package/dist/createWithMcp.js +15 -0
- package/dist/createWithMcp.js.map +1 -0
- package/dist/describeControllers.d.ts +34 -0
- package/dist/describeControllers.d.ts.map +1 -0
- package/dist/describeControllers.js +104 -0
- package/dist/describeControllers.js.map +1 -0
- package/dist/executeTool.d.ts +12 -0
- package/dist/executeTool.d.ts.map +1 -0
- package/dist/executeTool.js +63 -0
- package/dist/executeTool.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/mcpServer.d.ts +35 -0
- package/dist/mcpServer.d.ts.map +1 -0
- package/dist/mcpServer.js +189 -0
- package/dist/mcpServer.js.map +1 -0
- package/dist/serialization.d.ts +28 -0
- package/dist/serialization.d.ts.map +1 -0
- package/dist/serialization.js +53 -0
- package/dist/serialization.js.map +1 -0
- package/dist/sessionRegistry.d.ts +19 -0
- package/dist/sessionRegistry.d.ts.map +1 -0
- package/dist/sessionRegistry.js +41 -0
- package/dist/sessionRegistry.js.map +1 -0
- package/dist/toolSchemas.d.ts +14 -0
- package/dist/toolSchemas.d.ts.map +1 -0
- package/dist/toolSchemas.js +216 -0
- package/dist/toolSchemas.js.map +1 -0
- package/dist/types.d.ts +75 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +63 -0
- package/src/__tests__/chartBridge.integration.test.ts +100 -0
- package/src/__tests__/describeControllers.test.ts +163 -0
- package/src/__tests__/executeTool.test.ts +187 -0
- package/src/__tests__/mcpServer.integration.test.ts +155 -0
- package/src/__tests__/mcpServer.test.ts +30 -0
- package/src/__tests__/serialization.test.ts +116 -0
- package/src/__tests__/sessionRegistry.test.ts +139 -0
- package/src/__tests__/toolSchemas.test.ts +149 -0
- package/src/createWithMcp.ts +28 -0
- package/src/describeControllers.ts +166 -0
- package/src/executeTool.ts +92 -0
- package/src/index.ts +38 -0
- package/src/mcpServer.ts +268 -0
- package/src/serialization.ts +88 -0
- package/src/sessionRegistry.ts +61 -0
- package/src/toolSchemas.ts +235 -0
- 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
|
+
}
|