12 KiB
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-datastime_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:00–14: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[]sx= 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_idvimport_ote_pricesumožní pro manuální volání bez explicitního data použítsite.timezonemí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:*).
- oddělené timeouty (
- API endpoint
POST /api/v1/sites/{site_id}/prices/importvrací 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.py má timezone=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_kwhasell_raw_price_czk_kwhjsou oddělené- Pro OTE CZ je v první verzi
sell_raw_price = buy_raw_price(reference cena) imported_atslouží 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čtoubuy_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).
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)proraw_ote >= 0, resp.raw_ote × (1 - P/100)proraw_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-csvnebo--db) a zkontroluj vypsané shrnutí režimu nákupu - pro asymetrickou variantu ověř, že záporné ceny používají faktor
1 - P/100, nikoli1 + 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 nasiteani 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ýmvalid_from). - Výpočet příjmu za interval:
ems.fn_green_bonus_revenue(pv_array_id, interval_start, production_wh)kdeproduction_whje 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á doaudit_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-datatak, ž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řesservices.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ů