preview
Быстрая интеграция большой языковой модели и MetaTrader 5 (Часть II): Файнтьюн на реальных данных, бэктест и онлайн-торговля модели

Быстрая интеграция большой языковой модели и MetaTrader 5 (Часть II): Файнтьюн на реальных данных, бэктест и онлайн-торговля модели

MetaTrader 5Интеграция |
1 169 2
Yevgeniy Koshtenko
Yevgeniy Koshtenko

Проблема базовых LLM в торговле

После развертывания языковой модели из первой части статьи, система работала с техническими индикаторами: RSI, MACD, анализ объемов функционировали корректно, модель генерировала торговые сигналы BUY или SELL. Однако, при тестировании на демо-счете в течение недели, проявилась существенная проблема.

Рассмотрим конкретный пример. Модель зафиксировала сигнал BUY на паре EURUSD при значении RSI 32, что формально соответствует зоне перепроданности. После входа в позицию, цена продолжила падение еще на 200 пунктов, и только через три дня развернулась вверх. Стоп-лосс был выбит, депозит просел на 3%. На следующий день аналогичная ситуация повторилась на GBPUSD: при RSI 28 модель сгенерировала сигнал BUY, но цена провалилась еще на 300 пунктов, что привело к дополнительным убыткам в 3%.

Проблема заключается не в корректности расчета индикаторов, а в отсутствии практического опыта. Базовая языковая модель функционирует, как начинающий трейдер, который изучил теоретическое правило "RSI ниже 30 — сигнал к покупке", но не обладает знаниями о том, как конкретная валютная пара реагирует на перепроданность в различных рыночных условиях. Например, модель не учитывает, что EURUSD в азиатскую сессию при сильном дневном нисходящем тренде может продолжать падение, несмотря на низкие значения RSI.

Базовая LLM владеет теоретическими основами технического анализа, но не имеет эмпирических данных о поведении конкретных инструментов. В частности, модель не знает, что EURUSD при RSI 25 статистически падает еще в среднем на 40 пунктов перед разворотом, GBPUSD в аналогичной ситуации может снизиться на 150 пунктов, а дивергенция MACD на H4 у пары USDCHF дает успешный разворот в 70% случаев, тогда как у USDCAD этот показатель составляет только 40%.

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

Решение: файнтьюнинг на исторических данных

Файнтьюнинг представляет собой процесс дополнительного обучения предобученной модели на специализированном наборе данных. Если провести аналогию с практикой трейдинга, это похоже на ситуацию, когда выпускник финансового университета приходит в проп-фирму и в течение первого месяца торгует под руководством опытного старшего трейдера. Наставник показывает ему тысячу реальных сделок и подробно объясняет, почему каждая из них сработала или не сработала. Через месяц такой практики выпускник трансформируется в более опытного трейдера, который владеет не только теоретической базой, но и практическим пониманием реального поведения торговых инструментов.

Файнтьюнинг применяет аналогичный подход к языковой модели. Процесс разделяется на три последовательных этапа: генерация обучающего датасета из исторических данных MetaTrader 5, обучение модели через фреймворк Ollama, и верификация результатов на честном бэктесте без утечки данных из будущего. Каждый этап решает конкретную техническую задачу и требует внимательного подхода к деталям реализации.


Генерация сбалансированного датасета

При анализе графика EURUSD за последние 6 месяцев наблюдается рост цены с уровня 1.0500 до 1.1200, что составляет 700 пунктов роста. Если сформировать обучающий датасет путем последовательного взятия всех примеров из этого периода, получится выборка с существенным дисбалансом: примерно 70% примеров будут иметь метку UP (рост цены) и только 30% — метку DOWN (падение цены).

Модель, обученная на таком несбалансированном датасете, оптимизирует функцию потерь путем простого запоминания, что правильный ответ — это UP в большинстве случаев. Это приведет к видимой точности 70% на обучающих данных, но к катастрофическим результатам на реальной торговле, когда рыночная ситуация изменится, и начнется период коррекции или нисходящего тренда.

Решение этой проблемы — балансировка классов в датасете. Необходимо загрузить историю котировок за 6 месяцев, идентифицировать все точки, в которых цена через 24 часа показала рост, и все точки, в которых цена снизилась. Затем из этих множеств случайным образом отбирается 500 примеров роста и 500 примеров падения. Результатом становится сбалансированный датасет из 1000 примеров с соотношением классов 50/50.

Почему выбран горизонт предсказания 24 часа

Горизонт предсказания в 24 часа (96 15-минутных баров) выбран на основе нескольких соображений. Во-первых, это достаточный временной интервал для проявления значимых рыночных движений, которые могут быть зафиксированы техническими индикаторами. Краткосрочные горизонты в 1-4 часа содержат слишком много рыночного шума и случайных флуктуаций, что снижает предсказуемость. Во-вторых, 24-часовой период позволяет учесть влияние различных торговых сессий (азиатской, европейской, американской), что важно для валютных пар. В-третьих, с практической точки зрения, это удобный интервал для автоматической торговой системы, которая анализирует рынок раз в сутки.

Важное замечание: это не единственно возможный выбор. Для разных торговых стратегий могут быть оптимальны другие горизонты (например, 4-6 часов для интрадей-трейдинга или 48-72 часа для свинг-трейдинга). Выбор горизонта должен соответствовать вашей торговой философии и риск-профилю.

Почему выбраны именно эти валютные пары

В текущей реализации используются четыре мажорные валютные пары: EURUSD, GBPUSD, USDCHF, USDCAD. Выбор обусловлен несколькими факторами. Эти пары характеризуются высокой ликвидностью и относительно узкими спредами, что критически важно для алгоритмической торговли. Они демонстрируют различные паттерны поведения: EURUSD и GBPUSD часто показывают схожую динамику из-за корреляции EUR и GBP, тогда как USDCHF часто движется в противоположном направлении (отрицательная корреляция с EURUSD), а USDCAD имеет свою специфику, связанную с ценами на нефть.

