208 lines
12 KiB
Markdown
208 lines
12 KiB
Markdown
# 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).
|
||
|
||
**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é)
|
||
|
||
```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ů
|