Files
ems/docs/04-modules/market-prices.md
Dusan Vojacek 6074535d96
Some checks failed
CI and deploy / migration-check (push) Failing after 25s
CI and deploy / deploy (push) Has been skipped
OTE informatin discord
2026-04-29 14:17:24 +02:00

164 lines
8.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.py``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 |
| `sell_margin_fixed_czk` | Kč/kWh | -0.02 (srážka) |
| `sell_margin_percent` | % | 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ů