Критическое замечание: смешивание всех пар в одну модель может приводить к усреднению паттернов и снижению точности. Более оптимальным подходом может быть обучение отдельных специализированных моделей для каждой пары или группировка похожих пар (например, EURUSD + GBPUSD в одну модель, USDCHF + USDCAD в другую). Это требует больших вычислительных ресурсов, но может значительно улучшить результаты.

Почему достаточно 1000 примеров

Количество обучающих примеров в 1000 представляет собой компромисс между качеством обучения и вычислительными затратами. Для файнтьюнинга уже предобученной модели (которая знает общие принципы технического анализа) требуется меньше данных, чем для обучения с нуля. 1000 примеров — это минимальный объем, который позволяет модели выявить устойчивые паттерны в поведении выбранных валютных пар.

Важное предупреждение: это достаточно малый датасет по меркам машинного обучения. Более крупные датасеты (5000-10000 примеров) могут дать существенно лучшие результаты, особенно если модель будет использоваться в различных рыночных условиях. Малый размер датасета ограничивает способность модели обобщать знания на новые ситуации.


Реализация генерации датасета

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

def generate_real_dataset_from_mt5(num_samples: int = 1000) -> list:
    if not mt5.initialize():
        print("MT5 не подключен!")
        return []
    
    dataset = []
    up_count = 0
    down_count = 0
    target_up = num_samples // 2
    target_down = num_samples // 2
    
    end = datetime.now()
    start = end - timedelta(days=180)
    
    for symbol in ["EURUSD", "GBPUSD", "USDCHF", "USDCAD"]:
        rates = mt5.copy_rates_range(symbol, mt5.TIMEFRAME_M15, start, end)
        if rates is None:
            continue
            
        df = pd.DataFrame(rates)
        df = calculate_features(df)
        
        all_candidates = []
        
        for idx in range(LOOKBACK, len(df) - PREDICTION_HORIZON):
            row = df.iloc[idx]
            future_row = df.iloc[idx + 96]  # 96 баров по 15 минут = 24 часа
            
            actual_price = future_row['close']
            price_change = actual_price - row['close']
            direction = "UP" if price_change > 0 else "DOWN"
            
            all_candidates.append({
                'idx': idx,
                'direction': direction,
                'row': row,
                'future_row': future_row
            })
        
        up_candidates = [c for c in all_candidates if c['direction'] == 'UP']
        down_candidates = [c for c in all_candidates if c['direction'] == 'DOWN']
        
        symbol_target = num_samples // len(["EURUSD", "GBPUSD", "USDCHF", "USDCAD"])
        symbol_up_target = symbol_target // 2
        symbol_down_target = symbol_target // 2
        
        selected_up = np.random.choice(
            len(up_candidates),
            size=min(symbol_up_target, len(up_candidates)),
            replace=False
        ) if len(up_candidates) > 0 else []
        
        selected_down = np.random.choice(
            len(down_candidates),
            size=min(symbol_down_target, len(down_candidates)),
            replace=False
        ) if len(down_candidates) > 0 else []
        
        for idx in selected_up:
            candidate = up_candidates[idx]
            example = create_training_example(
                symbol,
                candidate['row'],
                candidate['future_row'],
                df.index[candidate['idx']]
            )
            dataset.append(example)
            up_count += 1
        
        for idx in selected_down:
            candidate = down_candidates[idx]
            example = create_training_example(
                symbol,
                candidate['row'],
                candidate['future_row'],
                df.index[candidate['idx']]
            )
            dataset.append(example)
            down_count += 1
    
    mt5.shutdown()
    return dataset

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

Каждый пример содержит текущую рыночную ситуацию и реальный результат через сутки:

def create_training_example(symbol, row, future_row, current_time):
    actual_price_24h = future_row['close']
    price_change = actual_price_24h - row['close']
    price_change_pips = int(price_change / 0.0001)
    direction = "UP" if price_change > 0 else "DOWN"
    
    analysis_parts = []
    
    if row['RSI'] < 30:
        analysis_parts.append(
            f"RSI {row['RSI']:.1f} — сильная перепроданность, "
            f"через 24ч произошёл отскок на {abs(price_change_pips)} пунктов"
        )
    
    if row['MACD'] > 0:
        analysis_parts.append(
            "MACD позитивный — бычий импульс подтвердился в течение суток"
        )
    
    if row['vol_ratio'] > 1.5:
        analysis_parts.append(
            "Объёмы выше средних на 50%+ — импульс продолжился в течение суток"
        )
    
    if row['BB_position'] < 0.2:
        analysis_parts.append(
            "Цена у нижней границы Боллинджера — через 24ч произошёл возврат к средней"
        )
    
    analysis = "\n- ".join(analysis_parts)
    
    prompt = f"""{symbol} {current_time.strftime('%Y-%m-%d %H:%M')}
Текущая цена: {row['close']:.5f}
RSI: {row['RSI']:.1f}
MACD: {row['MACD']:.6f}
ATR: {row['ATR']:.5f}
Объёмы: {row['vol_ratio']:.2f}x
BB позиция: {row['BB_position']:.2f}
Stochastic K: {row['Stoch_K']:.1f}
Проанализируй ситуацию объективно и дай точный прогноз цены через 24 часа."""
    
    response = f"""НАПРАВЛЕНИЕ: {direction}
УВЕРЕННОСТЬ: 87%
ПРОГНОЗ ЦЕНЫ ЧЕРЕЗ 24Ч: {actual_price_24h:.5f} ({price_change_pips:+d} пунктов)
ОБЪЕКТИВНЫЙ АНАЛИЗ НА 24 ЧАСА:
- {analysis}
ВЫВОД: Фактическое движение за 24 часа составило {abs(price_change_pips)} пунктов {direction}. Конечная цена: {actual_price_24h:.5f}."""
    
    return {
        "prompt": prompt,
        "response": response,
        "direction": direction
    }

