diff --git a/README.md b/README.md index dd438a0..e3eba3b 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,9 @@ tradingview-cli presets # Screen stocks using a preset tradingview-cli screen stocks --preset quality_stocks --limit 10 +# Or load a strict versioned JSON preset file +tradingview-cli screen stocks --preset-file ./my-preset.json --limit 10 + # Screen with custom filters tradingview-cli screen stocks --filters '[{"field":"price_earnings_ttm","operator":"less","value":15}]' @@ -152,7 +155,8 @@ tradingview-cli screen stocks --preset value_stocks -f table | Flag | Description | |---|---| | `--filters ` | Filter array as JSON string | -| `--preset ` | Load a preset (merges with `--filters`) | +| `--preset ` | Load a built-in preset (exclusive with `--preset-file`) | +| `--preset-file ` | Load a strict `schemaVersion: 1` JSON preset file (exclusive with `--preset`) | | `--markets ` | Market to screen (repeatable, stocks/etf only) | | `--sort-by ` | Sort by field | | `--sort-order ` | Sort direction | diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 731a38b..7e09f83 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -27,7 +27,7 @@ Screen stocks based on fundamental and technical criteria. Returns stocks matchi |-------|------|----------|-------| | `field` | `string` | Yes | Field name to filter on. String fields (sector, exchange, industry, market) support `equal` and `in_range`. Cross-field comparison: use another field name as value (e.g., `SMA50 crosses_above SMA200`). | | `operator` | `string` | Yes | One of the 18 supported operators (see Operators section) | -| `value` | `number \| string \| [number, number] \| string[]` | Conditional | Not required for `empty` and `not_empty` operators. Use array `[min, max]` for `in_range`. | +| `value` | `number \| string \| boolean \| [number, number] \| [string, number] \| string[]` | Conditional | Not required for `empty` and `not_empty`. Operator-specific shapes are listed below. | **Default columns:** `name`, `close`, `market_cap_basic`, `return_on_equity`, `price_earnings_ttm`, `debt_to_equity`, `exchange` @@ -263,10 +263,10 @@ All 18 operators supported by the filter system: | Operator | TradingView Operation | Value Type | Description | Example | |----------|-----------------------|------------|-------------|---------| -| `greater` | `greater` | number | Field > value | `{"field": "return_on_equity", "operator": "greater", "value": 15}` | -| `less` | `less` | number | Field < value | `{"field": "price_earnings_ttm", "operator": "less", "value": 20}` | -| `greater_or_equal` | `egreater` | number | Field >= value | `{"field": "market_cap_basic", "operator": "greater_or_equal", "value": 1000000000}` | -| `less_or_equal` | `eless` | number | Field <= value | `{"field": "Volatility.M", "operator": "less_or_equal", "value": 3}` | +| `greater` | `greater` | number / field name | Field > value or field | `{"field": "return_on_equity", "operator": "greater", "value": 15}` | +| `less` | `less` | number / field name | Field < value or field | `{"field": "price_earnings_ttm", "operator": "less", "value": 20}` | +| `greater_or_equal` | `egreater` | number / field name | Field >= value or field | `{"field": "market_cap_basic", "operator": "greater_or_equal", "value": 1000000000}` | +| `less_or_equal` | `eless` | number / field name | Field <= value or field | `{"field": "Volatility.M", "operator": "less_or_equal", "value": 3}` | | `equal` | `equal` | string / number / boolean | Field = value | `{"field": "sector", "operator": "equal", "value": "Technology"}` | | `not_equal` | `nequal` | string / number | Field != value | `{"field": "industry", "operator": "not_equal", "value": "Real Estate Investment Trusts"}` | | `in_range` | `in_range` | `[min, max]` or `string[]` | Field between min and max (inclusive), or field matches any string in array | `{"field": "RSI", "operator": "in_range", "value": [40, 70]}` | @@ -275,9 +275,9 @@ All 18 operators supported by the filter system: | `crosses_above` | `crosses_above` | string (field name) | Field crosses above another field (bullish) | `{"field": "SMA50", "operator": "crosses_above", "value": "SMA200"}` | | `crosses_below` | `crosses_below` | string (field name) | Field crosses below another field (bearish) | `{"field": "SMA50", "operator": "crosses_below", "value": "SMA200"}` | | `match` | `match` | string | String pattern match | `{"field": "sector", "operator": "match", "value": "Tech"}` | -| `above_percent` | `above%` | number | Field is X% above another field | `{"field": "close", "operator": "above_percent", "value": 5}` | -| `below_percent` | `below%` | number | Field is X% below another field | `{"field": "close", "operator": "below_percent", "value": 10}` | -| `has` | `has` | string / string[] | Field contains value (for set/list fields) | `{"field": "indexes", "operator": "has", "value": "S&P 500"}` | +| `above_percent` | `above%` | `[field, percent]` | Field is X% above another field | `{"field": "close", "operator": "above_percent", "value": ["SMA200", 5]}` | +| `below_percent` | `below%` | `[field, percent]` | Field is X% below another field | `{"field": "close", "operator": "below_percent", "value": ["SMA200", 10]}` | +| `has` | `has` | `string[]` | Field contains one of the listed values (for set/list fields) | `{"field": "indexes", "operator": "has", "value": ["S&P 500"]}` | | `has_none_of` | `has_none_of` | string[] | Field contains none of the given values | `{"field": "indexes", "operator": "has_none_of", "value": ["S&P 500"]}` | | `empty` | `empty` | (none) | Field is null/empty — no value required | `{"field": "earnings_release_next_trading_date_fq", "operator": "empty"}` | | `not_empty` | `nempty` | (none) | Field is not null/empty — no value required | `{"field": "dividend_yield_recent", "operator": "not_empty"}` | diff --git a/docs/presets.md b/docs/presets.md index 2f0e790..efccc76 100644 --- a/docs/presets.md +++ b/docs/presets.md @@ -24,6 +24,7 @@ Each preset codifies the criteria that define an investment approach - quality, ## Table of Contents - [Overview](#overview) +- [Preset Files](#preset-files) - [Quality Stocks (Conservative)](#quality-stocks-conservative) - [Value Stocks](#value-stocks) - [Dividend Stocks](#dividend-stocks) @@ -43,6 +44,34 @@ Presets can be accessed via: Each preset is designed for a specific investment strategy and returns different column sets based on the analysis depth required. +## Preset Files + +The CLI can load a local preset explicitly without changing the built-in registry: + +```bash +tradingview-cli screen stocks --preset-file ./my-preset.json +``` + +`--preset-file` and `--preset` are mutually exclusive. Paths resolve from the current working directory, symlinks resolve to a regular file, and files are limited to 1 MiB. The CLI validates UTF-8 JSON, strict keys, operators, limits, and value shapes before screening. Diagnostics expose only the basename and SHA-256 hash, not the absolute path. + +```json +{ + "schemaVersion": 1, + "name": "Common US shares", + "description": "Example research preset", + "filters": [ + { "field": "typespecs", "operator": "has", "value": ["common"] } + ], + "markets": ["america"], + "sort_by": "market_cap_basic", + "sort_order": "desc", + "limit": 25, + "columns": ["name", "close", "market_cap_basic"] +} +``` + +A preset must define exactly one of `filters` or `symbols`. It cannot import other files, reference URLs, or contain unknown metadata keys. + --- ## Quality Stocks (Conservative) diff --git a/src/cli.ts b/src/cli.ts index 44dacdd..9d5d160 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -18,6 +18,7 @@ import { PresetsTool } from "./resources/presets.js"; import { Cache } from "./utils/cache.js"; import { RateLimiter } from "./utils/rateLimit.js"; import { formatOutput, type OutputFormat } from "./cli/formatters.js"; +import { loadPresetFile } from "./cli/presetFile.js"; import { parseTopLevel, parseScreenArgs, @@ -152,16 +153,28 @@ async function handleScreen(subPositionals: string[], fullArgv: string[]) { } const format = (values.format as OutputFormat) || "json"; + if (values.preset && values["preset-file"]) { + throw new Error("--preset and --preset-file are mutually exclusive"); + } + const loadedPreset = values["preset-file"] + ? loadPresetFile(values["preset-file"] as string) + : undefined; + if (loadedPreset) { + process.stderr.write( + `Loaded preset file ${loadedPreset.provenance.basename} sha256=${loadedPreset.provenance.sha256}\n` + ); + } const { input, isSymbolLookup, symbols } = buildScreenInput( values, - presetsTool + presetsTool, + loadedPreset?.preset ); let result: any; if (isSymbolLookup) { process.stderr.write( - `Note: preset '${values.preset}' uses direct symbol lookup\n` + `Note: preset '${values.preset ?? loadedPreset?.provenance.basename}' uses direct symbol lookup\n` ); result = await screenTool.lookupSymbols({ symbols: symbols!, diff --git a/src/cli/help.ts b/src/cli/help.ts index 2d63559..9c0240b 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -42,7 +42,8 @@ export const SCREEN_HELP = `Usage: tradingview-cli screen Filter array as JSON string - --preset Load a preset strategy (merges with --filters) + --preset Load a built-in preset (exclusive with --preset-file) + --preset-file Load a versioned preset JSON file (exclusive with --preset) --markets Market to screen (repeatable, stocks/etf only) --sort-by Field to sort by --sort-order Sort direction diff --git a/src/cli/parseArgs.ts b/src/cli/parseArgs.ts index 3fb2c3a..30592f1 100644 --- a/src/cli/parseArgs.ts +++ b/src/cli/parseArgs.ts @@ -4,7 +4,7 @@ import { parseArgs } from "node:util"; import type { ScreenStocksInput, ListFieldsInput } from "../api/types.js"; -import type { PresetsTool } from "../resources/presets.js"; +import type { Preset, PresetsTool } from "../resources/presets.js"; // Option configs for util.parseArgs @@ -101,6 +101,7 @@ export const TOP_LEVEL_OPTIONS = { export const SCREEN_OPTIONS = { filters: { type: "string" as const }, preset: { type: "string" as const }, + "preset-file": { type: "string" as const }, markets: { type: "string" as const, multiple: true }, "sort-by": { type: "string" as const }, "sort-order": { type: "string" as const }, @@ -265,14 +266,19 @@ export interface ScreenBuildResult { */ export function buildScreenInput( values: Record, - presetsTool: PresetsTool + presetsTool: PresetsTool, + filePreset?: Preset ): ScreenBuildResult { let base: Partial & { symbols?: string[] } = {}; let isSymbolLookup = false; let symbols: string[] | undefined; - if (values.preset) { - const preset = presetsTool.getPreset(values.preset); + if (values.preset && values["preset-file"]) { + throw new Error("--preset and --preset-file are mutually exclusive"); + } + + if (values.preset || filePreset) { + const preset = filePreset ?? presetsTool.getPreset(values.preset); if (!preset) { throw new Error( `Unknown preset: ${values.preset}. Run 'tradingview-cli presets' to see available presets.` @@ -290,6 +296,7 @@ export function buildScreenInput( sort_by: preset.sort_by, sort_order: preset.sort_order, columns: preset.columns, + limit: preset.limit, }; } } @@ -301,7 +308,7 @@ export function buildScreenInput( markets: values.markets ?? base.markets, sort_by: values["sort-by"] ?? base.sort_by, sort_order: values["sort-order"] ?? base.sort_order, - limit: values.limit ? parseInt(values.limit, 10) : undefined, + limit: values.limit ? parseInt(values.limit, 10) : base.limit, columns: values.columns ?? base.columns, }; diff --git a/src/cli/presetFile.ts b/src/cli/presetFile.ts new file mode 100644 index 0000000..222e0d2 --- /dev/null +++ b/src/cli/presetFile.ts @@ -0,0 +1,192 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import type { Preset } from "../resources/presets.js"; +import { validateScreenFilters } from "../tools/screen.js"; + +const MAX_PRESET_BYTES = 1024 * 1024; +const TOP_LEVEL_KEYS = new Set([ + "schemaVersion", "name", "description", "filters", "symbols", "markets", + "sort_by", "sort_order", "limit", "columns", +]); +const FILTER_KEYS = new Set(["field", "operator", "value"]); +class PresetFileValidationError extends Error {} + +function readBoundedFile(fd: number): Buffer { + const buffer = Buffer.allocUnsafe(MAX_PRESET_BYTES + 1); + let offset = 0; + while (offset < buffer.length) { + const bytesRead = fs.readSync(fd, buffer, offset, buffer.length - offset, null); + if (bytesRead === 0) break; + offset += bytesRead; + } + if (offset > MAX_PRESET_BYTES) { + throw new PresetFileValidationError(`Preset file exceeds ${MAX_PRESET_BYTES} bytes`); + } + return buffer.subarray(0, offset); +} + +export interface PresetFileProvenance { + kind: "file"; + basename: string; + sha256: string; + loadedAt: string; +} + +function assertExactKeys(value: Record, allowed: Set, context: string) { + const unknown = Object.keys(value).filter((key) => !allowed.has(key)); + if (unknown.length) throw new Error(`${context} has unknown keys: ${unknown.join(", ")}`); +} + +function validateFilterValue(value: unknown, operator: string, context: string) { + const isFiniteNumber = (item: unknown): item is number => + typeof item === "number" && Number.isFinite(item); + const isNonEmptyString = (item: unknown): item is string => + typeof item === "string" && item.length > 0; + const isNumericRange = (item: unknown): item is [number, number] => + Array.isArray(item) && item.length === 2 && item.every(isFiniteNumber); + const isStringList = (item: unknown): item is string[] => + Array.isArray(item) && item.length > 0 && item.every(isNonEmptyString); + + if (["empty", "not_empty"].includes(operator)) { + if (value !== undefined) throw new Error(`${context} operator ${operator} does not accept a value`); + return; + } + if (["greater", "less", "greater_or_equal", "less_or_equal"].includes(operator)) { + if (!(isFiniteNumber(value) || isNonEmptyString(value))) { + throw new Error(`${context} operator ${operator} requires a finite number or field-name string`); + } + return; + } + if (operator === "equal") { + if (!(isNonEmptyString(value) || isFiniteNumber(value) || typeof value === "boolean")) { + throw new Error(`${context} operator equal requires a non-empty string, finite number, or boolean`); + } + return; + } + if (operator === "not_equal") { + if (!(isNonEmptyString(value) || isFiniteNumber(value))) { + throw new Error(`${context} operator not_equal requires a non-empty string or finite number`); + } + return; + } + if (operator === "in_range") { + // TradingView also uses in_range as membership for string fields. + if (!(isNumericRange(value) || isStringList(value))) { + throw new Error(`${context} operator in_range requires two finite numbers or a non-empty string array`); + } + return; + } + if (operator === "not_in_range") { + if (!isNumericRange(value)) throw new Error(`${context} operator not_in_range requires two finite numbers`); + return; + } + if (["crosses", "crosses_above", "crosses_below", "match"].includes(operator)) { + if (!isNonEmptyString(value)) throw new Error(`${context} operator ${operator} requires a non-empty string`); + return; + } + if (["above_percent", "below_percent"].includes(operator)) { + if (!Array.isArray(value) || value.length !== 2 || !isNonEmptyString(value[0]) || !isFiniteNumber(value[1])) { + throw new Error(`${context} operator ${operator} requires [field, finite percent]`); + } + return; + } + if (["has", "has_none_of"].includes(operator)) { + if (!isStringList(value)) throw new Error(`${context} operator ${operator} requires a non-empty string array`); + return; + } + // Unknown operators are rejected by the shared runtime validator. +} + +function stringArray(value: unknown, context: string): string[] | undefined { + if (value === undefined) return undefined; + if (!Array.isArray(value) || value.length === 0 || value.some((item) => typeof item !== "string" || !item)) { + throw new Error(`${context} must be a non-empty string array`); + } + return value; +} + +export function validatePresetDocument(document: unknown): Preset { + if (!document || typeof document !== "object" || Array.isArray(document)) { + throw new Error("Preset file must contain a JSON object"); + } + const value = document as Record; + assertExactKeys(value, TOP_LEVEL_KEYS, "Preset file"); + if (value.schemaVersion !== 1) throw new Error("Preset file schemaVersion must be 1"); + if (typeof value.name !== "string" || !value.name.trim()) throw new Error("Preset file name is required"); + if (typeof value.description !== "string" || !value.description.trim()) throw new Error("Preset file description is required"); + + const filters = value.filters; + const symbols = stringArray(value.symbols, "Preset symbols"); + if ((filters === undefined) === (symbols === undefined)) { + throw new Error("Preset file must define exactly one of filters or symbols"); + } + if (filters !== undefined) { + if (!Array.isArray(filters)) throw new Error("Preset filters must be an array"); + for (const [index, filter] of filters.entries()) { + if (!filter || typeof filter !== "object" || Array.isArray(filter)) throw new Error(`Preset filter[${index}] must be an object`); + assertExactKeys(filter as Record, FILTER_KEYS, `Preset filter[${index}]`); + const record = filter as Record; + if (typeof record.field !== "string" || !record.field || typeof record.operator !== "string" || !record.operator) throw new Error(`Preset filter[${index}] field and operator must be non-empty strings`); + validateFilterValue(record.value, record.operator, `Preset filter[${index}]`); + } + validateScreenFilters(filters as any); + } + + if (value.sort_order !== undefined && value.sort_order !== "asc" && value.sort_order !== "desc") { + throw new Error("Preset sort_order must be asc or desc"); + } + if (value.limit !== undefined && (!Number.isInteger(value.limit) || (value.limit as number) < 1 || (value.limit as number) > 200)) { + throw new Error("Preset limit must be an integer from 1 to 200"); + } + for (const key of ["sort_by"] as const) { + if (value[key] !== undefined && (typeof value[key] !== "string" || !value[key])) throw new Error(`Preset ${key} must be a non-empty string`); + } + + return { + name: value.name, + description: value.description, + filters: filters as Preset["filters"], + symbols, + markets: stringArray(value.markets, "Preset markets"), + sort_by: value.sort_by as string | undefined, + sort_order: value.sort_order as "asc" | "desc" | undefined, + limit: value.limit as number | undefined, + columns: stringArray(value.columns, "Preset columns"), + }; +} + +export function loadPresetFile(requestedPath: string, cwd = process.cwd()): { preset: Preset; provenance: PresetFileProvenance } { + const displayName = path.basename(requestedPath); + let resolved: string; + let buffer: Buffer; + try { + resolved = fs.realpathSync(path.resolve(cwd, requestedPath)); + const fd = fs.openSync( + resolved, + fs.constants.O_RDONLY | fs.constants.O_NONBLOCK + ); + try { + const stat = fs.fstatSync(fd); + if (!stat.isFile()) throw new PresetFileValidationError("Preset path must resolve to a regular file"); + if (stat.size > MAX_PRESET_BYTES) throw new PresetFileValidationError(`Preset file exceeds ${MAX_PRESET_BYTES} bytes`); + buffer = readBoundedFile(fd); + } finally { + fs.closeSync(fd); + } + } catch (error) { + if (error instanceof PresetFileValidationError) throw error; + throw new Error(`Unable to load preset file ${displayName}`); + } + const text = new TextDecoder("utf-8", { fatal: true }).decode(buffer); + const document = JSON.parse(text); + return { + preset: validatePresetDocument(document), + provenance: { + kind: "file", + basename: path.basename(resolved), + sha256: crypto.createHash("sha256").update(buffer).digest("hex"), + loadedAt: new Date().toISOString(), + }, + }; +} diff --git a/src/resources/presets.ts b/src/resources/presets.ts index 4b7c783..8cda4c3 100644 --- a/src/resources/presets.ts +++ b/src/resources/presets.ts @@ -11,12 +11,13 @@ export interface Preset { filters?: Array<{ field: string; operator: string; - value: number | string | boolean | [number, number] | string[]; + value?: number | string | boolean | [number, number] | [string, number] | string[]; }>; symbols?: string[]; // For direct symbol lookup (e.g., indexes) markets?: string[]; sort_by?: string; sort_order?: "asc" | "desc"; + limit?: number; columns?: string[]; // Optional: override default columns for this preset } diff --git a/src/tests/cli.test.ts b/src/tests/cli.test.ts index efce778..eaa0cc7 100644 --- a/src/tests/cli.test.ts +++ b/src/tests/cli.test.ts @@ -617,6 +617,19 @@ describe("CLI - End-to-End (child process)", () => { const { stdout } = await cli(["screen", "stocks", "--help"]); assert.ok(stdout.includes("--filters")); assert.ok(stdout.includes("--preset")); + assert.ok(stdout.includes("--preset-file")); + }); + + it("should reject multiple preset sources before accessing the file", async () => { + await assert.rejects( + () => cli(["screen", "stocks", "--preset", "quality_stocks", "--preset-file", "/private/missing.json"]), + (err: any) => { + assert.ok(err.stderr.includes("--preset and --preset-file are mutually exclusive")); + assert.ok(!err.stderr.includes("Unable to load preset file")); + assert.ok(!err.stderr.includes("/private/missing.json")); + return true; + } + ); }); it("should reject unknown command", async () => { diff --git a/src/tests/presetFile.test.ts b/src/tests/presetFile.test.ts new file mode 100644 index 0000000..4cbd955 --- /dev/null +++ b/src/tests/presetFile.test.ts @@ -0,0 +1,182 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; + +import { parseScreenArgs, buildScreenInput } from "../cli/parseArgs.js"; +import { loadPresetFile, validatePresetDocument } from "../cli/presetFile.js"; +import { PRESETS, PresetsTool } from "../resources/presets.js"; + +const presetsTool = new PresetsTool(); +const valid = { + schemaVersion: 1, + name: "Test preset", + description: "Strict file preset", + filters: [{ field: "typespecs", operator: "has", value: ["common"] }], + markets: ["america"], + sort_by: "market_cap_basic", + sort_order: "desc", + limit: 25, + columns: ["name", "close"], +}; + +describe("preset file contract", () => { + it("parses --preset-file and rejects combining sources", () => { + const { values } = parseScreenArgs(["--preset-file", "preset.json"]); + assert.strictEqual(values["preset-file"], "preset.json"); + assert.throws( + () => buildScreenInput({ preset: "quality_stocks", "preset-file": "x" }, presetsTool, validatePresetDocument(valid)), + /mutually exclusive/ + ); + }); + + it("validates and builds a file preset with limit", () => { + const preset = validatePresetDocument(valid); + const { input } = buildScreenInput({ "preset-file": "x" }, presetsTool, preset); + assert.strictEqual(input.filters[0].operator, "has"); + assert.strictEqual(input.limit, 25); + assert.deepStrictEqual(input.columns, ["name", "close"]); + }); + + it("accepts file-schema equivalents of every built-in preset", () => { + for (const [name, preset] of Object.entries(PRESETS)) { + assert.doesNotThrow( + () => validatePresetDocument({ schemaVersion: 1, ...preset }), + name + ); + } + }); + + it("enforces value contracts for all supported operators", () => { + const validCases: Array<{ operator: string; value?: unknown }> = [ + { operator: "greater", value: 1 }, + { operator: "less", value: "SMA50" }, + { operator: "greater_or_equal", value: 1 }, + { operator: "less_or_equal", value: "SMA200" }, + { operator: "equal", value: true }, + { operator: "not_equal", value: "Real Estate" }, + { operator: "in_range", value: ["NASDAQ", "NYSE"] }, + { operator: "not_in_range", value: [0, 5] }, + { operator: "crosses", value: "SMA50" }, + { operator: "crosses_above", value: "SMA200" }, + { operator: "crosses_below", value: "SMA200" }, + { operator: "match", value: "Tech" }, + { operator: "above_percent", value: ["SMA200", 10] }, + { operator: "below_percent", value: ["SMA50", 5] }, + { operator: "has", value: ["common"] }, + { operator: "has_none_of", value: ["reit"] }, + { operator: "empty" }, + { operator: "not_empty" }, + ]; + for (const filter of validCases) { + assert.doesNotThrow( + () => validatePresetDocument({ ...valid, filters: [{ field: "close", ...filter }] }), + filter.operator + ); + } + + const invalidCases: Array<{ operator: string; value: unknown }> = [ + { operator: "greater", value: true }, + { operator: "less", value: null }, + { operator: "greater_or_equal", value: [] }, + { operator: "less_or_equal", value: {} }, + { operator: "equal", value: {} }, + { operator: "not_equal", value: false }, + { operator: "in_range", value: [1, "NASDAQ"] }, + { operator: "not_in_range", value: ["NASDAQ", "NYSE"] }, + { operator: "crosses", value: 5 }, + { operator: "crosses_above", value: false }, + { operator: "crosses_below", value: [] }, + { operator: "match", value: true }, + { operator: "above_percent", value: 10 }, + { operator: "below_percent", value: ["SMA50", "5"] }, + { operator: "has", value: "common" }, + { operator: "has_none_of", value: [] }, + { operator: "empty", value: null }, + { operator: "not_empty", value: false }, + ]; + for (const filter of invalidCases) { + assert.throws( + () => validatePresetDocument({ ...valid, filters: [{ field: "close", ...filter }] }), + Error, + filter.operator + ); + } + }); + + it("rejects unknown keys, operators, invalid versions, and mixed query types", () => { + assert.throws(() => validatePresetDocument({ ...valid, surprise: true }), /unknown keys/); + assert.throws(() => validatePresetDocument({ ...valid, schemaVersion: 2 }), /schemaVersion/); + assert.throws( + () => validatePresetDocument({ ...valid, filters: [{ field: "x", operator: "bad", value: 1 }] }), + /Unknown operator/ + ); + assert.throws(() => validatePresetDocument({ ...valid, filters: [{ field: "x", operator: "constructor", value: 1 }] }), /Unknown operator/); + assert.throws(() => validatePresetDocument({ ...valid, filters: [{ field: 123, operator: "equal", value: 1 }] }), /non-empty strings/); + assert.throws(() => validatePresetDocument({ ...valid, filters: [{ field: "x", operator: "in_range", value: [1] }] }), /two finite numbers or a non-empty string array/); + assert.throws(() => validatePresetDocument({ ...valid, filters: [{ field: "x", operator: "in_range", value: [1, "NASDAQ"] }] }), /two finite numbers or a non-empty string array/); + assert.doesNotThrow(() => validatePresetDocument({ ...valid, filters: [{ field: "exchange", operator: "in_range", value: ["NASDAQ", "NYSE"] }] })); + assert.throws(() => validatePresetDocument({ ...valid, filters: [{ field: "x", operator: "crosses", value: 5 }] }), /non-empty string/); + assert.throws(() => validatePresetDocument({ ...valid, filters: [{ field: "x", operator: "match", value: true }] }), /non-empty string/); + assert.throws(() => validatePresetDocument({ ...valid, filters: [{ field: "x", operator: "has", value: 1 }] }), /string array/); + assert.throws( + () => validatePresetDocument({ ...valid, filters: [{ field: "x", operator: "equal", value: { unsafe: true } }] }), + /requires a non-empty string, finite number, or boolean/ + ); + assert.throws(() => validatePresetDocument({ ...valid, symbols: ["TVC:SPX"] }), /exactly one/); + }); + + it("loads relative and symlinked files with redacted provenance", () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "preset-file-")); + try { + fs.writeFileSync(path.join(directory, "preset.json"), JSON.stringify(valid)); + fs.symlinkSync(path.join(directory, "preset.json"), path.join(directory, "link.json")); + const loaded = loadPresetFile("link.json", directory); + assert.strictEqual(loaded.provenance.basename, "preset.json"); + assert.match(loaded.provenance.sha256, /^[a-f0-9]{64}$/); + assert.strictEqual("resolvedPath" in loaded.provenance, false); + assert.strictEqual(loaded.preset.name, valid.name); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } + }); + + it("rejects FIFOs without blocking", { skip: process.platform === "win32" }, () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "preset-file-")); + try { + const fifo = path.join(directory, "preset.fifo"); + execFileSync("mkfifo", [fifo]); + const startedAt = Date.now(); + assert.throws(() => loadPresetFile("preset.fifo", directory), /regular file/); + assert.ok(Date.now() - startedAt < 1000, "FIFO validation should not block"); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } + }); + + it("rejects directories, oversized files, and malformed JSON", () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "preset-file-")); + try { + assert.throws(() => loadPresetFile(".", directory), /regular file/); + assert.throws( + () => loadPresetFile("missing.json", directory), + (error: Error) => error.message === "Unable to load preset file missing.json" && !error.message.includes(directory) + ); + fs.mkdirSync(path.join(directory, "exceeds-dir")); + assert.throws( + () => loadPresetFile("exceeds-dir/missing.json", directory), + (error: Error) => error.message === "Unable to load preset file missing.json" && !error.message.includes(directory) + ); + fs.writeFileSync(path.join(directory, "large.json"), " ".repeat(1024 * 1024 + 1)); + assert.throws(() => loadPresetFile("large.json", directory), /exceeds/); + fs.writeFileSync(path.join(directory, "bad.json"), "{"); + assert.throws(() => loadPresetFile("bad.json", directory), SyntaxError); + fs.writeFileSync(path.join(directory, "invalid-utf8.json"), Buffer.from([0xc3, 0x28])); + assert.throws(() => loadPresetFile("invalid-utf8.json", directory), /encoded data was not valid/); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/src/tools/screen.ts b/src/tools/screen.ts index 9bac705..a91fd57 100644 --- a/src/tools/screen.ts +++ b/src/tools/screen.ts @@ -86,6 +86,47 @@ export const EXTENDED_COLUMNS = [ "fundamental_currency_code", ]; +export function validateScreenFilters( + filters: ScreenStocksInput["filters"] +): Filter[] { + return filters.map((f, index) => { + if (!f || typeof f !== "object" || Array.isArray(f)) { + throw new Error( + `Invalid filter at index ${index}: expected object with {field, operator, value}, got ${typeof f}` + ); + } + + if (!f.field || !f.operator) { + throw new Error( + `Invalid filter at index ${index}: missing required properties (field: ${f.field}, operator: ${f.operator}, value: ${f.value})` + ); + } + if (typeof f.field !== "string" || typeof f.operator !== "string") { + throw new Error(`Invalid filter at index ${index}: field and operator must be non-empty strings`); + } + const noValueOperators = ["empty", "not_empty"]; + if (f.value === undefined && !noValueOperators.includes(f.operator)) { + throw new Error( + `Invalid filter at index ${index}: missing required properties (field: ${f.field}, operator: ${f.operator}, value: ${f.value})` + ); + } + + if (!Object.hasOwn(OPERATOR_MAP, f.operator)) { + throw new Error( + `Unknown operator: ${f.operator}. Valid operators: ${Object.keys(OPERATOR_MAP).join(", ")}` + ); + } + const operation = OPERATOR_MAP[f.operator]; + if (!operation) { + throw new Error( + `Unknown operator: ${f.operator}. Valid operators: ${Object.keys(OPERATOR_MAP).join(", ")}` + ); + } + + return { left: f.field, operation, right: f.value }; + }); +} + export class ScreenTool { constructor( private client: TradingViewClient, @@ -100,34 +141,7 @@ export class ScreenTool { private validateAndConvertFilters( filters: ScreenStocksInput["filters"] ): Filter[] { - return filters.map((f, index) => { - // Validate filter structure - if (!f || typeof f !== "object" || Array.isArray(f)) { - throw new Error( - `Invalid filter at index ${index}: expected object with {field, operator, value}, got ${typeof f}` - ); - } - - const noValueOperators = ["empty", "not_empty"]; - if (!f.field || !f.operator || (f.value === undefined && !noValueOperators.includes(f.operator))) { - throw new Error( - `Invalid filter at index ${index}: missing required properties (field: ${f.field}, operator: ${f.operator}, value: ${f.value})` - ); - } - - const operation = OPERATOR_MAP[f.operator]; - if (!operation) { - throw new Error( - `Unknown operator: ${f.operator}. Valid operators: ${Object.keys(OPERATOR_MAP).join(", ")}` - ); - } - - return { - left: f.field, - operation, - right: f.value, - }; - }); + return validateScreenFilters(filters); } async screenStocks(input: ScreenStocksInput): Promise {