🥬 Case Study · Agri-tech / Commodity

System AI do analizy cen warzyw i owoców w całej Unii Europejskiej

Klient — gracz na rynku hurtowym warzyw i owoców — potrzebował codziennie odpowiadać na pytania: gdzie jest najtaniej, gdzie ceny zaraz wzrosną, gdzie pojawia się arbitraż między krajami. Zbudowaliśmy system, który zbiera ceny z 27 krajów UE, predykuje ich ruch z wyprzedzeniem 7-14 dni i alarmuje w czasie rzeczywistym o anomaliach. Claude Code do scrapingu i orkiestracji, własny model do predykcji.

📅 Październik 2025
⏱️ 11 min czytania
🏷️ Agri-tech · Time series
⚙️ Claude Code · LSTM · Prophet
27
Krajów
UE
~180
Źródeł
danych
7-14
Dni
horyzontu predykcji
~4M
Punktów
cenowych miesięcznie

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:

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." — Właściciel firmy hurtowej, klient SULI

Zakres projektu

Po dwóch tygodniach analizy wymagań zdefiniowaliśmy trzy moduły systemu:

M1
Pipeline zbierania danych (Claude Code jako agent ETL) ~180 źródeł z 27 krajów UE. Giełdy hurtowe, biuletyny ministerialne, platformy transakcyjne, dane Eurostat, REST API tam gdzie są, scraping tam gdzie nie ma.
M2
Model predykcyjny (własny, fine-tunowany) Hybryda LSTM + Prophet + boosted trees. Predykcja ceny 7-14 dni naprzód dla każdej kombinacji (produkt × kraj × kategoria jakości). Uwzględnia sezonowość, pogodę, kursy walut, transport.
M3
System alarmów i detekcji arbitrażu Real-time monitorowanie różnic cenowych między krajami z uwzględnieniem kosztów transportu. Alarm SMS/email gdy pojawia się okazja arbitrażowa >X% marży po kosztach.

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:

🟢 łatwe

Eurostat + EC Agri Markets

Publiczne API z cenami referencyjnymi, ale z opóźnieniem 1-2 tygodni. Świetne do historycznych szeregów czasowych.

🟡 średnie

Krajowe portale (HRP, IRH, AMI)

Strony hurtowe Bronisze, Mercabarna, AMI Marktwoche, Mercados de Interés Regional. HTML do parsowania, czasem PDF, codzienna aktualizacja.

🟠 trudne

Biuletyny ministerialne (PDF)

Większość ministerstw rolnictwa publikuje cotygodniowe biuletyny w PDF z tabelami. Klucz to dobry OCR + parser tabel.

🔴 bardzo trudne

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.

YAML · definicja źródła
# 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).

Python · normalizer
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)

1
Prophet — komponent sezonowy Modeluje sezonowość roczną (warzywa cieplarniane vs gruntowe), tygodniową (poniedziałek vs środa) i święta (Wielkanoc → wyższe ceny papryki).
2
LSTM — komponent krótkoterminowy Sieć rekurencyjna na ostatnich 60 dniach cen z 27 krajów jednocześnie — uczy się, że spadek w Hiszpanii o X% w dniu N koreluje ze spadkiem w Polsce o Y% w dniu N+3.
3
XGBoost — komponent „zewnętrzny" Dodaje sygnały: pogodę w regionach upraw (deszcz w Almerii = pomidory drożeją za 7 dni), kursy walut (PLN/EUR), ceny paliwa (transport), wolumeny eksportu.
4
Meta-learner — ridge regression Łączy trzy predykcje, ucząc się, w jakich warunkach ważyć który komponent (np. w okresie zbiorów dominuje sygnał pogodowy, poza sezonem — Prophet).
5
Confidence interval Bootstrap na ostatnich 500 predykcjach — model nie tylko daje wartość, ale i pasmo 80% i 95% pewności. To kluczowe dla podejmowania decyzji.

Wzbogacanie danych — zewnętrzne sygnały

Same historyczne ceny są niewystarczające. Cena pomidora w Polsce jutro zależy od:

Pipeline wzbogacania pobiera te sygnały automatycznie:

Python · enrichment
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:

Python · transport cost model
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:

  1. Dla każdego produktu w bazie kanonicznej...
  2. Pobiera aktualne ceny we wszystkich 27 krajach...
  3. Pobiera predykcje cen na +7 i +14 dni...
  4. Dla każdej pary krajów (origin → destination) liczy: cena_destination_jutro − (cena_origin_dziś + transport)
  5. Jeśli marża > X% (X konfigurowalny per produkt, typowo 12-18%) → generuje alarm

Alarmy lecą do klienta przez:

⚠️

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

Dashboard webowy — interfejs dla analityków

Dla użytkownika końcowego — klienta i jego zespołu (4 osoby) — zbudowaliśmy dashboard. Najważniejsze widoki:

Efekty po 4 miesiącach

Klient udostępnił nam dane efektów do publikacji (zanonimizowane co do skali — wartości względne):

„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." — Właściciel firmy hurtowej

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

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 →