Критически важная деталь: в ответе используются реальные данные из будущего. Модель учится не на теоретических расчётах вроде "RSI ниже 30 значит рост", а на том, что действительно произошло на конкретной валютной паре в конкретное время. EURUSD при RSI 25 двадцатого октября упал ещё на сорок пунктов. GBPUSD при RSI 28 пятнадцатого ноября вырос на восемьдесят пунктов. Модель запоминает эти паттерны.

После генерации датасета, сохраните его в файл:

def save_dataset(dataset: list, filename: str = "dataset/finetune_data.jsonl"):
    with open(filename, 'w', encoding='utf-8') as f:
        for item in dataset:
            f.write(json.dumps(item, ensure_ascii=False) + '\n')
    print(f"Датасет сохранён: {filename}")
    return filename

Получится файл размером около одного мегабайта с тысячей примеров в формате JSONL. Каждая строка — это один обучающий пример с промптом и правильным ответом.


Второй этап: файнтьюн через Ollama

До появления Ollama процесс файнтьюнинга требовал значительных технических знаний: глубокого понимания PyTorch, корректной настройки CUDA для работы с GPU, знания методов квантизации моделей, написания сложных обучающих скриптов с правильной конфигурацией гиперпараметров. Ollama радикально упрощает этот процесс, сводя его к созданию конфигурационного файла (Modelfile) с обучающими примерами и запуску одной команды в терминале.

Почему выбрана модель llama3.2:3b

Модель llama3.2 с 3 миллиардами параметров выбрана как оптимальный баланс между качеством предсказаний и вычислительными требованиями. Модели меньшего размера (1B параметров) показывают недостаточную точность в анализе сложных рыночных ситуаций. Модели большего размера (7B, 13B параметров) требуют существенно больше оперативной памяти и времени на генерацию ответа, что критично для торговой системы, работающей в реальном времени. 3B модель может работать на обычном компьютере с 8-16 ГБ RAM и видеокартой среднего уровня, при этом показывая приемлемое качество анализа.

Обоснование выбора гиперпараметров

def finetune_with_ollama(dataset_path: str):
    print("ЗАПУСК ФАЙНТЬЮНА ЧЕРЕЗ OLLAMA\n")
    
    with open(dataset_path, 'r', encoding='utf-8') as f:
        training_data = [json.loads(line) for line in f]
    
    training_sample = training_data[:min(100, len(training_data))]
    
    modelfile_content = f"""FROM llama3.2:3b
PARAMETER temperature 0.55
PARAMETER top_p 0.92
PARAMETER top_k 30
PARAMETER num_ctx 8192
PARAMETER num_predict 768
PARAMETER repeat_penalty 1.1

SYSTEM \"\"\"
Ты — ShtencoAiTrader-3B-Analyst — специализированный аналитик валютного рынка.

КОНТЕКСТ РАБОТЫ:
- Ты анализируешь валютные пары на основе технических индикаторов
- Твой горизонт прогнозирования: 24 часа
- Ты работаешь с историческими паттернами конкретных инструментов

СТРОГИЕ ПРАВИЛА:
1. Только UP или DOWN — никакого FLAT, боковика, неуверенности
2. Уверенность всегда 65-98%
3. ОБЯЗАТЕЛЬНО давай прогноз цены через 24 часа в формате: X.XXXXX (±NN пунктов)
4. Детальный анализ каждого индикатора с учётом суточного таймфрейма
5. Конкретные рекомендации с целевой ценой

ФОРМАТ ОТВЕТА (СТРОГО):
НАПРАВЛЕНИЕ: UP/DOWN
УВЕРЕННОСТЬ: XX%
ПРОГНОЗ ЦЕНЫ ЧЕРЕЗ 24Ч: X.XXXXX (±NN пунктов)
ПОЛНЫЙ АНАЛИЗ НА 24 ЧАСА:
- RSI: [детальный анализ с прогнозом на сутки]
- MACD: [детальный анализ с прогнозом на сутки]
- ATR: [детальный анализ с прогнозом на сутки]
- Объёмы: [детальный анализ с прогнозом на сутки]
- Bollinger Bands: [детальный анализ с прогнозом на сутки]
- Stochastic: [детальный анализ с прогнозом на сутки]
ИТОГ: [конкретная рекомендация с целевой ценой через 24 часа и обоснованием]
\"\"\"
"""
    
    for i, example in enumerate(training_sample[:50], 1):
        modelfile_content += f"""
MESSAGE user \"\"\"{example['prompt']}\"\"\"
MESSAGE assistant \"\"\"{example['response']}\"\"\"
"""
    
    modelfile_path = "Modelfile_finetune"
    with open(modelfile_path, 'w', encoding='utf-8') as f:
        f.write(modelfile_content)
    
    print(f"Modelfile создан с {min(50, len(training_sample))} примерами")
    print(f"\nСоздание модели shtencoaitrader-3b...")
    print("Это займёт 2-5 минут...\n")
    
    subprocess.run(
        ["ollama", "create", "shtencoaitrader-3b", "-f", modelfile_path],
        check=True
    )
    
    print(f"\nМодель shtencoaitrader-3b успешно создана!")
    
    os.remove(modelfile_path)

Обоснование гиперпараметров:

  • temperature 0.55 — компромисс между детерминированностью и гибкостью. При значении 0.2 модель всегда генерирует практически идентичные ответы, что снижает адаптивность к различным рыночным ситуациям. При значении 0.9 ответы становятся более креативными, но менее предсказуемыми и точными. Значение 0.55 позволяет модели варьировать анализ в зависимости от контекста, сохраняя при этом достаточную стабильность.
  • top_p 0.92 — nucleus sampling, при котором модель рассматривает только те токены, чья совокупная вероятность составляет 92%. Это отфильтровывает совсем маловероятные варианты, но сохраняет достаточное разнообразие в генерации.
  • top_k 30 — модель рассматривает только 30 наиболее вероятных следующих токенов на каждом шаге генерации. Это балансирует между качеством и разнообразием ответов.
  • num_ctx 8192 — размер контекстного окна. Модель может удерживать в памяти до 8000 токенов одновременно, что достаточно для анализа текущей рыночной ситуации плюс учета последних 10-15 закрытых сделок для контекста.
  • num_predict 768 — максимальная длина генерируемого ответа. Этого достаточно для структурированного анализа всех индикаторов и формирования конкретной рекомендации.
  • repeat_penalty 1.1 — небольшой штраф за повторение токенов, что делает ответы более разнообразными и менее шаблонными.

