2025, Oct 04 11:17

Как точечно обновить day_high в 08:30 в pandas MultiIndex

Разбираем типичную ловушку pandas с MultiIndex: значение дневного high расползается по дню. Как записать day_high только в 08:30 с маской, groupby и transform.

Запись дневной агрегированной метрики в единственную строку — классическая ловушка в pandas: значение вычисляется верно, но при присваивании оно незаметно распространяется на все строки для выбранного ключа. Ниже — короткое пошаговое объяснение, как прицельно обновлять ровно одну строку для пары (Symbol, Date) — запись на 08:30 — и делать это одинаково надежно как для одного контракта, так и для всего набора данных.

Воспроизводимость: почему значение попадает в каждую строку дня

Рассмотрим внутридневные бары опционов с MultiIndex по Symbol и Date. Нужно сохранить дневной максимум high в столбце day_high, но только в той строке, где hour равен 08:30:00.

import pandas as pd
import csv

rows = [['SPXW 250715C06310000', '7/14/2025', 2.74, 2.87, 2.60, 2.65, 14, '8:30:00'],
        ['SPXW 250715C06310000', '7/14/2025', 2.80, 2.80, 2.50, 2.53, 61, '8:31:00'],
        ['SPXW 250715C06310000', '7/14/2025', 2.45, 2.45, 2.45, 2.45, 2, '8:32:00'],
        ['SPXW 250715C06310000', '7/14/2025', 2.58, 2.80, 2.58, 2.60, 32, '8:33:00'],
        ['SPXW 250715C06310000', '7/14/2025', 2.50, 2.50, 2.25, 2.30, 5, '8:34:00'],
        ['SPXW 250709C06345000', '7/9/2025', 0.05, 0.05, 0.03, 0.03, 246, '8:30:00'],
        ['SPXW 250709C06345000', '7/9/2025', 0.05, 0.10, 0.03, 0.07, 452, '8:31:00'],
        ['SPXW 250709C06345000', '7/9/2025', 0.07, 0.10, 0.05, 0.07, 137, '8:32:00'],
        ['SPXW 250709C06345000', '7/9/2025', 0.07, 0.07, 0.07, 0.07, 5, '8:33:00'],
        ['SPXW 250709C06345000', '7/9/2025', 0.07, 0.07, 0.05, 0.05, 225, '8:34:00'],
        ['SPXW 250715C06310000', '7/11/2025', 7.30, 7.30, 7.30, 7.30, 2, '8:30:00'],
        ['SPXW 250715C06310000', '7/11/2025', 7.20, 7.20, 7.20, 7.20, 2, '8:31:00'],
        ['SPXW 250715C06310000', '7/11/2025', 6.92, 6.92, 6.92, 6.92, 20, '8:32:00'],
        ['SPXW 250715C06310000', '7/11/2025', 6.58, 6.58, 6.58, 6.58, 1, '8:34:00'],
        ['SPXW 250715C06310000', '7/11/2025', 6.41, 6.41, 6.41, 6.41, 2, '8:35:00']]

frame = pd.DataFrame(rows, columns=['Symbol', 'Date', 'open', 'high', 'low', 'close', 'volume', 'hour'])

frame['Date'] = pd.to_datetime(frame['Date'])
frame['hour'] = pd.to_datetime(frame['hour'], format='%H:%M:%S')
frame = frame.set_index(['Symbol', 'Date'])

# Попытка: заполняет каждую строку для этого (Symbol, Date)
frame.loc[('SPXW 250715C06310000', '2025-07-14'), 'day_high'] = (
    frame.loc[('SPXW 250715C06310000', '2025-07-14'), 'high'].max()
)

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

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

Выбрать одну строку для одного контракта/дня

Исправление — присваивать по булевой маске, которая истинна только для нужной строки. Нужны два условия: время 08:30 и совпадение MultiIndex с конкретной парой (Symbol, Date).

# Выбираем ровно одну строку с помощью булевой маски
flag = (
    frame['hour'].dt.strftime('%H:%M').eq('08:30') &
    (frame.index == ('SPXW 250715C06310000', pd.Timestamp('2025-07-14')))
)

frame.loc[flag, 'day_high'] = (
    frame.loc[('SPXW 250715C06310000', '2025-07-14'), 'high'].max()
)

Если уровень Date уже имеет тип datetime, сработает сравнение с «голой» датой; в этом случае для проверки равенства можно обойтись без pd.Timestamp.

Сделать это для каждого контракта/дня без циклов

Цикл for не нужен. Однажды вычислите максимум для каждой пары (Symbol, Date) и позвольте pandas выровнять значения по нужным местам. Есть два идиоматических подхода.

Первый подход вычисляет дневной максимум и присваивает его только там, где hour равен 08:30. Результат GroupBy выравнивается по индексу (Symbol, Date) при присваивании.

flag = frame['hour'].dt.strftime('%H:%M').eq('08:30')
frame.loc[flag, 'day_high'] = frame.groupby(['Symbol', 'Date'])['high'].max()

Второй подход использует transform: агрегированное значение транслируется к исходному индексу, а затем маскируется, чтобы оставить только строку 08:30. Этот вариант часто проще для понимания, потому что выравнивание уже происходит построчно.

flag = frame['hour'].dt.strftime('%H:%M').eq('08:30')
frame['day_high'] = frame.groupby(['Symbol', 'Date'])['high'].transform('max').where(flag)

Почему это важно

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

Выводы

При присваивании в срез MultiIndex помните: ключ из двух уровней выбирает всю группу. Добавьте построчную маску, чтобы указать единственную запись, которая вам нужна. Для массовых обновлений по всем символам и дням используйте GroupBy.max с выравниванием по индексу или GroupBy.transform вместе с where — так вы избежите ручных циклов. И если уровень Date уже хранится как datetime, для проверки равенства достаточно сравнить именно с этим значением.

Статья основана на вопросе с StackOverflow от Dan и ответе jezrael.