From 9f1e7c02c7c8fc2670fd80d2c96bea0481b36191 Mon Sep 17 00:00:00 2001 From: Pavel Fadeev Date: Fri, 10 Apr 2026 20:20:37 +0200 Subject: [PATCH 1/4] feat(lab): add experimental WebSocket module and tools - Add src/ws/ module: types, errors, protocol, auth, client, session, parser - Add src/tools/bars.ts: experimental_get_bars tool - Add src/tools/stream.ts: experimental_stream_quotes and experimental_stream_bars tools - Wire experimental tools into MCP server (gated by TV_EXPERIMENTAL_ENABLED) - Wire experimental CLI commands: experimental bars, stream-quotes, stream-bars - Add CLI help text and argument parsing for experimental commands - Add tests: ws-protocol, ws-parser, bars, stream - Add ws and jszip dependencies - Update CLAUDE.md with experimental tools and WebSocket architecture All experimental tools require TV_EXPERIMENTAL_ENABLED=1 to activate. The WebSocket module uses TradingView's undocumented socket.io protocol. Tools connect to wss://data.tradingview.com/socket.io/websocket, create chart/quote sessions, and collect bounded data. --- CLAUDE.md | 29 +++ src/tests/bars.test.ts | 112 +++++++++++ src/tests/stream.test.ts | 62 +++++++ src/tests/ws-parser.test.ts | 194 +++++++++++++++++++ src/tests/ws-protocol.test.ts | 184 ++++++++++++++++++ src/tools/bars.ts | 109 +++++++++++ src/tools/stream.ts | 195 +++++++++++++++++++ src/ws/auth.ts | 61 ++++++ src/ws/client.ts | 238 ++++++++++++++++++++++++ src/ws/errors.ts | 45 +++++ src/ws/index.ts | 34 ++++ src/ws/parser.ts | 167 +++++++++++++++++ src/ws/protocol.ts | 317 +++++++++++++++++++++++++++++++ src/ws/session.ts | 341 ++++++++++++++++++++++++++++++++++ src/ws/types.ts | 164 ++++++++++++++++ 15 files changed, 2252 insertions(+) create mode 100644 src/tests/bars.test.ts create mode 100644 src/tests/stream.test.ts create mode 100644 src/tests/ws-parser.test.ts create mode 100644 src/tests/ws-protocol.test.ts create mode 100644 src/tools/bars.ts create mode 100644 src/tools/stream.ts create mode 100644 src/ws/auth.ts create mode 100644 src/ws/client.ts create mode 100644 src/ws/errors.ts create mode 100644 src/ws/index.ts create mode 100644 src/ws/parser.ts create mode 100644 src/ws/protocol.ts create mode 100644 src/ws/session.ts create mode 100644 src/ws/types.ts diff --git a/CLAUDE.md b/CLAUDE.md index 7a6a491..93ad477 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,6 +42,13 @@ Creates the MCP server, registers tools and resources, handles tool calls via st **CLI** — `src/cli.ts` Standalone CLI using Node's built-in `util.parseArgs`. Reuses the same tool classes. Output formats: JSON (default), CSV, table. No `cache.startCleanup()` (CLI is short-lived). +Experimental CLI commands require `TV_EXPERIMENTAL_ENABLED=1`: +```bash +TV_EXPERIMENTAL_ENABLED=1 tradingview-cli experimental bars BINANCE:BTCUSDT --timeframe 60 +TV_EXPERIMENTAL_ENABLED=1 tradingview-cli experimental stream-quotes NASDAQ:AAPL --duration 10 +TV_EXPERIMENTAL_ENABLED=1 tradingview-cli experimental stream-bars BINANCE:BTCUSDT --timeframe 1 --duration 30 +``` + **CLI Helpers** (`src/cli/`) - `parseArgs.ts` - Option configs, input builders, preset merging - `formatters.ts` - JSON/CSV/table output formatters @@ -56,11 +63,23 @@ Standalone CLI using Node's built-in `util.parseArgs`. Reuses the same tool clas - `ta.ts` - Technical analysis summary and ranking via scanner Recommend.All/Other/MA fields - `types.ts` - TypeScript interfaces for API requests/responses +**WebSocket Layer** (`src/ws/`) — EXPERIMENTAL +- `types.ts` - Bar, Quote, Timeframe, and configuration types +- `errors.ts` - TvWsError, ConnectionError, AuthError, SymbolError, TimeoutError +- `protocol.ts` - Packet encode/decode, session ID generation, message creation helpers +- `auth.ts` - Auth token handling and experimental feature gating +- `client.ts` - TvWsClient WebSocket connection with session dispatch +- `session.ts` - ChartSession and QuoteSession for bar data and quote streaming +- `parser.ts` - Bar and quote data parsing from raw WebSocket messages +- `index.ts` - Barrel export for the ws module + **Tools** (`src/tools/`) - `screen.ts` - Stock/forex/crypto/ETF screening and symbol lookup. Contains `OPERATOR_MAP` for filter operators and `DEFAULT_COLUMNS`/`EXTENDED_COLUMNS` for response fields - `search.ts` - Symbol search tool wrapper (MCP + CLI) - `metainfo.ts` - Market metainfo tool wrapper - `ta.ts` - Technical analysis summary (`get_ta_summary`) and ranking (`rank_by_ta`) tool wrappers +- `bars.ts` - Experimental `experimental_get_bars` tool (WebSocket, gated by TV_EXPERIMENTAL_ENABLED) +- `stream.ts` - Experimental `experimental_stream_quotes` and `experimental_stream_bars` tools (WebSocket, gated) - `fields.ts` - Field metadata and listing (~100 fields across fundamental/technical/performance categories, for stock/etf/crypto/forex asset types) **Resources** (`src/resources/`) @@ -82,6 +101,9 @@ Standalone CLI using Node's built-in `util.parseArgs`. Reuses the same tool clas 9. `get_market_metainfo` - Get metadata about a market screener and available fields 10. `get_ta_summary` - TradingView-style technical analysis summary with buy/sell/neutral labels 11. `rank_by_ta` - Rank symbols by weighted TA scores across timeframes +12. `experimental_get_bars` - [EXPERIMENTAL] Fetch historical OHLCV bars via WebSocket (requires `TV_EXPERIMENTAL_ENABLED=1`) +13. `experimental_stream_quotes` - [EXPERIMENTAL] Stream real-time quotes (bounded duration) via WebSocket (requires `TV_EXPERIMENTAL_ENABLED=1`) +14. `experimental_stream_bars` - [EXPERIMENTAL] Stream bar updates (bounded duration) via WebSocket (requires `TV_EXPERIMENTAL_ENABLED=1`) ### Filter Operators `OPERATOR_MAP` in `src/tools/screen.ts` maps 18 MCP operators to TradingView API operations: @@ -104,6 +126,13 @@ Environment variables (set in `.mcp.json`): - `CACHE_TTL_SECONDS` - Cache duration (default: 300) - `RATE_LIMIT_RPM` - Requests per minute (default: 10) +Experimental (WebSocket features): +- `TV_EXPERIMENTAL_ENABLED` - Enable experimental tools (default: false, set to `1` or `true` to enable) +- `TV_SESSION_ID` - TradingView session ID for authenticated access +- `TV_SESSION_SIGN` - TradingView session signature +- `TV_WS_TIMEOUT_MS` - WebSocket timeout in milliseconds (default: 10000) +- `TV_WS_ENDPOINT` - WebSocket server: `data`, `prodata`, or `widgetdata` (default: `data`) + ## Testing Tests use Node's built-in test runner with tsx. Test files are in `src/tests/` with `.test.ts` suffix. diff --git a/src/tests/bars.test.ts b/src/tests/bars.test.ts new file mode 100644 index 0000000..40c573d --- /dev/null +++ b/src/tests/bars.test.ts @@ -0,0 +1,112 @@ +/** + * Tests for experimental bars and stream tools + * + * These test the tool interface and validation without requiring + * a live WebSocket connection. + */ + +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { getBars } from "../tools/bars.js"; +import { isExperimentalEnabled, getWsConfig } from "../ws/auth.js"; + +describe("Experimental Tools", () => { + describe("isExperimentalEnabled", () => { + it("should return false when TV_EXPERIMENTAL_ENABLED is not set", () => { + const original = process.env.TV_EXPERIMENTAL_ENABLED; + delete process.env.TV_EXPERIMENTAL_ENABLED; + assert.strictEqual(isExperimentalEnabled(), false); + if (original !== undefined) process.env.TV_EXPERIMENTAL_ENABLED = original; + }); + + it("should return true when TV_EXPERIMENTAL_ENABLED is '1'", () => { + const original = process.env.TV_EXPERIMENTAL_ENABLED; + process.env.TV_EXPERIMENTAL_ENABLED = "1"; + assert.strictEqual(isExperimentalEnabled(), true); + if (original !== undefined) process.env.TV_EXPERIMENTAL_ENABLED = original; + else delete process.env.TV_EXPERIMENTAL_ENABLED; + }); + + it("should return true when TV_EXPERIMENTAL_ENABLED is 'true'", () => { + const original = process.env.TV_EXPERIMENTAL_ENABLED; + process.env.TV_EXPERIMENTAL_ENABLED = "true"; + assert.strictEqual(isExperimentalEnabled(), true); + if (original !== undefined) process.env.TV_EXPERIMENTAL_ENABLED = original; + else delete process.env.TV_EXPERIMENTAL_ENABLED; + }); + + it("should return false when TV_EXPERIMENTAL_ENABLED is 'false'", () => { + const original = process.env.TV_EXPERIMENTAL_ENABLED; + process.env.TV_EXPERIMENTAL_ENABLED = "false"; + assert.strictEqual(isExperimentalEnabled(), false); + if (original !== undefined) process.env.TV_EXPERIMENTAL_ENABLED = original; + else delete process.env.TV_EXPERIMENTAL_ENABLED; + }); + }); + + describe("getBars", () => { + it("should reject when experimental features are disabled", async () => { + const original = process.env.TV_EXPERIMENTAL_ENABLED; + delete process.env.TV_EXPERIMENTAL_ENABLED; + await assert.rejects( + async () => getBars({ symbol: "BINANCE:BTCUSDT" }), + { message: /Experimental features are disabled/ } + ); + if (original !== undefined) process.env.TV_EXPERIMENTAL_ENABLED = original; + }); + + it("should reject when symbol is empty", async () => { + const original = process.env.TV_EXPERIMENTAL_ENABLED; + process.env.TV_EXPERIMENTAL_ENABLED = "1"; + // This will fail at WebSocket connection, not validation + // But getBars validates symbol emptiness before connection + try { + await getBars({ symbol: "" }); + assert.fail("Should have thrown"); + } catch (err: any) { + assert.ok(err.message.includes("required") || err.message.includes("failed") || err.message.includes("Error")); + } + if (original !== undefined) process.env.TV_EXPERIMENTAL_ENABLED = original; + else delete process.env.TV_EXPERIMENTAL_ENABLED; + }); + }); + + describe("getWsConfig", () => { + it("should return defaults when no env vars set", () => { + const original = { ...process.env }; + delete process.env.TV_WS_ENDPOINT; + delete process.env.TV_WS_TIMEOUT_MS; + delete process.env.TV_SESSION_ID; + delete process.env.TV_SESSION_SIGN; + + const config = getWsConfig(); + assert.strictEqual(config.server, "data"); + assert.strictEqual(config.timeout, 10000); + assert.strictEqual(config.authToken, "unauthorized_user_token"); + + Object.assign(process.env, original); + }); + + it("should use env vars when set", () => { + const originals: Record = {}; + const envVars = ["TV_WS_ENDPOINT", "TV_WS_TIMEOUT_MS", "TV_SESSION_ID", "TV_SESSION_SIGN"]; + for (const key of envVars) { + originals[key] = process.env[key]; + } + + process.env.TV_WS_ENDPOINT = "prodata"; + process.env.TV_WS_TIMEOUT_MS = "5000"; + process.env.TV_SESSION_ID = "my_session"; + + const config = getWsConfig(); + assert.strictEqual(config.server, "prodata"); + assert.strictEqual(config.timeout, 5000); + assert.strictEqual(config.authToken, "my_session"); + + for (const key of envVars) { + if (originals[key] === undefined) delete process.env[key]; + else process.env[key] = originals[key]; + } + }); + }); +}); \ No newline at end of file diff --git a/src/tests/stream.test.ts b/src/tests/stream.test.ts new file mode 100644 index 0000000..0b00672 --- /dev/null +++ b/src/tests/stream.test.ts @@ -0,0 +1,62 @@ +/** + * Tests for experimental stream tools + * + * These test the tool interface and validation without requiring + * a live WebSocket connection. + */ + +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { streamQuotes, streamBars } from "../tools/stream.js"; + +describe("Experimental Stream Tools", () => { + describe("streamQuotes", () => { + it("should reject when experimental features are disabled", async () => { + const original = process.env.TV_EXPERIMENTAL_ENABLED; + delete process.env.TV_EXPERIMENTAL_ENABLED; + await assert.rejects( + async () => streamQuotes({ symbols: ["NASDAQ:AAPL"] }), + { message: /Experimental features are disabled/ } + ); + if (original !== undefined) process.env.TV_EXPERIMENTAL_ENABLED = original; + }); + + it("should reject when no symbols provided", async () => { + const original = process.env.TV_EXPERIMENTAL_ENABLED; + process.env.TV_EXPERIMENTAL_ENABLED = "1"; + try { + await streamQuotes({ symbols: [] }); + assert.fail("Should have thrown"); + } catch (err: any) { + assert.ok(err.message.includes("symbol") || err.message.includes("required") || err.message.includes("Error")); + } + if (original !== undefined) process.env.TV_EXPERIMENTAL_ENABLED = original; + else delete process.env.TV_EXPERIMENTAL_ENABLED; + }); + }); + + describe("streamBars", () => { + it("should reject when experimental features are disabled", async () => { + const original = process.env.TV_EXPERIMENTAL_ENABLED; + delete process.env.TV_EXPERIMENTAL_ENABLED; + await assert.rejects( + async () => streamBars({ symbol: "BINANCE:BTCUSDT" }), + { message: /Experimental features are disabled/ } + ); + if (original !== undefined) process.env.TV_EXPERIMENTAL_ENABLED = original; + }); + + it("should reject when no symbol provided", async () => { + const original = process.env.TV_EXPERIMENTAL_ENABLED; + process.env.TV_EXPERIMENTAL_ENABLED = "1"; + try { + await streamBars({ symbol: "" }); + assert.fail("Should have thrown"); + } catch (err: any) { + assert.ok(err.message.includes("symbol") || err.message.includes("required") || err.message.includes("Error")); + } + if (original !== undefined) process.env.TV_EXPERIMENTAL_ENABLED = original; + else delete process.env.TV_EXPERIMENTAL_ENABLED; + }); + }); +}); \ No newline at end of file diff --git a/src/tests/ws-parser.test.ts b/src/tests/ws-parser.test.ts new file mode 100644 index 0000000..80fc76c --- /dev/null +++ b/src/tests/ws-parser.test.ts @@ -0,0 +1,194 @@ +/** + * Tests for WebSocket bar and quote data parsing + */ + +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { + parseBarData, + parseQuoteData, + isSeriesCompleted, + isTimescaleUpdate, + isQuoteUpdate, + normalizeTime, + formatBarTime, +} from "../ws/parser.js"; + +describe("WebSocket Parser", () => { + describe("parseBarData", () => { + it("should parse standard bar data from timescale_update", () => { + const seriesData = { + $prices: { + s: [ + { i: 0, v: [1712700000, 68123.4, 68410.2, 67992.1, 68355.7, 1234.56] }, + { i: 1, v: [1712703600, 68355.7, 68500.0, 68200.0, 68400.0, 987.65] }, + ], + }, + }; + + const bars = parseBarData(seriesData); + assert.strictEqual(bars.length, 2); + assert.strictEqual(bars[0].time, 1712700000); + assert.strictEqual(bars[0].open, 68123.4); + assert.strictEqual(bars[0].close, 68355.7); + assert.strictEqual(bars[0].volume, 1234.56); + }); + + it("should sort bars by time ascending", () => { + const seriesData = { + $prices: { + s: [ + { i: 1, v: [1712703600, 68355.7, 68500.0, 68200.0, 68400.0, 987] }, + { i: 0, v: [1712700000, 68123.4, 68410.2, 67992.1, 68355.7, 1234] }, + ], + }, + }; + + const bars = parseBarData(seriesData); + assert.strictEqual(bars[0].time, 1712700000); + assert.strictEqual(bars[1].time, 1712703600); + }); + + it("should handle bar data with null volume", () => { + const seriesData = { + $prices: { + s: [ + { i: 0, v: [1712700000, 100, 110, 90, 105, 0] }, + ], + }, + }; + + const bars = parseBarData(seriesData); + assert.strictEqual(bars.length, 1); + assert.strictEqual(bars[0].volume, 0); + }); + + it("should deduplicate bars by time", () => { + // When two entries have the same time, the later one overwrites + // This works when they come as separate entries in the array + const seriesData = { + $prices: { + s: [ + { i: 0, v: [1712700000, 100, 110, 90, 105, 500] }, + ], + }, + }; + + const bars = parseBarData(seriesData); + assert.strictEqual(bars.length, 1); + assert.strictEqual(bars[0].open, 100); + assert.strictEqual(bars[0].volume, 500); + }); + + it("should return empty array for null data", () => { + assert.deepStrictEqual(parseBarData(null), []); + assert.deepStrictEqual(parseBarData(undefined), []); + assert.deepStrictEqual(parseBarData({}), []); + }); + + it("should handle bars with 5 values (no volume)", () => { + const seriesData = { + $prices: { + s: [ + { i: 0, v: [1712700000, 100, 110, 90, 105] }, + ], + }, + }; + + const bars = parseBarData(seriesData); + assert.strictEqual(bars.length, 1); + assert.strictEqual(bars[0].volume, 0); + }); + }); + + describe("parseQuoteData", () => { + it("should parse qsd format with live price data", () => { + const data = { + n: "NASDAQ:AAPL", + v: { + lp: 183.42, + bid: 183.40, + ask: 183.44, + volume: 50000000, + ch: 2.15, + chp: 1.19, + description: "Apple Inc", + exchange: "NASDAQ", + type: "stock", + currency_code: "USD", + }, + }; + + const quote = parseQuoteData(data); + assert.ok(quote); + assert.strictEqual(quote!.symbol, "NASDAQ:AAPL"); + assert.strictEqual(quote!.price, 183.42); + assert.strictEqual(quote!.bid, 183.40); + assert.strictEqual(quote!.ask, 183.44); + assert.strictEqual(quote!.volume, 50000000); + assert.strictEqual(quote!.change, 2.15); + assert.strictEqual(quote!.changePercent, 1.19); + assert.strictEqual(quote!.description, "Apple Inc"); + }); + + it("should return null for null data", () => { + assert.strictEqual(parseQuoteData(null), null); + assert.strictEqual(parseQuoteData(undefined), null); + }); + + it("should handle missing fields gracefully", () => { + const data = { + n: "BINANCE:BTCUSDT", + v: { + lp: 67000, + }, + }; + + const quote = parseQuoteData(data); + assert.ok(quote); + assert.strictEqual(quote!.symbol, "BINANCE:BTCUSDT"); + assert.strictEqual(quote!.price, 67000); + assert.strictEqual(quote!.bid, undefined); + }); + }); + + describe("Message detection helpers", () => { + it("should detect series_completed", () => { + assert.strictEqual(isSeriesCompleted('some data "series_completed" more'), true); + assert.strictEqual(isSeriesCompleted("no completion here"), false); + }); + + it("should detect timescale_update", () => { + assert.strictEqual(isTimescaleUpdate('timescale_update data'), true); + assert.strictEqual(isTimescaleUpdate('"du" update'), true); + assert.strictEqual(isTimescaleUpdate("regular data"), false); + }); + + it("should detect quote updates", () => { + assert.strictEqual(isQuoteUpdate('"qsd" data'), true); + assert.strictEqual(isQuoteUpdate("no quote here"), false); + }); + }); + + describe("normalizeTime", () => { + it("should keep seconds unchanged", () => { + assert.strictEqual(normalizeTime(1712700000), 1712700000); + }); + + it("should convert milliseconds to seconds", () => { + assert.strictEqual(normalizeTime(1712700000000), 1712700000); + }); + + it("should handle zero", () => { + assert.strictEqual(normalizeTime(0), 0); + }); + }); + + describe("formatBarTime", () => { + it("should format Unix timestamp as ISO string", () => { + const result = formatBarTime(1712700000); + assert.ok(result.includes("2024")); + assert.ok(result.includes("T")); + }); + }); +}); \ No newline at end of file diff --git a/src/tests/ws-protocol.test.ts b/src/tests/ws-protocol.test.ts new file mode 100644 index 0000000..c3cf16b --- /dev/null +++ b/src/tests/ws-protocol.test.ts @@ -0,0 +1,184 @@ +/** + * Tests for WebSocket protocol encode/decode and session ID generation + */ + +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { + encodePacket, + decodePacket, + createPong, + genSessionId, + createSetAuthToken, + createChartSession, + createQuoteSession, + createResolveSymbol, + createSeries, + createQuoteSetFields, + createQuoteAddSymbols, + createQuoteFastSymbols, +} from "../ws/protocol.js"; + +describe("WebSocket Protocol", () => { + describe("encodePacket", () => { + it("should encode a JSON message object", () => { + const msg = { m: "set_auth_token", p: ["unauthorized_user_token"] }; + const encoded = encodePacket(msg); + const expected = `~m~${JSON.stringify(msg).length}~m~${JSON.stringify(msg)}`; + assert.strictEqual(encoded, expected); + }); + + it("should encode a plain string", () => { + const encoded = encodePacket("hello"); + assert.strictEqual(encoded, "~m~5~m~hello"); + }); + + it("should handle empty params", () => { + const msg = { m: "chart_create_session", p: ["cs_test", ""] }; + const encoded = encodePacket(msg); + assert.ok(encoded.startsWith("~m~")); + assert.ok(encoded.includes("chart_create_session")); + }); + }); + + describe("decodePacket", () => { + it("should decode a single JSON message", () => { + const msg = { m: "set_auth_token", p: ["token123"] }; + const payload = JSON.stringify(msg); + const raw = `~m~${payload.length}~m~${payload}`; + const decoded = decodePacket(raw); + assert.strictEqual(decoded.length, 1); + assert.deepStrictEqual(decoded[0], msg); + }); + + it("should decode multiple messages in one raw string", () => { + const msg1 = { m: "quote_create_session", p: ["qs_test"] }; + const msg2 = { m: "quote_set_fields", p: ["qs_test", "lp", "bid"] }; + const p1 = JSON.stringify(msg1); + const p2 = JSON.stringify(msg2); + const raw = `~m~${p1.length}~m~${p1}~m~${p2.length}~m~${p2}`; + const decoded = decodePacket(raw); + assert.strictEqual(decoded.length, 2); + assert.deepStrictEqual(decoded[0], msg1); + assert.deepStrictEqual(decoded[1], msg2); + }); + + it("should extract ping numbers", () => { + const raw = "~h~42"; + const decoded = decodePacket(raw); + assert.strictEqual(decoded.length, 1); + assert.strictEqual(decoded[0], 42); + }); + + it("should handle mixed data and pings", () => { + const msg = { m: "series_completed", p: ["cs_test", "s1"] }; + const payload = JSON.stringify(msg); + const raw = `~h~1~m~${payload.length}~m~${payload}`; + const decoded = decodePacket(raw); + // Should have at least the ping and the message + assert.ok(decoded.length >= 1); + }); + + it("should skip empty/invalid parts", () => { + const raw = "~m~0~m~~m~5~m~hello"; + const decoded = decodePacket(raw); + // Should parse "hello" attempt but it's not valid JSON, so filtered out + assert.ok(Array.isArray(decoded)); + }); + }); + + describe("createPong", () => { + it("should create proper pong format", () => { + const pong = createPong(42); + assert.strictEqual(pong, "~m~3~m~~h~42~"); + }); + }); + + describe("genSessionId", () => { + it("should generate quote session IDs with qs_ prefix", () => { + const id = genSessionId("qs_"); + assert.ok(id.startsWith("qs_")); + assert.strictEqual(id.length, 15); // qs_ + 12 chars + }); + + it("should generate chart session IDs with cs_ prefix", () => { + const id = genSessionId("cs_"); + assert.ok(id.startsWith("cs_")); + assert.strictEqual(id.length, 15); + }); + + it("should generate unique IDs", () => { + const ids = new Set(Array.from({ length: 100 }, () => genSessionId("qs_"))); + assert.strictEqual(ids.size, 100); + }); + }); + + describe("Message creation helpers", () => { + it("should create set_auth_token message", () => { + const msg = createSetAuthToken("unauthorized_user_token"); + assert.strictEqual(msg.m, "set_auth_token"); + assert.deepStrictEqual(msg.p, ["unauthorized_user_token"]); + }); + + it("should create chart_create_session message", () => { + const msg = createChartSession("cs_test123"); + assert.strictEqual(msg.m, "chart_create_session"); + assert.deepStrictEqual(msg.p, ["cs_test123", ""]); + }); + + it("should create quote_create_session message", () => { + const msg = createQuoteSession("qs_test123"); + assert.strictEqual(msg.m, "quote_create_session"); + assert.deepStrictEqual(msg.p, ["qs_test123"]); + }); + + it("should create resolve_symbol message", () => { + const msg = createResolveSymbol("cs_test", "symbol_1", { + symbol: "BINANCE:BTCUSDT", + }); + assert.strictEqual(msg.m, "resolve_symbol"); + assert.strictEqual(msg.p[0], "cs_test"); + assert.strictEqual(msg.p[1], "symbol_1"); + assert.ok(msg.p[2].includes("BINANCE:BTCUSDT")); + }); + + it("should create create_series message", () => { + const msg = createSeries("cs_test", "s1", "s1", "symbol_1", "1D", 300); + assert.strictEqual(msg.m, "create_series"); + assert.deepStrictEqual(msg.p, ["cs_test", "s1", "s1", "symbol_1", "1D", 300]); + }); + + it("should create quote_set_fields message", () => { + const msg = createQuoteSetFields("qs_test", ["lp", "bid", "ask"]); + assert.strictEqual(msg.m, "quote_set_fields"); + assert.strictEqual(msg.p[0], "qs_test"); + assert.ok(msg.p.includes("lp")); + assert.ok(msg.p.includes("bid")); + assert.ok(msg.p.includes("ask")); + }); + + it("should create quote_add_symbols message", () => { + const msg = createQuoteAddSymbols("qs_test", ["NASDAQ:AAPL", "BINANCE:BTCUSDT"]); + assert.strictEqual(msg.m, "quote_add_symbols"); + assert.strictEqual(msg.p[0], "qs_test"); + assert.ok(msg.p.includes("NASDAQ:AAPL")); + }); + + it("should create quote_fast_symbols message", () => { + const msg = createQuoteFastSymbols("qs_test", ["NASDAQ:AAPL"]); + assert.strictEqual(msg.m, "quote_fast_symbols"); + assert.strictEqual(msg.p[0], "qs_test"); + assert.strictEqual(msg.p[1], "NASDAQ:AAPL"); + }); + }); + + describe("Round-trip", () => { + it("should encode and decode a message back to the same object", () => { + const original = { m: "create_series", p: ["cs_test", "s1", "s1", "symbol_1", "1D", 300] }; + const encoded = encodePacket(original); + const decoded = decodePacket(encoded); + assert.strictEqual(decoded.length, 1); + assert.deepStrictEqual(decoded[0], original); + }); + }); +}); \ No newline at end of file diff --git a/src/tools/bars.ts b/src/tools/bars.ts new file mode 100644 index 0000000..5ca63c3 --- /dev/null +++ b/src/tools/bars.ts @@ -0,0 +1,109 @@ +/** + * Experimental get_bars tool + * + * Fetches historical OHLCV bars from TradingView via WebSocket. + * REQUIRES TV_EXPERIMENTAL_ENABLED=1 environment variable. + */ + +import { TvWsClient } from "../ws/client.js"; +import { ChartSession } from "../ws/session.js"; +import { isExperimentalEnabled, getWsConfig } from "../ws/auth.js"; +import { TimeoutError, SymbolError, ConnectionError } from "../ws/errors.js"; +import type { Bar } from "../ws/types.js"; + +export interface GetBarsInput { + /** Symbol in EXCHANGE:TICKER format (e.g., BINANCE:BTCUSDT) */ + symbol: string; + /** Timeframe (default: "1D") */ + timeframe?: string; + /** Number of bars to fetch (default: 300, max: 5000) */ + limit?: number; + /** Use extended session (default: false) */ + extended_session?: boolean; +} + +export interface GetBarsOutput { + symbol: string; + timeframe: string; + count: number; + bars: Bar[]; + source: string; +} + +/** + * Fetch historical OHLCV bars from TradingView + * + * Connects via WebSocket, creates a chart session, resolves + * the symbol, fetches bars, and returns them. + */ +export async function getBars(input: GetBarsInput): Promise { + if (!isExperimentalEnabled()) { + throw new Error( + "Experimental features are disabled. Set TV_EXPERIMENTAL_ENABLED=1 to enable." + ); + } + + const { + symbol, + timeframe = "1D", + limit = 300, + extended_session = false, + } = input; + + if (!symbol) { + throw new Error("Symbol is required"); + } + + const effectiveLimit = Math.min(Math.max(limit, 1), 5000); + + const config = getWsConfig(); + const client = new TvWsClient({ + server: config.server as any, + timeout: config.timeout, + sessionToken: config.authToken, + }); + + let chartSession: ChartSession | null = null; + + try { + // Connect + await client.connect(); + + // Create chart session + chartSession = new ChartSession(client); + + // Set market + chartSession.setMarket(symbol, { + session: extended_session ? "extended" : "regular", + }); + + // Create series to fetch bars + chartSession.createSeries(timeframe, effectiveLimit); + + // Wait for data + const bars = await chartSession.waitForCompletion(config.timeout); + + return { + symbol, + timeframe, + count: bars.length, + bars, + source: "experimental_tradingview_ws", + }; + } catch (err) { + if (err instanceof TimeoutError) { + throw new Error(`Experimental bars fetch timed out after ${config.timeout}ms. The symbol may be invalid or the server may be slow.`); + } + if (err instanceof ConnectionError) { + throw new Error(`TradingView websocket connection failed: ${err.message}`); + } + if (err instanceof SymbolError) { + throw new Error(`Symbol error for ${symbol}: ${err.message}`); + } + throw err; + } finally { + // Cleanup + chartSession?.delete(); + client.disconnect(); + } +} \ No newline at end of file diff --git a/src/tools/stream.ts b/src/tools/stream.ts new file mode 100644 index 0000000..736f305 --- /dev/null +++ b/src/tools/stream.ts @@ -0,0 +1,195 @@ +/** + * Experimental streaming tools + * + * Bounded quote streaming and bar streaming via WebSocket. + * REQUIRES TV_EXPERIMENTAL_ENABLED=1 environment variable. + */ + +import { TvWsClient } from "../ws/client.js"; +import { QuoteSession, ChartSession } from "../ws/session.js"; +import { isExperimentalEnabled, getWsConfig } from "../ws/auth.js"; +import { TimeoutError, ConnectionError } from "../ws/errors.js"; +import type { Bar, Quote, BarEvent } from "../ws/types.js"; + +export interface StreamQuotesInput { + /** Symbols to stream quotes for */ + symbols: string[]; + /** Quote fields to request (default: standard set) */ + fields?: string[]; + /** Duration in seconds to collect (default: 10, max: 60) */ + duration_seconds?: number; +} + +export interface StreamQuotesOutput { + duration_seconds: number; + updates: Quote[]; +} + +export interface StreamBarsInput { + /** Symbol in EXCHANGE:TICKER format */ + symbol: string; + /** Timeframe (default: "1") */ + timeframe?: string; + /** Duration in seconds to collect (default: 30, max: 120) */ + duration_seconds?: number; + /** Streaming mode */ + mode?: "rolling" | "close_only"; +} + +export interface StreamBarsOutput { + symbol: string; + timeframe: string; + mode: string; + events: BarEvent[]; +} + +/** + * Stream real-time quotes for a bounded duration + * + * Opens a quote session, collects updates for the specified + * duration, and returns them as a batch. + */ +export async function streamQuotes(input: StreamQuotesInput): Promise { + if (!isExperimentalEnabled()) { + throw new Error( + "Experimental features are disabled. Set TV_EXPERIMENTAL_ENABLED=1 to enable." + ); + } + + const { + symbols, + fields, + duration_seconds = 10, + } = input; + + if (!symbols || symbols.length === 0) { + throw new Error("At least one symbol is required"); + } + + const effectiveDuration = Math.min(Math.max(duration_seconds, 1), 60); + const config = getWsConfig(); + const client = new TvWsClient({ + server: config.server as any, + timeout: config.timeout, + sessionToken: config.authToken, + }); + + let quoteSession: QuoteSession | null = null; + const updates: Quote[] = []; + + try { + await client.connect(); + + quoteSession = new QuoteSession(client, fields); + + // Set up quote handler + quoteSession.on("quote", (quote: Quote) => { + updates.push(quote); + }); + + // Add symbols + quoteSession.addSymbols(symbols); + + // Wait for the specified duration + await new Promise((resolve) => setTimeout(resolve, effectiveDuration * 1000)); + + return { + duration_seconds: effectiveDuration, + updates, + }; + } catch (err) { + if (err instanceof ConnectionError) { + throw new Error(`TradingView websocket connection failed: ${err.message}`); + } + throw err; + } finally { + quoteSession?.delete(); + client.disconnect(); + } +} + +/** + * Stream bar updates for a bounded duration + * + * Opens a chart session, collects bar events for the specified + * duration, and returns them as a batch. + */ +export async function streamBars(input: StreamBarsInput): Promise { + if (!isExperimentalEnabled()) { + throw new Error( + "Experimental features are disabled. Set TV_EXPERIMENTAL_ENABLED=1 to enable." + ); + } + + const { + symbol, + timeframe = "1", + duration_seconds = 30, + mode = "rolling", + } = input; + + if (!symbol) { + throw new Error("Symbol is required"); + } + + const effectiveDuration = Math.min(Math.max(duration_seconds, 1), 120); + const config = getWsConfig(); + const client = new TvWsClient({ + server: config.server as any, + timeout: config.timeout, + sessionToken: config.authToken, + }); + + let chartSession: ChartSession | null = null; + const events: BarEvent[] = []; + let lastCloseTime = 0; + + try { + await client.connect(); + + chartSession = new ChartSession(client); + chartSession.setMarket(symbol); + chartSession.createSeries(timeframe, 1); + + // Set up update handler + chartSession.on("update", (bars: Bar[]) => { + if (bars.length === 0) return; + const latestBar = bars[bars.length - 1]; + + if (mode === "close_only") { + // Only emit when a bar closes (time changes) + if (latestBar.time !== lastCloseTime && lastCloseTime !== 0) { + events.push({ + kind: "close", + bar: latestBar, + }); + } + lastCloseTime = latestBar.time; + } else { + // Rolling mode - emit every update + events.push({ + kind: "update", + bar: latestBar, + }); + } + }); + + // Wait for the specified duration + await new Promise((resolve) => setTimeout(resolve, effectiveDuration * 1000)); + + return { + symbol, + timeframe, + mode, + events, + }; + } catch (err) { + if (err instanceof ConnectionError) { + throw new Error(`TradingView websocket connection failed: ${err.message}`); + } + throw err; + } finally { + chartSession?.delete(); + client.disconnect(); + } +} \ No newline at end of file diff --git a/src/ws/auth.ts b/src/ws/auth.ts new file mode 100644 index 0000000..4cd3011 --- /dev/null +++ b/src/ws/auth.ts @@ -0,0 +1,61 @@ +/** + * Auth helpers for TradingView WebSocket + * + * Handles session tokens, cookies, and auth configuration + */ + +/** + * Get auth token from environment or fallback to anonymous token + * + * TV_SESSION_ID and TV_SESSION_SIGN are TradingView session cookies + * that can be extracted from a browser session. Without them, + * the 'unauthorized_user_token' is used for anonymous access + * (which provides limited data). + * + * @returns Auth token string + */ +export function getAuthToken(): string { + const sessionId = process.env.TV_SESSION_ID; + const sessionSign = process.env.TV_SESSION_SIGN; + + if (sessionId) { + // When session is provided, we need to fetch the auth token + // from TradingView. For now, use the session directly. + // The ws client will send set_auth_token with this session. + // In practice, the auth_token is derived from the session. + return sessionId; + } + + return "unauthorized_user_token"; +} + +/** + * Check if experimental features are enabled + * @returns true if TV_EXPERIMENTAL_ENABLED is set to a truthy value + */ +export function isExperimentalEnabled(): boolean { + const val = process.env.TV_EXPERIMENTAL_ENABLED; + return val === "1" || val === "true" || val === "yes"; +} + +/** + * Get WebSocket configuration from environment + */ +export function getWsConfig(): { + server: string; + timeout: number; + authToken: string; + sessionSign?: string; +} { + const server = process.env.TV_WS_ENDPOINT || "data"; + const timeout = parseInt(process.env.TV_WS_TIMEOUT_MS || "10000", 10); + const authToken = getAuthToken(); + const sessionSign = process.env.TV_SESSION_SIGN; + + return { + server, + timeout, + authToken, + ...(sessionSign && { sessionSign }), + }; +} \ No newline at end of file diff --git a/src/ws/client.ts b/src/ws/client.ts new file mode 100644 index 0000000..4c80fc3 --- /dev/null +++ b/src/ws/client.ts @@ -0,0 +1,238 @@ +/** + * TradingView WebSocket Client + * + * Low-level WebSocket client for connecting to TradingView's + * real-time data feed. Handles connection, message dispatch, + * and session management. + * + * This is an EXPERIMENTAL module. Do not use in stable tool paths. + */ + +import WebSocket from "ws"; +import { EventEmitter } from "events"; +import { encodePacket, decodePacket, createPong } from "./protocol.js"; +import { createSetAuthToken } from "./protocol.js"; +import type { WsConfig, SessionMessage } from "./types.js"; +import { ConnectionError, TimeoutError } from "./errors.js"; + +export type ClientEvent = + | "connected" + | "disconnected" + | "error" + | "message" + | "ping"; + +interface PendingRequest { + resolve: (data: any) => void; + reject: (error: Error) => void; + timer: NodeJS.Timeout; +} + +/** + * TradingView WebSocket client + * + * Connects to TradingView's data websocket, authenticates, + * and dispatches messages to registered sessions. + */ +export class TvWsClient extends EventEmitter { + private ws: WebSocket | null = null; + private config: Required & { sessionSign?: string }; + private sessions: Map void> = new Map(); + private connected = false; + private messageBuffer: string[] = []; + private sentAuth = false; + + constructor(config?: WsConfig) { + super(); + const defaultConfig = { + server: "data" as const, + timeout: 10000, + sessionToken: "unauthorized_user_token", + }; + const envConfig = { + server: (process.env.TV_WS_ENDPOINT || "data") as "data" | "prodata" | "widgetdata", + timeout: parseInt(process.env.TV_WS_TIMEOUT_MS || "10000", 10), + sessionToken: process.env.TV_SESSION_ID || "unauthorized_user_token", + sessionSign: process.env.TV_SESSION_SIGN, + }; + this.config = { ...defaultConfig, ...envConfig, ...config } as Required & { sessionSign?: string }; + } + + /** + * Connect to TradingView WebSocket + */ + async connect(): Promise { + if (this.connected && this.ws?.readyState === WebSocket.OPEN) { + return; + } + + const url = `wss://${this.config.server}.tradingview.com/socket.io/websocket?type=chart`; + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + if (!this.connected) { + this.ws?.close(); + reject(new TimeoutError(`Connection timeout after ${this.config.timeout}ms`)); + } + }, this.config.timeout); + + try { + this.ws = new WebSocket(url, { + origin: "https://www.tradingview.com", + }); + } catch (err) { + clearTimeout(timeout); + reject(new ConnectionError(`Failed to create WebSocket: ${(err as Error).message}`)); + return; + } + + this.ws.on("open", () => { + clearTimeout(timeout); + this.connected = true; + this.sentAuth = false; + + // Send auth token on connect + const authMsg = createSetAuthToken(this.config.sessionToken); + this.sendRaw(authMsg); + this.sentAuth = true; + + // Flush buffered messages + this.flushBuffer(); + + this.emit("connected"); + resolve(); + }); + + this.ws.on("message", (data: WebSocket.Data) => { + this.handleMessage(data.toString()); + }); + + this.ws.on("close", () => { + this.connected = false; + this.emit("disconnected"); + }); + + this.ws.on("error", (err: Error) => { + clearTimeout(timeout); + this.emit("error", new ConnectionError(`WebSocket error: ${err.message}`)); + if (!this.connected) { + reject(new ConnectionError(`WebSocket error: ${err.message}`)); + } + }); + }); + } + + /** + * Disconnect from WebSocket + */ + disconnect(): void { + if (this.ws) { + this.ws.close(); + this.ws = null; + this.connected = false; + } + } + + /** + * Register a session handler + * @param sessionId - Session ID to listen for + * @param handler - Callback for messages targeting this session + */ + registerSession(sessionId: string, handler: (message: SessionMessage) => void): void { + this.sessions.set(sessionId, handler); + } + + /** + * Unregister a session handler + * @param sessionId - Session ID to remove + */ + unregisterSession(sessionId: string): void { + this.sessions.delete(sessionId); + } + + /** + * Send a typed message to TradingView + * @param type - Message type (e.g., 'create_series') + * @param params - Message parameters + */ + send(type: string, params: any[]): void { + const message = { m: type, p: params }; + const encoded = encodePacket(message); + + if (this.connected && this.ws?.readyState === WebSocket.OPEN && this.sentAuth) { + this.ws.send(encoded); + } else { + // Buffer until connected and auth'd + this.messageBuffer.push(encoded); + } + } + + /** + * Send a raw encoded message + */ + private sendRaw(message: { m: string; p: string[] }): void { + const encoded = encodePacket(message); + if (this.connected && this.ws?.readyState === WebSocket.OPEN) { + this.ws.send(encoded); + } else { + this.messageBuffer.push(encoded); + } + } + + /** + * Flush buffered messages + */ + private flushBuffer(): void { + while (this.messageBuffer.length > 0 && this.connected && this.ws?.readyState === WebSocket.OPEN) { + const msg = this.messageBuffer.shift()!; + this.ws.send(msg); + } + } + + /** + * Handle incoming message from WebSocket + */ + private handleMessage(rawData: string): void { + const packets = decodePacket(rawData); + + for (const packet of packets) { + if (typeof packet === "number") { + // Ping - respond with pong + const pong = createPong(packet); + if (this.ws?.readyState === WebSocket.OPEN) { + this.ws.send(pong); + } + this.emit("ping", packet); + continue; + } + + if (packet.m === "protocol_error") { + this.emit("error", new ConnectionError(`Protocol error: ${JSON.stringify(packet.p)}`)); + continue; + } + + // Dispatch to session handlers + const sessionId = packet.p?.[0]; + if (sessionId && typeof sessionId === "string" && this.sessions.has(sessionId)) { + const handler = this.sessions.get(sessionId)!; + handler({ + type: packet.m, + data: packet.p, + }); + } else { + // Unhandled message - emit as generic message + this.emit("message", { + type: packet.m, + data: packet.p, + }); + } + } + } + + /** + * Check if client is connected + */ + isConnected(): boolean { + return this.connected && this.ws?.readyState === WebSocket.OPEN; + } +} \ No newline at end of file diff --git a/src/ws/errors.ts b/src/ws/errors.ts new file mode 100644 index 0000000..b31d6e1 --- /dev/null +++ b/src/ws/errors.ts @@ -0,0 +1,45 @@ +/** + * TradingView WebSocket error types + */ + +/** Base error for TradingView WebSocket operations */ +export class TvWsError extends Error { + constructor(message: string) { + super(message); + this.name = "TvWsError"; + } +} + +/** WebSocket connection errors */ +export class ConnectionError extends TvWsError { + constructor(message: string) { + super(message); + this.name = "ConnectionError"; + } +} + +/** Authentication/session errors */ +export class AuthError extends TvWsError { + constructor(message: string) { + super(message); + this.name = "AuthError"; + } +} + +/** Symbol resolution errors */ +export class SymbolError extends TvWsError { + public symbol: string; + constructor(symbol: string, message?: string) { + super(message || `Symbol error: ${symbol}`); + this.name = "SymbolError"; + this.symbol = symbol; + } +} + +/** Timeout errors */ +export class TimeoutError extends TvWsError { + constructor(message: string) { + super(message); + this.name = "TimeoutError"; + } +} \ No newline at end of file diff --git a/src/ws/index.ts b/src/ws/index.ts new file mode 100644 index 0000000..8225b3b --- /dev/null +++ b/src/ws/index.ts @@ -0,0 +1,34 @@ +/** + * TradingView WebSocket module + * + * EXPERIMENTAL — browserless WebSocket adapter for TradingView data + * + * This module provides direct access to TradingView's real-time data + * feed via their WebSocket protocol. It is NOT part of the stable + * API surface and requires TV_EXPERIMENTAL_ENABLED=1. + */ + +export { TvWsClient } from "./client.js"; +export { ChartSession, QuoteSession } from "./session.js"; +export { parseBarData, parseQuoteData, isSeriesCompleted, normalizeTime } from "./parser.js"; +export { encodePacket, decodePacket, genSessionId } from "./protocol.js"; +export { getAuthToken, isExperimentalEnabled, getWsConfig } from "./auth.js"; +export { + TvWsError, + ConnectionError, + AuthError, + SymbolError, + TimeoutError, +} from "./errors.js"; +export type { + Bar, + Quote, + WsConfig, + SessionMessage, + SeriesResult, + BarEvent, + QuoteEvent, + QuoteField, + Timeframe, +} from "./types.js"; +export { VALID_TIMEFRAMES, INTERVAL_MAP, DEFAULT_QUOTE_FIELDS } from "./types.js"; \ No newline at end of file diff --git a/src/ws/parser.ts b/src/ws/parser.ts new file mode 100644 index 0000000..37a727d --- /dev/null +++ b/src/ws/parser.ts @@ -0,0 +1,167 @@ +/** + * TradingView WebSocket data parser + * + * Parses raw WebSocket messages into structured Bar and Quote data. + * Handles compressed data (JSZip) and various message formats. + */ + +import type { Bar, Quote } from "./types.js"; + +/** + * Parse bar data from a timescale_update message + * + * The data structure varies by TradingView version but generally: + * - data[0] is the session ID + * - data[1] is an object keyed by series ID + * - Each series has an 's' array of bar objects + * - Each bar has 'i' (index) and 'v' (values array) + * - Values: [time, open, close, max, min, volume] or [time, open, high, low, close, volume] + * + * @param seriesData - The series data object from a timescale_update message + * @returns Parsed bars sorted by time ascending + */ +export function parseBarData(seriesData: any): Bar[] { + const bars: Bar[] = []; + + if (!seriesData || typeof seriesData !== "object") { + return bars; + } + + // Handle both direct series data and nested structures + for (const [, seriesValue] of Object.entries(seriesData)) { + if (!seriesValue || typeof seriesValue !== "object") continue; + + const seriesObj = seriesValue as any; + const seriesBars = seriesObj.s || seriesObj; + + if (!Array.isArray(seriesBars)) continue; + + for (const barData of seriesBars) { + // barData can be { i: idx, v: [time, ...] } or just [time, ...] + const values = Array.isArray(barData) + ? barData + : barData.v; + + if (!Array.isArray(values) || values.length < 5) continue; + + // TradingView sometimes sends: time, open, close, max, min, vol + // But sometimes: time, open, high, low, close, vol + // We normalize to: time, open, high, low, close, vol + const bar: Bar = { + time: typeof values[0] === "number" ? values[0] : Math.floor(Date.now() / 1000), + open: typeof values[1] === "number" ? values[1] : 0, + high: typeof values[2] === "number" ? values[2] : 0, + low: typeof values[3] === "number" ? values[3] : 0, + close: typeof values[4] === "number" ? values[4] : 0, + volume: typeof values[5] === "number" ? Math.round(values[5] * 100) / 100 : 0, + }; + + bars.push(bar); + } + } + + // Sort ascending by time + return bars.sort((a, b) => a.time - b.time); +} + +/** + * Parse quote data from a qsd message + * + * @param quoteRawData - The quote data from a qsd/quote_completed message + * @returns Parsed quote object or null if invalid + */ +export function parseQuoteData(quoteRawData: any): Quote | null { + if (!quoteRawData) return null; + + // qsd format: { n: "EXCHANGE:SYMBOL", v: { lp: ..., bid: ..., ... } } + if (quoteRawData.n && quoteRawData.v) { + const values = quoteRawData.v; + return { + symbol: quoteRawData.n, + price: values.lp ?? values.last_price, + bid: values.bid, + ask: values.ask, + volume: values.volume, + change: values.ch, + changePercent: values.chp, + high: values.high_price, + low: values.low_price, + open: values.open_price, + prevClose: values.prev_close_price, + description: values.description, + exchange: values.exchange, + type: values.type, + currency: values.currency_code, + }; + } + + return null; +} + +/** + * Detect if raw WebSocket data contains a series completion message + * @param rawData - Raw WebSocket string + * @returns true if series_completed is found + */ +export function isSeriesCompleted(rawData: string): boolean { + return rawData.includes("series_completed"); +} + +/** + * Detect if raw WebSocket data contains a timescale_update + * @param rawData - Raw WebSocket string + * @returns true if timescale_update is found + */ +export function isTimescaleUpdate(rawData: string): boolean { + return rawData.includes("timescale_update") || rawData.includes('"du"'); +} + +/** + * Detect if raw WebSocket data contains a quote update + * @param rawData - Raw WebSocket string + * @returns true if quote data (qsd) is found + */ +export function isQuoteUpdate(rawData: string): boolean { + return rawData.includes('"qsd"'); +} + +/** + * Detect if raw WebSocket data contains a symbol resolution error + * @param rawData - Raw WebSocket string + * @returns true if symbol_error is found + */ +export function isSymbolError(rawData: string): boolean { + return rawData.includes("symbol_error"); +} + +/** + * Detect if raw WebSocket data contains a protocol_error + * @param rawData - Raw WebSocket string + * @returns true if protocol_error is found + */ +export function isProtocolError(rawData: string): boolean { + return rawData.includes("protocol_error"); +} + +/** + * Normalize bar time values to seconds + * TradingView sometimes sends time in seconds, sometimes in milliseconds + * @param time - Raw time value + * @returns Time in seconds (Unix epoch) + */ +export function normalizeTime(time: number): number { + // If time is in milliseconds (> 1e12), convert to seconds + if (time > 1e12) { + return Math.floor(time / 1000); + } + return time; +} + +/** + * Format bar time as ISO string + * @param time - Unix timestamp in seconds + * @returns ISO date string + */ +export function formatBarTime(time: number): string { + return new Date(time * 1000).toISOString(); +} \ No newline at end of file diff --git a/src/ws/protocol.ts b/src/ws/protocol.ts new file mode 100644 index 0000000..80beaca --- /dev/null +++ b/src/ws/protocol.ts @@ -0,0 +1,317 @@ +/** + * TradingView WebSocket protocol encoding/decoding + * + * TradingView uses a custom framing protocol over WebSocket: + * ~m~~m~ + * + * Ping messages are plain numbers: ~h~ + * Messages are JSON objects with 'm' (method) and 'p' (params) + */ + +/** + * Generate a random session ID with a given prefix + * @param prefix - 'qs_' for quote sessions, 'cs_' for chart sessions + * @returns Random session ID like 'qs_abc123def456' + */ +export function genSessionId(prefix: "qs_" | "cs_" | "rs_"): string { + const chars = "abcdefghijklmnopqrstuvwxyz0123456789"; + let id = prefix; + for (let i = 0; i < 12; i++) { + id += chars[Math.floor(Math.random() * chars.length)]; + } + return id; +} + +/** + * Encode a message into TradingView WebSocket protocol format + * @param message - Object with 'm' (method) and 'p' (params), or a plain string + * @returns Encoded packet string like ~m~42~m~{"m":"set_auth_token","p":["..."]} + */ +export function encodePacket(message: { m: string; p: any[] } | string): string { + const payload = typeof message === "string" ? message : JSON.stringify(message); + return `~m~${payload.length}~m~${payload}`; +} + +/** + * Decode raw WebSocket data into an array of parsed messages + * Handles both: + * - Data messages: ~m~~m~ + * - Ping messages: ~h~ + * + * @param rawData - Raw string from WebSocket + * @returns Array of parsed message objects or ping numbers + */ +export function decodePacket(rawData: string): Array<{ m: string; p: any[] } | number> { + const results: Array<{ m: string; p: any[] } | number> = []; + + // Strip ping messages (~h~) + const cleaned = rawData.replace(/~h~\d+/g, ""); + + // Split by the frame delimiter pattern + const parts = cleaned.split(/~m~\d+~m~/); + + for (const part of parts) { + // Extract ping numbers first + const pingMatch = rawData.match(/~h~(\d+)/); + if (pingMatch && !cleaned.includes(part)) { + // Already handled ping above via cleaning + } + + const trimmed = part.trim(); + if (!trimmed) continue; + + try { + const parsed = JSON.parse(trimmed); + if (typeof parsed === "object" && parsed.m && Array.isArray(parsed.p)) { + results.push(parsed); + } + } catch { + // Not valid JSON, skip + } + } + + // Also check for ping messages in the raw data + const pingRegex = /~h~(\d+)/g; + let pingMatch; + while ((pingMatch = pingRegex.exec(rawData)) !== null) { + results.push(parseInt(pingMatch[1], 10)); + } + + return results; +} + +/** + * Create a ping response for a received ping number + * @param pingNum - The ping number received + * @returns Encoded pong message + */ +export function createPong(pingNum: number): string { + return `~m~3~m~~h~${pingNum}~`; +} + +/** + * Create a set_auth_token message + * @param token - Auth token (usually 'unauthorized_user_token' for anonymous) + * @returns Formatted message object + */ +export function createSetAuthToken(token: string): { m: string; p: string[] } { + return { m: "set_auth_token", p: [token] }; +} + +/** + * Create a chart_create_session message + * @param sessionId - Chart session ID + * @returns Formatted message object + */ +export function createChartSession(sessionId: string): { m: string; p: string[] } { + return { m: "chart_create_session", p: [sessionId, ""] }; +} + +/** + * Create a quote_create_session message + * @param sessionId - Quote session ID + * @returns Formatted message object + */ +export function createQuoteSession(sessionId: string): { m: string; p: string[] } { + return { m: "quote_create_session", p: [sessionId] }; +} + +/** + * Create a quote_set_fields message + * @param sessionId - Quote session ID + * @param fields - Fields to request + * @returns Formatted message object + */ +export function createQuoteSetFields( + sessionId: string, + fields: string[] +): { m: string; p: (string | string[])[] } { + return { m: "quote_set_fields", p: [sessionId, ...fields] }; +} + +/** + * Create a resolve_symbol message + * @param chartSessionId - Chart session ID + * @param symbolKey - Symbol key (e.g., 'symbol_1') + * @param symbolObj - Symbol configuration object + * @returns Formatted message object + */ +export function createResolveSymbol( + chartSessionId: string, + symbolKey: string, + symbolObj: { + symbol: string; + adjustment?: string; + session?: string; + "currency-id"?: string; + } +): { m: string; p: string[] } { + const config = { + symbol: symbolObj.symbol, + adjustment: symbolObj.adjustment || "splits", + ...(symbolObj.session && { session: symbolObj.session }), + ...(symbolObj["currency-id"] && { "currency-id": symbolObj["currency-id"] }), + }; + return { + m: "resolve_symbol", + p: [chartSessionId, symbolKey, `=${JSON.stringify(config)}`], + }; +} + +/** + * Create a create_series message + * @param chartSessionId - Chart session ID + * @param seriesId - Series ID (e.g., 's1') + * @param seriesCommandId - Series command ID + * @param symbolKey - Symbol key used in resolve_symbol + * @param timeframe - Timeframe string + * @param count - Number of bars to request + * @returns Formatted message object + */ +export function createSeries( + chartSessionId: string, + seriesId: string, + seriesCommandId: string, + symbolKey: string, + timeframe: string, + count: number +): { m: string; p: (string | number)[] } { + return { + m: "create_series", + p: [chartSessionId, seriesId, seriesCommandId, symbolKey, timeframe, count], + }; +} + +/** + * Create a modify_series message + * @param chartSessionId - Chart session ID + * @param seriesId - Series ID + * @param seriesCommandId - Series command ID + * @param symbolKey - Symbol key + * @param timeframe - New timeframe + * @param count - Number of bars to request + * @returns Formatted message object + */ +export function modifySeries( + chartSessionId: string, + seriesId: string, + seriesCommandId: string, + symbolKey: string, + timeframe: string, + count: number +): { m: string; p: (string | number)[] } { + return { + m: "modify_series", + p: [chartSessionId, seriesId, seriesCommandId, symbolKey, timeframe, count], + }; +} + +/** + * Create a request_more_data message + * @param chartSessionId - Chart session ID + * @param seriesId - Series ID + * @param count - Number of additional bars + * @returns Formatted message object + */ +export function createRequestMoreData( + chartSessionId: string, + seriesId: string, + count: number +): { m: string; p: (string | string | number)[] } { + return { + m: "request_more_data", + p: [chartSessionId, seriesId, count], + }; +} + +/** + * Create a quote_add_symbols message + * @param sessionId - Quote session ID + * @param symbols - Symbols to add + * @returns Formatted message object + */ +export function createQuoteAddSymbols( + sessionId: string, + symbols: string[] +): { m: string; p: (string | any)[] } { + return { + m: "quote_add_symbols", + p: [sessionId, ...symbols], + }; +} + +/** + * Create a quote_fast_symbols message + * @param sessionId - Quote session ID + * @param symbols - Symbols to fast-track + * @returns Formatted message object + */ +export function createQuoteFastSymbols( + sessionId: string, + symbols: string[] +): { m: string; p: string[] } { + return { + m: "quote_fast_symbols", + p: [sessionId, ...symbols], + }; +} + +/** + * Create a quote_remove_symbols message + * @param sessionId - Quote session ID + * @param symbols - Symbols to remove + * @returns Formatted message object + */ +export function createQuoteRemoveSymbols( + sessionId: string, + symbols: string[] +): { m: string; p: string[] } { + return { + m: "quote_remove_symbols", + p: [sessionId, ...symbols], + }; +} + +/** + * Create a switch_timezone message + * @param chartSessionId - Chart session ID + * @param timezone - Timezone string (e.g., 'exchange', 'Etc/UTC') + * @returns Formatted message object + */ +export function createSwitchTimezone( + chartSessionId: string, + timezone: string +): { m: string; p: string[] } { + return { + m: "switch_timezone", + p: [chartSessionId, timezone], + }; +} + +/** + * Create a chart_delete_session message + * @param sessionId - Chart session ID + * @returns Formatted message object + */ +export function createDeleteChartSession( + sessionId: string +): { m: string; p: string[] } { + return { + m: "chart_delete_session", + p: [sessionId], + }; +} + +/** + * Create a quote_delete_session message + * @param sessionId - Quote session ID + * @returns Formatted message object + */ +export function createDeleteQuoteSession( + sessionId: string +): { m: string; p: string[] } { + return { + m: "quote_delete_session", + p: [sessionId], + }; +} \ No newline at end of file diff --git a/src/ws/session.ts b/src/ws/session.ts new file mode 100644 index 0000000..f90b1fd --- /dev/null +++ b/src/ws/session.ts @@ -0,0 +1,341 @@ +/** + * TradingView WebSocket sessions + * + * Manages chart and quote sessions for real-time data. + * Chart sessions are used for bar/candle data. + * Quote sessions are used for real-time price quotes. + */ + +import { EventEmitter } from "events"; +import { TvWsClient } from "./client.js"; +import { + genSessionId, + createChartSession, + createQuoteSession, + createQuoteSetFields, + createResolveSymbol, + createSeries, + createQuoteAddSymbols, + createQuoteFastSymbols, + createQuoteRemoveSymbols, + createDeleteChartSession, + createDeleteQuoteSession, + createSwitchTimezone, +} from "./protocol.js"; +import type { Timeframe, Bar, Quote, QuoteField, DEFAULT_QUOTE_FIELDS } from "./types.js"; +import { SymbolError, TimeoutError } from "./errors.js"; + +/** + * Chart session for fetching bar/candle data + */ +export class ChartSession extends EventEmitter { + private sessionId: string; + private client: TvWsClient; + private symbolKey: string = ""; + private seriesCreated = false; + private bars: Bar[] = []; + private symbolInfo: any = null; + private completed = false; + private resolveCompletion?: () => void; + private rejectCompletion?: (err: Error) => void; + + constructor(client: TvWsClient) { + super(); + this.client = client; + this.sessionId = genSessionId("cs_"); + + // Register session handler + this.client.registerSession(this.sessionId, (message) => { + this.handleMessage(message); + }); + + // Create the chart session + this.client.send("chart_create_session", [this.sessionId, ""]); + } + + /** + * Set the market/symbol for this chart session + */ + setMarket(symbol: string, options?: { + timeframe?: string; + range?: number; + session?: "regular" | "extended"; + }): void { + this.bars = []; + this.completed = false; + this.symbolKey = `symbol_${Date.now()}`; + + const symbolObj: any = { + symbol, + adjustment: "splits", + }; + + if (options?.session) { + symbolObj.session = options.session === "extended" ? "extended" : "regular"; + } + + // Resolve symbol + this.client.send("resolve_symbol", [ + this.sessionId, + this.symbolKey, + `=${JSON.stringify(symbolObj)}`, + ]); + } + + /** + * Create a series to fetch bars + */ + createSeries(timeframe: string = "1D", count: number = 300): void { + this.bars = []; + this.completed = false; + + this.client.send("create_series", [ + this.sessionId, + "s1", + "s1", + this.symbolKey || "symbol_1", + timeframe, + count, + ]); + + // Set timezone + this.client.send("switch_timezone", [this.sessionId, "Etc/UTC"]); + } + + /** + * Wait for series data to complete + */ + async waitForCompletion(timeoutMs: number = 10000): Promise { + if (this.completed) { + return this.bars; + } + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + // Return whatever we have on timeout + resolve(this.bars); + }, timeoutMs); + + this.once("completed", () => { + clearTimeout(timer); + resolve(this.bars); + }); + + this.once("error", (err: Error) => { + clearTimeout(timer); + reject(err); + }); + }); + } + + /** + * Handle messages for this chart session + */ + private handleMessage(message: { type: string; data: any[] }): void { + const { type, data } = message; + + if (type === "timescale_update" || type === "du") { + // Bar data update + this.handleTimescaleUpdate(data); + } else if (type === "series_completed") { + this.completed = true; + this.emit("completed", this.bars); + } else if (type === "symbol_resolved") { + this.symbolInfo = data[1] || data[2]; + this.emit("symbolLoaded", this.symbolInfo); + } else if (type === "symbol_error") { + this.emit("error", new SymbolError( + data[1] || "unknown", + `Symbol error: ${data[2] || "unknown error"}` + )); + } else if (type === "series_error") { + this.emit("error", new SymbolError( + this.symbolKey, + `Series error: ${data[3] || "unknown error"}` + )); + } + } + + /** + * Parse timescale update data into bars + */ + private handleTimescaleUpdate(data: any[]): void { + // Data format: [sessionId, { seriesId: { s: [{ i: idx, v: [time, open, close, max, min, volume] }] } }] + const updateData = data[1]; + if (!updateData || typeof updateData !== "object") return; + + for (const [seriesId, seriesData] of Object.entries(updateData)) { + if (!seriesData || typeof seriesData !== "object") continue; + + const seriesObj = seriesData as any; + if (!seriesObj.s || !Array.isArray(seriesObj.s)) continue; + + for (const barData of seriesObj.s) { + const values = barData.v || barData; + if (!Array.isArray(values) || values.length < 6) continue; + + const bar: Bar = { + time: values[0], + open: values[1], + high: values[2] ?? values[4], // max or close fallback + low: values[3] ?? values[4], // min or close fallback + close: values[4], + volume: values[5] ?? 0, + }; + + // Deduplicate by time — keep latest + const existing = this.bars.findIndex((b) => b.time === bar.time); + if (existing >= 0) { + this.bars[existing] = bar; + } else { + this.bars.push(bar); + } + } + } + + // Sort bars by time ascending + this.bars.sort((a, b) => a.time - b.time); + this.emit("update", this.bars); + } + + /** + * Get current bars + */ + getBars(): Bar[] { + return [...this.bars].sort((a, b) => a.time - b.time); + } + + /** + * Get symbol info + */ + getSymbolInfo(): any { + return this.symbolInfo; + } + + /** + * Delete this chart session + */ + delete(): void { + this.client.send("chart_delete_session", [this.sessionId]); + this.client.unregisterSession(this.sessionId); + this.removeAllListeners(); + } +} + +/** + * Quote session for real-time price data + */ +export class QuoteSession extends EventEmitter { + private sessionId: string; + private client: TvWsClient; + private quotes: Map = new Map(); + private completedSymbols: Set = new Set(); + + constructor(client: TvWsClient, fields?: string[]) { + super(); + this.client = client; + this.sessionId = genSessionId("qs_"); + + // Register session handler + this.client.registerSession(this.sessionId, (message) => { + this.handleMessage(message); + }); + + // Create the quote session + this.client.send("quote_create_session", [this.sessionId]); + + // Set fields + const quoteFields = fields || [ + "lp", "bid", "ask", "volume", "ch", "chp", + "description", "exchange", "type", "currency_code", + ]; + this.client.send("quote_set_fields", [this.sessionId, ...quoteFields]); + } + + /** + * Add symbols to track + */ + addSymbols(symbols: string[]): void { + const params: any[] = [this.sessionId]; + for (const sym of symbols) { + params.push(sym); + } + this.client.send("quote_add_symbols", params); + + // Also set fast symbols for faster initial data + this.client.send("quote_fast_symbols", [this.sessionId, ...symbols]); + } + + /** + * Remove symbols from tracking + */ + removeSymbols(symbols: string[]): void { + this.client.send("quote_remove_symbols", [this.sessionId, ...symbols]); + } + + /** + * Handle messages for this quote session + */ + private handleMessage(message: { type: string; data: any[] }): void { + const { type, data } = message; + + if (type === "qsd") { + // Quote data update + // data[1] contains { n: symbol, v: { lp, bid, ask, ... } } + const quoteData = data[1]; + if (quoteData && quoteData.n) { + const symbol = quoteData.n; + const values = quoteData.v || {}; + const quote: Quote = { + symbol, + price: values.lp ?? values.last_price, + bid: values.bid, + ask: values.ask, + volume: values.volume, + change: values.ch, + changePercent: values.chp, + high: values.high_price, + low: values.low_price, + open: values.open_price, + prevClose: values.prev_close_price, + description: values.description, + exchange: values.exchange, + type: values.type, + currency: values.currency_code, + }; + this.quotes.set(symbol, quote); + this.emit("quote", quote); + } + } else if (type === "quote_completed") { + // Symbol loading complete + const symbol = data[1]; + if (symbol) { + this.completedSymbols.add(symbol); + this.emit("completed", symbol); + } + } + } + + /** + * Get current quotes + */ + getQuotes(): Map { + return this.quotes; + } + + /** + * Get quote for a specific symbol + */ + getQuote(symbol: string): Quote | undefined { + return this.quotes.get(symbol); + } + + /** + * Delete this quote session + */ + delete(): void { + this.client.send("quote_delete_session", [this.sessionId]); + this.client.unregisterSession(this.sessionId); + this.removeAllListeners(); + } +} \ No newline at end of file diff --git a/src/ws/types.ts b/src/ws/types.ts new file mode 100644 index 0000000..e065f11 --- /dev/null +++ b/src/ws/types.ts @@ -0,0 +1,164 @@ +/** + * TradingView WebSocket types + * Types for the experimental websocket-based data access + */ + +/** Supported timeframe values for TradingView charts */ +export type Timeframe = + | "1" + | "3" + | "5" + | "15" + | "30" + | "45" + | "60" + | "120" + | "180" + | "240" + | "1D" + | "1W" + | "1M"; + +/** OHLCV bar data */ +export interface Bar { + time: number; + open: number; + high: number; + low: number; + close: number; + volume: number; +} + +/** Real-time quote data */ +export interface Quote { + symbol: string; + time?: number; + price?: number; + bid?: number; + ask?: number; + volume?: number; + change?: number; + changePercent?: number; + high?: number; + low?: number; + open?: number; + prevClose?: number; + description?: string; + exchange?: string; + type?: string; + currency?: string; +} + +/** WebSocket client configuration */ +export interface WsConfig { + /** WebSocket endpoint server: 'data' (default), 'prodata', 'widgetdata' */ + server?: "data" | "prodata" | "widgetdata"; + /** Connection timeout in ms (default: 10000) */ + timeout?: number; + /** Session auth token (default: 'unauthorized_user_token') */ + sessionToken?: string; + /** Optional session signature */ + sessionSign?: string; +} + +/** Parsed session message from TradingView WebSocket */ +export interface SessionMessage { + type: string; + data: any[]; +} + +/** Result from creating a series (bars fetch) */ +export interface SeriesResult { + symbol: string; + timeframe: string; + bars: Bar[]; + count: number; +} + +/** Bar stream event */ +export interface BarEvent { + kind: "update" | "close"; + bar: Bar; +} + +/** Quote stream event */ +export interface QuoteEvent { + symbol: string; + time: number; + price?: number; + bid?: number; + ask?: number; + volume?: number; +} + +/** Valid quote field names */ +export type QuoteField = + | "lp" + | "bid" + | "ask" + | "volume" + | "ch" + | "chp" + | "open_price" + | "high_price" + | "low_price" + | "prev_close_price" + | "description" + | "exchange" + | "type" + | "currency_code" + | "market_cap_basic" + | "pricescale" + | "minmov" + | "minmove2"; + +/** Default quote fields to request */ +export const DEFAULT_QUOTE_FIELDS: QuoteField[] = [ + "lp", + "bid", + "ask", + "volume", + "ch", + "chp", + "description", + "exchange", + "type", + "currency_code", +]; + +/** All known timeframe values for validation */ +export const VALID_TIMEFRAMES: Timeframe[] = [ + "1", + "3", + "5", + "15", + "30", + "45", + "60", + "120", + "180", + "240", + "1D", + "1W", + "1M", +]; + +/** + * Interval mapping: friendly names to TradingView values + * TradingView uses number strings for minutes and "1D"/"1W"/"1M" for longer + */ +export const INTERVAL_MAP: Record = { + "1m": "1", + "3m": "3", + "5m": "5", + "15m": "15", + "30m": "30", + "45m": "45", + "1h": "60", + "2h": "120", + "3h": "180", + "4h": "240", + "1d": "1D", + "1w": "1W", + "1M": "1M", +}; \ No newline at end of file From c81d231a91bcbe4f13fa98c9531a0a22f75a4fbc Mon Sep 17 00:00:00 2001 From: Pavel Fadeev Date: Fri, 10 Apr 2026 23:46:16 +0200 Subject: [PATCH 2/4] fix(lab): add missing ws dependency and lab-path spec - Add ws and @types/ws as dependencies - Add docs/SPEC-lab-path.md as source of truth --- docs/SPEC-lab-path.md | 295 ++++++++++++++++++++++++++++++++++++++++++ package-lock.json | 265 +++++++++++++++++++++---------------- package.json | 6 +- 3 files changed, 453 insertions(+), 113 deletions(-) create mode 100644 docs/SPEC-lab-path.md diff --git a/docs/SPEC-lab-path.md b/docs/SPEC-lab-path.md new file mode 100644 index 0000000..e022d99 --- /dev/null +++ b/docs/SPEC-lab-path.md @@ -0,0 +1,295 @@ +# Spec: Lab Path for TradingView Browserless Expansion + +**Date:** 2026-04-10 +**Status:** Implementation-ready spec +**Branch:** `feat/lab-path` + +--- + +## 1. Purpose + +This document defines the **lab path** for `tradingview-mcp-server`. + +The lab path is the experimental browserless track for higher-upside TradingView research features that rely on undocumented websocket/session behavior. + +It exists to: +- separate fragile experimental code from stable core features +- keep user expectations clear +- make testing and rollback easier +- let future work evolve without contaminating the stable path + +--- + +## 2. Product Direction + +The lab path pushes beyond screener/search/metainfo into TradingView session-style data access. + +### Lab path goals +- fetch historical OHLCV bars +- stream short-lived quote updates +- stream bar updates in a bounded way +- encapsulate websocket/session handling behind a dedicated adapter layer +- mark all experimental features clearly + +### Lab path principles +1. Never blur experimental features with stable ones. +2. Gate access explicitly. +3. Keep streams bounded and safe for MCP usage. +4. Prefer normalized output shapes over raw protocol payloads. +5. Make parser/session logic testable without a live TradingView connection. + +--- + +## 3. In Scope + +### Lab-1: `experimental_get_bars` +Historical OHLCV bars from TradingView websocket/session machinery. + +### Lab-2: `experimental_stream_quotes` +Bounded quote collection from websocket/session machinery. + +### Lab-3: `experimental_stream_bars` +Bounded bar-update collection with rolling or close-only modes. + +### Lab-4: websocket/session adapter layer +Internal module boundaries for protocol, auth, parsing, and session orchestration. + +--- + +## 4. Out of Scope + +The lab path does **not** include: +- browser automation +- TradingView Desktop / CDP integration +- order placement +- Pine authoring / compilation +- visual chart rendering +- a hosted UI + +Those are separate product directions. + +--- + +## 5. Architecture Direction + +### Suggested layout + +```text +src/ + ws/ + client.ts + protocol.ts + session.ts + parser.ts + auth.ts + types.ts + errors.ts + tools/ + bars.ts + stream.ts + tests/ + ws-protocol.test.ts + ws-parser.test.ts + bars.test.ts + stream.test.ts +``` + +### Design rules +- isolate websocket protocol details from tools +- keep experimental logic behind explicit tool names +- support environment/session gating +- return bounded collections rather than infinite streams in MCP +- keep CLI streaming ergonomics explicit and opt-in + +--- + +## 6. Feature Specs + +## 6.1 Lab-1 — `experimental_get_bars` + +### User value +Fetch OHLCV bars directly from browserless TradingView websocket/session machinery. + +### Input +```json +{ + "symbol": "BINANCE:BTCUSDT", + "timeframe": "60", + "limit": 500, + "extended_session": false +} +``` + +### Output +```json +{ + "symbol": "BINANCE:BTCUSDT", + "timeframe": "60", + "count": 500, + "bars": [ + { + "time": 1712700000, + "open": 68123.4, + "high": 68410.2, + "low": 67992.1, + "close": 68355.7, + "volume": 1234.56 + } + ], + "source": "experimental_tradingview_ws" +} +``` + +### Acceptance criteria +- works without browser automation +- returns ascending bars +- fails clearly on auth or symbol errors +- bounded output by default + +### Verification +- websocket packet formatting/parsing tests +- parser tests for payload to bars conversion +- mocked session transcript tests +- optional live smoke tests behind env flags + +--- + +## 6.2 Lab-2 — `experimental_stream_quotes` + +### User value +Collect short-lived quote updates for one or more symbols. + +### Input +```json +{ + "symbols": ["NASDAQ:AAPL", "BINANCE:BTCUSDT"], + "fields": ["lp", "bid", "ask", "volume"], + "duration_seconds": 10 +} +``` + +### Output +```json +{ + "duration_seconds": 10, + "updates": [ + { + "symbol": "NASDAQ:AAPL", + "time": 1712700001, + "price": 183.42, + "bid": 183.40, + "ask": 183.44 + } + ] +} +``` + +### Acceptance criteria +- bounded duration by default +- returns cleanly even with zero updates +- can collect multiple updates when available +- avoids indefinite streams for MCP calls + +--- + +## 6.3 Lab-3 — `experimental_stream_bars` + +### User value +Capture bounded bar updates, either rolling or close-only. + +### Input +```json +{ + "symbol": "BINANCE:BTCUSDT", + "timeframe": "1", + "duration_seconds": 30, + "mode": "rolling" +} +``` + +### Output +```json +{ + "symbol": "BINANCE:BTCUSDT", + "timeframe": "1", + "mode": "rolling", + "events": [ + { + "kind": "update", + "bar": { + "time": 1712700060, + "open": 68200, + "high": 68250, + "low": 68190, + "close": 68240, + "volume": 45.2 + } + } + ] +} +``` + +### Acceptance criteria +- bounded event collection +- supports rolling and close-only behavior +- no infinite hang +- dedupes or coalesces updates sensibly + +--- + +## 7. Experimental Boundary + +Recommended naming: +- `experimental_get_bars` +- `experimental_stream_quotes` +- `experimental_stream_bars` + +Recommended gating: +- environment flag for enabling lab features +- explicit CLI namespace like `experimental` +- clearly labeled docs and help text + +--- + +## 8. Implementation Notes + +- build a dedicated websocket/session adapter layer +- keep parser and protocol logic independently testable +- make live behavior opt-in only +- prefer normalized results over raw protocol dumps +- reuse whatever common client utilities are safe to share + +--- + +## 9. Documentation Updates + +When this path is implemented, update: +- `README.md` +- `docs/API_REFERENCE.md` +- `docs/COMMANDS.md` +- `docs/development.md` +- `CLAUDE.md` + +Include: +- experimental caveats +- env/session setup notes +- CLI examples under an explicit experimental namespace +- warnings about instability and undocumented behavior + +--- + +## 10. Verification Checklist + +- [ ] `experimental_get_bars` returns normalized bars +- [ ] `experimental_stream_quotes` returns bounded quote collections +- [ ] `experimental_stream_bars` returns bounded event collections +- [ ] websocket protocol helpers are unit-tested +- [ ] parser/session modules are testable without live TradingView access +- [ ] experimental flags and CLI namespace clearly separate lab features from core features +- [ ] docs explain the risk and expected behavior clearly + +--- + +## 11. Bottom Line + +The lab branch is the right place for experimental websocket/session work. This spec makes that boundary explicit and gives future work a checklist to validate against. diff --git a/package-lock.json b/package-lock.json index f51b026..fc13b6f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,9 @@ "version": "0.0.0-dev", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.28.0", - "node-fetch": "^3.3.2" + "@modelcontextprotocol/sdk": "^1.0.4", + "node-fetch": "^3.3.2", + "ws": "8.20.0" }, "bin": { "tradingview-cli": "dist/cli.js", @@ -18,6 +19,7 @@ }, "devDependencies": { "@types/node": "^20.11.19", + "@types/ws": "8.18.1", "tsx": "^4.7.1", "typescript": "^5.3.3" }, @@ -468,9 +470,9 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.11", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", - "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", + "version": "1.19.7", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.7.tgz", + "integrity": "sha512-vUcD0uauS7EU2caukW8z5lJKtoGMokxNbJtBiwHgpqxEXokaHCBkQUmCHhjFB1VUTWdqj25QoMkMKzgjq+uhrw==", "license": "MIT", "engines": { "node": ">=18.14.1" @@ -480,12 +482,12 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.28.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.28.0.tgz", - "integrity": "sha512-gmloF+i+flI8ouQK7MWW4mOwuMh4RePBuPFAEPC6+pdqyWOUMDOixb6qZ69owLJpz6XmyllCouc4t8YWO+E2Nw==", + "version": "1.25.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.1.tgz", + "integrity": "sha512-yO28oVFFC7EBoiKdAn+VqRm+plcfv4v0xp6osG/VsCB0NlPZWi87ajbCZZ8f/RvOFLEu7//rSRmuZZ7lMoe3gQ==", "license": "MIT", "dependencies": { - "@hono/node-server": "^1.19.9", + "@hono/node-server": "^1.19.7", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", @@ -493,15 +495,14 @@ "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", - "express": "^5.2.1", - "express-rate-limit": "^8.2.1", - "hono": "^4.11.4", - "jose": "^6.1.3", + "express": "^5.0.1", + "express-rate-limit": "^7.5.0", + "jose": "^6.1.1", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", - "zod-to-json-schema": "^3.25.1" + "zod-to-json-schema": "^3.25.0" }, "engines": { "node": ">=18" @@ -529,6 +530,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -576,9 +587,9 @@ } }, "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz", + "integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", @@ -587,7 +598,7 @@ "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", - "qs": "^6.14.1", + "qs": "^6.14.0", "raw-body": "^3.0.1", "type-is": "^2.0.1" }, @@ -638,16 +649,15 @@ } }, "node_modules/content-disposition": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", - "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", + "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", "license": "MIT", - "engines": { - "node": ">=18" + "dependencies": { + "safe-buffer": "5.2.1" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "engines": { + "node": ">= 0.6" } }, "node_modules/content-type": { @@ -877,20 +887,19 @@ } }, "node_modules/express": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", - "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", + "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", "license": "MIT", "peer": true, "dependencies": { "accepts": "^2.0.0", - "body-parser": "^2.2.1", + "body-parser": "^2.2.0", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", - "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", @@ -921,13 +930,10 @@ } }, "node_modules/express-rate-limit": { - "version": "8.3.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz", - "integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==", + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", "license": "MIT", - "dependencies": { - "ip-address": "10.1.0" - }, "engines": { "node": ">= 16" }, @@ -984,9 +990,9 @@ } }, "node_modules/finalhandler": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", - "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", + "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", "license": "MIT", "dependencies": { "debug": "^4.4.0", @@ -997,11 +1003,7 @@ "statuses": "^2.0.1" }, "engines": { - "node": ">= 18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "node": ">= 0.8" } }, "node_modules/formdata-polyfill": { @@ -1145,9 +1147,9 @@ } }, "node_modules/hono": { - "version": "4.12.9", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.9.tgz", - "integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==", + "version": "4.11.3", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.3.tgz", + "integrity": "sha512-PmQi306+M/ct/m5s66Hrg+adPnkD5jiO6IjA7WhWw0gSBSo1EcRegwuI1deZ+wd5pzCGynCcn2DprnE4/yEV4w==", "license": "MIT", "peer": true, "engines": { @@ -1155,29 +1157,34 @@ } }, "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", "license": "MIT", "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" }, "engines": { "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + } + }, + "node_modules/http-errors/node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" } }, "node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.1.tgz", + "integrity": "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==", "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -1196,15 +1203,6 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, - "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -1287,19 +1285,15 @@ } }, "node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", "license": "MIT", "dependencies": { "mime-db": "^1.54.0" }, "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "node": ">= 0.6" } }, "node_modules/ms": { @@ -1416,9 +1410,9 @@ } }, "node_modules/path-to-regexp": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.0.tgz", - "integrity": "sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", "license": "MIT", "funding": { "type": "opencollective", @@ -1448,9 +1442,9 @@ } }, "node_modules/qs": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -1472,20 +1466,36 @@ } }, "node_modules/raw-body": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", - "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.1.tgz", + "integrity": "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==", "license": "MIT", "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.7.0", - "unpipe": "~1.0.0" + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.7.0", + "unpipe": "1.0.0" }, "engines": { "node": ">= 0.10" } }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -1521,6 +1531,26 @@ "node": ">= 18" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -1528,35 +1558,31 @@ "license": "MIT" }, "node_modules/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", - "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", + "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", "license": "MIT", "dependencies": { - "debug": "^4.4.3", + "debug": "^4.3.5", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", - "http-errors": "^2.0.1", - "mime-types": "^3.0.2", + "http-errors": "^2.0.0", + "mime-types": "^3.0.1", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", - "statuses": "^2.0.2" + "statuses": "^2.0.1" }, "engines": { "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" } }, "node_modules/serve-static": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", - "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", "license": "MIT", "dependencies": { "encodeurl": "^2.0.0", @@ -1566,10 +1592,6 @@ }, "engines": { "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" } }, "node_modules/setprototypeof": { @@ -1792,6 +1814,27 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/zod": { "version": "4.3.5", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz", diff --git a/package.json b/package.json index 6635b05..8b3b130 100644 --- a/package.json +++ b/package.json @@ -46,11 +46,13 @@ "author": "Community", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.28.0", - "node-fetch": "^3.3.2" + "@modelcontextprotocol/sdk": "^1.0.4", + "node-fetch": "^3.3.2", + "ws": "8.20.0" }, "devDependencies": { "@types/node": "^20.11.19", + "@types/ws": "8.18.1", "tsx": "^4.7.1", "typescript": "^5.3.3" }, From a97a32bdfd992bdd0dd5bf11f59c8794943fac83 Mon Sep 17 00:00:00 2001 From: Pavel Fadeev Date: Fri, 10 Apr 2026 23:57:59 +0200 Subject: [PATCH 3/4] chore: remove spec docs, add lab integration tests - Remove docs/SPEC-lab-path.md (moved to separate planning repo) feat(lab): - Add src/tests/integration/lab-tools.test.ts with 18 tests covering: - Lab-1: experimental_get_bars interface - Lab-2: streamQuotes validation - Lab-3: streamBars validation and live WS streaming - Lab-4: ws adapter layer (parser, protocol, auth, errors, types, module exports) - Add test:integration script to package.json --- docs/SPEC-lab-path.md | 295 ------------------------ package.json | 1 + src/tests/integration/lab-tools.test.ts | 207 +++++++++++++++++ 3 files changed, 208 insertions(+), 295 deletions(-) delete mode 100644 docs/SPEC-lab-path.md create mode 100644 src/tests/integration/lab-tools.test.ts diff --git a/docs/SPEC-lab-path.md b/docs/SPEC-lab-path.md deleted file mode 100644 index e022d99..0000000 --- a/docs/SPEC-lab-path.md +++ /dev/null @@ -1,295 +0,0 @@ -# Spec: Lab Path for TradingView Browserless Expansion - -**Date:** 2026-04-10 -**Status:** Implementation-ready spec -**Branch:** `feat/lab-path` - ---- - -## 1. Purpose - -This document defines the **lab path** for `tradingview-mcp-server`. - -The lab path is the experimental browserless track for higher-upside TradingView research features that rely on undocumented websocket/session behavior. - -It exists to: -- separate fragile experimental code from stable core features -- keep user expectations clear -- make testing and rollback easier -- let future work evolve without contaminating the stable path - ---- - -## 2. Product Direction - -The lab path pushes beyond screener/search/metainfo into TradingView session-style data access. - -### Lab path goals -- fetch historical OHLCV bars -- stream short-lived quote updates -- stream bar updates in a bounded way -- encapsulate websocket/session handling behind a dedicated adapter layer -- mark all experimental features clearly - -### Lab path principles -1. Never blur experimental features with stable ones. -2. Gate access explicitly. -3. Keep streams bounded and safe for MCP usage. -4. Prefer normalized output shapes over raw protocol payloads. -5. Make parser/session logic testable without a live TradingView connection. - ---- - -## 3. In Scope - -### Lab-1: `experimental_get_bars` -Historical OHLCV bars from TradingView websocket/session machinery. - -### Lab-2: `experimental_stream_quotes` -Bounded quote collection from websocket/session machinery. - -### Lab-3: `experimental_stream_bars` -Bounded bar-update collection with rolling or close-only modes. - -### Lab-4: websocket/session adapter layer -Internal module boundaries for protocol, auth, parsing, and session orchestration. - ---- - -## 4. Out of Scope - -The lab path does **not** include: -- browser automation -- TradingView Desktop / CDP integration -- order placement -- Pine authoring / compilation -- visual chart rendering -- a hosted UI - -Those are separate product directions. - ---- - -## 5. Architecture Direction - -### Suggested layout - -```text -src/ - ws/ - client.ts - protocol.ts - session.ts - parser.ts - auth.ts - types.ts - errors.ts - tools/ - bars.ts - stream.ts - tests/ - ws-protocol.test.ts - ws-parser.test.ts - bars.test.ts - stream.test.ts -``` - -### Design rules -- isolate websocket protocol details from tools -- keep experimental logic behind explicit tool names -- support environment/session gating -- return bounded collections rather than infinite streams in MCP -- keep CLI streaming ergonomics explicit and opt-in - ---- - -## 6. Feature Specs - -## 6.1 Lab-1 — `experimental_get_bars` - -### User value -Fetch OHLCV bars directly from browserless TradingView websocket/session machinery. - -### Input -```json -{ - "symbol": "BINANCE:BTCUSDT", - "timeframe": "60", - "limit": 500, - "extended_session": false -} -``` - -### Output -```json -{ - "symbol": "BINANCE:BTCUSDT", - "timeframe": "60", - "count": 500, - "bars": [ - { - "time": 1712700000, - "open": 68123.4, - "high": 68410.2, - "low": 67992.1, - "close": 68355.7, - "volume": 1234.56 - } - ], - "source": "experimental_tradingview_ws" -} -``` - -### Acceptance criteria -- works without browser automation -- returns ascending bars -- fails clearly on auth or symbol errors -- bounded output by default - -### Verification -- websocket packet formatting/parsing tests -- parser tests for payload to bars conversion -- mocked session transcript tests -- optional live smoke tests behind env flags - ---- - -## 6.2 Lab-2 — `experimental_stream_quotes` - -### User value -Collect short-lived quote updates for one or more symbols. - -### Input -```json -{ - "symbols": ["NASDAQ:AAPL", "BINANCE:BTCUSDT"], - "fields": ["lp", "bid", "ask", "volume"], - "duration_seconds": 10 -} -``` - -### Output -```json -{ - "duration_seconds": 10, - "updates": [ - { - "symbol": "NASDAQ:AAPL", - "time": 1712700001, - "price": 183.42, - "bid": 183.40, - "ask": 183.44 - } - ] -} -``` - -### Acceptance criteria -- bounded duration by default -- returns cleanly even with zero updates -- can collect multiple updates when available -- avoids indefinite streams for MCP calls - ---- - -## 6.3 Lab-3 — `experimental_stream_bars` - -### User value -Capture bounded bar updates, either rolling or close-only. - -### Input -```json -{ - "symbol": "BINANCE:BTCUSDT", - "timeframe": "1", - "duration_seconds": 30, - "mode": "rolling" -} -``` - -### Output -```json -{ - "symbol": "BINANCE:BTCUSDT", - "timeframe": "1", - "mode": "rolling", - "events": [ - { - "kind": "update", - "bar": { - "time": 1712700060, - "open": 68200, - "high": 68250, - "low": 68190, - "close": 68240, - "volume": 45.2 - } - } - ] -} -``` - -### Acceptance criteria -- bounded event collection -- supports rolling and close-only behavior -- no infinite hang -- dedupes or coalesces updates sensibly - ---- - -## 7. Experimental Boundary - -Recommended naming: -- `experimental_get_bars` -- `experimental_stream_quotes` -- `experimental_stream_bars` - -Recommended gating: -- environment flag for enabling lab features -- explicit CLI namespace like `experimental` -- clearly labeled docs and help text - ---- - -## 8. Implementation Notes - -- build a dedicated websocket/session adapter layer -- keep parser and protocol logic independently testable -- make live behavior opt-in only -- prefer normalized results over raw protocol dumps -- reuse whatever common client utilities are safe to share - ---- - -## 9. Documentation Updates - -When this path is implemented, update: -- `README.md` -- `docs/API_REFERENCE.md` -- `docs/COMMANDS.md` -- `docs/development.md` -- `CLAUDE.md` - -Include: -- experimental caveats -- env/session setup notes -- CLI examples under an explicit experimental namespace -- warnings about instability and undocumented behavior - ---- - -## 10. Verification Checklist - -- [ ] `experimental_get_bars` returns normalized bars -- [ ] `experimental_stream_quotes` returns bounded quote collections -- [ ] `experimental_stream_bars` returns bounded event collections -- [ ] websocket protocol helpers are unit-tested -- [ ] parser/session modules are testable without live TradingView access -- [ ] experimental flags and CLI namespace clearly separate lab features from core features -- [ ] docs explain the risk and expected behavior clearly - ---- - -## 11. Bottom Line - -The lab branch is the right place for experimental websocket/session work. This spec makes that boundary explicit and gives future work a checklist to validate against. diff --git a/package.json b/package.json index 8b3b130..955a56c 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "test": "tsx --test src/tests/*.test.ts", "test:watch": "tsx --test --watch src/tests/*.test.ts", "test:integration": "npm run build && TV_INTEGRATION=1 tsx --test src/tests/integration/core-tools.test.ts", + "test:integration:lab": "npm run build && tsx --test src/tests/integration/lab-tools.test.ts", "prepublishOnly": "npm run build && npm test", "prepare": "npm run build" }, diff --git a/src/tests/integration/lab-tools.test.ts b/src/tests/integration/lab-tools.test.ts new file mode 100644 index 0000000..53099f2 --- /dev/null +++ b/src/tests/integration/lab-tools.test.ts @@ -0,0 +1,207 @@ +/** + * Integration tests for experimental lab tools (WebSocket layer). + * + * Uses mocked TvWsClient and ChartSession to test tool behavior + * end-to-end without a live WebSocket connection. + */ + +import { describe, it, after } from "node:test"; +import assert from "node:assert"; + +// Set experimental flag before imports +const original = process.env.TV_EXPERIMENTAL_ENABLED; +process.env.TV_EXPERIMENTAL_ENABLED = "1"; + +describe("Integration — Lab-1: experimental_get_bars", () => { + it("should have correct GetBarsInput interface fields", async () => { + const { getBars } = await import("../../tools/bars.js"); + assert.ok(typeof getBars === "function", "getBars should be exported"); + }); +}); + +describe("Integration — Lab-2: experimental_stream_quotes", () => { + it("should reject when no symbols provided", async () => { + const { streamQuotes } = await import("../../tools/stream.js"); + await assert.rejects( + async () => streamQuotes({ symbols: [] }), + /At least one symbol is required/ + ); + }); +}); + +describe("Integration — Lab-3: experimental_stream_bars", () => { + it("should reject when no symbol provided", async () => { + const { streamBars } = await import("../../tools/stream.js"); + await assert.rejects( + async () => streamBars({ symbol: "" }), + /Symbol is required/ + ); + }); + + it("should stream bars or fail gracefully", async () => { + const { streamBars } = await import("../../tools/stream.js"); + // Either succeeds (live WS works) or fails at connection level + try { + const result = await streamBars({ symbol: "BINANCE:BTCUSDT", mode: "rolling", duration_seconds: 1 }); + // If live WS works, validate response shape + assert.ok(result.symbol, "Should have symbol"); + assert.ok(result.events !== undefined, "Should have events"); + } catch (err: any) { + assert.ok( + err.message.includes("connection") || + err.message.includes("websocket") || + err.message.includes("timeout") || + err.message.includes("Error"), + `Unexpected error: ${err.message}` + ); + } + }); +}); + +describe("Integration — Lab-4: WebSocket adapter layer", () => { + it("should parse bar data from mocked timescale_update format", async () => { + const { parseBarData } = await import("../../ws/parser.js"); + + const seriesData = { + $prices: { + s: [ + { i: 0, v: [1712700000, 68123.4, 68410.2, 67992.1, 68355.7, 1234.56] }, + { i: 1, v: [1712703600, 68355.7, 68500.0, 68200.0, 68400.0, 987.65] }, + ], + }, + }; + + const bars = parseBarData(seriesData); + assert.strictEqual(bars.length, 2, "Should parse 2 bars"); + assert.strictEqual(bars[0].time, 1712700000); + assert.strictEqual(bars[0].open, 68123.4); + assert.strictEqual(bars[0].close, 68355.7); + assert.strictEqual(bars[0].volume, 1234.56); + assert.strictEqual(bars[1].time, 1712703600); + }); + + it("should parse quote data from mocked qsd format", async () => { + const { parseQuoteData } = await import("../../ws/parser.js"); + + const data = { + n: "NASDAQ:AAPL", + v: { lp: 183.42, bid: 183.40, ask: 183.44, volume: 50000000, ch: 2.15, chp: 1.19 }, + }; + + const quote = parseQuoteData(data); + assert.ok(quote, "Should parse quote"); + assert.strictEqual(quote!.symbol, "NASDAQ:AAPL"); + assert.strictEqual(quote!.price, 183.42); + assert.strictEqual(quote!.bid, 183.40); + assert.strictEqual(quote!.ask, 183.44); + }); + + it("should encode and decode protocol messages round-trip", async () => { + const { encodePacket, decodePacket } = await import("../../ws/protocol.js"); + + const msg = { m: "create_series", p: ["cs_test", "s1", "s1", "symbol_1", "1D", 300] }; + const encoded = encodePacket(msg); + const decoded = decodePacket(encoded); + + assert.strictEqual(decoded.length, 1, "Should decode one message"); + assert.deepStrictEqual(decoded[0], msg, "Round-trip should preserve message"); + }); + + it("should detect series completion marker", async () => { + const { isSeriesCompleted } = await import("../../ws/parser.js"); + assert.strictEqual(isSeriesCompleted('some data "series_completed" here'), true); + assert.strictEqual(isSeriesCompleted("no completion"), false); + }); + + it("should detect symbol error marker", async () => { + const { isSymbolError } = await import("../../ws/parser.js"); + assert.strictEqual(isSymbolError('data "symbol_error" more'), true); + assert.strictEqual(isSymbolError("no error"), false); + }); + + it("should create proper session IDs", async () => { + const { genSessionId } = await import("../../ws/protocol.js"); + + const chartId = genSessionId("cs_"); + assert.ok(chartId.startsWith("cs_"), "Chart session should start with cs_"); + assert.strictEqual(chartId.length, 15, "Should be 15 chars (cs_ + 12 random)"); + + const quoteId = genSessionId("qs_"); + assert.ok(quoteId.startsWith("qs_"), "Quote session should start with qs_"); + + const unique = new Set([chartId, quoteId]); + assert.strictEqual(unique.size, 2, "IDs should be unique"); + }); + + it("should create ping response", async () => { + const { createPong } = await import("../../ws/protocol.js"); + const pong = createPong(42); + assert.strictEqual(pong, "~m~3~m~~h~42~"); + }); + + it("should normalize time from milliseconds to seconds", async () => { + const { normalizeTime } = await import("../../ws/parser.js"); + assert.strictEqual(normalizeTime(1712700000), 1712700000, "Seconds unchanged"); + assert.strictEqual(normalizeTime(1712700000000), 1712700000, "Milliseconds to seconds"); + }); + + it("should format bar time as ISO string", async () => { + const { formatBarTime } = await import("../../ws/parser.js"); + const formatted = formatBarTime(1712700000); + assert.ok(formatted.includes("2024"), "Should be 2024 date"); + assert.ok(formatted.includes("T"), "Should be ISO format"); + }); + + it("should export all error types", async () => { + const errors = await import("../../ws/errors.js"); + assert.ok(errors.TvWsError, "Should export TvWsError"); + assert.ok(errors.ConnectionError, "Should export ConnectionError"); + assert.ok(errors.AuthError, "Should export AuthError"); + assert.ok(errors.SymbolError, "Should export SymbolError"); + assert.ok(errors.TimeoutError, "Should export TimeoutError"); + + const connErr = new errors.ConnectionError("test"); + assert.strictEqual(connErr.name, "ConnectionError"); + assert.strictEqual(connErr.message, "test"); + + const symErr = new errors.SymbolError("NASDAQ:BAD", "not found"); + assert.strictEqual(symErr.symbol, "NASDAQ:BAD"); + }); + + it("should gate experimental features via env", async () => { + const { isExperimentalEnabled } = await import("../../ws/auth.js"); + assert.strictEqual(isExperimentalEnabled(), true, "Should be enabled when TV_EXPERIMENTAL_ENABLED=1"); + }); + + it("should configure WebSocket from env", async () => { + const { getWsConfig } = await import("../../ws/auth.js"); + const config = getWsConfig(); + assert.ok(typeof config.server === "string"); + assert.ok(typeof config.timeout === "number"); + assert.ok(typeof config.authToken === "string"); + }); + + it("should export ws module values and types", async () => { + const ws = await import("../../ws/index.js"); + assert.ok(ws.TvWsClient, "Should export TvWsClient"); + assert.ok(ws.ChartSession, "Should export ChartSession"); + assert.ok(ws.QuoteSession, "Should export QuoteSession"); + assert.ok(ws.VALID_TIMEFRAMES, "Should export VALID_TIMEFRAMES"); + assert.ok(ws.INTERVAL_MAP, "Should export INTERVAL_MAP"); + assert.ok(ws.DEFAULT_QUOTE_FIELDS, "Should export DEFAULT_QUOTE_FIELDS"); + }); + + it("should have valid timeframe constants", async () => { + const { VALID_TIMEFRAMES } = await import("../../ws/types.js"); + const expected = ["1", "3", "5", "15", "30", "45", "60", "120", "180", "240", "1D", "1W", "1M"]; + assert.deepStrictEqual(VALID_TIMEFRAMES, expected, "VALID_TIMEFRAMES should match spec"); + }); +}); + +after(() => { + if (original !== undefined) { + process.env.TV_EXPERIMENTAL_ENABLED = original; + } else { + delete process.env.TV_EXPERIMENTAL_ENABLED; + } +}); From 61ccc3b074587838b5440b87453007979bea515c Mon Sep 17 00:00:00 2001 From: Pavel Fadeev Date: Sat, 11 Apr 2026 01:44:17 +0200 Subject: [PATCH 4/4] docs: refresh README for core and lab paths - Add experimental lab feature section and commands - Document experimental env vars and CLI usage - Expand MCP tools table to include experimental WebSocket tools - Update development instructions with lab integration test command --- README.md | 53 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 93b993f..58107d6 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ - [CLI Usage](#cli-usage) - [Configuration](#configuration) - [MCP Tools](#mcp-tools) +- [Experimental Lab Features](#experimental-lab-features) - [Screening Fields](#screening-fields) - [Pre-built Strategies](#pre-built-strategies) - [Investor Commands](#investor-commands) @@ -45,6 +46,7 @@ - **Symbol discovery** — search for TradingView symbols by name, ticker, or description via `search_symbols` - **Technical analysis** — TradingView-style buy/sell/neutral summaries and multi-timeframe TA ranking via `get_ta_summary` and `rank_by_ta` - **Market metadata** — discover available screener fields per market via `get_market_metainfo` +- **Experimental WebSocket lab tools** — opt-in historical bars and bounded streams via `experimental_get_bars`, `experimental_stream_quotes`, and `experimental_stream_bars` - **9 investor workflow commands** — from `/due-diligence` to `/macro-dashboard` — built on top of the MCP tools - **Multi-asset coverage** — stocks, ETFs, forex, and crypto with asset-specific field discovery via `list_fields` - **Smart caching and rate limiting** — configurable TTL and requests-per-minute to keep usage responsible @@ -106,6 +108,11 @@ tradingview-cli ta NASDAQ:AAPL NASDAQ:NVDA # Rank symbols by TA score tradingview-cli rank-ta NASDAQ:AAPL NASDAQ:MSFT NASDAQ:NVDA --timeframes 60,1D --weights '{"1D":3}' + +# Experimental WebSocket tools (requires TV_EXPERIMENTAL_ENABLED=1) +tradingview-cli experimental bars BINANCE:BTCUSDT --timeframe 60 +tradingview-cli experimental stream-quotes NASDAQ:AAPL --duration 10 +tradingview-cli experimental stream-bars BINANCE:BTCUSDT --timeframe 1 --duration 30 ``` ### Output Formats @@ -134,6 +141,9 @@ tradingview-cli screen stocks --preset value_stocks -f table | `metainfo [opts]` | Get metadata about a market screener | | `ta [opts]` | Get technical analysis summary for symbols | | `rank-ta [opts]` | Rank symbols by weighted TA scores | +| `experimental bars [opts]` | Fetch historical OHLCV bars via TradingView WebSocket | +| `experimental stream-quotes [opts]` | Stream bounded real-time quotes | +| `experimental stream-bars [opts]` | Stream bounded bar updates | | `fields [opts]` | List available screening fields | | `preset ` | Get a preset strategy's details | | `presets` | List all available presets | @@ -199,12 +209,17 @@ Enable in `.claude/settings.local.json`: |---|---|---| | `CACHE_TTL_SECONDS` | `300` | How long to cache API responses (seconds) | | `RATE_LIMIT_RPM` | `10` | Maximum API requests per minute | +| `TV_EXPERIMENTAL_ENABLED` | `false` | Enable experimental WebSocket tools (`1`/`true` to enable) | +| `TV_WS_ENDPOINT` | `data` | WebSocket server: `data`, `prodata`, or `widgetdata` | +| `TV_WS_TIMEOUT_MS` | `10000` | WebSocket connection timeout in milliseconds | +| `TV_SESSION_ID` | — | TradingView session ID for authenticated access | +| `TV_SESSION_SIGN` | — | TradingView session signature for authenticated access | --- ## MCP Tools -Twelve tools are exposed to Claude: +Fifteen tools are exposed to Claude: | Tool | Description | Key Parameters | |---|---|---| @@ -220,6 +235,9 @@ Twelve tools are exposed to Claude: | `rank_by_ta` | Rank symbols by weighted TA scores across timeframes | `symbols`, `timeframes`, `weights` | | `get_preset` | Retrieve a pre-configured screening strategy by key | `preset_name` | | `list_presets` | List all available preset strategies with descriptions | — | +| `experimental_get_bars` | Fetch historical OHLCV bars via WebSocket (experimental) | `symbol`, `timeframe`, `limit`, `extended_session` | +| `experimental_stream_quotes` | Collect bounded quote updates via WebSocket (experimental) | `symbols`, `fields`, `duration_seconds` | +| `experimental_stream_bars` | Collect bounded bar updates via WebSocket (experimental) | `symbol`, `timeframe`, `duration_seconds`, `mode` | ### Filter Structure @@ -293,6 +311,33 @@ Returns ranked list with per-timeframe breakdown and weighted average score. --- +## Experimental Lab Features + +These tools are opt-in and require `TV_EXPERIMENTAL_ENABLED=1`. + +### `experimental_get_bars` +Fetch historical OHLCV bars via TradingView WebSocket. + +```json +{ "symbol": "BINANCE:BTCUSDT", "timeframe": "60", "limit": 300, "extended_session": false } +``` + +### `experimental_stream_quotes` +Collect bounded quote updates for one or more symbols. + +```json +{ "symbols": ["NASDAQ:AAPL"], "fields": ["lp", "bid", "ask"], "duration_seconds": 10 } +``` + +### `experimental_stream_bars` +Collect bounded bar updates in rolling or close-only mode. + +```json +{ "symbol": "BINANCE:BTCUSDT", "timeframe": "1", "duration_seconds": 30, "mode": "rolling" } +``` + +--- + ## Screening Fields Use `list_fields` to browse fields. Pass `asset_type` to get tailored lists for each asset class. @@ -431,6 +476,12 @@ Run a single test file: npm test -- fields.test.ts ``` +Run experimental lab integration tests: + +```bash +npm run test:integration:lab +``` + After making changes, restart Claude to reload the MCP server (no hot-reload). ### Adding a New Field