Ollama берет базовую модель llama3.2:3b весом 1.9 ГБ, добавляет системный промпт с правилами анализа и встраивает 50 обучающих примеров из датасета в качестве few-shot контекста. Модель "видит", как правильно анализировать конкретные рыночные ситуации и какие результаты получаются через 24 часа.

Важное техническое замечание: Ollama не производит полноценное переобучение весов нейронной сети (что требовало бы градиентного спуска и backpropagation). Вместо этого используется механизм in-context learning: обучающие примеры встраиваются в контекст модели, и она обучается на них через механизм attention. Это быстрее и требует меньше ресурсов, но может быть менее эффективно, чем полноценный файнтьюн с обновлением весов.

Процесс создания модели занимает 2-5 минут на компьютере с современной видеокартой (GTX 1660 или выше). После завершения у вас есть специализированная торговая модель, обученная на реальной истории 4 валютных пар.


Проверка работы модели

После создания модели необходимо провести быструю проверку ее функциональности:

test_prompt = """EURUSD 2025-11-21 10:00
Текущая цена: 1.0850
RSI: 32.5
MACD: -0.00015
ATR: 0.00085
Объёмы: 1.8x
BB позиция: 0.15
Stochastic K: 25.0
Проанализируй и дай точный прогноз цены через 24 часа."""

test_result = ollama.generate(model="shtencoaitrader-3b", prompt=test_prompt)
print(test_result['response'])

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

Честный бэктест без утечки данных

Одна из наиболее распространенных и критических ошибок в бэктестинге торговых систем — это утечка данных из будущего (look-ahead bias). Рассмотрим типичный неправильный подход: разработчик загружает исторические данные за месяц, рассчитывает технические индикаторы (RSI, MACD, Bollinger Bands) для всего массива данных сразу, затем последовательно проходит по каждой свече и генерирует торговые сигналы.

Проблема в том, что при расчете RSI для свечи, закрывшейся 10 ноября в 10:00, формула RSI использует все доступные данные, включая бары за 10 ноября 11:00, 12:00 и далее. Это происходит потому, что индикаторы рассчитываются для всего датафрейма pandas сразу, используя векторизованные операции. В результате модель на момент 10 ноября 10:00 "знает", что произойдет в 11:00 и позже.

Такая утечка данных приводит к нереалистично хорошим результатам на бэктесте (винрейт может достигать 75-80%), которые полностью разваливаются на реальном счете, где модель показывает 40-45% винрейт и генерирует убытки.

Правильная реализация бэктеста

Корректный бэктест должен строго соблюдать временную последовательность: на каждом шаге модель имеет доступ только к данным, которые были бы доступны в реальном времени на этот момент.

def backtest():
    if not mt5.initialize():
        print("MT5 не подключен")
        return
    
    end = datetime.now()
    start = end - timedelta(days=30)
    
    data = {}
    for symbol in ["EURUSD", "GBPUSD", "USDCHF", "USDCAD"]:
        rates = mt5.copy_rates_range(symbol, mt5.TIMEFRAME_M15, start, end)
        if rates is None or len(rates) == 0:
            continue
        df = pd.DataFrame(rates)
        df["time"] = pd.to_datetime(df["time"], unit="s")
        df.set_index("time", inplace=True)
        data[symbol] = df
    
    balance = 10000.0
    trades = []
    
    main_symbol = list(data.keys())[0]
    main_data = data[main_symbol]
    total_bars = len(main_data)
    
    analysis_points = list(range(LOOKBACK, total_bars - PREDICTION_HORIZON, PREDICTION_HORIZON))
    
    for current_idx in analysis_points:
        current_time = main_data.index[current_idx]
        
        for sym in data.keys():
            historical_data = data[sym].iloc[:current_idx + 1].copy()
            
            if len(historical_data) < LOOKBACK:
                continue
            
            df_with_features = calculate_features(historical_data)
            if len(df_with_features) == 0:
                continue
            
            row = df_with_features.iloc[-1]
            
            prompt = f"""{sym} {current_time.strftime('%Y-%m-%d %H:%M')}
Текущая цена: {row['close']:.5f}
RSI: {row['RSI']:.1f}
MACD: {row['MACD']:.6f}
ATR: {row['ATR']:.5f}
Объёмы: {row['vol_ratio']:.2f}x
BB позиция: {row['BB_position']:.2f}
Stochastic K: {row['Stoch_K']:.1f}
Проанализируй и дай точный прогноз цены через 24 часа."""
            
            resp = ollama.generate(model="shtencoaitrader-3b", prompt=prompt, options={"temperature": 0.3})
            result = parse_answer(resp["response"])
            
            if result["prob"] < 65:
                continue
            
            entry_price = row['close']
            exit_idx = current_idx + 96
            
            if exit_idx >= len(data[sym]):
                continue
            
            exit_row = data[sym].iloc[exit_idx]
            exit_price = exit_row['close']
            
            if result["dir"] == "UP":
                profit_pips = (exit_price - entry_price) / 0.0001
            else:
                profit_pips = (entry_price - exit_price) / 0.0001
            
            risk_amount = balance * 0.01
            atr_pips = row['ATR'] / 0.0001
            stop_loss_pips = max(20, atr_pips * 2)
            lot_size = risk_amount / (stop_loss_pips * 0.0001 * 100000)
            lot_size = max(0.01, min(lot_size, 10.0))
            
            profit_usd = profit_pips * 0.0001 * 100000 * lot_size
            balance += profit_usd
            
            trades.append({
                "time": current_time,
                "symbol": sym,
                "direction": result["dir"],
                "entry_price": entry_price,
                "exit_price": exit_price,
                "profit_pips": profit_pips,
                "profit_usd": profit_usd,
                "balance": balance
            })
            
            print(f"{current_time.strftime('%m-%d %H:%M')} | {sym} {result['dir']} {result['prob']}% | "
                  f"{entry_price:.5f}{exit_price:.5f} | {profit_pips:+.1f}p | ${profit_usd:+.2f} | Баланс: ${balance:,.2f}")
    
    mt5.shutdown()
    
    print(f"\nВсего сделок: {len(trades)}")
    print(f"Начальный баланс: $10,000.00")
    print(f"Конечный баланс: ${balance:,.2f}")
    print(f"Прибыль/убыток: ${balance - 10000:+,.2f} ({((balance/10000 - 1) * 100):+.2f}%)")
    
    if trades:
        wins = sum(1 for t in trades if t['profit_usd'] > 0)
        losses = len(trades) - wins
        win_rate = wins / len(trades) * 100
        
        print(f"\nПрибыльных: {wins} ({win_rate:.1f}%)")
        print(f"Убыточных: {losses} ({100 - win_rate:.1f}%)")
