Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}]'

Expand Down Expand Up @@ -152,7 +155,8 @@ tradingview-cli screen stocks --preset value_stocks -f table
| Flag | Description |
|---|---|
| `--filters <json>` | Filter array as JSON string |
| `--preset <name>` | Load a preset (merges with `--filters`) |
| `--preset <name>` | Load a built-in preset (exclusive with `--preset-file`) |
| `--preset-file <path>` | Load a strict `schemaVersion: 1` JSON preset file (exclusive with `--preset`) |
| `--markets <market>` | Market to screen (repeatable, stocks/etf only) |
| `--sort-by <field>` | Sort by field |
| `--sort-order <asc\|desc>` | Sort direction |
Expand Down
16 changes: 8 additions & 8 deletions docs/API_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down Expand Up @@ -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]}` |
Expand All @@ -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"}` |
Expand Down
29 changes: 29 additions & 0 deletions docs/presets.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
17 changes: 15 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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!,
Expand Down
3 changes: 2 additions & 1 deletion src/cli/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ export const SCREEN_HELP = `Usage: tradingview-cli screen <stocks|forex|crypto|e

Options:
--filters <json> Filter array as JSON string
--preset <name> Load a preset strategy (merges with --filters)
--preset <name> Load a built-in preset (exclusive with --preset-file)
--preset-file <path> Load a versioned preset JSON file (exclusive with --preset)
--markets <market> Market to screen (repeatable, stocks/etf only)
--sort-by <field> Field to sort by
--sort-order <asc|desc> Sort direction
Expand Down
17 changes: 12 additions & 5 deletions src/cli/parseArgs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -265,14 +266,19 @@ export interface ScreenBuildResult {
*/
export function buildScreenInput(
values: Record<string, any>,
presetsTool: PresetsTool
presetsTool: PresetsTool,
filePreset?: Preset
): ScreenBuildResult {
let base: Partial<ScreenStocksInput> & { 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.`
Expand All @@ -290,6 +296,7 @@ export function buildScreenInput(
sort_by: preset.sort_by,
sort_order: preset.sort_order,
columns: preset.columns,
limit: preset.limit,
};
}
}
Expand All @@ -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,
};

Expand Down
192 changes: 192 additions & 0 deletions src/cli/presetFile.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>, allowed: Set<string>, 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<string, unknown>;
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<string, unknown>, FILTER_KEYS, `Preset filter[${index}]`);
const record = filter as Record<string, unknown>;
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(),
},
};
}
Loading
Loading