Problem — fragmentacja rynku Unii
Wbrew pozorom, rynek hurtowy warzyw i owoców w Unii nie jest jednym rynkiem. To 27 oddzielnych rynków, każdy ze swoimi giełdami, hurtownikami, platformami transakcyjnymi, lokalnymi specyfikami. Cena kalafiora w Almerii (Hiszpania) na rynku Mercavalencia może się różnić od ceny tego samego kalafiora w Bronisze pod Warszawą o 40-60% w tym samym dniu. Cebula z Holandii potrafi być w Niemczech o 20% tańsza niż polska cebula w Polsce.
Klient — średniej wielkości firma handlowo-hurtowa — codziennie podejmował decyzje typu „skąd kupić tonę papryki w przyszłym tygodniu, żeby zarobić na sprzedaży w Polsce". Decyzje te opierał na:
- 3-4 stałych dostawcach (Polska, Hiszpania, Włochy, Holandia)
- Telefonach do brokerów w Madrycie i Rotterdamie
- Codziennej analizie biuletynów MIR (Mercados de Interés Regional) i niemieckiego AMI
- Własnym Excelu z historycznymi cenami z ostatnich 2-3 lat
To działało. Marża była. Ale właściciel firmy widział, że co tydzień traci kontrakty, bo „ktoś inny" trafił z lepszym timingiem na zakup. Brakowało systemowego widzenia całej Unii naraz.
„Telefonów wykonuję 30-40 dziennie. Mam człowieka w Madrycie, mam człowieka w Rotterdamie, mam dwóch w Warszawie. Każdy z nich opowiada mi ceny ze swojego ogródka. Ja siedzę i robię z tego średnią w głowie. Gdy widzę, że ogórek w Bronisze rośnie, dzwonię do hiszpana, czy ma. Ale to wszystko jest ręczne. A na Słowacji albo w Czechach mogę nie mieć w ogóle pojęcia, co się dzieje."
Zakres projektu
Po dwóch tygodniach analizy wymagań zdefiniowaliśmy trzy moduły systemu:
Moduł 1 — zbieranie danych z 27 krajów
To największa i najmniej „efektowna" część projektu. Ale bez niej cała reszta nie ma sensu, więc poświęciliśmy temu 60% czasu wdrożenia.
Typologia źródeł
Źródła w UE dzielą się na cztery kategorie pod kątem trudności integracji:
Eurostat + EC Agri Markets
Publiczne API z cenami referencyjnymi, ale z opóźnieniem 1-2 tygodni. Świetne do historycznych szeregów czasowych.
Krajowe portale (HRP, IRH, AMI)
Strony hurtowe Bronisze, Mercabarna, AMI Marktwoche, Mercados de Interés Regional. HTML do parsowania, czasem PDF, codzienna aktualizacja.
Biuletyny ministerialne (PDF)
Większość ministerstw rolnictwa publikuje cotygodniowe biuletyny w PDF z tabelami. Klucz to dobry OCR + parser tabel.
Platformy B2B z logowaniem
Niektóre platformy transakcyjne wymagają konta hurtowego. Klient ma kilka takich kont — automatyzacja przez Playwright z bezpiecznym zarządzaniem credentialami.
Claude Code jako agent ETL
Zamiast pisać 180 customowych scraperów (czysty horror w utrzymaniu — każda zmiana HTML na jednym portalu wymagałaby kodu), poszliśmy inną drogą. Claude Code jest naszym agentem ETL. Każdy nowy portal opisujemy w pliku konfiguracyjnym, a Claude generuje, testuje i utrzymuje scraper.
# sources/de_ami_marktwoche.yaml
id: de_ami_marktwoche
country: DE
name: AMI Marktwoche (Gemüse)
url: https://www.ami-informiert.de/maerkte/marktinformationen/[...]
type: web_scrape
frequency: daily_morning
products:
- tomato
- cucumber
- bell_pepper
- cauliflower
- cabbage
schema:
date: "header row, last column"
product: "first column"
quality: "second column (Klasse I / Klasse II)"
price_eur_per_kg: "main data column"
unit: kg
source_market: "Frankfurt / München / Hamburg"
notes: |
AMI publikuje w środy ceny z poprzedniego tygodnia.
Tabela ma czasem dodatkowe wiersze podsumowujące — pomijać.
Ceny czasem w widełkach (0.85-1.10 €/kg) — parsować jako min/max/avg.
validation:
price_range: [0.10, 50.00] # sanity check
required_fields: [date, product, price]
Mając taki plik, Claude Code dostaje zadanie:
„Tu jest definicja źródła. Tu jest aktualny HTML strony (lub PDF, lub odpowiedź API). Wygeneruj scraper w Pythonie używając Playwright (jeśli SPA) lub requests+BeautifulSoup (jeśli statyczne). Dodaj testy. Jeśli format zmienił się od ostatniego runu, zaktualizuj scraper i zapisz wersjonowanie."
Claude pisze kod, uruchamia testy, parsuje, generuje próbkę danych, prosi ML engineera o zatwierdzenie. Dla nowych źródeł — 30 minut od konfiguracji do działającego scrapera. Dla zmian w istniejących źródłach (a HTML często się zmienia) — automatyczna naprawa po nocnym cronie.
Self-healing scrapers
Kluczowa korzyść z Claude Code jako agenta ETL: scrapery same się naprawiają. Gdy nocny cron wykryje, że scraper portal X przestał zwracać dane (np. zmienił się selektor CSS), Claude Code dostaje nowy HTML, porównuje ze starym, lokalizuje zmianę i generuje patch. Człowiek dostaje rano notyfikację „naprawiłem scraper dla AMI, oto diff, sprawdź". Zamiast „scraper padł, musisz to naprawić ręcznie". Średnio 2-3 takie samonaprawy tygodniowo.
Normalizacja i czyszczenie
180 źródeł = 180 sposobów na ten sam pomidor. Niemiecki AMI używa „Tomaten, rund", hiszpańska Mercabarna „Tomate redondo", francuski Réseau des Nouvelles des Marchés „Tomate ronde". Wszystkie to ten sam produkt.
Zbudowaliśmy słownik kanoniczny z mapowaniem multi-językowym (8 głównych języków UE) i fuzzy matching dla nieznanych zapisów. Plus kategoryzacja jakości (Klasse I/II/III, Extra/Cat. I/Cat. II, etc. → zunifikowane A/B/C). Plus konwersja jednostek (kg / lb / boxa 5 kg / paleta).
from typing import Optional
from .canonical_dict import PRODUCT_DICT, QUALITY_MAP, UNIT_CONVERTER
class ProductNormalizer:
def __init__(self):
self.dict = PRODUCT_DICT
self.fuzzy_matcher = FuzzyMatcher(threshold=0.85)
def normalize(self, raw: dict) -> Optional[CanonicalPrice]:
# Krok 1: identyfikacja produktu
canonical_id = self.match_product(
raw['product_name'],
raw['country']
)
if not canonical_id:
self.log_unknown(raw)
return None
# Krok 2: normalizacja jakości
quality = QUALITY_MAP.get(raw.get('quality', ''), 'unknown')
# Krok 3: konwersja jednostek do EUR/kg
price_per_kg = UNIT_CONVERTER.to_eur_per_kg(
price=raw['price'],
unit=raw['unit'],
currency=raw.get('currency', 'EUR'),
date=raw['date']
)
# Krok 4: sanity check
if not self.sanity_check(canonical_id, price_per_kg):
self.flag_anomaly(raw)
return None
return CanonicalPrice(
product_id=canonical_id,
country=raw['country'],
market=raw.get('source_market'),
date=raw['date'],
quality=quality,
price_eur_kg=price_per_kg,
source_id=raw['source_id']
)
Moduł 2 — model predykcyjny
Mając czyste dane historyczne (5+ lat dla większości produktów × krajów), mogliśmy zacząć budować model predykcyjny. Tu wyzwanie nie polegało na uzyskaniu „jakiejś" predykcji — ARIMA czy Prophet to potrafią od ręki. Wyzwaniem było uzyskanie predykcji wystarczająco dokładnej, żeby na jej podstawie podejmować decyzje za 50-100 tysięcy euro tygodniowo.
Architektura predykcji — hybryda
Po testach pojedynczych podejść (czysty LSTM, czysty Prophet, czysty XGBoost) zdecydowaliśmy się na ensemble:
Pipeline predykcji ceny (na 7-14 dni)
Wzbogacanie danych — zewnętrzne sygnały
Same historyczne ceny są niewystarczające. Cena pomidora w Polsce jutro zależy od:
- Pogody w Almerii sprzed 5-7 dni (czas transportu z Hiszpanii do Polski)
- Pogody w Polsce dziś (decyzje konsumenckie reagują na pogodę z opóźnieniem 1-3 dni)
- Kursu EUR/PLN (każdy procent ruchu waluty = procent ruchu ceny dla importowanych warzyw)
- Ilości papryki/ogórka w eksporcie z Hiszpanii (Komisja Europejska publikuje to z opóźnieniem ~2 tygodnie)
- Cen paliw (transport ciężarowy = 15-25% kosztu kalkulacyjnego)
- Świąt i weekendów (przed Wielkanocą warzywa drożeją, po — spadają)
Pipeline wzbogacania pobiera te sygnały automatycznie:
class FeatureEnricher:
"""
Dla każdej (product, country, date) wzbogaca o:
- pogodę w regionach upraw (Open-Meteo API)
- kursy walut (ECB)
- ceny paliw (Eurostat)
- poprzednie 60 dni cen we wszystkich 27 krajach (long-format)
- sezonowe flagi (święta, weekendy)
- sygnały eksportu (Eurostat COMEXT z opóźnieniem)
"""
def enrich(self, sample: PricePoint) -> FeatureVector:
production_regions = self.product_to_regions(sample.product_id)
features = {}
for region in production_regions:
weather = self.weather_api.get_history(
region.lat, region.lon,
start=sample.date - timedelta(days=14),
end=sample.date
)
features[f"temp_avg_{region.code}"] = weather.temp_mean
features[f"rain_sum_{region.code}"] = weather.precipitation_sum
features[f"frost_days_{region.code}"] = weather.frost_days
features["eur_pln"] = self.fx.get(sample.date, "EUR/PLN")
features["diesel_eur_l"] = self.fuel.get(sample.country, sample.date)
features["is_pre_easter"] = self.calendar.is_pre_holiday(
sample.date, "easter", days_before=10
)
# ... ~40 cech łącznie
return FeatureVector(features)
Trening i walidacja
Dataset: 5 lat danych historycznych × 27 krajów × ~40 produktów × ~3 klasy jakości = ~1.6 mln punktów cenowych. Po deduplikacji i czyszczeniu: ~1.2 mln. Po dodaniu cech zewnętrznych: ~50 GB w PostgreSQL.
Walidacja: klasyczna time-series cross-validation (walk-forward). Trenujemy na latach 2019-2023, walidujemy na 2024, testujemy na 2025. NIE losowy train/test split — to byłaby zbrodnia w time series, gdzie kolejność dni ma znaczenie.
Wyniki na zbiorze testowym (2025, 6 miesięcy)
MAPE (Mean Absolute Percentage Error) na predykcji 7-dniowej: 8.4% (czyli średnio przewidujemy cenę z dokładnością ±8.4%).
MAPE na predykcji 14-dniowej: 14.1%.
Hit rate kierunku: 78% (przewidujemy poprawnie czy cena rośnie czy spada). Dla porównania, baseline (naive — „cena jutro = cena dzisiaj") miał MAPE 12.7% na 7 dni. Model to konkretne 30% poprawy nad baseline, a hit rate kierunku — wystarczy do skutecznego tradingu.
Moduł 3 — detekcja arbitrażu i alarmy
Najważniejszy moduł z punktu widzenia biznesu klienta. Mając predykcje cen dla wszystkich produktów we wszystkich 27 krajach, możemy odpowiedzieć na pytanie: „gdzie kupić, gdzie sprzedać, ile zarobię po kosztach".
Model kosztu transportu
Arbitraż istnieje tylko wtedy, gdy różnica cen pokrywa koszty transportu i zostaje marża. Zbudowaliśmy uproszczony, ale skuteczny model kosztu:
class TransportCostModel:
"""
Szacuje koszt transportu między dwoma rynkami w UE.
Czynniki:
- dystans drogowy (Google Maps Distance Matrix)
- typ produktu (chłodzony / zwykły)
- sezonowe szczyty (Boże Narodzenie, lato — drożej)
- aktualna cena diesla
- typowa stawka €/km/paleta dla relacji
"""
BASE_RATE_PER_KM_PALLET = 0.85 # €/km/paleta (1 paleta = ~600 kg warzyw)
COLD_CHAIN_MULTIPLIER = 1.35
PEAK_SEASON_MULTIPLIER = 1.20
def estimate(self,
origin: str,
destination: str,
product_id: str,
date: date,
quantity_kg: float) -> TransportCost:
distance_km = self.distance_matrix.get(origin, destination)
pallets = math.ceil(quantity_kg / 600)
base = distance_km * pallets * self.BASE_RATE_PER_KM_PALLET
if self.requires_cold_chain(product_id):
base *= self.COLD_CHAIN_MULTIPLIER
if self.is_peak_season(date):
base *= self.PEAK_SEASON_MULTIPLIER
# Korekta na aktualną cenę paliwa
fuel_factor = self.current_diesel_price() / 1.55 # baseline 1.55 €/l
base *= (0.6 + 0.4 * fuel_factor) # 60% kosztu stała, 40% paliwo
return TransportCost(
total_eur=base,
per_kg_eur=base / quantity_kg,
transit_days=self.transit_days(distance_km),
confidence="medium"
)
Silnik detekcji okazji
Codziennie rano (5:30) system uruchamia detekcję arbitrażu:
- Dla każdego produktu w bazie kanonicznej...
- Pobiera aktualne ceny we wszystkich 27 krajach...
- Pobiera predykcje cen na +7 i +14 dni...
- Dla każdej pary krajów (origin → destination) liczy: cena_destination_jutro − (cena_origin_dziś + transport)
- Jeśli marża > X% (X konfigurowalny per produkt, typowo 12-18%) → generuje alarm
Alarmy lecą do klienta przez:
- Email rano — top 20 okazji dnia z kontekstem (cena, marża, ryzyko)
- SMS — tylko alarmy o ekstremalnej marży (>25%) — natychmiast
- Dashboard webowy — pełna mapa cen UE + lista okazji + drilldown na konkretny produkt
- Slack bot — kanał w teamie klienta z notyfikacjami
Filtr fałszywych alarmów
Pierwsza wersja systemu generowała ~200 alarmów dziennie. Większość — fałszywych albo nierealnych (np. cena w bazie była błędna, albo dostawca po prostu nie istnieje w tym kraju). Wdrożyliśmy dwa filtry: (1) price consistency check — alarm tylko jeśli niska cena potwierdzona z >1 niezależnego źródła, (2) supplier reality check — czy mamy w bazie rzeczywistych dostawców tego produktu w tym kraju. Po filtrach: ~15-25 alarmów dziennie, z czego klient typowo działa na 3-5.
Stack i infrastruktura
Stack techniczny
- Python 3.12
- Claude Code (ETL agent)
- Playwright (scraping)
- PostgreSQL 16 + TimescaleDB
- Prophet
- PyTorch (LSTM)
- XGBoost
- scikit-learn
- FastAPI
- Redis (cache + queue)
- Celery (cron jobs)
- Open-Meteo API
- ECB API (FX)
- Eurostat API
- Grafana (monitoring)
- RTX 5090 (trening modeli)
Dashboard webowy — interfejs dla analityków
Dla użytkownika końcowego — klienta i jego zespołu (4 osoby) — zbudowaliśmy dashboard. Najważniejsze widoki:
- Mapa Europy — heatmapa cen wybranego produktu, kraje pokolorowane od czerwieni (najdroższe) do zieleni (najtańsze). Kliknięcie kraju otwiera szczegóły
- Predykcja per produkt — wykres ceny historycznej + przewidywanie 14 dni z pasmem niepewności
- Tabela okazji — sortowalna lista wszystkich aktywnych alarmów z marżą, transportem, dostawcami
- Comparator — porównanie cen dla wybranego produktu we wszystkich 27 krajach na osi czasu
- What-if simulator — „co się stanie z marżą, jeśli kupię 5 ton papryki w Hiszpanii za 2 tygodnie po przewidywanej cenie X?"
Efekty po 4 miesiącach
Klient udostępnił nam dane efektów do publikacji (zanonimizowane co do skali — wartości względne):
- +18% marży średniej na zakupach — przez lepszy timing i odkrycie nowych ścieżek zakupowych (Słowacja, Czechy, Bułgaria — kraje, których wcześniej w ogóle nie monitorowano)
- -40% czasu spędzonego na researchu cen — z 3-4 godzin dziennie do ~30 minut na sprawdzenie dashboardu
- 3 nowych korytarze handlowe uruchomione na podstawie alarmów (transport pomidorów Bułgaria → Polska, ogórków Holandia → Czechy, kalafiora Francja → Słowacja)
- 2 razy uniknięte straty — system ostrzegł przed planowanym dużym zakupem, predykcja pokazała >90% prawdopodobieństwo spadku ceny w ciągu 5 dni
„Najbardziej zaskakuje mnie nie sama predykcja — to działa, ale fluktuacje są. Najbardziej zaskakuje mnie, że system widzi rynki, których ja w ogóle nie widziałem. Bułgaria, Rumunia, Słowacja — wcześniej dla mnie nie istniały, bo nie miałem tam telefonu. Teraz robię tygodniowo 1-2 transakcje w tych krajach. To są pieniądze, które wcześniej były zostawiane na stole."
Czego nauczyliśmy się przy tym projekcie
1. ETL to 60-70% projektu, ale daje przewagę konkurencyjną
Każda firma analityczna potrafi nauczyć LSTM przewidywać szereg czasowy. Nie każda potrafi zbudować i utrzymać pipeline scrapingu 180 portali w 8 językach. Bariera wejścia jest właśnie w danych, nie w modelu. Klient teraz ma asset wartościowy sam w sobie — bazę cen, której nie ma żaden konkurent.
2. Claude Code jako agent ETL = oszczędność ~50 tys. PLN miesięcznie
Utrzymanie 180 scraperów przez człowieka to praca dla 2-3 etatów. Claude Code robi to dla nas przy nadzorze 1 ML engineera w wymiarze pół etatu. Oszczędność miesięczna pokrywa nasze utrzymanie systemu z dużym zapasem.
3. Confidence interval ≫ punktowa predykcja
Klient nie potrzebuje wiedzieć, że „pomidor jutro będzie kosztował 4.32 €/kg". On potrzebuje wiedzieć, że „pomidor jutro będzie kosztował 4.30 €/kg z 80% pewnością w zakresie 4.10-4.55". Pasmo niepewności mówi, czy może składać deklarację na 50 ton dziś, czy lepiej poczekać do jutra na potwierdzenie.
4. Sygnały zewnętrzne (pogoda, FX, paliwo) dają większy wzrost dokładności niż lepszy model
Z czystych historycznych cen miały MAPE 13%. Dodanie pogody zmniejszyło do 10%. Dodanie FX i paliwa — do 8.4%. Mogłbyśmy włożyć kolejne miesiące w lepszą sieć neuronową, ale na większy zysk daje znalezienie kolejnego dobrego sygnału zewnętrznego.
5. False positives zabijają zaufanie
Lepiej wysłać 5 dobrych alarmów dziennie niż 50 alarmów, z których 45 jest do niczego. Po tygodniu klient przestaje czytać alarmy. Filtrowanie i ranking alarmów to często ważniejsze od samej ich generacji.
Co dalej — roadmapa
- Rozszerzenie poza UE — Wielka Brytania, Turcja, Maroko (główni dostawcy poza UE)
- Modele specyficzne per produkt — obecnie jeden generyczny model dla 40 produktów. Migracja na model per kategoria (jagodowe, cieplarniane, gruntowe...)
- Optymalizator zakupów — nie tylko „gdzie najtaniej", ale „jak ułożyć portfel zakupów, żeby zmaksymalizować marżę przy ograniczeniach magazynowych"
- Integracja z systemem CRM klienta — auto-generowane propozycje cenowe dla klientów końcowych na podstawie predykcji
Masz dane, które można przewidywać?
Predykcja cen towarów to jedna z naszych specjalności, ale ta sama architektura (multi-source ETL + ensemble model + system alarmów) działa wszędzie — energia, frachty, kursy. Pokażmy Ci, co da się zrobić z Twoich danych.
Porozmawiajmy →