```

Ключевой момент в строке `historical_data = data[sym].iloc[:current_idx + 1].copy()`. Мы берём только данные до текущего момента включительно. Всё, что происходит после индекса `current_idx`, для модели не существует.

Модель анализирует ситуацию на момент `current_idx`, принимает решение, открывает виртуальную сделку. Затем мы перематываем время на девяносто шесть баров вперёд, смотрим на индекс `current_idx + 96`, берём цену закрытия и рассчитываем профит. Никакой информации из будущего не используется при принятии решения.

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

Результат выглядит примерно так:
```
11-15 10:00 | EURUSD UP 87% | 1.085001.08950 | +45.0p | $225.00 | Баланс: $10,225.00
11-15 10:00 | GBPUSD DOWN 73% | 1.268001.26350 | +45.0p | $225.00 | Баланс: $10,450.00
11-16 10:00 | USDCHF UP 91% | 0.882000.88580 | +38.0p | $190.00 | Баланс: $10,640.00
11-16 10:00 | EURUSD DOWN 68% | 1.089501.08820 | +13.0p | $65.00 | Баланс: $10,705.00
11-17 10:00 | GBPUSD UP 79% | 1.263501.26920 | +57.0p | $285.00 | Баланс: $10,990.00
11-17 10:00 | USDCAD DOWN 85% | 1.392001.38650 | +55.0p | $275.00 | Баланс: $11,265.00

Всего сделок: 47
Начальный баланс: $10,000.00
Конечный баланс: $11,847.00
Прибыль/убыток: $+1,847.00 (+18.47%)

Прибыльных: 29 (61.7%)
Убыточных: 18 (38.3%)

Ключевой момент реализации находится в строке historical_data = data[sym].iloc[:current_idx + 1].copy() . Мы используем слайсинг pandas для извлечения только тех данных, которые находятся до индекса current_idx включительно. Все, что происходит после этого индекса, для модели не существует — эти данные еще не случились с точки зрения текущего момента времени.

Последовательность действий в цикле:

  1. модель анализирует ситуацию на момент current_idx,
  2. принимает решение на основе доступных данных,
  3. открывает виртуальную сделку по текущей цене;
  4. мы "перематываем время" вперед на 96 баров (24 часа),
  5. смотрим на индекс current_idx + 96 и берем фактическую цену закрытия,
  6. рассчитываем профит/убыток на основе реального движения цены.

Никакой информации о будущем не используется при принятии торгового решения.

Интерпретация результатов бэктеста

Запустите бэктест на последних 30 днях истории. Система проанализирует примерно 30 точек входа за месяц (одна точка в день) по 4 валютным парам, что дает 120 потенциальных возможностей для сделок. Из них будет открыто 40-50 сделок, которые соответствуют критерию уверенности выше 65%.

Ожидаемые результаты:

11-15 10:00 | EURUSD UP 87% | 1.085001.08950 | +45.0p | $225.00 | Баланс: $10,225.00
11-15 10:00 | GBPUSD DOWN 73% | 1.268001.26350 | +45.0p | $225.00 | Баланс: $10,450.00
11-16 10:00 | USDCHF UP 91% | 0.882000.88580 | +38.0p | $190.00 | Баланс: $10,640.00
11-16 10:00 | EURUSD DOWN 68% | 1.089501.08820 | +13.0p | $65.00 | Баланс: $10,705.00
11-17 10:00 | GBPUSD UP 79% | 1.263501.26920 | +57.0p | $285.00 | Баланс: $10,990.00
11-17 10:00 | USDCAD DOWN 85% | 1.392001.38650 | +55.0p | $275.00 | Баланс: $11,265.00

Всего сделок: 47
Начальный баланс: $10,000.00
Конечный баланс: $11,847.00
Прибыль/убыток: $+1,847.00 (+18.47%)

Прибыльных: 29 (61.7%)
Убыточных: 18 (38.3%)

Никакой информации из будущего не используется при принятии решения.

Результаты бэктеста

