Отбор акций на .NET: 14,5 млн баров в SQLite, фильтр, который мерил не в тех единицах, и перестановочный тест

от автора

Каждый вечер мне нужно ответить на один вопрос: на какие бумаги из примерно тринадцати тысяч имеет смысл потратить время сегодня. Не «какие вырастут» — этого никто не знает, — а на каких стоит нарисовать уровень и поставить заявку.

Год назад я написал под эту задачу программу и с тех пор работаю только через неё. Ниже — её устройство: как читаются 14,5 миллиона баров, как считается ранг относительной силы, как устроена воронка фильтров, какой из них год работал неправильно и почему, чем разбирается исход сделки и чем я проверяю, что отбор вообще чего-то стоит. Кода будет много: половина статьи именно про то, где решения оказались не такими, как выглядели на бумаге.

Стек, чтобы дальше не отвлекаться: .NET 10, C#, WinForms для окна, SQLite (Microsoft.Data.Sqlite) вместо сервера, ScottPlot для графиков, xUnit — 867 тестов на три проекта. Ни одного внешнего сервиса, кроме поставщика котировок, ключ к которому вводит пользователь.

Воронка

В базе 16 066 тикеров. Первым делом из них выбрасываются мёртвые: делистнутые, поглощённые, переставшие торговаться. Их треть, и они опасны не тем, что попадут в выдачу, а тем, что искажают всё, что считается «по вселенной». Остаётся 13 420.

Дальше — фильтры одного профиля. Вот на каком из них бумага отсеивается первой, за один конкретный вечер:

Фильтр

Отсеял первым

Объём (средний за 20 дней ниже порога)

7481

Цена (вне коридора)

3462

Трендовый шаблон (цена выше EMA50 выше EMA200, EMA200 растёт)

1179

ATR в процентах (дневной ход меньше 2%)

722

Откат к EMA21 (цена не в зоне отката)

219

Ранг относительной силы (ниже 70-го процентиля)

74

Гэпы

72

Долларовый объём

59

Прочее (наклон EMA, недельный тренд, растяжение)

75

Прошли

77

Половину вселенной снимает объём, ещё четверть — цена. Это не хитрая часть: она отвечает на вопрос «в этой бумаге вообще можно торговать моим размером». Интересное начинается дальше.

Но прежде чем про фильтры — про то, откуда берутся данные, на которых они считаются. Это оказалось не подготовкой к задаче, а самой задачей: три четверти проблем в проекте были не про алгоритмы отбора.

Данные: 14,5 млн баров в одном файле SQLite

Дневные бары всей американской вселенной — 16 066 тикеров, 14,5 млн баров, файл примерно на два гигабайта. Схема нарочито плоская:

CREATE TABLE IF NOT EXISTS daily_bars (    ticker TEXT NOT NULL,    date TEXT NOT NULL,    open REAL NOT NULL,    high REAL NOT NULL,    low REAL NOT NULL,    close REAL NOT NULL,    volume REAL NOT NULL,    transactions INTEGER NULL,    source_file TEXT NOT NULL,    PRIMARY KEY (ticker, date));CREATE INDEX IF NOT EXISTS idx_daily_bars_date ON daily_bars(date);-- (ticker, date) уже покрыт PRIMARY KEY; отдельный индекс только замедлял вставки.DROP INDEX IF EXISTS idx_daily_bars_ticker_date;

Дата хранится строкой yyyy-MM-dd, а не числом. Это сознательный размен: строка сортируется лексикографически в том же порядке, что и хронологически, поэтому ORDER BY date и сравнения работают без преобразований, а база остаётся читаемой любым просмотрщиком. Цена расплаты — разбор строки в DateTime при чтении, и он оказался заметным (ниже).

Почему не сервер: второго пользователя нет. Сервер добавляет разворачивание, бэкап, порты, обновления и версии — цена за общий доступ, которого не требуется. Один файл копируется целиком, кладётся в архив и переносится между машинами перетаскиванием.

Что действительно пришлось решать — чтение. Скан держит в памяти всю вселенную за последние пятьсот торговых дней: по одному тикеру ранг относительной силы не посчитать, он про распределение по всем живым бумагам. Это около пяти с половиной миллионов баров за один запрос.

