Files
ems/docs/04-modules/market-prices.md
Dusan Vojacek 8494ea26de
Some checks failed
CI and deploy / migration-check (push) Failing after 28s
CI and deploy / deploy (push) Has been skipped
nerezta PV A pri prodeji z baterie
2026-05-26 07:34:52 +02:00

212 lines
12 KiB
Markdown
Raw Permalink 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 (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 screening režimy bez vazby na konkrétní `site_market_config` (kromě presetu home-01):
- **`--buy-home-01`:** stejná struktura jako `ems.fn_effective_buy_price` pro **home-01** dle živé `site_market_config` (ověř MCP): raw OTE ×**(1+9 %)** / ×**(19 %)** při záporné raw, + distribuce **NT 0,2243 / VT 0,74987** Kč/kWh dle HDO **0910, 1213, 1617, 2021**, + SS **0,192**, OTE **0,001**, DPH **×1,21**; prodej v EMS **`sell_margin_fixed = 0,30`** (ne 0,02 ze seedu).
Dále obecné režimy:
- `--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ů