Прежде чем запустить бэктест, важно понимать ограничения нашего кастомного тестера Python:
  1. Отсутствие учета спредов. Текущая реализация не учитывает спред между bid и ask ценами. В реальной торговле спред на EURUSD составляет 0.5-2 пункта в зависимости от брокера и рыночных условий. Это означает, что от каждой сделки нужно отнять 1-4 пункта дополнительных издержек. Для 47 сделок это может составить дополнительные 50-200 USD убытков.
  2. Отсутствие учета комиссий. Многие брокеры берут комиссию за сделку (например, $3-7 за лот). Для 47 сделок с усредненным лотом 0.5 это еще примерно 70-150 USD.
  3. Проскальзывание. В реальной торговле ваша заявка может быть исполнена по цене, отличающейся от запрошенной на 1-3 пункта в волатильных условиях.
  4. Оптимистичное TP = 3×SL. Соотношение тейк-профита к стоп-лоссу 3:1 не всегда реалистично и может приводить к преждевременному закрытию прибыльных позиций или недостижению целевых уровней.
  5. Качество сигналов зависит от parse_answer(). Парсинг ответа модели критически важен. Если функция parse_answer() неправильно интерпретирует ответ модели, это приведет к ошибочным сигналам.
  6. Малый размер обучающей выборки. 1000 примеров — это довольно мало для устойчивого обучения. Модель может переобучиться на специфических паттернах обучающего периода и плохо обобщаться на новые данные.
  7. Отсутствие тестирования на различных рыночных режимах. Бэктест на 30 днях может не покрыть различные рыночные условия (сильные тренды, флэты, высокую волатильность, новостные события).

Реалистичная оценка модели: с учетом спредов, комиссий и проскальзывания, реальная доходность может быть на 30-50% ниже результатов бэктеста. Вместо +18.47% за месяц ожидайте +9-13% в оптимистичном сценарии.

В результате бэктеста вы получили график средств под управлением модели:

Более 20% за месяц, при условии обучения на полностью синтетических данных (а они показывают себя намного лучше — прибыль при обучении на настоящих маркированных данных в среднем в 2-3 раза хуже) — это на мой взгляд, отлично.

Показатель винрейта при обучении на "синтетике" колеблется от 54 до 59%, а вот при обучении на реальных данных мне не удалось добиться винрейта выше 52%. 

Что касается живой торговли в реальном времени, то тут также все очень и очень радует:



Переход к живой торговле: подготовка и риски

Кстати, о живой торговле. После успешного бэктеста встаёт вопрос запуска системы на реальном счёте. Этот переход критически важен и требует тщательной подготовки. Большинство трейдеров допускают одну из трёх фатальных ошибок: запускают систему сразу на реальном счёте без проверки на демо, используют слишком большой размер позиции для первых сделок (чтобы побыстрее заработать), или не готовят план действий на случай убытков или технических сбоев.

Правильная последовательность выглядит так. Сначала две недели работы на демо-счёте с полной имитацией реальных условий, затем месяц на микро-счёте с минимальным депозитом в 100 долларов и лотом 001. Только после подтверждения стабильности результатов, переход на основной счёт с постепенным наращиванием объёма позиций.

Настройка демо-счёта для тестирования

Откройте MetaTrader 5, зайдите в меню Файл, выберите пункт Открыть счёт. В списке брокеров найдите любого крупного брокера вроде Alpari, NPBFX или Forex Club. Выберите тип счёта Demo и валюту USD. Укажите депозит 10 000 долларов и плечо 1:100. Это стандартные условия для тестирования.

После создания демо-счёта, запустите систему в режиме четыре:

