2025, Dec 26 15:02

Условный ffill в pandas: заполняем X до первого консенсуса X=Y=Z

Разбираем, как в pandas выполнять условный ffill по временным рядам: начинать после непустого X и останавливать на первом консенсусе X=Y=Z без циклов.

Заполнение вперёд кажется элементарным, пока бизнес-правило не звучит так: «распространять значения только до момента, когда выполняется условие консенсуса, затем остановиться и ждать следующего сигнала». В данных с временным индексом и категориальными событиями вроде buy/sell и пропусками (NaN) часто нужно переносить значение из опорного столбца ровно настолько — до первого таймстемпа, где все связанные столбцы согласованы, — а затем сбрасывать. Ниже — аккуратный способ сделать это в pandas без построчных циклов.

Постановка задачи

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

import pandas as pd
import numpy as np

frame = pd.DataFrame({
    "X": ["sell", np.nan, np.nan, np.nan, np.nan, "buy", np.nan, np.nan, np.nan, np.nan],
    "Y": ["buy", "buy", "sell", "buy", "buy", np.nan, "buy", "buy", "buy", np.nan],
    "Z": ["sell", "sell", "sell", "sell", "buy", "sell", "sell", "buy", "buy", np.nan]
}, index=pd.date_range("2025-05-22", periods=10, freq="15min"))

print(frame)

Что здесь происходит на самом деле

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

Такой подход естественно делит индекс на сегменты, привязанные к появлениям непустых значений в X. Внутри каждого сегмента можно заранее получить кандидат заполнения вперёд и затем обрезать его на первом индексе, где X == Y == Z. Функция notna().cumsum() формирует эти сегменты, а idxmax() помогает эффективно найти первую точку совпадения. Метод infer_objects(copy=False) применяем перед ffill, чтобы типы оставались корректными даже если самый первый элемент отсутствует.

Решение

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

def propagate_until_sync(part):
    ahead = part['X'].infer_objects(copy=False).ffill()
    cutoff = (ahead.eq(part['Y']) & part['Y'].eq(part['Z'])).idxmax()
    part.loc[:cutoff, 'X'] = ahead[:cutoff]
    return part

spans = frame['X'].notna().cumsum()
result = frame.groupby(spans, as_index=False).apply(propagate_until_sync).reset_index(level=0, drop=True)

print(result)

Это выполняется векторизованно. Сначала spans — это нарастающий счётчик, который увеличивается каждый раз, когда в X встречается непустое значение; так формируются непрерывные блоки, начинающиеся с каждой новой опоры. Затем внутри каждого блока ahead — это версия X, заполненная вперёд. Первая точка, где три столбца выравниваются, находится через булеву маску и idxmax(), возвращающую самый ранний индекс, на котором условие истинно. Наконец, X обновляется только до этого индекса внутри блока. Лишний индекс группы удаляется с помощью reset_index(level=0, drop=True).

Зачем это нужно

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

Выводы

Когда требуется условное заполнение вперёд, постройте ключ группировки из опорного столбца через notna().cumsum(), подготовьте кандидат на ffill внутри каждой группы, затем обрежьте на первом таймстемпе, удовлетворяющем условию остановки, используя булеву маску и idxmax(). Этот приём лаконичен, быстр и явно задаёт, где распространение начинается и где заканчивается, так что логика работы с временными рядами остаётся проверяемой и предсказуемой.