# 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: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[]` 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.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 ```python # 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). ### 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é) ```env 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ů