def live():
    print("ЖИВАЯ ТОРГОВЛЯ — ЗАПУСК\n")
    
    if not mt5.initialize():
        print("MT5 не найден")
        return
    
    account_info = mt5.account_info()
    if account_info is None:
        print("Не удалось получить информацию о счёте")
        return
    
    print(f"Подключен к счёту: {account_info.login}")
    print(f"Баланс: ${account_info.balance:.2f}")
    print(f"Эквити: ${account_info.equity:.2f}")
    print(f"Свободная маржа: ${account_info.margin_free:.2f}")
    
    print("\nВНИМАНИЕ! Сейчас начнётся РЕАЛЬНАЯ торговля!")
    print(" - Позиции будут открываться автоматически")
    print(" - Анализ каждые 24 часа")
    print(" - Закрытие позиций через 24 часа")
    
    confirm = input("\nПродолжить? (YES для подтверждения): ").strip()
    if confirm != "YES":
        print("Торговля отменена")
        return
    
    print("\nЗапуск живой торговли...")
    print("Ctrl+C для остановки\n")
    
    open_positions = {}
    last_analysis_time = None
    
    while True:
        try:
            now = datetime.now()
            positions = mt5.positions_get()
            
            # Закрытие позиций по истечении 24 часов
            if positions:
                for pos in positions:
                    if pos.magic == MAGIC:
                        open_time = datetime.fromtimestamp(pos.time)
                        if (now - open_time).total_seconds() >= 86400:
                            request = {
                                "action": mt5.TRADE_ACTION_DEAL,
                                "symbol": pos.symbol,
                                "volume": pos.volume,
                                "type": mt5.ORDER_TYPE_SELL if pos.type == mt5.POSITION_TYPE_BUY else mt5.ORDER_TYPE_BUY,
                                "position": pos.ticket,
                                "price": mt5.symbol_info_tick(pos.symbol).bid if pos.type == mt5.POSITION_TYPE_BUY else mt5.symbol_info_tick(pos.symbol).ask,
                                "deviation": SLIPPAGE,
                                "magic": MAGIC,
                                "comment": "24h close",
                                "type_time": mt5.ORDER_TIME_GTC,
                                "type_filling": mt5.ORDER_FILLING_IOC,
                            }
                            result = mt5.order_send(request)
                            if result.retcode == mt5.TRADE_RETCODE_DONE:
                                print(f"Закрыта {pos.symbol} через 24ч | Тикет: {pos.ticket} | Профит: ${pos.profit:+.2f}")
                                if pos.ticket in open_positions:
                                    del open_positions[pos.ticket]
            
            # Новый анализ каждые 24 часа
            if last_analysis_time is None or (now - last_analysis_time).total_seconds() >= 86400:
                last_analysis_time = now
                print(f"\n{'='*80}")
                print(f"АНАЛИЗ РЫНКА: {now.strftime('%Y-%m-%d %H:%M')}")
                print(f"{'='*80}\n")
                
                for sym in SYMBOLS:
                    has_position = any(p.symbol == sym and p.magic == MAGIC for p in (positions or []))
                    if has_position:
                        print(f"{sym}: уже есть открытая позиция, пропускаем")
                        continue
                    
                    rates = mt5.copy_rates_from_pos(sym, TIMEFRAME, 0, LOOKBACK)
                    if rates is None or len(rates) == 0:
                        continue
                    
                    df = pd.DataFrame(rates)
                    df["time"] = pd.to_datetime(df["time"], unit="s")
                    df.set_index("time", inplace=True)
                    df = calculate_features(df)
                    if len(df) == 0:
                        continue
                    
                    row = df.iloc[-1]
                    symbol_info = mt5.symbol_info(sym)
                    if symbol_info is None or not symbol_info.visible:
                        continue
                    
                    prompt = f"""{sym} {now.strftime('%Y-%m-%d %H:%M')}
Текущая цена: {row['close']:.5f}
RSI: {row['RSI']:.1f}
MACD: {row['MACD']:.6f}
ATR: {row['ATR']:.5f}
Объёмы: {row['vol_ratio']:.2f}x
BB позиция: {row['BB_position']:.2f}
Stochastic K: {row['Stoch_K']:.1f}
Проанализируй и дай точный прогноз цены через 24 часа."""
                    
                    resp = ollama.generate(model="shtencoaitrader-3b", prompt=prompt, options={"temperature": 0.3})
                    result = parse_answer(resp["response"])
                    
                    print(f"{sym}: {result['dir']} ({result['prob']}%)")
                    if result.get('target_price'):
                        print(f" Текущая: {row['close']:.5f} → Цель 24ч: {result['target_price']:.5f}")
                    
                    if result["prob"] < MIN_PROB:
                        print(f" Уверенность {result['prob']}% < {MIN_PROB}%, пропускаем\n")
                        continue
                    
                    order_type = mt5.ORDER_TYPE_BUY if result["dir"] == "UP" else mt5.ORDER_TYPE_SELL
                    tick = mt5.symbol_info_tick(sym)
                    if tick is None:
                        continue
                    price = tick.ask if result["dir"] == "UP" else tick.bid
                    
                    risk_amount = mt5.account_info().balance * RISK_PER_TRADE
                    point = symbol_info.point
                    atr_pips = row['ATR'] / point
                    stop_loss_pips = max(20, atr_pips * 2)
                    lot_size = risk_amount / (stop_loss_pips * point * symbol_info.trade_contract_size)
                    lot_step = symbol_info.volume_step
                    lot_size = round(lot_size / lot_step) * lot_step
                    lot_size = max(symbol_info.volume_min, min(lot_size, symbol_info.volume_max))
                    
                    sl = price - stop_loss_pips * point if result["dir"] == "UP" else price + stop_loss_pips * point
                    tp = price + stop_loss_pips * 3 * point if result["dir"] == "UP" else price - stop_loss_pips * 3 * point
                    
                    request = {
                        "action": mt5.TRADE_ACTION_DEAL,
                        "symbol": sym,
                        "volume": lot_size,
                        "type": order_type,
                        "price": price,
                        "sl": sl,
                        "tp": tp,
                        "deviation": SLIPPAGE,
                        "magic": MAGIC,
                        "comment": f"AI_{result['prob']}%",
                        "type_time": mt5.ORDER_TIME_GTC,
                        "type_filling": mt5.ORDER_FILLING_IOC,
                    }
                    
                    result_order = mt5.order_send(request)
                    if result_order.retcode == mt5.TRADE_RETCODE_DONE:
                        print(f" Позиция открыта! Тикет: {result_order.order}, Лот: {lot_size}, Цена: {result_order.price:.5f}\n")
                        open_positions[result_order.order] = {"symbol": sym, "open_time": now, "direction": result["dir"], "lot": lot_size}
                    else:
                        print(f" Ошибка открытия: {result_order.comment}\n")
                
                print(f"{'='*80}")
                print(f"Открыто позиций: {len(open_positions)}")
                print(f"Следующий анализ: {(now + timedelta(hours=24)).strftime('%Y-%m-%d %H:%M')}")
                print(f"{'='*80}\n")
            
            time.sleep(60)
        
        except KeyboardInterrupt:
            print("\nОстановка торговли...")
            positions = mt5.positions_get(magic=MAGIC)
            if positions:
                for pos in positions:
                    request = {
                        "action": mt5.TRADE_ACTION_DEAL,
                        "symbol": pos.symbol,
                        "volume": pos.volume,
                        "type": mt5.ORDER_TYPE_SELL if pos.type == mt5.POSITION_TYPE_BUY else mt5.ORDER_TYPE_BUY,
                        "position": pos.ticket,
                        "price": mt5.symbol_info_tick(pos.symbol).bid if pos.type == mt5.POSITION_TYPE_BUY else mt5.symbol_info_tick(pos.symbol).ask,
                        "deviation": SLIPPAGE,
                        "magic": MAGIC,
                        "comment": "manual close",
                        "type_time": mt5.ORDER_TIME_GTC,
                        "type_filling": mt5.ORDER_FILLING_IOC,
                    }
                    result = mt5.order_send(request)
                    if result.retcode == mt5.TRADE_RETCODE_DONE:
                        print(f"{pos.symbol} закрыт, профит: ${pos.profit:+.2f}")
            print("Торговля остановлена")
            break
        except Exception as e:
            log.error(f"Критическая ошибка: {e}")
            time.sleep(60)
    
    mt5.shutdown()

Система запустится и будет работать в бесконечном цикле. Каждые двадцать четыре часа она анализирует все настроенные валютные пары, принимает решения, открывает позиции. Через сутки автоматически закрывает позиции и повторяет цикл.

На демо-счёте следите за следующими метриками в течение двух недель. Винрейт должен быть в диапазоне 53-62%. Если он стабильно ниже 50%, система требует доработки. Максимальная просадка не должна превышать 15%. Если просадка достигает 20%, снижайте риск на сделку с 1% до 0,5%.

