Files
ems/docs/04-modules/market-prices.md
Dusan Vojacek 08f1b6741a
Some checks failed
CI and deploy / migration-check (push) Failing after 24s
CI and deploy / deploy (push) Has been skipped
zasadni uprava LP planneru
2026-05-21 11:18:09 +02:00

12 KiB
Raw Blame History

Modul: Market Prices (Spotové ceny OTE CZ)

Co modul dělá

  • Stahuje spotové ceny elektřiny z OTE CZ
  • Ukládá raw data bez vazby na lokalitu (sdílená tabulka)
  • Efektivní ceny (s marží) se dopočítávají per site přes view
  • Granularita: 15 minut nativně (veřejný JSON @@chart-data s time_resolution=PT15M)

Zdroj dat: OTE CZ

URL: https://www.ote-cr.cz/cs/kratkodobe-trhy/elektrina/denni-trh

OTE CZ publikuje denní ceny zpravidla den předem (D-1) okolo 13:0014:00 středoevropského času.

Formát dat OTE CZ (implementace)

Primární zdroj: JSON grafu denního trhu (96 bodů na den):

https://www.ote-cr.cz/cs/kratkodobe-trhy/elektrina/denni-trh/@@chart-data?report_date=YYYY-MM-DD&time_resolution=PT15M

  • Body: data.dataLine[0].point[] s x = 1…96 (15min slot), y = cena.
  • Jednotka ceny se bere z axis.y.legend (typicky EUR/MWh); kód podporuje i CZK/MWh a CZK/kWh.
  • Přepočet do buy_raw_price_czk_kwh: EUR/MWh → × EUR_CZK / 1000; CZK/MWh → / 1000; CZK/kWh beze změny.
  • Časové razítko slotu při importu: závisí na volbě dne; scheduler a globální import používají Europe/Prague. Volitelný parametr site_id v import_ote_prices umožní pro manuální volání bez explicitního data použít site.timezone místo Prahy.

Poznámka k výběru série (tooltipy): OTE v čase mění názvy dataLine[].tooltip. Import v DB (ems.fn_ote_parse_15m_price_json) umí jak legacy tooltipy typu flash_chart_01_y_15m_price_tooltip, tak i novější „lidské“ názvy jako 15min cena (EUR/MWh) / 60min cena (EUR/MWh).

Legacy / alternativa

  • OTE pubapi: https://www.ote-cr.cz/pubapi/v1/market-data/dam?... (hodinová data v projektu už není primární)

Kdo stahuje data

Python service: price_importer

Samostatný modul (ne součást FastAPI, ale může být volán z ní jako task).

Multi-site: jeden import na tick

Tabulka ems.market_interval_price je globální (jeden zdroj OTE_CZ pro celou instalaci). Plánovaný job v backend/app/main.py proto pro dnešek a zítřek v Europe/Prague doplňuje chybějící dny nejednou v cyklu po každé lokalitě, ale jedním HTTP dotazem na OTE a jedním zápisem do DB. Po úspěšném importu se pro každou aktivní ems.site znovu naplní cache predikce záporných cen (fn_predict_negative_price_windows).

Funkce import_ote_prices(db, site_id=None, target_date=…) akceptuje volitelný site_id jen pro odvození „dnes/zítra“, pokud není zadán target_date; při site_id is None se použije Europe/Prague (shodně se schedulerem).

Manuální POST /api/v1/sites/{site_id}/prices/import ponechává site_id v cestě kvůli kompatibilitě s UI (ověření, že lokalita existuje), ale zapisuje stejná sdílená data jako scheduler.

Implementované provozní změny (2026-03)

  • Robustní HTTP fetch přes httpx:
    • oddělené timeouty (connect/read/write/pool),
    • retry s exponential backoff pro transient chyby,
    • detailní chybové kódy (http_status:*, timeout_or_connect:*, db_import:*).
  • API endpoint POST /api/v1/sites/{site_id}/prices/import vrací při chybě konkrétní důvod.
  • Pokud je import volán bez date, importer nejdřív zkusí D+1 a při neúspěchu fallback na dnešní den.

Kdy se spouští

Trigger Čas Popis
Scheduled (cron) každý den 13:30 CET Předběžný pokus importu (D+1 + doplnění dneška)
Scheduled (cron) každý den 14:00 CET Hlavní import OTE (D+1 + doplnění dneška)
Scheduled (cron) každý den 00:05 CET Backfill kontrola úplnosti 96 slotů pro dnešek/zítřek
Manual trigger na vyžádání API endpoint POST /api/v1/sites/{site_id}/prices/import?date=YYYY-MM-DD
Retry při chybě Automatický opakovaný pokus v importéru

