@spfunctions/cli 1.4.4 → 1.4.5
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 +205 -48
- package/dist/cache.d.ts +6 -0
- package/dist/cache.js +31 -0
- package/dist/cache.test.d.ts +1 -0
- package/dist/cache.test.js +73 -0
- package/dist/client.test.d.ts +1 -0
- package/dist/client.test.js +89 -0
- package/dist/commands/agent.js +245 -67
- package/dist/commands/dashboard.d.ts +6 -3
- package/dist/commands/dashboard.js +28 -26
- package/dist/commands/performance.js +9 -2
- package/dist/commands/telegram.d.ts +15 -0
- package/dist/commands/telegram.js +125 -0
- package/dist/config.d.ts +1 -0
- package/dist/config.js +1 -0
- package/dist/config.test.d.ts +1 -0
- package/dist/config.test.js +138 -0
- package/dist/index.js +16 -2
- package/dist/telegram/agent-bridge.d.ts +15 -0
- package/dist/telegram/agent-bridge.js +368 -0
- package/dist/telegram/bot.d.ts +10 -0
- package/dist/telegram/bot.js +297 -0
- package/dist/telegram/commands.d.ts +11 -0
- package/dist/telegram/commands.js +120 -0
- package/dist/telegram/format.d.ts +11 -0
- package/dist/telegram/format.js +51 -0
- package/dist/telegram/format.test.d.ts +1 -0
- package/dist/telegram/format.test.js +73 -0
- package/dist/telegram/poller.d.ts +6 -0
- package/dist/telegram/poller.js +32 -0
- package/dist/topics.test.d.ts +1 -0
- package/dist/topics.test.js +54 -0
- package/dist/tui/border.d.ts +33 -0
- package/dist/tui/border.js +87 -0
- package/dist/tui/chart.d.ts +19 -0
- package/dist/tui/chart.js +117 -0
- package/dist/tui/dashboard.d.ts +9 -0
- package/dist/tui/dashboard.js +779 -0
- package/dist/tui/layout.d.ts +16 -0
- package/dist/tui/layout.js +41 -0
- package/dist/tui/screen.d.ts +33 -0
- package/dist/tui/screen.js +102 -0
- package/dist/tui/state.d.ts +40 -0
- package/dist/tui/state.js +36 -0
- package/dist/tui/widgets/commandbar.d.ts +8 -0
- package/dist/tui/widgets/commandbar.js +82 -0
- package/dist/tui/widgets/detail.d.ts +9 -0
- package/dist/tui/widgets/detail.js +151 -0
- package/dist/tui/widgets/edges.d.ts +4 -0
- package/dist/tui/widgets/edges.js +33 -0
- package/dist/tui/widgets/liquidity.d.ts +9 -0
- package/dist/tui/widgets/liquidity.js +142 -0
- package/dist/tui/widgets/orders.d.ts +4 -0
- package/dist/tui/widgets/orders.js +37 -0
- package/dist/tui/widgets/portfolio.d.ts +4 -0
- package/dist/tui/widgets/portfolio.js +58 -0
- package/dist/tui/widgets/signals.d.ts +4 -0
- package/dist/tui/widgets/signals.js +31 -0
- package/dist/tui/widgets/statusbar.d.ts +8 -0
- package/dist/tui/widgets/statusbar.js +72 -0
- package/dist/tui/widgets/thesis.d.ts +4 -0
- package/dist/tui/widgets/thesis.js +66 -0
- package/dist/tui/widgets/trade.d.ts +9 -0
- package/dist/tui/widgets/trade.js +117 -0
- package/dist/tui/widgets/upcoming.d.ts +4 -0
- package/dist/tui/widgets/upcoming.js +41 -0
- package/dist/tui/widgets/whatif.d.ts +7 -0
- package/dist/tui/widgets/whatif.js +113 -0
- package/dist/utils.test.d.ts +1 -0
- package/dist/utils.test.js +111 -0
- package/package.json +6 -2
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram slash command handlers — direct API calls, zero LLM cost
|
|
3
|
+
*/
|
|
4
|
+
import { SFClient } from '../client.js';
|
|
5
|
+
export declare function handleContext(client: SFClient, thesisId: string): Promise<string>;
|
|
6
|
+
export declare function handlePositions(): Promise<string>;
|
|
7
|
+
export declare function handleEdges(client: SFClient): Promise<string>;
|
|
8
|
+
export declare function handleBalance(): Promise<string>;
|
|
9
|
+
export declare function handleOrders(): Promise<string>;
|
|
10
|
+
export declare function handleEval(client: SFClient, thesisId: string): Promise<string>;
|
|
11
|
+
export declare function handleList(client: SFClient): Promise<string>;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Telegram slash command handlers — direct API calls, zero LLM cost
|
|
4
|
+
*/
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.handleContext = handleContext;
|
|
7
|
+
exports.handlePositions = handlePositions;
|
|
8
|
+
exports.handleEdges = handleEdges;
|
|
9
|
+
exports.handleBalance = handleBalance;
|
|
10
|
+
exports.handleOrders = handleOrders;
|
|
11
|
+
exports.handleEval = handleEval;
|
|
12
|
+
exports.handleList = handleList;
|
|
13
|
+
const kalshi_js_1 = require("../kalshi.js");
|
|
14
|
+
const format_js_1 = require("./format.js");
|
|
15
|
+
async function handleContext(client, thesisId) {
|
|
16
|
+
const ctx = await client.getContext(thesisId);
|
|
17
|
+
const conf = typeof ctx.confidence === 'number' ? Math.round(ctx.confidence * 100) : '?';
|
|
18
|
+
const thesis = ctx.thesis || ctx.rawThesis || 'N/A';
|
|
19
|
+
let text = `📋 <b>${(0, format_js_1.escapeHtml)(thesis.slice(0, 80))}</b>\n`;
|
|
20
|
+
text += `Confidence: <b>${conf}%</b> | Status: ${ctx.status}\n\n`;
|
|
21
|
+
const edges = (ctx.edges || []).slice(0, 8);
|
|
22
|
+
if (edges.length > 0) {
|
|
23
|
+
text += `<b>Top Edges:</b>\n<pre>`;
|
|
24
|
+
for (const e of edges) {
|
|
25
|
+
const name = (e.market || e.marketTitle || '???').slice(0, 35);
|
|
26
|
+
text += `${name.padEnd(36)} ${String(e.edge || 0).padStart(3)}¢ ${e.direction || 'yes'}\n`;
|
|
27
|
+
}
|
|
28
|
+
text += `</pre>`;
|
|
29
|
+
}
|
|
30
|
+
return text;
|
|
31
|
+
}
|
|
32
|
+
async function handlePositions() {
|
|
33
|
+
if (!(0, kalshi_js_1.isKalshiConfigured)())
|
|
34
|
+
return '⚠️ Kalshi not configured. Run <code>sf setup --kalshi</code>';
|
|
35
|
+
const positions = await (0, kalshi_js_1.getPositions)();
|
|
36
|
+
if (!positions || positions.length === 0)
|
|
37
|
+
return 'No open positions.';
|
|
38
|
+
// Enrich with live prices
|
|
39
|
+
let totalPnlCents = 0;
|
|
40
|
+
const lines = [];
|
|
41
|
+
for (const pos of positions) {
|
|
42
|
+
const livePrice = await (0, kalshi_js_1.getMarketPrice)(pos.ticker);
|
|
43
|
+
const current = livePrice ?? pos.average_price_paid;
|
|
44
|
+
const pnlCents = (current - pos.average_price_paid) * pos.quantity;
|
|
45
|
+
totalPnlCents += pnlCents;
|
|
46
|
+
const pnl = (0, format_js_1.fmtDollar)(pnlCents);
|
|
47
|
+
lines.push(`${pos.ticker.slice(0, 28).padEnd(29)} ${String(pos.quantity).padStart(5)} ${String(pos.average_price_paid).padStart(3)}¢→${String(current).padStart(3)}¢ ${pnl}`);
|
|
48
|
+
}
|
|
49
|
+
let text = `📊 <b>Positions</b>\n<pre>`;
|
|
50
|
+
text += lines.join('\n');
|
|
51
|
+
text += `\n${'─'.repeat(50)}\nTotal P&L: ${(0, format_js_1.fmtDollar)(totalPnlCents)}`;
|
|
52
|
+
text += `</pre>`;
|
|
53
|
+
return text;
|
|
54
|
+
}
|
|
55
|
+
async function handleEdges(client) {
|
|
56
|
+
const { theses } = await client.listTheses();
|
|
57
|
+
const active = (theses || []).filter((t) => t.status === 'active');
|
|
58
|
+
const results = await Promise.allSettled(active.map(async (t) => {
|
|
59
|
+
const ctx = await client.getContext(t.id);
|
|
60
|
+
return (ctx.edges || []).map((e) => ({ ...e, thesisId: t.id }));
|
|
61
|
+
}));
|
|
62
|
+
const allEdges = [];
|
|
63
|
+
for (const r of results) {
|
|
64
|
+
if (r.status === 'fulfilled')
|
|
65
|
+
allEdges.push(...r.value);
|
|
66
|
+
}
|
|
67
|
+
allEdges.sort((a, b) => Math.abs(b.edge || 0) - Math.abs(a.edge || 0));
|
|
68
|
+
const top = allEdges.slice(0, 10);
|
|
69
|
+
if (top.length === 0)
|
|
70
|
+
return 'No edges found.';
|
|
71
|
+
let text = `📈 <b>Top Edges</b>\n<pre>`;
|
|
72
|
+
for (const e of top) {
|
|
73
|
+
const name = (e.market || '???').slice(0, 35);
|
|
74
|
+
const liq = e.orderbook?.liquidityScore || '?';
|
|
75
|
+
text += `${name.padEnd(36)} +${String(e.edge || 0).padStart(2)}¢ ${liq}\n`;
|
|
76
|
+
}
|
|
77
|
+
text += `</pre>`;
|
|
78
|
+
return text;
|
|
79
|
+
}
|
|
80
|
+
async function handleBalance() {
|
|
81
|
+
if (!(0, kalshi_js_1.isKalshiConfigured)())
|
|
82
|
+
return '⚠️ Kalshi not configured.';
|
|
83
|
+
const bal = await (0, kalshi_js_1.getBalance)();
|
|
84
|
+
if (!bal)
|
|
85
|
+
return '⚠️ Failed to fetch balance.';
|
|
86
|
+
return `💰 Balance: <b>$${bal.balance.toFixed(2)}</b> | Portfolio: <b>$${bal.portfolioValue.toFixed(2)}</b>`;
|
|
87
|
+
}
|
|
88
|
+
async function handleOrders() {
|
|
89
|
+
if (!(0, kalshi_js_1.isKalshiConfigured)())
|
|
90
|
+
return '⚠️ Kalshi not configured.';
|
|
91
|
+
const result = await (0, kalshi_js_1.getOrders)({ status: 'resting', limit: 20 });
|
|
92
|
+
if (!result || !result.orders || result.orders.length === 0)
|
|
93
|
+
return 'No resting orders.';
|
|
94
|
+
let text = `📋 <b>Resting Orders</b>\n<pre>`;
|
|
95
|
+
for (const o of result.orders) {
|
|
96
|
+
const qty = Math.round(parseFloat(o.remaining_count_fp || '0'));
|
|
97
|
+
const price = Math.round(parseFloat(o.yes_price_dollars || '0') * 100);
|
|
98
|
+
const ticker = (o.ticker || '???').slice(0, 25);
|
|
99
|
+
text += `${o.action} ${qty}x ${ticker} @ ${price}¢\n`;
|
|
100
|
+
}
|
|
101
|
+
text += `</pre>`;
|
|
102
|
+
return text;
|
|
103
|
+
}
|
|
104
|
+
async function handleEval(client, thesisId) {
|
|
105
|
+
await client.evaluate(thesisId);
|
|
106
|
+
return '⚡ Evaluation triggered. Results in ~2 minutes.';
|
|
107
|
+
}
|
|
108
|
+
async function handleList(client) {
|
|
109
|
+
const { theses } = await client.listTheses();
|
|
110
|
+
if (!theses || theses.length === 0)
|
|
111
|
+
return 'No theses.';
|
|
112
|
+
let text = `📝 <b>Theses</b>\n<pre>`;
|
|
113
|
+
for (const t of theses) {
|
|
114
|
+
const conf = typeof t.confidence === 'number' ? Math.round(t.confidence * 100) : 0;
|
|
115
|
+
const title = (t.rawThesis || t.thesis || '').slice(0, 50);
|
|
116
|
+
text += `${t.id.slice(0, 8)} ${String(conf).padStart(3)}% ${t.status.padEnd(8)} ${title}\n`;
|
|
117
|
+
}
|
|
118
|
+
text += `</pre>`;
|
|
119
|
+
return text;
|
|
120
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram message formatting utilities
|
|
3
|
+
*/
|
|
4
|
+
/** Split long text into chunks under Telegram's 4096 char limit */
|
|
5
|
+
export declare function splitMessage(text: string, maxLen?: number): string[];
|
|
6
|
+
/** Format a number as dollars */
|
|
7
|
+
export declare function fmtDollar(cents: number): string;
|
|
8
|
+
/** Simple sparkline */
|
|
9
|
+
export declare function sparkline(values: number[]): string;
|
|
10
|
+
/** Escape HTML special characters for Telegram HTML parse mode */
|
|
11
|
+
export declare function escapeHtml(s: string): string;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Telegram message formatting utilities
|
|
4
|
+
*/
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.splitMessage = splitMessage;
|
|
7
|
+
exports.fmtDollar = fmtDollar;
|
|
8
|
+
exports.sparkline = sparkline;
|
|
9
|
+
exports.escapeHtml = escapeHtml;
|
|
10
|
+
/** Split long text into chunks under Telegram's 4096 char limit */
|
|
11
|
+
function splitMessage(text, maxLen = 4000) {
|
|
12
|
+
if (text.length <= maxLen)
|
|
13
|
+
return [text];
|
|
14
|
+
const chunks = [];
|
|
15
|
+
let remaining = text;
|
|
16
|
+
while (remaining.length > 0) {
|
|
17
|
+
if (remaining.length <= maxLen) {
|
|
18
|
+
chunks.push(remaining);
|
|
19
|
+
break;
|
|
20
|
+
}
|
|
21
|
+
// Find a good split point (newline near maxLen)
|
|
22
|
+
let splitAt = remaining.lastIndexOf('\n', maxLen);
|
|
23
|
+
if (splitAt < maxLen * 0.5)
|
|
24
|
+
splitAt = maxLen;
|
|
25
|
+
chunks.push(remaining.slice(0, splitAt));
|
|
26
|
+
remaining = remaining.slice(splitAt);
|
|
27
|
+
}
|
|
28
|
+
return chunks;
|
|
29
|
+
}
|
|
30
|
+
/** Format a number as dollars */
|
|
31
|
+
function fmtDollar(cents) {
|
|
32
|
+
const d = cents / 100;
|
|
33
|
+
return d >= 0 ? `+$${d.toFixed(2)}` : `-$${Math.abs(d).toFixed(2)}`;
|
|
34
|
+
}
|
|
35
|
+
/** Simple sparkline */
|
|
36
|
+
function sparkline(values) {
|
|
37
|
+
if (values.length === 0)
|
|
38
|
+
return '';
|
|
39
|
+
const min = Math.min(...values);
|
|
40
|
+
const max = Math.max(...values);
|
|
41
|
+
const range = max - min || 1;
|
|
42
|
+
const blocks = '▁▂▃▄▅▆▇█';
|
|
43
|
+
return values.map(v => {
|
|
44
|
+
const idx = Math.round(((v - min) / range) * 7);
|
|
45
|
+
return blocks[idx];
|
|
46
|
+
}).join('');
|
|
47
|
+
}
|
|
48
|
+
/** Escape HTML special characters for Telegram HTML parse mode */
|
|
49
|
+
function escapeHtml(s) {
|
|
50
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
51
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const vitest_1 = require("vitest");
|
|
4
|
+
const format_js_1 = require("./format.js");
|
|
5
|
+
(0, vitest_1.describe)('splitMessage', () => {
|
|
6
|
+
(0, vitest_1.it)('returns single chunk for short text', () => {
|
|
7
|
+
(0, vitest_1.expect)((0, format_js_1.splitMessage)('short text')).toEqual(['short text']);
|
|
8
|
+
});
|
|
9
|
+
(0, vitest_1.it)('splits long text at newlines', () => {
|
|
10
|
+
const lines = Array.from({ length: 50 }, (_, i) => `Line ${i}`).join('\n');
|
|
11
|
+
const chunks = (0, format_js_1.splitMessage)(lines, 100);
|
|
12
|
+
for (const chunk of chunks) {
|
|
13
|
+
(0, vitest_1.expect)(chunk.length).toBeLessThanOrEqual(100);
|
|
14
|
+
}
|
|
15
|
+
(0, vitest_1.expect)(chunks.join('')).toBe(lines);
|
|
16
|
+
});
|
|
17
|
+
(0, vitest_1.it)('splits at maxLen when no good newline found', () => {
|
|
18
|
+
const long = 'x'.repeat(200);
|
|
19
|
+
const chunks = (0, format_js_1.splitMessage)(long, 100);
|
|
20
|
+
(0, vitest_1.expect)(chunks.length).toBe(2);
|
|
21
|
+
(0, vitest_1.expect)(chunks[0].length).toBe(100);
|
|
22
|
+
});
|
|
23
|
+
(0, vitest_1.it)('handles empty string', () => {
|
|
24
|
+
(0, vitest_1.expect)((0, format_js_1.splitMessage)('')).toEqual(['']);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
(0, vitest_1.describe)('fmtDollar', () => {
|
|
28
|
+
(0, vitest_1.it)('formats positive cents', () => {
|
|
29
|
+
(0, vitest_1.expect)((0, format_js_1.fmtDollar)(550)).toBe('+$5.50');
|
|
30
|
+
});
|
|
31
|
+
(0, vitest_1.it)('formats negative cents', () => {
|
|
32
|
+
(0, vitest_1.expect)((0, format_js_1.fmtDollar)(-200)).toBe('-$2.00');
|
|
33
|
+
});
|
|
34
|
+
(0, vitest_1.it)('formats zero', () => {
|
|
35
|
+
(0, vitest_1.expect)((0, format_js_1.fmtDollar)(0)).toBe('+$0.00');
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
(0, vitest_1.describe)('sparkline', () => {
|
|
39
|
+
(0, vitest_1.it)('generates sparkline of correct length', () => {
|
|
40
|
+
const result = (0, format_js_1.sparkline)([1, 5, 3, 8, 2]);
|
|
41
|
+
(0, vitest_1.expect)(result.length).toBe(5);
|
|
42
|
+
});
|
|
43
|
+
(0, vitest_1.it)('returns empty for empty array', () => {
|
|
44
|
+
(0, vitest_1.expect)((0, format_js_1.sparkline)([])).toBe('');
|
|
45
|
+
});
|
|
46
|
+
(0, vitest_1.it)('handles single value', () => {
|
|
47
|
+
const result = (0, format_js_1.sparkline)([5]);
|
|
48
|
+
(0, vitest_1.expect)(result.length).toBe(1);
|
|
49
|
+
});
|
|
50
|
+
(0, vitest_1.it)('handles equal values', () => {
|
|
51
|
+
const result = (0, format_js_1.sparkline)([3, 3, 3]);
|
|
52
|
+
(0, vitest_1.expect)(result.length).toBe(3);
|
|
53
|
+
});
|
|
54
|
+
(0, vitest_1.it)('uses correct block chars for min/max', () => {
|
|
55
|
+
const result = (0, format_js_1.sparkline)([0, 100]);
|
|
56
|
+
(0, vitest_1.expect)(result[0]).toBe('▁');
|
|
57
|
+
(0, vitest_1.expect)(result[1]).toBe('█');
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
(0, vitest_1.describe)('escapeHtml', () => {
|
|
61
|
+
(0, vitest_1.it)('escapes ampersands', () => {
|
|
62
|
+
(0, vitest_1.expect)((0, format_js_1.escapeHtml)('a&b')).toBe('a&b');
|
|
63
|
+
});
|
|
64
|
+
(0, vitest_1.it)('escapes angle brackets', () => {
|
|
65
|
+
(0, vitest_1.expect)((0, format_js_1.escapeHtml)('<div>')).toBe('<div>');
|
|
66
|
+
});
|
|
67
|
+
(0, vitest_1.it)('escapes multiple characters', () => {
|
|
68
|
+
(0, vitest_1.expect)((0, format_js_1.escapeHtml)('<script>alert("xss")&</script>')).toBe('<script>alert("xss")&</script>');
|
|
69
|
+
});
|
|
70
|
+
(0, vitest_1.it)('leaves safe strings unchanged', () => {
|
|
71
|
+
(0, vitest_1.expect)((0, format_js_1.escapeHtml)('hello world')).toBe('hello world');
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Delta API poller — push confidence changes to Telegram
|
|
4
|
+
*/
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.startPoller = startPoller;
|
|
7
|
+
const format_js_1 = require("./format.js");
|
|
8
|
+
function startPoller(bot, chatId, client, thesisId) {
|
|
9
|
+
let lastCheck = new Date().toISOString();
|
|
10
|
+
return setInterval(async () => {
|
|
11
|
+
try {
|
|
12
|
+
const changes = await client.getChanges(thesisId, lastCheck);
|
|
13
|
+
lastCheck = new Date().toISOString();
|
|
14
|
+
if (!changes || !changes.changed)
|
|
15
|
+
return;
|
|
16
|
+
const delta = changes.confidenceDelta ?? changes.delta ?? 0;
|
|
17
|
+
if (Math.abs(delta) < 0.02)
|
|
18
|
+
return;
|
|
19
|
+
const conf = typeof changes.confidence === 'number' ? Math.round(changes.confidence * 100) : '?';
|
|
20
|
+
const arrow = delta > 0 ? '📈' : '📉';
|
|
21
|
+
const sign = delta > 0 ? '+' : '';
|
|
22
|
+
const summary = changes.summary || changes.latestSummary || '';
|
|
23
|
+
let text = `${arrow} <b>Confidence ${sign}${Math.round(delta * 100)}% → ${conf}%</b>\n`;
|
|
24
|
+
if (summary)
|
|
25
|
+
text += `${(0, format_js_1.escapeHtml)(summary.slice(0, 200))}`;
|
|
26
|
+
await bot.api.sendMessage(chatId, text, { parse_mode: 'HTML' });
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
// Silent — polling errors should not crash the bot
|
|
30
|
+
}
|
|
31
|
+
}, 60_000); // every 60 seconds
|
|
32
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const vitest_1 = require("vitest");
|
|
4
|
+
const topics_js_1 = require("./topics.js");
|
|
5
|
+
(0, vitest_1.describe)('tickerToTopic', () => {
|
|
6
|
+
(0, vitest_1.it)('matches oil tickers (longest prefix first)', () => {
|
|
7
|
+
(0, vitest_1.expect)((0, topics_js_1.tickerToTopic)('KXWTIMAX-26DEC31-T135')).toBe('OIL');
|
|
8
|
+
(0, vitest_1.expect)((0, topics_js_1.tickerToTopic)('KXWTID-something')).toBe('OIL');
|
|
9
|
+
(0, vitest_1.expect)((0, topics_js_1.tickerToTopic)('KXWTIW-2026')).toBe('OIL');
|
|
10
|
+
});
|
|
11
|
+
(0, vitest_1.it)('matches recession', () => {
|
|
12
|
+
(0, vitest_1.expect)((0, topics_js_1.tickerToTopic)('KXRECSSNBER-26')).toBe('RECESSION');
|
|
13
|
+
});
|
|
14
|
+
(0, vitest_1.it)('matches fed', () => {
|
|
15
|
+
(0, vitest_1.expect)((0, topics_js_1.tickerToTopic)('KXFEDDECISION-2026')).toBe('FED');
|
|
16
|
+
});
|
|
17
|
+
(0, vitest_1.it)('matches cpi', () => {
|
|
18
|
+
(0, vitest_1.expect)((0, topics_js_1.tickerToTopic)('KXCPI-26MAY-T0.4')).toBe('CPI');
|
|
19
|
+
});
|
|
20
|
+
(0, vitest_1.it)('matches gas', () => {
|
|
21
|
+
(0, vitest_1.expect)((0, topics_js_1.tickerToTopic)('KXAAAGASM-26MAR31-4.40')).toBe('GAS');
|
|
22
|
+
});
|
|
23
|
+
(0, vitest_1.it)('matches sp500', () => {
|
|
24
|
+
(0, vitest_1.expect)((0, topics_js_1.tickerToTopic)('KXINXY-26DEC31H1600-T4000')).toBe('SP500');
|
|
25
|
+
});
|
|
26
|
+
(0, vitest_1.it)('returns OTHER for unknown tickers', () => {
|
|
27
|
+
(0, vitest_1.expect)((0, topics_js_1.tickerToTopic)('UNKNOWN-TICKER')).toBe('OTHER');
|
|
28
|
+
(0, vitest_1.expect)((0, topics_js_1.tickerToTopic)('RANDOMSERIES-123')).toBe('OTHER');
|
|
29
|
+
});
|
|
30
|
+
(0, vitest_1.it)('is case-insensitive', () => {
|
|
31
|
+
(0, vitest_1.expect)((0, topics_js_1.tickerToTopic)('kxwtimax-lower')).toBe('OIL');
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
(0, vitest_1.describe)('TOPIC_SERIES', () => {
|
|
35
|
+
(0, vitest_1.it)('has expected topics', () => {
|
|
36
|
+
(0, vitest_1.expect)(Object.keys(topics_js_1.TOPIC_SERIES)).toEqual(vitest_1.expect.arrayContaining(['oil', 'recession', 'fed', 'cpi', 'gas', 'sp500']));
|
|
37
|
+
});
|
|
38
|
+
(0, vitest_1.it)('each topic has at least one series', () => {
|
|
39
|
+
for (const [, series] of Object.entries(topics_js_1.TOPIC_SERIES)) {
|
|
40
|
+
(0, vitest_1.expect)(series.length).toBeGreaterThan(0);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
(0, vitest_1.describe)('RISK_CATEGORIES', () => {
|
|
45
|
+
(0, vitest_1.it)('maps KXWTIMAX to Oil', () => {
|
|
46
|
+
(0, vitest_1.expect)(topics_js_1.RISK_CATEGORIES['KXWTIMAX']).toBe('Oil');
|
|
47
|
+
});
|
|
48
|
+
(0, vitest_1.it)('maps KXRECSSNBER to Recession', () => {
|
|
49
|
+
(0, vitest_1.expect)(topics_js_1.RISK_CATEGORIES['KXRECSSNBER']).toBe('Recession');
|
|
50
|
+
});
|
|
51
|
+
(0, vitest_1.it)('maps KXFEDDECISION to Fed Rate', () => {
|
|
52
|
+
(0, vitest_1.expect)(topics_js_1.RISK_CATEGORIES['KXFEDDECISION']).toBe('Fed Rate');
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Border drawing — thin box-drawing characters
|
|
3
|
+
*/
|
|
4
|
+
import type { ScreenBuffer } from './screen.js';
|
|
5
|
+
import type { Region } from './layout.js';
|
|
6
|
+
export declare const rgb: (r: number, g: number, b: number) => string;
|
|
7
|
+
export declare const bgRgb: (r: number, g: number, b: number) => string;
|
|
8
|
+
export declare const CLR: {
|
|
9
|
+
border: string;
|
|
10
|
+
borderDim: string;
|
|
11
|
+
title: string;
|
|
12
|
+
text: string;
|
|
13
|
+
dim: string;
|
|
14
|
+
veryDim: string;
|
|
15
|
+
emerald: string;
|
|
16
|
+
green: string;
|
|
17
|
+
red: string;
|
|
18
|
+
yellow: string;
|
|
19
|
+
white: string;
|
|
20
|
+
bg: string;
|
|
21
|
+
bgPanel: string;
|
|
22
|
+
};
|
|
23
|
+
/** Draw a bordered box with title */
|
|
24
|
+
export declare function drawBorder(screen: ScreenBuffer, region: Region, title?: string): void;
|
|
25
|
+
/** Draw a horizontal divider across a region at a relative row */
|
|
26
|
+
export declare function drawHDivider(screen: ScreenBuffer, region: Region, relRow: number): void;
|
|
27
|
+
/** Pad/truncate string to exact width */
|
|
28
|
+
export declare function fit(s: string, width: number, align?: 'left' | 'right'): string;
|
|
29
|
+
/** Build sparkline from values */
|
|
30
|
+
export declare function sparkline(values: number[], colorFn?: (v: number) => string): Array<{
|
|
31
|
+
text: string;
|
|
32
|
+
fg?: string;
|
|
33
|
+
}>;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Border drawing — thin box-drawing characters
|
|
4
|
+
*/
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.CLR = exports.bgRgb = exports.rgb = void 0;
|
|
7
|
+
exports.drawBorder = drawBorder;
|
|
8
|
+
exports.drawHDivider = drawHDivider;
|
|
9
|
+
exports.fit = fit;
|
|
10
|
+
exports.sparkline = sparkline;
|
|
11
|
+
// 24-bit RGB ANSI helpers
|
|
12
|
+
const rgb = (r, g, b) => `\x1b[38;2;${r};${g};${b}m`;
|
|
13
|
+
exports.rgb = rgb;
|
|
14
|
+
const bgRgb = (r, g, b) => `\x1b[48;2;${r};${g};${b}m`;
|
|
15
|
+
exports.bgRgb = bgRgb;
|
|
16
|
+
// Color palette (matching landing page: emerald on near-black)
|
|
17
|
+
exports.CLR = {
|
|
18
|
+
border: (0, exports.rgb)(63, 63, 70), // zinc-700
|
|
19
|
+
borderDim: (0, exports.rgb)(39, 39, 42), // zinc-800
|
|
20
|
+
title: (0, exports.rgb)(161, 161, 170), // zinc-400
|
|
21
|
+
text: (0, exports.rgb)(212, 212, 216), // zinc-300
|
|
22
|
+
dim: (0, exports.rgb)(113, 113, 122), // zinc-500
|
|
23
|
+
veryDim: (0, exports.rgb)(63, 63, 70), // zinc-700
|
|
24
|
+
emerald: (0, exports.rgb)(16, 185, 129), // emerald-500
|
|
25
|
+
green: (0, exports.rgb)(52, 211, 153), // emerald-400
|
|
26
|
+
red: (0, exports.rgb)(239, 68, 68), // red-500
|
|
27
|
+
yellow: (0, exports.rgb)(250, 204, 21), // yellow-400
|
|
28
|
+
white: (0, exports.rgb)(244, 244, 245), // zinc-100
|
|
29
|
+
bg: (0, exports.bgRgb)(10, 10, 10), // #0a0a0a
|
|
30
|
+
bgPanel: (0, exports.bgRgb)(13, 13, 13), // #0d0d0d
|
|
31
|
+
};
|
|
32
|
+
/** Draw a bordered box with title */
|
|
33
|
+
function drawBorder(screen, region, title) {
|
|
34
|
+
const { row, col, width, height } = region;
|
|
35
|
+
if (width < 3 || height < 3)
|
|
36
|
+
return;
|
|
37
|
+
const fg = exports.CLR.borderDim;
|
|
38
|
+
// Top
|
|
39
|
+
screen.write(row, col, '┌', fg);
|
|
40
|
+
for (let c = col + 1; c < col + width - 1; c++)
|
|
41
|
+
screen.write(row, c, '─', fg);
|
|
42
|
+
screen.write(row, col + width - 1, '┐', fg);
|
|
43
|
+
// Title
|
|
44
|
+
if (title) {
|
|
45
|
+
screen.write(row, col + 2, ` ${title} `, exports.CLR.title);
|
|
46
|
+
}
|
|
47
|
+
// Sides
|
|
48
|
+
for (let r = row + 1; r < row + height - 1; r++) {
|
|
49
|
+
screen.write(r, col, '│', fg);
|
|
50
|
+
screen.write(r, col + width - 1, '│', fg);
|
|
51
|
+
}
|
|
52
|
+
// Bottom
|
|
53
|
+
screen.write(row + height - 1, col, '└', fg);
|
|
54
|
+
for (let c = col + 1; c < col + width - 1; c++)
|
|
55
|
+
screen.write(row + height - 1, c, '─', fg);
|
|
56
|
+
screen.write(row + height - 1, col + width - 1, '┘', fg);
|
|
57
|
+
}
|
|
58
|
+
/** Draw a horizontal divider across a region at a relative row */
|
|
59
|
+
function drawHDivider(screen, region, relRow) {
|
|
60
|
+
const r = region.row + relRow;
|
|
61
|
+
const fg = exports.CLR.borderDim;
|
|
62
|
+
screen.write(r, region.col, '├', fg);
|
|
63
|
+
for (let c = region.col + 1; c < region.col + region.width - 1; c++)
|
|
64
|
+
screen.write(r, c, '─', fg);
|
|
65
|
+
screen.write(r, region.col + region.width - 1, '┤', fg);
|
|
66
|
+
}
|
|
67
|
+
/** Pad/truncate string to exact width */
|
|
68
|
+
function fit(s, width, align = 'left') {
|
|
69
|
+
if (s.length > width)
|
|
70
|
+
return s.slice(0, width - 1) + '…';
|
|
71
|
+
if (align === 'right')
|
|
72
|
+
return s.padStart(width);
|
|
73
|
+
return s.padEnd(width);
|
|
74
|
+
}
|
|
75
|
+
/** Build sparkline from values */
|
|
76
|
+
function sparkline(values, colorFn) {
|
|
77
|
+
if (values.length === 0)
|
|
78
|
+
return [];
|
|
79
|
+
const min = Math.min(...values);
|
|
80
|
+
const max = Math.max(...values);
|
|
81
|
+
const range = max - min || 1;
|
|
82
|
+
const blocks = '▁▂▃▄▅▆▇█';
|
|
83
|
+
return values.map(v => {
|
|
84
|
+
const idx = Math.round(((v - min) / range) * 7);
|
|
85
|
+
return { text: blocks[idx], fg: colorFn ? colorFn(v) : exports.CLR.dim };
|
|
86
|
+
});
|
|
87
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ASCII area chart using block characters
|
|
3
|
+
*
|
|
4
|
+
* Uses ▁▂▃▄▅▆▇█ to draw a filled area chart that fills the width.
|
|
5
|
+
* Much cleaner than box-drawing line charts for small datasets.
|
|
6
|
+
*/
|
|
7
|
+
import type { ScreenBuffer } from './screen.js';
|
|
8
|
+
/**
|
|
9
|
+
* Draw an area chart at the given position.
|
|
10
|
+
*
|
|
11
|
+
* @param screen - ScreenBuffer to write to
|
|
12
|
+
* @param row - Top-left row of chart area
|
|
13
|
+
* @param col - Left column (after Y-axis labels)
|
|
14
|
+
* @param width - Total width including Y-axis labels
|
|
15
|
+
* @param height - Total height including X-axis row
|
|
16
|
+
* @param values - Data points (raw numbers)
|
|
17
|
+
* @param labels - Optional X-axis labels
|
|
18
|
+
*/
|
|
19
|
+
export declare function drawChart(screen: ScreenBuffer, row: number, col: number, width: number, height: number, values: number[], labels?: string[]): void;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* ASCII area chart using block characters
|
|
4
|
+
*
|
|
5
|
+
* Uses ▁▂▃▄▅▆▇█ to draw a filled area chart that fills the width.
|
|
6
|
+
* Much cleaner than box-drawing line charts for small datasets.
|
|
7
|
+
*/
|
|
8
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
+
exports.drawChart = drawChart;
|
|
10
|
+
const border_js_1 = require("./border.js");
|
|
11
|
+
/**
|
|
12
|
+
* Draw an area chart at the given position.
|
|
13
|
+
*
|
|
14
|
+
* @param screen - ScreenBuffer to write to
|
|
15
|
+
* @param row - Top-left row of chart area
|
|
16
|
+
* @param col - Left column (after Y-axis labels)
|
|
17
|
+
* @param width - Total width including Y-axis labels
|
|
18
|
+
* @param height - Total height including X-axis row
|
|
19
|
+
* @param values - Data points (raw numbers)
|
|
20
|
+
* @param labels - Optional X-axis labels
|
|
21
|
+
*/
|
|
22
|
+
function drawChart(screen, row, col, width, height, values, labels) {
|
|
23
|
+
if (values.length === 0 || width < 10 || height < 4)
|
|
24
|
+
return;
|
|
25
|
+
const yLabelWidth = 8;
|
|
26
|
+
const chartWidth = width - yLabelWidth - 1;
|
|
27
|
+
const chartHeight = height - 2; // reserve bottom for x-axis + labels
|
|
28
|
+
if (chartWidth < 4 || chartHeight < 2)
|
|
29
|
+
return;
|
|
30
|
+
const min = Math.min(...values);
|
|
31
|
+
const max = Math.max(...values);
|
|
32
|
+
const range = max - min || 1;
|
|
33
|
+
// Interpolate values to fill chart width
|
|
34
|
+
const interpolated = [];
|
|
35
|
+
for (let c = 0; c < chartWidth; c++) {
|
|
36
|
+
const t = (c / (chartWidth - 1)) * (values.length - 1);
|
|
37
|
+
const lo = Math.floor(t);
|
|
38
|
+
const hi = Math.min(lo + 1, values.length - 1);
|
|
39
|
+
const frac = t - lo;
|
|
40
|
+
interpolated.push(values[lo] * (1 - frac) + values[hi] * frac);
|
|
41
|
+
}
|
|
42
|
+
// Y-axis: show 3 labels (top, zero if in range, bottom)
|
|
43
|
+
const chartCol = col + yLabelWidth + 1;
|
|
44
|
+
const yLabels = [
|
|
45
|
+
{ row: 0, value: max },
|
|
46
|
+
{ row: chartHeight - 1, value: min },
|
|
47
|
+
];
|
|
48
|
+
// Add zero line if range crosses zero
|
|
49
|
+
let zeroChartRow = -1;
|
|
50
|
+
if (min < 0 && max > 0) {
|
|
51
|
+
zeroChartRow = Math.round((max / range) * (chartHeight - 1));
|
|
52
|
+
if (zeroChartRow > 0 && zeroChartRow < chartHeight - 1) {
|
|
53
|
+
yLabels.push({ row: zeroChartRow, value: 0 });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// Draw Y-axis
|
|
57
|
+
for (let r = 0; r < chartHeight; r++) {
|
|
58
|
+
screen.write(row + r, col + yLabelWidth, '│', border_js_1.CLR.borderDim);
|
|
59
|
+
}
|
|
60
|
+
for (const yl of yLabels) {
|
|
61
|
+
const fmt = Math.abs(yl.value) >= 100
|
|
62
|
+
? `$${(yl.value >= 0 ? '+' : '') + yl.value.toFixed(0)}`
|
|
63
|
+
: `$${(yl.value >= 0 ? '+' : '') + yl.value.toFixed(1)}`;
|
|
64
|
+
screen.write(row + yl.row, col, fmt.padStart(yLabelWidth), border_js_1.CLR.dim);
|
|
65
|
+
}
|
|
66
|
+
// Zero line (dashed)
|
|
67
|
+
if (zeroChartRow >= 0 && zeroChartRow < chartHeight) {
|
|
68
|
+
for (let c = 0; c < chartWidth; c++) {
|
|
69
|
+
screen.write(row + zeroChartRow, chartCol + c, '╌', border_js_1.CLR.veryDim);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// Draw area chart using block characters
|
|
73
|
+
// Each column: compute how high the bar should go
|
|
74
|
+
const blocks = ' ▁▂▃▄▅▆▇█';
|
|
75
|
+
for (let c = 0; c < chartWidth; c++) {
|
|
76
|
+
const val = interpolated[c];
|
|
77
|
+
// Normalize to 0..1 (0=min, 1=max)
|
|
78
|
+
const norm = (val - min) / range;
|
|
79
|
+
// Height in sub-rows (chartHeight * 8 granularity levels)
|
|
80
|
+
const barHeight = norm * chartHeight * 8;
|
|
81
|
+
const color = val >= 0 ? border_js_1.CLR.green : border_js_1.CLR.red;
|
|
82
|
+
// Fill from bottom up
|
|
83
|
+
for (let r = chartHeight - 1; r >= 0; r--) {
|
|
84
|
+
const rowFromBottom = chartHeight - 1 - r;
|
|
85
|
+
const fillLevel = barHeight - rowFromBottom * 8;
|
|
86
|
+
if (fillLevel >= 8) {
|
|
87
|
+
// Full block
|
|
88
|
+
screen.write(row + r, chartCol + c, '█', color);
|
|
89
|
+
}
|
|
90
|
+
else if (fillLevel > 0) {
|
|
91
|
+
// Partial block
|
|
92
|
+
const idx = Math.round(Math.max(1, Math.min(fillLevel, 8)));
|
|
93
|
+
screen.write(row + r, chartCol + c, blocks[idx], color);
|
|
94
|
+
}
|
|
95
|
+
// else: empty (space), already default
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// X-axis
|
|
99
|
+
const xRow = row + chartHeight;
|
|
100
|
+
screen.write(xRow, col + yLabelWidth, '└', border_js_1.CLR.borderDim);
|
|
101
|
+
for (let c = 0; c < chartWidth; c++) {
|
|
102
|
+
screen.write(xRow, chartCol + c, '─', border_js_1.CLR.borderDim);
|
|
103
|
+
}
|
|
104
|
+
// X-axis labels
|
|
105
|
+
if (labels && labels.length > 0) {
|
|
106
|
+
const labelRow = row + chartHeight + 1;
|
|
107
|
+
const numLabels = Math.min(labels.length, 6);
|
|
108
|
+
for (let i = 0; i < numLabels; i++) {
|
|
109
|
+
const srcIdx = Math.round(i * (labels.length - 1) / Math.max(numLabels - 1, 1));
|
|
110
|
+
const xPos = chartCol + Math.round(srcIdx * (chartWidth - 1) / Math.max(labels.length - 1, 1));
|
|
111
|
+
const label = labels[srcIdx] || '';
|
|
112
|
+
if (xPos >= chartCol && xPos + label.length < col + width) {
|
|
113
|
+
screen.write(labelRow, xPos, label.slice(0, 5), border_js_1.CLR.dim);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI Dashboard Engine — main loop
|
|
3
|
+
*
|
|
4
|
+
* Manages the render loop, data loading, keypress handling, and mode transitions.
|
|
5
|
+
* This is NOT the commander entry point — see commands/dashboard.ts for that.
|
|
6
|
+
*/
|
|
7
|
+
import { type DashboardState } from './state.js';
|
|
8
|
+
export declare function loadAllData(state: DashboardState): Promise<void>;
|
|
9
|
+
export declare function startDashboard(): Promise<void>;
|