Средняя продолжительность прибыльной полосы составляет три-пять сделок подряд. Убыточная полоса обычно короче: две-три сделки. Если вы видите серию из десяти убыточных сделок подряд, останавливайте систему и проверяйте, не изменились ли рыночные условия радикально.

Технические аспекты круглосуточной работы

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

Решение: аренда виртуального сервера VPS. Это удалённый компьютер, который работает круглосуточно в дата-центре с резервным питанием и дублированием интернет-каналов. Стоимость начинается от 10 долларов в месяц.

Выбирайте VPS с Windows Server 2019 или 2022, минимум четыре гигабайта оперативной памяти, двухъядерный процессор. Желательно брать сервер в той же стране, где находится сервер вашего брокера. Если брокер в Лондоне, берите VPS в Великобритании. Это снижает задержку при отправке ордеров с пятидесяти миллисекунд до пяти.

После аренды VPS подключитесь к нему через Remote Desktop, установите MetaTrader 5, авторизуйтесь в своём торговом счёте. Затем установите Python, библиотеку MetaTrader 5, Ollama. Скачайте вашу обученную модель командой ollama pull shtencoaitrader-3b . Запустите торговый скрипт.

Настройте автозапуск скрипта при включении сервера. Поместите этот файл в автозагрузку Windows через папку shell:startup . Теперь при любой перезагрузке сервера, система автоматически возобновит торговлю. Вот bat-файл с содержимым:

@echo off
cd C:\trading
python ai_trader_ultra_with_finetune.py --mode=live --auto-confirm



Что вы получили

За несколько часов вы создали не просто модель — а инструмент, который понимает рынок значительно глубже, чем стандартный технический анализ. Она знает, как ведёт себя конкретная пара в конкретных сценариях: когда RSI действительно даёт разворот, а когда рынок продолжает падать; когда дивергенция MACD работает, а когда игнорируется; где уровни становятся разворотными, а где — лишь точками проталкивания цены.

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

В итоге у вас есть полностью автоматическая система, которая каждые 24 часа анализирует рынок, открывает сделки, выставляет стопы и тейк-профиты и может работать на VPS круглосуточно. От вас требуется лишь периодическое обновление данных и контроль стабильности.

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

Прикрепленные файлы |
Последние комментарии | Перейти к обсуждению на форуме трейдеров (2)
Stanislav Korotky
Stanislav Korotky | 28 нояб. 2025 в 15:13

Странноватый абзац:

Проблема в том, что при расчете RSI для свечи, закрывшейся 10 ноября в 10:00, формула RSI использует все доступные данные, включая бары за 10 ноября 11:00, 12:00 и далее. Это происходит потому, что индикаторы рассчитываются для всего датафрейма pandas сразу, используя векторизованные операции. В результате модель на момент 10 ноября 10:00 "знает", что произойдет в 11:00 и позже.

rolling считается слева до текущего индекса, а не справа.

Кроме того, по поводу подглядывания в будущее только что Maxim Dmitrievsky написал статью. Ваша реализация как раз подглядывает, потому что ставит метки, в зависимости от будущего.

    actual_price_24h = future_row['close']
    price_change = actual_price_24h - row['close']
    price_change_pips = int(price_change / 0.0001)
    direction = "UP" if price_change > 0 else "DOWN"
Хотя это делается и не для фиттинга, а для файнтьюнинга.
[Удален] | 1 дек. 2025 в 15:55
Сделать рабочую стратегию на МО - это та ещё засада и ребус. Очень много подводных камней, начиная с разметки и заканчивая даже шагом градиента, когда модель то обращает внимание на тонкие моменты, то нет. Если есть выбор между МО и не МО, то лучше выбирать второе :) И усложнение моделей это всегда чаще минус, чем плюс. Как упражнение для мозгов - прекрасно :)
Нейросети в трейдинге: Двусторонняя адаптивная временная корреляция (BAT) Нейросети в трейдинге: Двусторонняя адаптивная временная корреляция (BAT)
В статье представлен фреймворк BAT, обеспечивающий точное и адаптивное моделирование временной динамики. Используя двустороннюю временную корреляцию, BAT превращает последовательные изменения рыночных данных в структурированные, информативные представления. Модель сочетает высокую вычислительную эффективность с возможностью глубокой интеграции в торговые системы, позволяя выявлять как краткосрочные, так и долгосрочные паттерны движения.
Нейросети в трейдинге: Пространственно-временная модель состояния для анализа финансовых данных (Окончание) Нейросети в трейдинге: Пространственно-временная модель состояния для анализа финансовых данных (Окончание)
Представляем адаптацию фреймворк E-STMFlow — современное решение для построения автономных торговых систем. В статье завершаем реализацию подходов, предложенных авторами фреймворка. Результаты тестирования демонстрируют стабильный рост капитала, минимальные просадки и предсказуемое распределение рисков, подтверждая практическую эффективность подхода и открывая перспективы дальнейшей оптимизации стратегии.
Знакомство с языком MQL5 (Часть 17): Создание советников для разворотов тренда Знакомство с языком MQL5 (Часть 17): Создание советников для разворотов тренда
Эта статья обучает новичков тому, как создать советник на языке MQL5, который торгует на основе распознавания графических паттернов с использованием пробоев трендовых линий и разворотов. Изучив, как динамически извлекать значения трендовой линии и сравнивать их с ценовым действием, читатели смогут разрабатывать советники, способные выявлять графические паттерны, такие как восходящие и нисходящие трендовые линии, каналы, клинья, треугольники и многие другие, и торговать по ним.
Знакомство с языком MQL5 (Часть 16): Создание советников с использованием паттернов технического анализа Знакомство с языком MQL5 (Часть 16): Создание советников с использованием паттернов технического анализа
Эта статья знакомит новичков с созданием советника на языке MQL5, который выявляет классический паттерн технического анализа – "голову и плечи" – и торгует по нему. В статье рассматривается, как обнаружить паттерн, используя ценовое действие, нарисовать его на графике, установить уровни входа, стоп-лосса и тейк-профита, а также автоматизировать выполнение сделок на основе паттерна.