public IReadOnlyList<PriceBar> LoadRecentBars(int lookbackTradingDays, CancellationToken cancellationToken = default){    using var connection = OpenConnection();    using var pragmaCmd = connection.CreateCommand();    pragmaCmd.CommandText = "PRAGMA cache_size = -32768";    pragmaCmd.ExecuteNonQuery();    pragmaCmd.CommandText = "PRAGMA temp_store = MEMORY";    pragmaCmd.ExecuteNonQuery();    using var command = connection.CreateCommand();    command.CommandText = """        WITH cutoff AS (            SELECT MIN(date) AS min_date            FROM (                SELECT DISTINCT date                FROM daily_bars                ORDER BY date DESC                LIMIT $lookback_days            )        )        SELECT b.ticker, b.date, b.open, b.high, b.low, b.close, b.volume        FROM daily_bars b        WHERE b.date >= (SELECT min_date FROM cutoff)        ORDER BY b.ticker, b.date        """;    command.Parameters.AddWithValue("$lookback_days", lookbackTradingDays);    using var reader = command.ExecuteReader();    var bars = new List<PriceBar>(1_000_000);    // Строки идут ORDER BY ticker: переиспользуем строку тикера предыдущего бара,    // иначе датасет удерживал бы миллионы дублей вместо ~16 тыс. уникальных строк.    var lastTicker = string.Empty;    var rowsRead = 0;    while (reader.Read())    {        if ((++rowsRead & 0xFFFF) == 0) cancellationToken.ThrowIfCancellationRequested();        var ticker = reader.GetString(0);        if (string.Equals(ticker, lastTicker, StringComparison.Ordinal)) ticker = lastTicker;        else lastTicker = ticker;        bars.Add(new PriceBar        {            Ticker = ticker,            Date = ParseDate(reader.GetString(1)),            Open = (decimal)reader.GetDouble(2),            // …        });    }    return bars;}

Здесь пять решений, и каждое взялось из замера, а не из вкуса.

Отсечка по дате — через CTE по различным датам, а не «сегодня минус N дней». Торговых дней в календарном интервале переменное число: праздники, укороченные сессии, выходные. DISTINCT date … ORDER BY date DESC LIMIT $lookback_days даёт ровно N торговых дней, какими бы они ни были, и граница не плывёт от того, попал ли в окно День благодарения.

Один запрос вместо запроса на тикер. Шестнадцать тысяч запросов по индексу быстрее не становятся оттого, что каждый из них дешёвый: накладные расходы на подготовку и разбор результата умножаются на 16 066. Один последовательный проход по таблице, отсортированный первичным ключом, читает то же самое за одно движение.

Ёмкость списка задана заранее. List<T> растёт удвоением с копированием; на пяти миллионах элементов это десяток перевыделений массива на сотни мегабайт суммарно.

Строка тикера переиспользуется — это самое дорогое из мелкого. reader.GetString создаёт новую строку на каждый вызов. Пять с половиной миллионов строк вместо шестнадцати тысяч уникальных — сотни мегабайт резидентной памяти, и виноват в них не алгоритм, а невинная строчка в цикле. Поскольку строки приходят отсортированными по тикеру, достаточно сравнить с предыдущей и при совпадении взять ту же ссылку. Интернирование через string.Intern тут хуже: оно кладёт строки в общую таблицу процесса навсегда.

Разбор даты — руками, без ParseExact. Формат наш собственный и неизменный:

/// Разбор даты формата yyyy-MM-dd без ParseExact: на загрузке всего датасета/// это миллионы вызовов, и общий парсер был заметной статьёй расходов.private static DateTime ParseDate(string text) =>    new(int.Parse(text.AsSpan(0, 4)), int.Parse(text.AsSpan(5, 2)), int.Parse(text.AsSpan(8, 2)));

Общий парсер разбирает формат, сверяется с культурой и проверяет диапазоны — всё это осмысленно ровно один раз, а не пять миллионов.

Отмена проверяется маской, а не на каждой строке. (++rowsRead & 0xFFFF) == 0 — раз в 65 536 строк. ThrowIfCancellationRequested на каждой строке заметен в профиле, а реакция на кнопку «Отмена» при таком шаге всё равно мгновенная: 65 тысяч строк читаются за доли миллисекунды.

Поставщик данных подключается свой: программа умеет забирать историю тремя способами (готовые файлы за день, REST, брокерский поток) и ни одного ключа в себе не носит. Чужие данные раздавать нельзя, а своя подписка и так оплачена.

Живые и мёртвые

База помнит бумаги вечно. Делистнутые и поглощённые просто перестают появляться в дневных файлах, и их последний бар навсегда остаётся в прошлом.

public static bool IsStale(DateTime tickerLastBar, DateTime marketLastBar, int staleAfterDays = StaleAfterDays) =>    (marketLastBar.Date - tickerLastBar.Date).TotalDays > staleAfterDays;

Отставание считается от последнего бара по всей вселенной, а не от сегодняшней даты: иначе скан в понедельник утром объявлял бы мёртвым весь рынок, а скан на данных двухнедельной давности — вообще всех. Порог календарный, а не в торговых днях: он про «бумага пропала», а не про точный счёт сессий. Тридцать дней — щедро намеренно: настоящий делистинг отстаёт на месяцы, а редко торгуемую живую бумагу такой порог не тронет.

На 11.08.2026 из 20 861 тикера в базе 6 868 отставали больше чем на полгода и ещё 904 — от одиннадцати дней. Треть вселенной каждый скан проходилась впустую, но хуже другое: эти бумаги участвовали в ранге относительной силы.

Ранг относительной силы: почему важно, кого мы ранжируем

Ранг — процентиль доходности за 63 торговых дня (около трёх месяцев) среди всех живых бумаг. Формула тривиальна, интересно другое:

private static void RankCore<TKey>(IReadOnlyList<(TKey Key, decimal Return)> returns, Action<TKey, int> assign){    if (returns.Count == 0) return;    if (returns.Count == 1)    {        assign(returns[0].Key, 50);        return;    }    var sorted = returns.OrderBy(x => x.Return).ToList();    for (var i = 0; i < sorted.Count; i++)        assign(sorted[i].Key, (int)Math.Round(i * 100.0 / (sorted.Count - 1)));}

Три вещи, которые тут не случайны.

OrderBy вместо Sort — потому что LINQ-сортировка стабильна, а List.Sort нет. При равных доходностях (а они равны сплошь и рядом: копеечные бумаги, фонды-клоны) нестабильная сортировка раздавала бы разные ранги от прогона к прогону на одних и тех же данных. Отладка «почему вчера было 71, а сегодня 69, хотя цены те же» — то ещё удовольствие.

Единственная бумага получает 50, а не 0 и не 100. Процентиль по выборке из одного элемента не определён, и любой из краёв был бы утверждением, которого данные не содержат. Середина — единственный честный ответ, и он важен для бэктеста на ранней истории, где живых бумаг мало.

Ранжируются только живые. Это тот самый эффект мёртвых бумаг: их последняя, давно застывшая доходность никуда не девается и раздвигает шкалу. Треть выборки с фиксированными значениями смещает все процентили — и порог «ранг не ниже 70» перестаёт означать то, что вы имели в виду, причём молча.

Скан: параллельность там, где она бесплатна

13 420 живых бумаг, каждая независима от остальных:

var activeTickers = TickerActivity.Active(barsIndex, staleAfterDays);var rsRanks = RsRankCalculator.Calculate(barsIndex, activeTickers);return activeTickers    .AsParallel()    .AsOrdered()    .Select(ticker => ScanTicker(barsIndex.GetBars(ticker), activeProfiles, /* … */))    .Where(r => r is not null)    .Cast<MultiScanResult>()    .ToList();

AsOrdered тут не для красоты: без него порядок строк в таблице менялся между прогонами на одних данных, и глазами сравнить два скана становилось невозможно. Плата за упорядочивание — буферизация результатов, но результатов тут тысячи, а не миллионы.

Дороже стоила защита от одного тикера. Неверный профиль или битые бары роняли исключением весь параллельный проход — то есть скан целиком:

try{    return UniversalScanner.Scan(tickerBars, profile, levelService, rsRank, nextEarningsDate, indicators);}catch (Exception ex) when (ex is not OperationCanceledException){    // Ловится всё, кроме отмены. Раньше перечислялись три типа, и любое другое    // исключение по ОДНОМУ тикеру вылетало из параллельного Select и обрывало скан    // по всем — то есть ровно то, от чего защита и ставилась.    return new ScanResult    {        Ticker = tickerBars[^1].Ticker,        Passed = false,        Reason = $"Profile error: {ex.GetType().Name}: {ex.Message}"    };}

Заметьте формулировку when (ex is not OperationCanceledException). Первая версия ловила три конкретных типа — и именно поэтому не работала: сбой ожидаемого вида она гасила, а любой неожиданный (тот, ради которого защита и ставится) пробивал её насквозь. Правило, которое я из этого вынес: в параллельном цикле по независимым элементам сбой элемента не должен уметь остановить цикл, и «независимым» тут значит вообще любым, кроме отмены.

Индикаторы считаются один раз на тикер и переиспользуются всеми профилями скана:

/// Ряды причинны: значение с индексом i зависит только от баров 0..i, поэтому значение/// индикатора для префикса истории — это элемент полного ряда с индексом «конец префикса»./// Не потокобезопасен: использовать в пределах одного потока (один тикер — один кэш).public sealed class IndicatorSeriesCache

Причинность рядов — не философия, а разрешение переиспользовать один посчитанный ряд EMA для всех дат бэктеста. Без неё пришлось бы пересчитывать EMA заново на каждый день окна. Ограничение «один тикер — один кэш» держит объект в пределах одного потока параллельного скана, поэтому синхронизации внутри нет вовсе.

Профили сканеров — это JSON, а не код

Каждый сканер — файл с порогами:

{  "Name": "RS Leader Pullback",  "Type": "PullbackToEmaScanner",  "Common": {    "VolumeFilter": { "Enabled": true, "MinVolume": 500000, "AverageLength": 20 },    "AtrFilter":    { "Enabled": true, "MinAtrPercent": 2.0 },    "RsRankFilter": { "Enabled": true, "MinRank": 70 },    "GapFilter":    { "Enabled": true, "MaxCurrentGapPercent": 3.0,                      "MaxHistoricalGapPercent": 6.0, "GapLookback": 5,                      "MaxAverageGapRangeRatio": 0.45 }  }}

Причина простая: пороги меняются не раз в релиз, а по ходу мысли. Пока они лежали в коде, каждая проверка гипотезы стоила пересборки, и я их не проверял. С JSON проверка стоит редактирования файла — и гипотезы наконец начали умирать, как им положено.

Побочный эффект оказался важнее замысла: раз профиль — это данные, его можно прогнать по истории. Бэктест берёт те же файлы, что и вечерний скан. Если бы фильтры жили в коде, отдельный «бэктест фильтров» я бы не написал никогда.

Второе следствие — каждый фильтр обязан объяснять свой вердикт строкой, и она видна в таблице:

Price OK: 42.58 between 10.00 and 500.00, Volume OK: avg20 1,159,718 >= 500,000,ATR% OK: 2.07% >= 2.00%, RsRank OK: 83 >= 70, Gap OK: current 1.50%, max5d 1.50%,avg5d 0.91%, gap/range20d 0.78, range 1.30%

Собирать строку нужно и для прошедших: интересен обычно не отказ, а то, с каким запасом бумага прошла. Именно эта строка и вскрыла сломанный фильтр — о нём дальше.

Фильтр, который мерил не в тех единицах

В выдачу попал TMV — Direxion Daily 20+ Year Treasury Bear 3X, тройной обратный фонд на длинные казначейские облигации. На дневном графике он гэпует почти каждый день.

Гэпы у него не сбой данных, а устройство инструмента: облигации торгуются фьючерсами почти круглосуточно, а фонд оценивается только в биржевую сессию, с 9:30 до 16:00 по Нью-Йорку. Всё, что рынок облигаций прошёл за ночь, попадает не в дневной ход фонда, а в разрыв на открытии, и тройное плечо этот разрыв утраивает.

Для торговли от уровней это приговор: цена перепрыгивает уровень, не торгуя через него. Заявка либо не исполняется, либо исполняется не там, где я её ставил.

Фильтр гэпов у меня был, и он отработал:

Gap OK: current 1.5%, max 1.5%, range 1.3%

Пороги: текущий гэп не больше 3%, максимальный за неделю не больше 6%. Полтора процента проходят с запасом. А теперь второе число: средний дневной ход TMV — 1,64%. То есть гэп размером 1,29% (среднее за двадцать сессий) — это четыре пятых всего, что бумага проходит за день.

Фильтр мерил гэп в процентах цены и потому не различал два случая:

  • гэп 1,3% у бумаги с дневным ходом 1,6% — ночью проходит почти всё движение;

  • гэп 1,3% у бумаги с дневным ходом 5% — ночью проходит четверть, и это норма.

В процентах цены они одинаковые. Для моей торговли — противоположные.

Мера, которая их различает

Отношение: средний гэп, делённый на средний дневной ход. Оба средних — за двадцать сессий, оба нормированы на закрытие предыдущего дня.

Вот верх списка среди тех 160 бумаг, что прошли скан в тот вечер по всем профилям:

Бумага

Гэп

Ход дня

Отношение

TMV — тройной обратный на 20-летние облигации

1,29%

1,64%

0,78

EWY — фонд на Корею

1,87%

2,50%

0,75

ASX — расписка на тайваньскую компанию

2,10%

3,33%

0,63

BHP — расписка на австралийскую

1,19%

2,06%

0,58

TSM — расписка на Тайвань

0,94%

2,00%

0,47

медиана по всем 160

0,24

AAPL — для масштаба

0,40%

1,97%

0,20

MSFT — для масштаба

0,48%

1,86%

0,26

Верх списка — не «волатильные бумаги», а бумаги, чей базовый актив торгуется, пока американская сессия закрыта: расписки на азиатские и австралийские компании, фонды на чужие рынки, плечевые фонды на облигации. Один механизм, разные обёртки.

Порог 0,45 убрал из выдачи семь бумаг из ста шестидесяти: ASX, BHP, EMBJ, EWY, TMV, TSM, VG. Больше ничего не изменилось. Ниже 0,40 начинают отваливаться обычные американские акции — для меня это признак, что порог занялся не тем, ради чего ставился.

Как это считается

Проверка живёт в одном методе, и он считает оба окна за один проход:

public static bool Evaluate(    IReadOnlyList<PriceBar> bars,    CleanGapFilterSettings settings,    out GapReading reading){    reading = new GapReading(100m, 100m, 100m, settings.GapLookback, 100m, null);    if (bars.Count < 2) return false;    var lastBar = bars[^1];    var previousBar = bars[^2];    var currentGapPercent = previousBar.Close > 0        ? Math.Abs(lastBar.Open - previousBar.Close) / previousBar.Close * 100m        : 100m;    // Оба окна проходятся одним циклом: скан идёт по 13 тысячам тикеров, а бэктест    // зовёт это на каждый день каждого тикера — списки на вызов здесь дороги.    var shortStart = Math.Max(1, bars.Count - settings.GapLookback);    var longStart  = Math.Max(1, bars.Count - RatioLookback);    var maxGapPercent = 0m;    var shortGapSum = 0m; var shortCount = 0;    var longGapSum  = 0m; var longRangeSum = 0m; var longCount = 0;    for (var i = Math.Min(shortStart, longStart); i < bars.Count; i++)    {        var previousClose = bars[i - 1].Close;        var bar = bars[i];        var gap = previousClose > 0            ? Math.Abs(bar.Open - previousClose) / previousClose * 100m            : 100m;        if (i >= shortStart)        {            if (gap > maxGapPercent) maxGapPercent = gap;            shortGapSum += gap;            shortCount++;        }        if (i >= longStart)        {            longGapSum += gap;            // Ход дня делится на то же вчерашнее закрытие, что и гэп: делить одно            // на закрытие предыдущего дня, а другое на закрытие текущего значит            // мерить их разными линейками — на росте отношение завышалось бы,            // на падении занижалось. Отрицательная ширина бывает только у битого            // бара, и вклад такого бара — ноль, а не вычитание из суммы.            longRangeSum += previousClose > 0                ? Math.Max(0m, bar.High - bar.Low) / previousClose * 100m                : 0m;            longCount++;        }    }    if (shortCount == 0)    {        maxGapPercent = currentGapPercent;        shortGapSum   = currentGapPercent;        shortCount    = 1;    }    // Средний гэп по короткому окну — про частоту, а не про выброс: бумага, гэпующая    // на 2.9% каждый день, проходит проверку максимума с порогом 3%, хотя от уровней    // в ней не поторгуешь.    var averageGapPercent = shortGapSum / shortCount;    var barRangePercent = lastBar.Close > 0        ? (lastBar.High - lastBar.Low) / lastBar.Close * 100m        : 100m;    var averageRange = longCount > 0 ? longRangeSum / longCount : 0m;    var gapToRange = longCount >= MinRatioSessions && averageRange > 0        ? longGapSum / longCount / averageRange        : (decimal?)null;    // Порог задан, а отношения нет — пропускать нельзя: проверка, которую не на чем    // провести, это не пройденная проверка.    var ratioOk = settings.MaxAverageGapRangeRatio >= 100m        || (gapToRange is { } ratio && ratio <= settings.MaxAverageGapRangeRatio);    return currentGapPercent <= settings.MaxCurrentGapPercent        && maxGapPercent <= settings.MaxHistoricalGapPercent        && averageGapPercent <= settings.MaxAverageGapPercent        && ratioOk        && barRangePercent <= settings.MaxBarRangePercent;}

Четыре решения тут оказались неочевидными.

Знаменатель — ход дня, а не ATR. Первая версия делила гэп на ATR, и это неверно дважды. Истинный диапазон включает сам гэп: чем сильнее бумага гэпует, тем больше знаменатель и тем меньше выглядит отношение — мера душит ровно то, что должна ловить. Плюс длина ATR приходила из настроек соседнего фильтра, обычно пять баров, и смысл порога начинал зависеть от чужой ручки и от того, был ли на неделе отчёт. High − Low гэпа не содержит.

Обе величины нормируются на одно и то же вчерашнее закрытие. Соблазн поделить ход дня на закрытие того же дня велик — так «естественнее». Но тогда числитель и знаменатель отношения меряются разными линейками: на росте отношение завышается, на падении занижается, и мера начинает зависеть от направления тренда, чего в её замысле нет.

У отношения своё окно. Максимальный гэп ищется по неделе — там ловят разовый выброс. А отношение описывает устройство инструмента, и по неделе оно не меряется: один отчёт, и обычная акция на этой неделе выглядит как расписка. Поэтому у отношения окно в двадцать сессий, а у максимума осталось недельное. Два окна в одной проверке выглядят странно ровно до того момента, как сформулируешь, что каждое из них измеряет. Чтобы это не путало уже в интерфейсе, каждое число в строке причины подписано своим окном: max5d 1.50%, avg5d 0.91%, gap/range20d 0.78.

Неизвестно — значит не прошла. Если истории меньше половины окна (MinRatioSessions = 10) или средний ход нулевой, отношения нет. Раньше такие случаи молча проходили дальше. Теперь не проходят. Это общее правило для всех фильтров программы: проверка, которую не на чем провести, — это не пройденная проверка. Дефолт MaxAverageGapRangeRatio = 100 означает «фильтр выключен» — при таком пороге отсутствие отношения уже никого не волнует.

Отдельно стоит назвать неудобный случай, иначе его назовут в комментариях. Мера флагует и спокойные индексные фонды: SPY на том же окне даёт 0,49 — гэп 0,28%, ход дня 0,58%. Не потому, что он плохо гэпует, а потому, что почти не ходит внутри дня. Это честный ответ меры: отношение говорит, какая доля движения проходит там, где меня нет, и для инструмента с крошечным ходом эта доля высока при ничтожных абсолютных числах. У меня вопрос снимается раньше — в профилях стоит минимум 2% дневного хода, — но порог всё равно калибруется по своей вселенной, а не берётся из чужой статьи.

Разрешающая способность: где дневной бар начинает врать

Отбор бессмысленно обсуждать, если нечем измерить результат. У меня для этого журнал: каждая заявка проигрывается по истории, и исход — выигрыш, убыток, «вход не сработал» — считается программой, а не вписывается руками.

Тут есть ловушка, которая стоит двадцати пунктов доли выигрышных.

Решает всё бар, внутри которого лежат и стоп, и цель. Дневной бар не говорит, что случилось раньше. Большинство бэктестов угадывает: кто-то считает, что сработала цель, кто-то — что стоп, кто-то выбрасывает такой день. Я перестал угадывать и стал забирать минутные бары за день входа.

Одни и те же 86 сделок:

  • разбор по минуткам: 24 выигрыша, 63 убытка — 27,6%;

  • те же заявки, то же окно, разбор по дневным барам: 42 выигрыша, 44 убытка — 48,8%.

Совпало 62 вердикта. Двадцать один убыток превратился в выигрыш, три выигрыша — в убыток. Догадка не симметрична: она льстит.

Почему это задевает почти каждую сделку, а не единицы: мой стоп стоит внутри дневного шума — среднее время в позиции меньше дня, — поэтому бар, в котором произошёл вход, обычно содержит и оба выхода. Что бы ни предполагала дневная модель, она предполагает это почти в каждой сделке журнала.

Разбор по минуткам выглядит так:

public static IntradayOutcome Resolve(    VirtualOrder order,    IReadOnlyList<IntradayBar> dayBars,    bool alreadyInPosition){    if (dayBars.Count == 0) return IntradayOutcome.Unknown;    var isLong = order.Direction == OrderDirection.Long;    var inPosition = alreadyInPosition;    foreach (var bar in dayBars)    {        var entryBar = false;        if (!inPosition)        {            // Отслеживаем вход по минуткам; до него касания стопа/тейка не считаются.            var entered = isLong ? bar.High >= order.Entry : bar.Low <= order.Entry;            if (!entered) continue;            inPosition = true;            entryBar = true;        }        var hitStop   = isLong ? bar.Low  <= order.Stop   : bar.High >= order.Stop;        var hitTarget = isLong ? bar.High >= order.Target : bar.Low  <= order.Target;        if (entryBar)        {            // Внутри самой минуты входа порядок событий не виден, но видна дорога: цена шла            // к Entry от Open, значит уровень МЕЖДУ ними она прошла ДО входа — считать его            // срабатыванием нельзя.            if (TradeAnalyzer.PassedBeforeEntry(order.Stop,   bar.Open, order.Entry, isLong)) hitStop   = false;            if (TradeAnalyzer.PassedBeforeEntry(order.Target, bar.Open, order.Entry, isLong)) hitTarget = false;        }        if (hitStop && hitTarget) continue;        if (hitStop)   return IntradayOutcome.Stopped;        if (hitTarget) return IntradayOutcome.TargetHit;    }    // …}

Минутка — не панацея, и внутри неё та же неоднозначность в миниатюре. Поправка PassedBeforeEntry появилась после конкретного случая (ICLR, 3 августа): шорт влетел в цену входа одной быстрой минутой сверху, минутный High этой же минуты захватил стоп, стоящий выше входа, — и сделка объявлялась стопнутой, хотя в момент касания позиции ещё не было. Аргумент здесь геометрический: цена шла к Entry от Open, значит всё, что лежит между ними, она прошла до входа.

Заметьте ещё тип возврата. IntradayOutcome — это не bool? и не статус сделки:

public enum IntradayOutcome{    /// Минуток нет или вход по ним не найден — решать придётся дневной эвристикой.    Unknown,    Stopped,    TargetHit,    /// Вход был, но за день ни один уровень не сработал: позиция осталась открытой.    NoExit,}

Раньше «минуток нет» и «за день ничего не сработало» приходили одинаковым null, различить их было нельзя, и разрешатель поэтому звали только в самом спорном случае — когда задеты оба уровня. Разделение этих двух ответов и позволило звать его всегда.

Модель заявки: считать надо ту заявку, которую вы отправляете

Второе место, где бэктесты врут систематически, — модель исполнения. Я вхожу стоп-лимитом, и он не исполняется по любой цене:

private static decimal? Fill(VirtualOrder order, PriceBar bar, bool isBreakout, bool isLong){    if (!isBreakout)    {        // Buy-limit ниже рынка (или sell-limit выше): исполняется, когда цена дошла,        // и гэп в свою сторону даёт цену лучше плановой.        var reached = isLong ? bar.Low <= order.Entry : bar.High >= order.Entry;        if (!reached) return null;        return isLong ? Math.Min(order.Entry, bar.Open) : Math.Max(order.Entry, bar.Open);    }    // Стоп сработал бы: цена дошла до Entry в сторону сделки.    var stopTouched = isLong ? bar.High >= order.Entry : bar.Low <= order.Entry;    if (!stopTouched) return null;    var slip  = LimitOffset(order);    var limit = isLong ? order.Entry + slip : order.Entry - slip;    // Цена на момент срабатывания: на гэпе — открытие, иначе сам уровень.    var atTrigger = isLong ? Math.Max(order.Entry, bar.Open) : Math.Min(order.Entry, bar.Open);    if (isLong ? atTrigger <= limit : atTrigger >= limit) return atTrigger;    // Открылись за лимитом. Заявка исполнится, только если цена вернулась к нему.    var returned = isLong ? bar.Low <= limit : bar.High >= limit;    return returned ? limit : null;}

Стоп-маркет на гэпе исполняется по открытию, каким бы плохим оно ни было; стоп-лимит хуже лимитной цены не исполняется вовсе — цена ушла, заявка стоит. Пока разбор считал по стоп-маркету, в журнал попадали сделки, которых в жизни не было бы: XLE 14 августа «заполнялся» по 61,37 при входе 61,1804, хотя минимум дня 61,25 до лимита не дошёл.

Ширина окна входа — это люфт самого ордера, а не отдельная настройка: порог входа у меня равен точке входа плюс люфт, а люфт считается от стопа (при стандартных настройках примерно шестая часть риска). Люфт узкий, поэтому мимо проходит почти любой гэп — и это не изъян модели, а свойство самого способа входить.

Рядом два правила, которые тоже вычищают из журнала несуществующие сделки:

/// Максимальный овернайт-гэп бара входа, при котором ордер ещё исполняется. Больше —/// акция открылась «сильно с гэпом» в любую сторону: сетап недействителен, ордер отменён.public const decimal MaxEntryOvernightGapPercent = 2.0m;/// Сколько торговых дней ордер ждёт входа. Один: ордер строится под картину сегодняшнего/// дня, и если назавтра вход не сработал — картина уже другая, ждать нечего.public const int DefaultEntryValidityDays = 1;

Оба значения одни и те же для журнала и для бэктеста — намеренно: бэктест обязан проверять ту стратегию, по которой торгуют, иначе его цифры не про неё.

И отдельная история, стоившая пары вечеров, — какой торговый день считать днём заявки:

internal static DateTime TradingDateOf(DateTime createdAt){    var utc = createdAt.Kind == DateTimeKind.Utc ? createdAt : createdAt.ToUniversalTime();    var eastern = TimeZoneInfo.ConvertTimeFromUtc(utc, Eastern);    return eastern.TimeOfDay < SessionOpen ? eastern.Date.AddDays(-1) : eastern.Date;}

Даты баров — торговые дни Нью-Йорка, а время создания ордера пишется местным. Ордер, заведённый до открытия торгов, относится к сессии, которая уже закрылась, то есть к предыдущему дню. Без этого сдвига вечер по тихоокеанскому времени (21:16 = 00:16 следующего дня в Нью-Йорке) давал завтрашнюю дату, и ближайшая сессия — ровно та, ради которой ордер и ставился, — выпадала из поиска входа. Поймано на живых данных: две заявки вход задели, а разбор пометил их отменёнными.

Бэктест: где память кончилась раньше времени

Бэктест прогоняет те же профили по истории, день за днём. Первая версия честно считала на каждую дату словарь рангов и умерла на памяти:

/// Раньше прогон с RS-фильтром держал Dictionary<string,int> на каждую дату окна:/// запись такого словаря стоит ~50 байт, на ~16 тыс. тикеров это ~800 КБ на дату,/// а дат в окне сотни — отсюда сотни МБ на прогон. Здесь тикер заменён на индекс/// в общем списке, а ранг — на байт (значения 0..100): ~16 КБ на дату.public sealed class RsRankTable{    private const byte NoRank = byte.MaxValue;    private readonly Dictionary<string, int> _indexByTicker;    private readonly Dictionary<DateTime, byte[]> _ranksByDate;

Пятьдесят раз меньше на ровном месте, и без единой хитрости: тикер один и тот же во все даты, значит его место — общий словарь индексов, а не копия ключа в каждой дате. Ранг по построению лежит в 0…100, значит его место — байт, а не int. Служебное значение byte.MaxValue отличает «не ранжировался» от «ранг ноль» — путать их нельзя, ноль это худшая бумага рынка, а не отсутствие данных.

Чем я проверяю, что отбор чего-то стоит

Теперь то, ради чего эта статья, наверное, и написана.

Имея журнал, я стал искать, что отличает выигрышные сделки от убыточных. Ранг относительной силы на входе, качество базы, расстояние до уровня, сектор, ширина дневного диапазона, близость к годовому максимуму — всё, что программа знает о бумаге до входа.

Сравнивать медианы двух групп и радоваться разрыву — самый простой способ обмануть себя на выборке в восемьдесят сделок. Поэтому рядом с каждым разрывом считается, насколько часто такой же даёт чистая случайность. Метки «выигрыш/убыток» перемешиваются, значения признака остаются на месте:

private const int Shuffles = 5000;// Метки исходов лежат подряд, перемешиваются целиком и режутся по размерам корзин:// это и есть случайное деление той же выборки на те же по размеру группы.var labels = new bool[trades];for (int i = 0; i < wins; i++) labels[i] = true;var random = new Random(Seed);var atLeastAsFar = 0;for (int shuffle = 0; shuffle < Shuffles; shuffle++){    Shuffle(labels, random);    // …раскладываем метки по корзинам тех же размеров    if (Deviation(winsPerBucket, sizes, wins, trades) >= observed) atLeastAsFar++;}return Round((decimal)(atLeastAsFar + 1) / (Shuffles + 1));

Четыре детали, каждая из которых меняет ответ.

Мера разлёта — сумма квадратов отклонений от ожидаемого, делённая на ожидаемое:

private static double Deviation(int[] winsPerBucket, int[] sizes, int wins, int trades){    var share = (double)wins / trades;    var total = 0.0;    for (int bucket = 0; bucket < sizes.Length; bucket++)    {        var expected = sizes[bucket] * share;        var gap      = winsPerBucket[bucket] - expected;        total += gap * gap / Math.Max(expected, 1e-9);    }    return total;}

Нормировка на ожидаемое — не украшение. Без неё корзина из трёх сделок со «100% выигрышных» перевешивает корзину из тридцати, а таких корзин в журнале полно: сектор, в котором я торговал трижды, даёт «идеальный результат» примерно всегда.

Считается на признак целиком, а не на корзину. Вопрос «бывает ли у сектора такой разброс по доле выигрышных случайно» осмысленный; вопрос «бывает ли случайно вот эта корзина такой хорошей» — нет, потому что выбрана она как раз за то, что оказалась лучшей.

Поправка +1 в числителе и знаменателе. Без неё признак, который случайность не повторила ни разу, получал бы ровно ноль — утверждение, которого пять тысяч перестановок не содержат. С поправкой минимально достижимое значение равно 1/5001, и это честная граница разрешающей способности метода: чтобы утверждать меньше, нужно больше перестановок.

Зерно случайности фиксировано. Один и тот же журнал обязан давать один и тот же ответ: иначе к обычной неопределённости добавляется ещё и дрожание метода, и отличить «признак слабый» от «сегодня перемешалось иначе» становится нельзя.

Результат: не нашлось ничего. Ни одного признака с долей случайных перестановок ниже 0,5. Это не «слабый сигнал», это его отсутствие: половина случайных делений той же выборки даёт разрыв не меньше наблюдаемого.

Выборка маленькая — восемьдесят с небольшим закрытых сделок, — и на ней ничего сильнее и не докажешь. Но вывод для работы всё равно есть, и он неприятный: любая моя уверенность вида «вот эти сетапы лучше» пока не подтверждается ничем, кроме памяти, а память в трейдинге известно чего стоит.

Поэтому фильтры в воронке у меня делятся на два сорта. Первый — про физику: можно ли в этой бумаге торговать (объём, цена, дневной ход, гэпы). Их я готов защищать: они не про предсказание, а про исполнимость, и проверяются не статистикой, а устройством инструмента. Второй — про состояние бумаги (тренд, откат, ранг силы). Они пока держатся на вере, и я это про них знаю.

И ещё одно, про доли выигрышных вообще: во что превращается 27,6%, зависит от множителя цели, а он — настройка, а не свойство мира. У меня цель 3R, безубыток около 25%, и те двадцать пунктов разницы между дневным и минутным разбором — ровно граница между системой, которая платит, и которая нет. При цели 1R нужно выигрывать больше половины, и то же искажение ляжет в другое место.

Чего в программе нет и не будет

Порт на другие системы. WinForms — не потому, что лучший выбор, а потому, что я умею на нём делать быстро, а окно с графиком и таблицей ему по силам.

Сигналы и подсказки «покупай». Программа считает и показывает; решение принимает человек. Разница не косметическая: я знаю, что ни один признак входа в моём журнале пока не отличает выигрышные сделки от убыточных, и продавать под видом сигналов то, что не проверено, — последнее, чем стоит заниматься.

Учётная запись, телеметрия, облако. Данные лежат локально, программа ходит только к тому поставщику, чей ключ вы ввели.

Что стоит забрать

Проверьте, в каких единицах заданы ваши фильтры и то ли свойство они измеряют, которое вы имели в виду. У меня фильтр гэпов был в процентах цены — единица привычная и не отвечающая на мой вопрос. Вопрос был: какую долю движения я не могу торговать. Ответ на него — отношение, а не процент.

Считайте исход данными той разрешающей способности, на которой он решается. Если стоп и цель помещаются в один дневной бар, дневного бара недостаточно, и никакая аккуратность в остальном этого не исправит.

Моделируйте тот тип заявки, который вы реально отправляете, а не тот, который проще симулировать. Стоп-маркет и стоп-лимит расходятся ровно там, где происходит самое интересное.

Проверка, которую не на чем провести, — это не пройденная проверка. Отсутствие данных должно приводить к отказу, а не к молчаливому «ну ладно».

Настройки, которые вы меняете чаще раза в месяц, обязаны быть данными, а не кодом. Иначе вы просто не будете их проверять — я не проверял.

То, что программа посчитала, должно уметь объяснить себя строкой текста. Именно строка причины, а не отладчик, показала мне, что фильтр гэпов пропускает бумагу, которая гэпует каждый день.

И считайте случайность рядом с каждой находкой. На выборке в восемьдесят наблюдений «общее у выигрышных сделок» находится всегда. Пять тысяч перестановок стоят пятнадцати строк кода и избавляют от целого класса самообмана.


Всё описанное живёт в моём личном сканере под Windows, я раздаю его бесплатно: https://github.com/levelbench/stockscanner-app. Но считать это можно на своих данных чем угодно — статья не про программу.

ссылка на оригинал статьи https://habr.com/ru/articles/1081206/