# Technical Stock Screener & Backtester (US / TW) (`yzy001/technical-stock-screener`) Actor

Rule-table stock screener with multi-timeframe and sequential conditions, plus a backtester with a holding-matched null baseline.

- **URL**: https://apify.com/yzy001/technical-stock-screener.md
- **Developed by:** [宗諭 楊](https://apify.com/yzy001) (community)
- **Categories:** Business, Developer tools, Automation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $5.00 / 1,000 stock results

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## Technical Stock Screener & Backtester (US / TW)

Describe technical conditions as a JSON rule, list the symbols that match **now**, and backtest
the same rule. Rules can mix **timeframes** (daily / weekly / monthly), require **ordered
conditions** ("a golden cross *after* KD dipped below 20"), combine **AND / OR groups**, and keep
**entry and exit conditions separate**. US stocks come from Yahoo Finance; Taiwan listed and OTC
stocks come from the official TWSE and TPEx endpoints.

### Checked against independent implementations

| Indicator | Reference | Max absolute difference |
|---|---|---|
| RSI(14), Wilder smoothing | `ta.momentum.RSIIndicator` | **0.000000** |
| MACD DIF / DEA | `ta.trend.MACD` | **0.000000** |
| MA(20) | pandas rolling mean | **0.000000** |
| 52-week position (252) | pandas rolling, computed separately | **0.000000** |
| Volume ratio (20) | pandas, computed separately | **0.000000** |
| RSV(9) | `ta.momentum.StochasticOscillator` | **0.000000** |
| Taiwan-convention KD (n=9, α=1/3, seed 50) | pandas rolling + `ewm(alpha=1/3, adjust=False)` on AAPL, MSFT, 2330.TW (5 years) | **5.7e-14** |

The Taiwan KD is the convention used by Taiwanese brokers:

```
RSV = (C − LLV(L,9)) / (HHV(H,9) − LLV(L,9)) × 100
K   = (2/3)·K_prev + (1/3)·RSV
D   = (2/3)·D_prev + (1/3)·K      seed K0 = D0 = 50
```

It is **not** the English-language Stochastic(14,3,3), which smooths with an SMA; the two differ
by up to 19.36 points on the same data and must not be mixed. What the comparison above does
not settle is whether every Taiwanese broker uses exactly α=1/3; that needs a reading from a
charting terminal.

### Rule format

```json
{
  "name": "Buy: weekly KD golden cross after K<20 / Sell: weekly MACD death cross, or weekly KD death cross after K>80",
  "entry": {"all":[
    {"tf":"W","ind":"kd","params":{"n":9},"event":"cross_above","a":"K","b":"D","within":1,
     "prior":{"ind":"kd","event":"below","a":"K","value":20},"prior_within":8}
  ]},
  "exit": {"any":[
    {"tf":"W","ind":"macd","event":"cross_below","a":"DIF","b":"DEA","within":1},
    {"tf":"W","ind":"kd","params":{"n":9},"event":"cross_below","a":"K","b":"D","within":1,
     "prior":{"ind":"kd","event":"above","a":"K","value":80},"prior_within":8}
  ]}
}
```

| Field | Meaning |
|---|---|
| `tf` | `D` daily, `W` weekly, `M` monthly (resampled from daily bars, ISO weeks) |
| `ind` | `kd`, `rsi`, `macd`, `ma`, `volume` (volume ratio), `range52` (52-week position), `breakout` (% from the N-day high), `squeeze` (moving-average squeeze), `gap`, `price`, `fundamental`; **new:** `prior_range`, `ma_cluster`, `turnover`, `high_n`, `kd_runs`, `macd_live` (see *New conditions* below) |
| `event` | `cross_above`, `cross_below`, `cross_above_or_approaching`, `above`, `below`, **`gte`, `lte`** (new: `>=` / `<=`), `rising`, `falling`, `approaching_level` |
| `b` / `value` | `b` is another key of the same series (for example KD's `"D"`) or another series spec; `value` is a literal number. A threshold is `"event":"below","value":30`, never `"b":30` |
| `within` | The event must happen within the last N bars. **`1` means the last bar only** |
| `lag` | **New.** Evaluate the whole condition `lag` bars earlier (`"lag":1` = "as of the previous bar"). Default 0 |
| `px` | **New.** `"adj"` (default: the price series you passed in, adjusted for dividends in the long-history tool) or `"raw"` (unadjusted prices, for absolute-price conditions) |
| `params.min_bars` | **New.** Warm-up: the indicator is treated as missing (condition false) for the first `min_bars-1` bars |
| `prior` + `prior_within` | **Ordered condition**: `prior` must have held within N bars before the main event (counted on its own timeframe) |
| `approaching_level` | Needs `level`, `from_above` and `max_distance`. `max_distance` is not optional: "the gap is shrinking" alone would count RSI falling from 70 to 58 as "approaching 30" |

A malformed rule is refused before any data is fetched, with a message naming the condition and
the missing key; a refused run writes no dataset rows and is not charged per row.

### New conditions (2026-10-13)

All of them are **strict**: when there is not enough history the value is missing and the condition
is false; nothing is computed on a partial window (the older `range52` / `breakout` / `squeeze` indicators keep their original behaviour, so existing rules give identical results).

| `ind` | Parameters | Keys | Typical use |
|---|---|---|---|
| `prior_range` | `n` | `HH` `LL` highest high / lowest low of the previous *n* bars **excluding today**, `HC` `LC` highest / lowest close, `AMP` = (HH−LL)/LL×100 | Breakout of the previous N-day high (price `high` above `HH`), close above the previous N-day highest close (`close` above `HC`), N-day box amplitude below 8% (`AMP` below 8) |
| `ma_cluster` | `periods` (default 5,10,20,60) | `CL` = (max MA − min MA)/min MA ×100, `MAX` `MIN` | "Moving averages tangled": `CL` lte 5. Add `"lag":1` to ask about the previous bar |
| `turnover` | `n` | `TO` = N-bar average traded value (TWD, includes today), `TOP` = same over the previous N bars | Liquidity: `TO` gte 50000000. Needs traded value, which only the official TWSE/TPEx sources carry |
| `high_n` | `n` (252 ≈ 52 weeks), `use` `high`/`close` | `H52` `L52`, `DTH52` = % below the N-bar high, `FRL52` = % above the N-bar low | Near the 52-week high: `DTH52` lte 15. Strict: fewer than *n* bars → false |
| `kd_runs` | `n` (9), `hi` (80), `lo` (20), `mid` (50) | `K` `D`, run lengths `K_HI_RUN` `D_HI_RUN` `K_LO_RUN` `K_MID_UP` `K_MID_DN` | KD "blunting" (鈍化): `K_HI_RUN` gte 3 means K>80 for 3 bars in a row; mid-axis: `K_MID_UP` gte 5 |
| `macd_live` | `period` `W`/`M`, `fast` `slow` `sig` | `DIF` `DEA` `OSC` aligned to daily bars | Weekly or monthly MACD **including the period in progress**, as a daily-timeframe indicator. Since all `tf:"W"`/`"M"` conditions are now evaluated as of each day (see *Weekly and monthly conditions* below), it gives the same values as `tf:"W"` + `macd` |

Rules that use only daily conditions give exactly the same entries and trades as before (checked old engine against new on 61 randomly generated daily rules × 24 price series, and on real Taiwan data).

### Weekly and monthly conditions are evaluated as of each day

A weekly or monthly condition sees the week (or month) **in progress up to that day**, the same
weekly candle a charting terminal shows mid-week, and the same one a screen run on that day uses.
So a backtest signal on a Wednesday is exactly what a screen run that Wednesday would have
reported, and no day after it is used. On the last trading day of a week or month the value is the
completed bar, as before.

Until 2026-10-14 the backtest instead used the last *completed* week or month on mid-period days,
so weekly and monthly signals could fire only on the last day of a period while a daily screen
could fire mid-week: backtest and screen answered different questions. Daily-only rules are not
affected; for weekly and monthly rules, results on the last day of each period are unchanged and
mid-period days now follow the screen.

### Output

- `matchesNow`: whether the last bar satisfies `entry`
- Backtest mode adds `trades`, `meanReturnPct`, `medianReturnPct`, `winRatePct`,
  `holdMedianBars` and **`exitReasons`**
- **`SUMMARY.backtest` aggregates `exitReasons` across symbols for you:**

| Field | Meaning |
|---|---|
| `totalTrades` | Trades across all symbols |
| `exitReasons` | `{max_hold: n, exit_rule: n, stop_loss: n}` |
| **`maxHoldSharePct`** | **Share of trades that ended at the cap. If this is high, your exit rule is not doing the exiting** |
| `exitRuleSharePct` | Share of trades closed by the exit condition |
| `symbolsWithNoTrades` | Symbols that never entered |
| `maxHoldBars` | The cap used in this run |

`notes` calls out three cases by name: zero trades, a `max_hold` share of 50% or more, and all
exits coming from the rule.

#### `maxHold` counts daily bars, even for weekly rules

Even when a rule says `tf: "W"`, the cap counts daily bars: **60 bars is about 12 weeks**.

| maxHold | Closed by the exit rule | Ended at the cap |
|---:|---:|---:|
| 60 (default) | 50% | **50%** |
| 400 | **100%** | 0% |

*(Measured 2026-10-10: 10 US stocks, 24 months, weekly KD + MACD rule, 16 trades.)*

Trades that end at the cap report "hold N bars after entry", **not the result of your exit
rule**. **For weekly rules use maxHold ≥ 250.**

### Fundamental conditions

Rules can also use fundamental fields:

```json
{"ind":"fundamental","a":"trailingPE","event":"below","value":22}
```

Available fields (20): `trailingPE` `forwardPE` `priceToBook` `dividendYield` `trailingEps`
`forwardEps` `marketCap` `beta` `profitMargins` `returnOnEquity` `debtToEquity`
`revenueGrowth` `earningsGrowth` `sharesShort` `shortRatio` `shortPercentOfFloat`
`pegRatio` `enterpriseToEbitda` `currentRatio` `payoutRatio`

#### Backtest mode always refuses them, with no switch

The source has only **today's** values, no history. Judging a bar from three years ago with
today's P/E uses future information to decide past trades: backtest returns come out
systematically too high while **every field looks filled and normal**. So a rule with
fundamentals is refused in `mode: backtest`, and there is no parameter to bypass it. Remove the
fundamental conditions to backtest, or run `screen` to use them.

#### The source mixes units

Raw values measured on 2026-10-11 for AAPL, JPM, XOM, KO, T and NVDA:

| Field | Raw range | Unit |
|---|---|---|
| `dividendYield` | AAPL 0.32, T **5.00** | **percent** |
| `debtToEquity` | 15.92 … 129.1 | **percent** |
| `profitMargins` / `returnOnEquity` / `payoutRatio` / `revenueGrowth` / `earningsGrowth` / `shortPercentOfFloat` | 0.0083 … 1.49 | fraction |
| `marketCap` / `sharesShort` | absolute | absolute |
| others | multiples | multiples |

**Only 2 of the 20 fields are percentages; the other ratios are fractions.** Writing
`dividendYield > 0.03` to mean 3% would match everything (0.32 > 0.03). This Actor therefore
**evaluates every condition on fractions** and outputs both the raw value and `_asFraction`.

#### Coverage is uneven by nature

AAPL and XOM have 20 of 20 fields, but **JPM has 17 of 20**: banks have no `currentRatio`,
`debtToEquity` or `enterpriseToEbitda`. A condition on a missing field evaluates to `false`
(no error), `SUMMARY.fundamentals.coverageByKey` gives the share of symbols that have each
field, and `symbolsMissingByKey` lists up to 10 symbols missing it, so "this symbol has no such
field" stays distinct from "this symbol does not match".

### Limits

| Limit | Detail |
|---|---|
| **Survivorship bias** | (Actor run) Only symbols listed today are included; the local long-history layer above keeps delisted names. Names delisted or acquired during the period are missing, so **returns are biased upward**, more so for longer holds |
| **Unfinished intraday bar** | If the median of "last bar volume ÷ previous 20-bar average" across symbols is below 0.5, the last bar is dropped and `notes` says so. Measured intraday the median ratio was **0.107** (normally about 0.9); keeping that bar would break every volume condition |
| **Weekly and monthly volume** | Resampling sums daily volume over the period (checked: \[100,200,300] + \[400,500,600,700] → \[600, 2200]). Only when the daily data carries no volume is a volume condition refused |
| **200 symbols per run** | Each run fetches its own data; refetching a whole market is not feasible (6,136 US stocks over 5 years took 254 seconds) |
| **Symbols are required for US** | `market: "us"` needs a symbol list. `market: "tw"` with an empty list scans every listed and OTC stock |
| **Wrong symbols** | If none of the symbols returns data while a control symbol (SPY or 2330) does, the run is refused with that explanation instead of returning an empty success; if the control fails too, the run fails because the source is down |
| **Taiwan OTC is slower** | TWSE has a per-symbol endpoint (used automatically for 40 symbols or fewer: requests = symbols × months). **TPEx has none**, so OTC data is fetched day by day for the whole market (requests = trading days, regardless of symbol count) |
| **No look-ahead** | Evaluating day t reads only data up to day t, including weekly and monthly conditions (the period in progress counts only up to day t). Tested by evaluating every rule both on the full history and on history cut at day t: the answers must match. Backtest entries fill at the **next bar's open**; exit signals fill at the **open of the following bar** |

This is a screening and research tool, not investment advice.

# Changelog

This Actor's version history is a separate document: https://apify.com/yzy001/technical-stock-screener/changelog.md

# Actor input Schema

## `market` (type: `string`):

us = US stocks (Yahoo Finance); tw = Taiwan listed and OTC stocks (official TWSE and TPEx data).

## `symbols` (type: `array`):

Symbols to scan, for example AAPL, MSFT or 2330, 2317. At most 200. Required for market us; for market tw an empty list scans every listed and OTC stock.

## `rule` (type: `object`):

Entry and exit conditions. entry is required; exit is optional (not needed for screening). See the README for the format. A fundamental condition looks like {"ind":"fundamental","a":"trailingPE","event":"below","value":20} and works only in screen mode.

## `mode` (type: `string`):

screen lists the symbols that match now; backtest also backtests the rule (needs exit or maxHold).

## `months` (type: `integer`):

Indicators need warm-up history. Weekly KD(9) needs at least 8 months; for backtests use 24 or more.

## `minPrice` (type: `integer`):

Drops low-priced stocks. 5 is the usual US convention.

## `minAvgVolume` (type: `integer`):

Drops illiquid stocks.

## `costBps` (type: `integer`):

Backtest only. 20 = 0.2% round trip.

## `maxHold` (type: `integer`):

Backtest only. An exit condition may never fire, so a cap is required. The unit is daily bars even when the rule runs on weekly bars (tf W): 60 bars is about 12 weeks. Measured: weekly KD/MACD exit rules with maxHold=60 had 50% of trades end at the cap (their returns describe 'hold 60 bars', not your exit rule); with maxHold=400 all exits came from the rule. For weekly rules use 250 or more. SUMMARY.backtest.maxHoldSharePct reports how many trades hit the cap.

## `fundamentalDelaySeconds` (type: `number`):

A rule that uses fundamental fields needs one extra request per symbol; this sets the gap between them. Fundamentals exist only as today's values, so backtest mode refuses them (no switch). Fields: beta, currentRatio, debtToEquity, dividendYield, earningsGrowth, enterpriseToEbitda, forwardEps, forwardPE, marketCap, payoutRatio, pegRatio, priceToBook, profitMargins, returnOnEquity, revenueGrowth, sharesShort, shortPercentOfFloat, shortRatio, trailingEps, trailingPE. The source mixes units: debtToEquity and dividendYield are percentages, the other ratios are fractions; conditions are evaluated on fractions and the output gives both the raw value and \_asFraction.

## Actor input object example

```json
{
  "market": "us",
  "symbols": [
    "AAPL",
    "MSFT",
    "NVDA"
  ],
  "rule": {
    "name": "Daily KD golden cross + weekly KD crossing",
    "entry": {
      "all": [
        {
          "tf": "D",
          "ind": "kd",
          "params": {
            "n": 9
          },
          "event": "cross_above",
          "a": "K",
          "b": "D",
          "within": 1
        },
        {
          "tf": "W",
          "ind": "kd",
          "params": {
            "n": 9
          },
          "event": "cross_above_or_approaching",
          "a": "K",
          "b": "D",
          "within": 2,
          "pre_bars": 3
        }
      ]
    }
  },
  "mode": "screen",
  "months": 12,
  "minPrice": 5,
  "minAvgVolume": 200000,
  "costBps": 20,
  "maxHold": 60,
  "fundamentalDelaySeconds": 0.3
}
```

# Actor output Schema

## `symbols` (type: `string`):

matchesNow, and in backtest mode trades, mean/median return, win rate, median hold and exitReasons.

## `summary` (type: `string`):

Includes backtest.maxHoldSharePct — the share of trades that hit the holding cap instead of your exit rule. A high value means the returns reflect a fixed hold, not your sell rule.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "symbols": [
        "AAPL",
        "MSFT",
        "NVDA"
    ],
    "rule": {
        "name": "Daily KD golden cross + weekly KD crossing",
        "entry": {
            "all": [
                {
                    "tf": "D",
                    "ind": "kd",
                    "params": {
                        "n": 9
                    },
                    "event": "cross_above",
                    "a": "K",
                    "b": "D",
                    "within": 1
                },
                {
                    "tf": "W",
                    "ind": "kd",
                    "params": {
                        "n": 9
                    },
                    "event": "cross_above_or_approaching",
                    "a": "K",
                    "b": "D",
                    "within": 2,
                    "pre_bars": 3
                }
            ]
        }
    },
    "fundamentalDelaySeconds": 0.3
};

// Run the Actor and wait for it to finish
const run = await client.actor("yzy001/technical-stock-screener").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "symbols": [
        "AAPL",
        "MSFT",
        "NVDA",
    ],
    "rule": {
        "name": "Daily KD golden cross + weekly KD crossing",
        "entry": { "all": [
                {
                    "tf": "D",
                    "ind": "kd",
                    "params": { "n": 9 },
                    "event": "cross_above",
                    "a": "K",
                    "b": "D",
                    "within": 1,
                },
                {
                    "tf": "W",
                    "ind": "kd",
                    "params": { "n": 9 },
                    "event": "cross_above_or_approaching",
                    "a": "K",
                    "b": "D",
                    "within": 2,
                    "pre_bars": 3,
                },
            ] },
    },
    "fundamentalDelaySeconds": 0.3,
}

# Run the Actor and wait for it to finish
run = client.actor("yzy001/technical-stock-screener").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "symbols": [
    "AAPL",
    "MSFT",
    "NVDA"
  ],
  "rule": {
    "name": "Daily KD golden cross + weekly KD crossing",
    "entry": {
      "all": [
        {
          "tf": "D",
          "ind": "kd",
          "params": {
            "n": 9
          },
          "event": "cross_above",
          "a": "K",
          "b": "D",
          "within": 1
        },
        {
          "tf": "W",
          "ind": "kd",
          "params": {
            "n": 9
          },
          "event": "cross_above_or_approaching",
          "a": "K",
          "b": "D",
          "within": 2,
          "pre_bars": 3
        }
      ]
    }
  },
  "fundamentalDelaySeconds": 0.3
}' |
apify call yzy001/technical-stock-screener --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,yzy001/technical-stock-screener"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/jiV3gOsgTwRNqwJJm/builds/YCKcBy1KYXXM6Idq3/openapi.json