Cronové časy v tabulce jsou v Europe/Prague (CET/CEST): AsyncIOScheduler v app/main.pytimezone=ZoneInfo("Europe/Prague"); kontejner backendu má TZ=Europe/Prague v docker-compose.yml. Bez toho by se hodiny/minuty v cronu vyhodnocovaly v UTC a např. „13:30 CET“ by odpovídalo jinému okamžiku na hodinách.

Logika importu

# Pseudologika importu (implementace v price_importer.py)

def import_prices_for_date(date: date, source: str = "OTE_CZ"):
    # 1. Zkontrolovat jestli data pro daný den už existují
    existing = db.query("SELECT COUNT(*) FROM market_interval_price WHERE interval_start::date = %s AND market_source = %s", date, source)
    if existing > 0 and not force_reimport:
        log.info("Data already exist, skipping")
        return

    # 2. Stáhnout chart-data (96× 15 min)
    raw_points = ote_client.fetch_chart_data_15m(date)

    # 3. Detekovat jednotku (EUR/MWh, CZK/MWh, …) a převést na CZK/kWh
    intervals = convert_points_to_czk_kwh(raw_points, date, site_tz)

    # 5. Upsert do DB (idempotentní)
    db.upsert_many("market_interval_price", intervals, conflict_keys=["market_source", "interval_start"])
    log.info(f"Imported {len(intervals)} intervals for {date}")

Struktura DB záznamu

Viz 03-data-model.md → tabulka market_interval_price.

Klíčové body:

  • buy_raw_price_czk_kwh a sell_raw_price_czk_kwh jsou oddělené
  • Pro OTE CZ je v první verzi sell_raw_price = buy_raw_price (reference cena)
  • imported_at slouží pro audit importů

Efektivní ceny per site

Viz view market_vw_site_effective_price v 03-data-model.md.

Marže se konfigurují v site_market_config:

Parametr Typ Příklad
buy_margin_fixed_czk Kč/kWh 0.05 (5 haléřů/kWh)
buy_margin_percent % 2.5 (nebo 9 u obchodníka ×1.09 / ×0.91, viz níže)
sell_margin_fixed_czk Kč/kWh -0.02 (srážka)
sell_margin_percent % 0

Nákup v režimu spot (purchase_pricing_mode = 'spot'): skutečná logika je v ems.fn_effective_buy_price (volá ji vw_site_effective_price, plán, audit). Složka OTE buy_raw před distribucí / poplatky / DPH:

  • buy_raw_price_czk_kwh ≥ 0: energie ze spotu se násobí (1 + buy_margin_percent/100) (např. 9 % → ×1.09 na tuto složku), pak se přičtou buy_margin_fixed_czk, distribuce NT/VT, systémové služby, poplatek OTE a nakonec DPH přes celek.
  • buy_raw_price_czk_kwh < 0: stejný procentní parametr se uplatní jako (1 buy_margin_percent/100) (např. 9 % → ×0,91 na zápornou spotovou složku), aby obchodní marže záporné ceny „nezesilovala“ směrem k ještě nižší (dražší) hodnotě.

Režim FIXED (uzavřená cena + příplatek VT): procentní marže zůstává symetricky jako fix + uzavřená_energie × (buy_margin_percent/100) jako dříve — asymmetric platí jen pro čistý spot ze market_interval_price.

Denní ekonomika v DB (ems.fn_economics_daily_for_window, repeatable R__068_fn_economics_daily_month.sql) musí používat stejnou kombinaci jako fn_effective_buy_price (komentář ve funkci).

Plánování: efektivní buy_price per 15min slot už nese skok VT→NT (distribuce v fn_effective_buy_price). Maska grid nabíjení v fn_load_planning_slots_full navíc vyžaduje buy ≤ min(buy v příštích 4 slotech) + ε, aby se neplánoval import v posledním VT slotu před levným NT — viz docs/04-modules/planning.md.

Screening skript pro dimenzování baterie

Analytický skript scripts/analysis/battery_sizing_screen.py umí pro nákup v režimu spot simulovat dva užitečné screening režimy bez vazby na konkrétní site_market_config:

  • --buy-spot-add-fixed-kwh X: základ nákupu = raw_ote + X
  • --buy-spot-asym-pct P: základ nákupu = raw_ote × (1 + P/100) pro raw_ote >= 0, resp. raw_ote × (1 - P/100) pro raw_ote < 0

V obou případech skript ke každému importnímu slotu fixně přičte:

  • --buy-distribution-kwh
  • --buy-other-fees-kwh

Volitelně pak na celý součet aplikuje:

  • --buy-vat-multiplier (např. 1.21)

Tato logika je implementovaná přímo ve build_buy_prices_96() v scripts/analysis/battery_sizing_screen.py. Účel je screening nové lokality nebo obchodního modelu ještě před seedem do DB; nejde o náhradu ems.fn_effective_buy_price.

Skript navíc v solve_one_day() explicitně zakazuje současný import a export do sítě v jednom 15min slotu a zároveň současné nabíjení a vybíjení baterie. Tím se eliminuje artefakt, kdy by při výhodnějším buy než sell model vytvářel umělý „loop“ bez fyzického významu.

Pro delší běhy (měsíce / rok) lze runtime řídit přímo z CLI:

  • --solver-time-limit-sec = CBC limit na jeden den
  • --progress-every-days = po kolika dnech skript vytiskne průběh (0 = ticho)

To je důležité hlavně po zavedení binárních proměnných pro zákaz současného import+export a charge+discharge, protože roční běhy jsou výrazně pomalejší než původní čisté LP.

Ověření:

  • spusť skript nad krátkým vzorkem OTE (--price-csv nebo --db) a zkontroluj vypsané shrnutí režimu nákupu
  • pro asymetrickou variantu ověř, že záporné ceny používají faktor 1 - P/100, nikoli 1 + P/100
  • pro arbitráž bez FVE použij --pv-daily-kwh-summer 0 --pv-daily-kwh-winter 0 --load-kw 0

Zelený bonus není součástí fn_effective_sell_price ani view efektivní prodejní ceny jde o samostatný příjem z výroby, viz níže.


Zelený bonus

  • Konfigurace je na ems.asset_pv_array (konkrétní FVE pole), ne na site ani na střídači. Sloupce: green_bonus_czk_kwh, green_bonus_valid_from, green_bonus_valid_to, green_bonus_meter_code.
  • Sazba se typicky mění ročně; verzování je přes valid_from / valid_to (při změně uzavři staré období a zadej novou sazbu s novým valid_from).
  • Výpočet příjmu za interval: ems.fn_green_bonus_revenue(pv_array_id, interval_start, production_wh) kde production_wh je skutečná nebo odhadnutá výroba pole v daném 15min slotu (Wh). Bonus platí z celkové výroby pole (interní spotřeba, baterie, EV, TČ i export) nejde o prodejní cenu.
  • Nezahrnovat do fn_effective_sell_price spot + prodejní marže jsou odděleně od dotace; v auditu se bonus ukládá do audit_interval.green_bonus_czk (plní fn_fill_audit_interval).

Historicky mohou v DB zůstat sloupce green_bonus_* na site_market_config; efektivní prodejní cena je z nich se nepočítá.


Konfigurace (env proměnné)

OTE_API_URL=https://www.ote-cr.cz/cs/kratkodobe-trhy/elektrina/denni-trh/@@chart-data
OTE_IMPORT_HOUR=14          # hodina kdy se spouští denní import
EUR_CZK_RATE=25.0           # fallback kurz pokud ČNB API nedostupné
CNB_API_URL=https://www.cnb.cz/cs/financni_trhy/devizovy_trh/kurzy_devizoveho_trhu/denni_kurz.xml
PRICE_IMPORT_RETRY_COUNT=3
PRICE_IMPORT_RETRY_BACKOFF_SEC=300

Monitoring a alerting

  • Alert pokud do 16:00 nejsou v DB ceny na zítřek
  • Discord (CRITICAL) pokud OTE změní formát @@chart-data tak, že DB parser (ems.fn_ote_parse_15m_price_json) nenajde vhodnou sérii (dataLine[].tooltip) nebo narazí na neočekávaný počet bodů; posílá services.notification_service.notify_ote_import_format_changed.
  • Discord (INFO) po úspěšném importu kompletního dne (92/96/100 slotů) krátký briefing pro další den (min/max + signály: záporné/okolo nuly/špička) z ems.fn_ote_day_signals_prague (read-model) přes services.notification_service.notify_ote_import_ok_brief (dedup).
  • Log každého importu (datum, počet intervalů, zdroj, trvání)
  • Endpoint GET /health/prices?date=YYYY-MM-DD → vrátí počet importovaných intervalů

Otevřené body

  • Kurz EUR/CZK: fixní hodnota vs denní stahování z ČNB rozhodnout před implementací
  • OTE nabízí i intraday ceny zatím neimplementujeme
  • Sell price: OTE nemá oddělenou nákupní a prodejní raw cenu, obě = DAM cena; může se lišit u jiných zdrojů