Reinforcement learning · rynki finansowe · praktyka

Uczenie ze wzmocnieniem na giełdzie i w krypto — kompletny kurs

Od pierwszego agenta ε-zachłannego w czystym Pythonie, przez własne środowisko Gymnasium i PPO na Twoim Macu M3, po walk-forward, koszty transakcyjne i szkielet systemu produkcyjnego. Każdy moduł ma teorię, działający kod z komentarzami, komendy do terminala i interaktywne symulacje.

Symulacja: agent uczony na syntetycznej cenie · ▲ long · ▼ short/flat animacja poglądowa, nie prognoza
17
modułów
13
interaktywnych symulacji
30+
plików kodu z komentarzami
16 tyg.
proponowana roadmapa
Byłeś tu wcześniej. Kontynuuj: moduł 0
Moduł 0 · Orientacja · ~15 min

Zanim zaczniesz: czym to jest, a czym nie jest

Cel: uczciwie skalibrować oczekiwania, zrozumieć dlaczego RL w tradingu jest trudny, i zobaczyć mapę całego kursu.

Uczciwa rozmowa na start

Reinforcement learning (RL) to gałąź uczenia maszynowego, w której agent uczy się podejmować sekwencyjne decyzje przez interakcję ze środowiskiem, maksymalizując skumulowaną nagrodę. Trading wygląda jak podręcznikowy przypadek: stan (rynek), akcje (kup/sprzedaj/czekaj), nagroda (zysk). I rzeczywiście — fundusze kwantowe używają RL, głównie do egzekucji zleceń i market makingu.

Ale zanim zainwestujesz 16 tygodni, musisz znać trzy fakty:

  • Rynki są niestacjonarne. Gra, której nauczył się agent, zmienia zasady w trakcie. Model wytrenowany na hossie 2021 był bezużyteczny w 2022. To fundamentalna różnica względem Atari czy szachów, gdzie reguły są stałe.
  • Sygnał jest słaby, szum ogromny. Stosunek sygnału do szumu w zwrotach dziennych jest bliski zera. RL, który potrzebuje milionów prób, bardzo łatwo „uczy się" szumu — czyli overfittuje.
  • Backtest kłamie domyślnie. Większość spektakularnych wyników RL w tradingu z internetu to artefakty: lookahead bias, brak kosztów transakcyjnych, dobór okresu. Połowa tego kursu to obrona przed samooszukiwaniem się.
Zasada nr 1

Traktuj ten kurs jako naukę inżynierii i metodologii badawczej, a nie przepis na pieniądze. Handluj wyłącznie na paper tradingu / testnecie, dopóki nie przejdziesz modułów 7–8 i nie zrozumiesz, dlaczego Twój backtest prawie na pewno przeszacowuje wynik. Nigdy nie ryzykuj pieniędzy, których utrata by Cię zabolała.

Dlaczego mimo to warto

  • Nauczysz się całego stosu quanta: dane → cechy → model → backtest → ewaluacja → egzekucja. To umiejętności przenośne do każdej pracy z ML na szeregach czasowych.
  • RL wymusza myślenie o decyzjach, nie prognozach — uwzględnia koszty, pozycję i ryzyko w samej funkcji celu, czego klasyczna predykcja nie robi.
  • Masz idealny sprzęt: M3 ze 128 GB unified memory spokojnie wystarcza na wszystko w tym kursie — RL w finansach to małe sieci i dużo iteracji, nie LLM-y.

Mapa kursu — jak iterujemy

Kurs jest zbudowany wokół czterech coraz dojrzalszych wersji tego samego projektu:

IteracjaCo budujeszModułyCzego się uczysz
v0 — zabawkaBandyta i Q-learning na gridworldzie, czysty Python + NumPy1–2Mechanika RL od zera: wartości, eksploracja, Bellman
v1 — prototypWłasny env Gymnasium na prawdziwych danych, tabularny agent3–4Dane, struktura środowiska, pierwszy „trading" agent
v2 — deep RLDQN/PPO ze Stable-Baselines3, sensowne cechy i nagroda5–6Sieci neuronowe jako polityki, projekt nagrody
v3 — rzetelny systemWalk-forward, koszty, metryki, risk layer, paper trading, live7–11Wszystko, co odróżnia zabawkę od systemu
v4 — poziom proMikrostruktura, market making, offline RL, CVaR, ensembles12–16Techniki, których używają fundusze — i ich granice

Wymagania wstępne

  • Python na poziomie: funkcje, klasy, NumPy. Pandas poznasz w trakcie.
  • Matematyka: średnia, wariancja, pochodna — intuicyjnie. Wszystko poza tym wyprowadzimy.
  • Terminal: podstawy zsh/bash. Każdą komendę podaję jawnie.

Jeśli chcesz od razu przygotować maszynę, skocz do modułu 15 (setup Mac M3) — możesz go zrobić w dowolnym momencie. Minimalny start na teraz:

_# Menedżer pakietów Homebrew (jeśli nie masz) + uv: szybki menedżer środowisk Pythona
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
brew install uv

_# Katalog projektu i środowisko z Pythonem 3.11 (najlepsza kompatybilność z bibliotekami RL)
mkdir -p ~/dev/rl-trading && cd ~/dev/rl-trading
uv venv --python 3.11
source .venv/bin/activate

_# Pakiety na moduły 1-2: tylko NumPy i matplotlib - fundamenty budujemy bez frameworków
uv pip install numpy matplotlib
Sprawdź się

Dlaczego RL w tradingu jest fundamentalnie trudniejszy niż w Atari czy szachach?

W Atari i szachach reguły są stałe, więc agent może je „przećwiczyć" do perfekcji. Rynek zmienia dynamikę w trakcie (hossa 2021 vs bessa 2022), więc nauczona polityka szybko traci ważność. To niestacjonarność, nie rozmiar sieci ani opóźnienie nagrody, jest sednem trudności.

Gdzie fundusze kwantowe faktycznie stosują RL najskuteczniej?

RL sprawdza się tam, gdzie sygnał jest silny i stabilny, a środowisko dobrze zdefiniowane — czyli w egzekucji i market makingu. Przewidywanie kierunku ceny to problem o sygnale bliskim szumowi, w którym RL łatwo overfittuje.

Jak najlepiej rozumieć zdanie „backtest kłamie domyślnie"?

Backtest nie jest bezużyteczny — jest domyślnie zbyt optymistyczny, bo bez świadomej obrony przecieka informacja z przyszłości i znikają koszty. Same prowizje to za mało; trzeba jeszcze audytu cech, walk-forwardu i liczenia prób.
Moduł 1 · Fundamenty · ~40 min

Fundamenty RL: agent, środowisko, nagroda

Cel: zrozumieć formalizm MDP i dylemat eksploracja–eksploatacja tak dobrze, że sam napiszesz agenta ε-zachłannego z pamięci.

Pętla, na której stoi wszystko

Każdy system RL — od bandyty po AlphaGo i agenta tradingowego — to ta sama pętla. W kroku t agent obserwuje stan st, wybiera akcję at według swojej polityki π(a|s), a środowisko zwraca nagrodę rt+1 i nowy stan st+1. Cel: maksymalizować sumę zdyskontowanych nagród, zwaną zwrotem (return):

Zwrot zdyskontowany Gt = rt+1 + γ·rt+2 + γ²·rt+3 + … ,   γ ∈ [0, 1)

Współczynnik dyskonta γ mówi, jak bardzo agent dba o przyszłość. γ=0 to agent-jętka (liczy się tylko następna nagroda), γ→1 to agent-strateg. W tradingu γ ma piękną interpretację: to Twój horyzont inwestycyjny. Scalper ma niskie efektywne γ, inwestor pozycyjny wysokie.

W języku tradingu:

Pojęcie RLSymbolW tradingu
StanstOkno cen/zwrotów, wskaźniki, aktualna pozycja, niezrealizowany PnL
AkcjaatDyskretna: long/flat/short. Ciągła: docelowa wielkość pozycji ∈ [−1, 1]
NagrodartZmiana wartości portfela minus koszty (to tylko punkt wyjścia — moduł 6)
Politykaπ(a|s)Twoja strategia: mapowanie sytuacji rynkowej na decyzję
EpizodPrzebieg przez okno historyczne, np. rok danych godzinowych

MDP: założenie, które rynek łamie

Formalnie pętla to proces decyzyjny Markowa (MDP): przyszłość zależy tylko od bieżącego stanu i akcji, nie od całej historii. Rynek tego założenia nie spełnia — cena zamknięcia nie zawiera pełnej informacji o świecie. Praktyczna odpowiedź: budujemy stan tak, by był „wystarczająco markowski" — dokładamy do niego okno historii, wskaźniki, zmienność, pozycję. To jest właśnie inżynieria cech (moduł 6). Zapamiętaj: jakość agenta ogranicza jakość stanu, który mu pokazujesz.

Wartość: ile „jest warta" sytuacja

Kluczowa idea RL to funkcja wartości. Q(s, a) odpowiada na pytanie: „jaki łączny zwrot mogę oczekiwać, jeśli w stanie s wykonam akcję a i dalej będę grał optymalnie?". Jeśli znasz prawdziwe Q, optymalna polityka jest trywialna: wybieraj akcję o największym Q. Cała trudność RL to estymacja Q z doświadczenia. Spina to równanie Bellmana:

Równanie optymalności Bellmana Q*(s, a) = 𝔼 [ r + γ · maxa′ Q*(s′, a′) ]

Czytaj: wartość akcji = oczekiwana natychmiastowa nagroda + zdyskontowana wartość najlepszej akcji w następnym stanie. To definicja rekurencyjna — i prawie każdy algorytm w tym kursie (Q-learning, DQN) to jakiś sposób iteracyjnego „dokręcania" estymat do tej równości.

Eksploracja kontra eksploatacja: wielorękI bandyta

Zanim dojdzie do stanów i przejść, wyizolujmy najczystszy dylemat RL. Masz 4 „automaty" (strategie) o nieznanych średnich wypłatach. Każda próba kosztuje. Grasz tym, który wydaje się najlepszy (eksploatacja), czy sprawdzasz inne (eksploracja)? Klasyczne rozwiązanie: ε-zachłanność — z prawdopodobieństwem ε wybierz losowo, w przeciwnym razie najlepszy według bieżących estymat. Poniżej możesz to poczuć na własnej myszce.

Symulacja: 4-ręki bandyta z ε-zachłannością interaktywne
estymata Q(a) prawdziwa średnia (ukryta przed agentem) żal skumulowany (regret)
0
kroki
wybierane ramię
0.0
żal łączny

Żal (regret) = ile straciliśmy względem grania od początku najlepszym ramieniem. Zauważ: przy ε=0 agent często „zakleszcza się" na złym ramieniu — krzywa żalu rośnie liniowo. Przy dużym ε żal też rośnie liniowo, bo agent wiecznie hazarduje. Słodki punkt jest pośrodku (albo: ε malejące w czasie).

Ten sam bandyta w kodzie

To dokładnie ta logika, którą właśnie oglądałeś — 40 linii czystego Pythona. Przepisz ją ręcznie (serio, nie kopiuj) — to najlepszy test zrozumienia.

import numpy as np

rng = np.random.default_rng(seed=42)   # generator z ziarnem = powtarzalne wyniki

_# --- Środowisko: 4 ramiona o nieznanych agentowi średnich wypłatach ---
true_means = rng.normal(0.0, 1.0, size=4)   # "prawda", której agent nie widzi

def pull(arm: int) -> float:
    """Pociągnięcie ramienia: wypłata = prawdziwa średnia + szum N(0,1).
    W tradingu odpowiednik: wynik jednej transakcji danej strategii."""
    return true_means[arm] + rng.normal(0.0, 1.0)

_# --- Agent epsilon-zachłanny ---
n_arms   = 4
Q        = np.zeros(n_arms)   # bieżące estymaty wartości każdego ramienia
N        = np.zeros(n_arms)   # licznik pociągnięć każdego ramienia
epsilon  = 0.1                # 10% kroków to eksploracja
n_steps  = 2000

for t in range(n_steps):
    if rng.random() < epsilon:
        arm = rng.integers(n_arms)        # EKSPLORACJA: losowe ramię
    else:
        arm = int(np.argmax(Q))           # EKSPLOATACJA: najlepsze wg estymat

    reward  = pull(arm)
    N[arm] += 1
    # Przyrostowa średnia: Q += (1/N) * (cel - Q).
    # To szkielet KAŻDEJ reguły uczenia w tym kursie:
    #   nowa_estymata = stara + krok * (obserwacja - stara)
    Q[arm] += (reward - Q[arm]) / N[arm]

print("Prawdziwe średnie:", np.round(true_means, 2))
print("Estymaty Q:       ", np.round(Q, 2))
print("Pociągnięcia:     ", N.astype(int))   # najlepsze ramię ~85-90% prób
_# Uruchomienie (w aktywowanym środowisku z modułu 0)
python 01_bandit.py
Wzór, który wraca wszędzie

estymata += krok · (cel − estymata). W bandycie cel = nagroda. W Q-learningu cel = r + γ·max Q(s′). W DQN cel liczy sieć docelowa. Gdy zrozumiesz tę jedną linijkę, zrozumiesz 80% RL.

Co musisz umieć po tym module

  • Narysować pętlę agent–środowisko i podpisać s, a, r, π, γ.
  • Wyjaśnić równanie Bellmana własnymi słowami.
  • Napisać z pamięci bandytę ε-zachłannego i wyjaśnić, czemu żal przy ε=0 bywa liniowy.
  • Powiedzieć, które założenie MDP rynek łamie i co z tym robimy.
Sprawdź się

Co w tradingu oznacza wysokie γ (bliskie 1)?

γ to waga przyszłości: przy γ bliskim 1 przyszłe nagrody prawie się nie kurczą, więc agent myśli długoterminowo. Niskie γ to „agent-jętka" liczący głównie następną nagrodę — bliżej scalpera niż inwestora pozycyjnego.

Które założenie MDP rynek łamie?

MDP zakłada, że przyszłość zależy wyłącznie od bieżącego stanu i akcji. Pojedyncza cena zamknięcia nie streszcza całego rynku, więc łamie tę własność. Dlatego budujemy stan „wystarczająco markowski" przez okno historii i wskaźniki — to sedno inżynierii cech.

Co dokładnie wyraża Q(s, a)?

Q to wartość akcji: przyszły zdyskontowany zwrot przy założeniu optymalnej dalszej gry, nie sama natychmiastowa nagroda ani prawdopodobieństwo. Gdybyś znał prawdziwe Q, polityka byłaby trywialna — wybieraj akcję o najwyższym Q.
Moduł 2 · Fundamenty · ~45 min

Q-learning w praktyce: od tabeli do polityki

Cel: zaimplementować pełny Q-learning, zobaczyć jak polityka „wyrasta" z tabeli wartości, i poznać hiperparametry, które później wrócą w deep RL.

Od bandyty do sekwencji decyzji

Bandyta nie ma stanów — każda decyzja jest niezależna. Trading (i życie) tak nie działa: dzisiejsza decyzja zmienia jutrzejszą sytuację. Q-learning rozszerza naszą regułę przyrostową na sekwencje. Po każdym przejściu (s, a, r, s′) aktualizujemy:

Aktualizacja Q-learningu Q(s,a) ← Q(s,a) + α · [ r + γ·maxa′ Q(s′,a′) − Q(s,a) ]

Wyraz w nawiasie to błąd TD (temporal difference): różnica między tym, co się właśnie okazało (r + zdyskontowana wartość następnego stanu), a tym, co sądziliśmy. α to szybkość uczenia. Q-learning jest off-policy: uczy się polityki optymalnej, nawet gdy zbiera dane polityką eksplorującą — ta własność wróci przy DQN i replay bufferze.

Gridworld: obejrzyj, jak rodzi się polityka

Zanim dotkniemy rynku, potrenujmy na świecie, w którym znamy optymalne rozwiązanie — inaczej nie odróżnisz „algorytm działa" od „mam szczęście". Agent (▲) startuje z lewego dolnego rogu, cel daje +10, wpadnięcie w „strefę strat" −10, każdy krok kosztuje −0.1 (jak prowizja!). Strzałki pokazują najlepszą akcję według bieżącej tabeli Q, intensywność tła — wartość stanu.

Symulacja: Q-learning na gridworldzie 8×6 interaktywne
0
epizody
1.00
ε (maleje)
śr. zwrot (100 ep.)

Obserwuj kolejność uczenia: wartości „rozlewają się" od celu wstecz — to równanie Bellmana w akcji. ε maleje wykładniczo: wczesne epizody to chaos (eksploracja), późne to niemal deterministyczna trasa. Kara za krok sprawia, że agent uczy się drogi najkrótszej, nie tylko bezpiecznej.

Pełna implementacja z komentarzami

import numpy as np

rng = np.random.default_rng(0)

_# --- Środowisko: siatka 8x6, cel i pułapka ---
W, H     = 8, 6
START    = (0, 5)            # (kolumna, wiersz) - lewy dolny róg
GOAL     = (7, 0)            # +10 i koniec epizodu
TRAP     = (4, 2)            # -10 i koniec epizodu
STEP_COST = -0.1             # kara za każdy krok (analog prowizji)
ACTIONS  = [(0,-1), (0,1), (-1,0), (1,0)]   # góra, dół, lewo, prawo

def step(state, action):
    """Wykonaj akcję; wyjście poza planszę = zostań w miejscu."""
    x = min(max(state[0] + ACTIONS[action][0], 0), W - 1)
    y = min(max(state[1] + ACTIONS[action][1], 0), H - 1)
    s2 = (x, y)
    if s2 == GOAL: return s2, 10.0,  True
    if s2 == TRAP: return s2, -10.0, True
    return s2, STEP_COST, False

_# --- Hiperparametry (te same nazwy zobaczysz w SB3 przy PPO/DQN) ---
ALPHA      = 0.1      # szybkość uczenia (learning_rate)
GAMMA      = 0.97     # dyskonto: 0.97^30 ~ 0.4, więc horyzont ~30 kroków
EPS_START  = 1.0      # zaczynamy w 100% losowo...
EPS_END    = 0.05     # ...kończymy prawie zachłannie
EPS_DECAY  = 0.995    # mnożnik po każdym epizodzie
N_EPISODES = 1500

Q = np.zeros((W, H, len(ACTIONS)))   # tabela: stan (x,y) -> wartości 4 akcji

eps, returns = EPS_START, []
for ep in range(N_EPISODES):
    s, done, ep_return = START, False, 0.0
    while not done:
        # wybór akcji: epsilon-zachłannie względem bieżącej tabeli
        if rng.random() < eps:
            a = rng.integers(len(ACTIONS))
        else:
            a = int(np.argmax(Q[s[0], s[1]]))

        s2, r, done = step(s, a)
        ep_return += r

        # cel TD: nagroda + zdyskontowane maksimum w nowym stanie;
        # dla stanu końcowego przyszłość = 0 (nie ma "dalej")
        target = r if done else r + GAMMA * np.max(Q[s2[0], s2[1]])
        Q[s[0], s[1], a] += ALPHA * (target - Q[s[0], s[1], a])
        s = s2

    returns.append(ep_return)
    eps = max(EPS_END, eps * EPS_DECAY)   # wygaszanie eksploracji

print(f"Śr. zwrot, ostatnie 100 epizodów: {np.mean(returns[-100:]):.2f}")
_# Optimum: 11 kroków po -0.1, a 12. wchodzi do celu (+10) = +8.9. Masz ~8.5+? Działa.

Hiperparametry — intuicje, które przenoszą się na deep RL

ParametrZa małyZa dużyW tradingu
α (learning rate)Uczenie trwa wiekiEstymaty skaczą, nie zbiegająRynkowy szum wymaga małego α — pojedyncza transakcja to prawie zero informacji
γ (dyskonto)Agent myopiczny, ignoruje trendyWariancja celu rośnie, uczenie niestabilneDobierz do horyzontu: intraday ~0.9, swing ~0.99
ε / entropiaZakleszczenie na złej strategiiAgent nigdy nie „dojrzewa"Eksploracja na rynku = realne straty; dlatego trenujemy na symulacji, nie live

Trzeci wiersz tej tabeli — eksploracja — ma własną oś czasu: ε nie jest stałe, tylko wygasa w trakcie treningu. Kształt tego wygasania to realny hiperparametr. Pobaw się nim, zanim spotkasz go ponownie jako exploration_fraction w DQN:

Harmonogramy zaniku ε — ile eksplorować i jak długo interaktywne
ε(t) — prawdopodobieństwo eksploracji skumulowany % eksploracji
ε w połowie treningu
ε końcowe
łączny % eksploracji

Za szybki zanik = agent zakleszcza się na suboptymalnej polityce (dokładnie jak bandyta z modułu 1 przy ε=0). Za wolny = agent wiecznie hazarduje i nigdy nie „dojrzewa". Na żywym rynku każdy eksploracyjny krok kosztuje realne pieniądze — dlatego eksplorację odbębniamy w symulacji, zanim wpuścimy agenta na produkcję.

Dlaczego tabela nie wystarczy na rynek

Tabela Q wymaga skończonej, małej liczby stanów. Stan rynkowy to wektor liczb rzeczywistych — dyskretyzacja grubą siatką gubi informację, a drobną eksploduje wykładniczo (przekleństwo wymiarowości). Rozwiązanie: zastąpić tabelę siecią neuronową aproksymującą Q(s,a) — to jest dokładnie DQN, moduł 5. Ale najpierw: dane i środowisko.

Ćwiczenia (zrób zanim pójdziesz dalej)

  1. Zmień STEP_COST na 0 i na −1. Jak zmienia się trasa? To pierwsza lekcja projektowania nagrody.
  2. Ustaw GAMMA=0.5. Dlaczego agent przestaje znajdować cel z odległych pól?
  3. Dodaj „wiatr": z prawdopodobieństwem 20% akcja przesuwa agenta losowo. Zobacz, że Q-learning nadal zbiega — radzi sobie ze stochastycznością. Rynek to wiatr 95%.
Sprawdź się

Co wyraża błąd TD: r + γ·max Q(s′,a′) − Q(s,a)?

Błąd TD to „zaskoczenie": porównuje świeży cel (r + γ·max Q(s′,·)) z tym, co sądziliśmy. Aktualizacja przesuwa Q o α·błąd — to ten sam szkielet „estymata += krok·(cel − estymata)" co w bandycie.

Q-learning jest off-policy. Dlaczego to praktycznie istotne?

Off-policy znaczy, że cel uczenia (optymalna polityka) jest niezależny od polityki zbierającej dane. To pozwala trenować na starych, ponownie użytych przejściach z bufora — kluczowa własność, która wróci w DQN. Eksploracja wciąż jest potrzebna.

Dlaczego tabela Q nie wystarczy na rynek?

Tabela wymaga skończonej, małej liczby stanów. Ciągły, wielowymiarowy stan rynkowy albo tracimy przez zgrubną siatkę, albo rozdmuchujemy do niemożliwej liczby komórek. Rozwiązaniem jest sieć aproksymująca Q — czyli DQN.
Moduł 3 · Rynek i dane · ~45 min

Dane rynkowe: skąd brać, jak czyścić, jak przechowywać

Cel: zbudować własny, powtarzalny pipeline danych — bo model jest wart dokładnie tyle, ile dane, na których go trenujesz.

Skąd brać dane — mapa źródeł

ŹródłoRynekKosztGranularnośćUwagi
Binance API / Vision (przez ccxt)kryptodarmowe1m OHLCV, trades, order bookNajlepszy darmowy zbiór do nauki: pełna historia od 2017, bez limitów przy pobieraniu paczek z data.binance.vision
yfinanceakcje, ETF, FX, indeksydarmowe1d pełna historia; 1m tylko ~30 dniNieoficjalne API Yahoo — dobre do prototypów, nie do produkcji (zmienia się bez ostrzeżenia)
Stooqakcje US/PL, indeksydarmowe1dProsty CSV przez URL, dobre dane GPW
Alpacaakcje US, kryptodarmowe + płatne1mDarmowe dane + darmowy paper trading — najprostsza ścieżka do live na akcjach US
Polygon.io / Databento / Finnhubakcje USfree tier + płatnetick / 1mJakość produkcyjna; free tiery ograniczone (Polygon: akcje ~1 rok wstecz), do nauki wystarczą
Kraken / Bybit / OKX APIkryptodarmowe1m OHLCV, fundingDrugie źródło do weryfikacji krzyżowej danych z Binance
CryptoDataDownload, Kagglekrypto, akcjedarmoweróżnaGotowe CSV — wygodne, ale zawsze weryfikuj próbkę względem giełdy
Rekomendacja dla tego kursu

Ucz się na krypto z Binance przez ccxt: dane minutowe za darmo, rynek 24/7 (brak luk nocnych i dywidend do obsługi), te same API do paper- i live-tradingu. Akcje przez yfinance traktuj jako drugi zbiór do sprawdzenia, czy strategia nie działa „tylko na BTC".

Pobieranie: kompletny skrypt do OHLCV

_# Pakiety do warstwy danych
uv pip install ccxt pandas pyarrow yfinance
"""Pobiera pełną historię świec z Binance i zapisuje do Parquet.
Uruchamiaj wielokrotnie - dociąga tylko nowe dane (idempotentny)."""
import ccxt, pandas as pd, time
from pathlib import Path

SYMBOL    = "BTC/USDT"
TIMEFRAME = "1h"                      # zacznij od 1h: 8 lat = ~70k świec, ideał do nauki
OUT       = Path(f"data/{SYMBOL.replace('/','')}_{TIMEFRAME}.parquet")
OUT.parent.mkdir(exist_ok=True)

ex = ccxt.binance()                   # publiczne endpointy - klucz API niepotrzebny

_# Start: od ostatniej zapisanej świecy albo od początku historii
if OUT.exists():
    since = int(pd.read_parquet(OUT).index[-1].timestamp() * 1000) + 1
else:
    since = ex.parse8601("2017-09-01T00:00:00Z")

rows = []
while True:
    # Binance zwraca max 1000 świec na zapytanie - stronicujemy
    batch = ex.fetch_ohlcv(SYMBOL, TIMEFRAME, since=since, limit=1000)
    if not batch:
        break
    rows += batch
    since = batch[-1][0] + 1          # następna strona: po ostatnim timestampie
    time.sleep(ex.rateLimit / 1000)   # szanuj limity API (ccxt zna właściwe opóźnienie)
    print(f"\r{len(rows):>8} świec, do {pd.to_datetime(batch[-1][0], unit='ms')}", end="")

if rows:
    df = pd.DataFrame(rows, columns=["ts", "open", "high", "low", "close", "volume"])
    df["ts"] = pd.to_datetime(df["ts"], unit="ms", utc=True)
    df = df.set_index("ts")
    if OUT.exists():                  # dołącz do istniejącego pliku, bez duplikatów
        df = pd.concat([pd.read_parquet(OUT), df])
        df = df[~df.index.duplicated(keep="last")].sort_index()
    df.to_parquet(OUT)                # Parquet: 10x mniejszy i szybszy niż CSV
    print(f"\nZapisano {len(df)} świec do {OUT}")
python 03_download.py
_#   70000+ świec, do 2026-07-06 ...
_# Zapisano 77000 świec do data/BTCUSDT_1h.parquet

_# Akcje dla porównania - jedna linijka:
python -c "import yfinance; yfinance.download('SPY', start='2000-01-01').to_parquet('data/SPY_1d.parquet')"

Walidacja: nie ufaj żadnym danym

Zanim cokolwiek wytrenujesz, przepuść dane przez kontrolę jakości. Typowe wady: luki czasowe, świece zerowe, splity/dywidendy (akcje), flash-crashe, duplikaty.

import pandas as pd

df = pd.read_parquet("data/BTCUSDT_1h.parquet")

_# 1. Luki: różnice między kolejnymi timestampami powinny być stałe (1h)
gaps = df.index.to_series().diff().value_counts()
print("Rozkład odstępów:\n", gaps.head())        # wszystko != 1h to luka (awaria giełdy?)

_# 2. Sanity check OHLC: high >= max(open,close), low <= min(open,close)
bad = ((df.high < df[["open","close"]].max(axis=1)) |
       (df.low  > df[["open","close"]].min(axis=1))).sum()
print("Świec z niespójnym OHLC:", bad)            # powinno być 0

_# 3. Ekstremalne zwroty: >20% w 1h to prawie na pewno artefakt (albo krach - sprawdź datę!)
ret = df.close.pct_change()
print("Zwroty |r| > 20%:\n", ret[ret.abs() > 0.20])

_# 4. Zerowy wolumen = martwy rynek albo błąd danych
print("Świec z volume==0:", (df.volume == 0).sum())
Akcje: ceny skorygowane

Przy akcjach zawsze używaj cen skorygowanych o splity i dywidendy (yfinance: auto_adjust=True, domyślne). Trening na cenach nieskorygowanych nauczy agenta „grać pod splity", które w danych wyglądają jak −90% krachy. To klasyczny, cichy błąd.

Strefy czasowe i resampling — dwa ciche lookaheady

Po pierwsze: wszystko w UTC (nasz skrypt robi utc=True) — mieszanie stref przy łączeniu np. akcji (zamknięcie 16:00 ET) z krypto to przesunięte o godziny „przyszłe" dane. Po drugie: przy agregacji świec (1h → 4h/1d) używaj df.resample("4h", label="right", closed="right"). Domyślna etykieta left sprawia, że świeca podpisana „12:00" zawiera dane do 16:00 — jeśli złączysz ją po indeksie z cechą liczoną „na 12:00", właśnie wstrzyknąłeś 4 godziny przyszłości do stanu.

Ceny → zwroty: dlaczego i jak

Sieci neuronowe (i tabele Q) źle znoszą wejścia niestacjonarne. Cena BTC to 3 000 USD w 2018 i 100 000 USD w 2025 — model uczony na surowych cenach nie generalizuje, bo „nowe" ceny leżą poza rozkładem treningowym. Standard: zwroty logarytmiczne:

Zwrot logarytmiczny rt = ln(Pt / Pt−1)
  • Addytywność: zwrot za tydzień = suma zwrotów dziennych (procenty się nie sumują, logi tak) — wygodne przy nagrodach.
  • Quasi-stacjonarność: rozkład zwrotów jest znacznie stabilniejszy w czasie niż poziom cen (choć zmienność też dryfuje — stąd normalizacja zmiennością w module 6).
  • Symetria: +10% i −10% logarytmicznie mają tę samą wielkość, procentowo nie.
import numpy as np, pandas as pd
df = pd.read_parquet("data/BTCUSDT_1h.parquet")
df["log_ret"] = np.log(df.close / df.close.shift(1))
print(df.log_ret.describe())    # mean ~1e-5, std ~0.008 dla BTC 1h
_# Kurtoza >> 3: grube ogony. Rynek to NIE rozkład normalny - zapamiętaj do modułu 8.
print("kurtoza:", df.log_ret.kurtosis())

Kurtoza >> 3 oznacza grube ogony, a grube ogony chodzą parami z klastrami zmienności: spokój skupia się ze spokojem, panika z paniką. To najbardziej „rynkowa" własność danych — i najłatwiejsza do przeoczenia. Sprawdź, czy widzisz ją gołym okiem:

Rynek czy biały szum? Zgadnij po wykresie ceny interaktywne
skumulowane zwroty (cena) |zwroty| — tu widać klastry zmienności
0 / 0
trafienia
?
kurtoza A
?
kurtoza B

Wykresy cen wyglądają niemal identycznie — obie to błądzenie losowe. Różnica siedzi w ZMIENNOŚCI: model rynkowy (GARCH) skleja okresy spokoju i paniki w klastry, ma grube ogony i kurtozę > 3. To dokładnie ta własność, którą cechy typu vol_regime z modułu 6 mają wyłapać — i powód, dla którego zwykły random walk z modułu 4 to zbyt łagodny test dla strategii.

Struktura projektu i podział czasowy

Od teraz trzymamy żelazną dyscyplinę podziału danych. Podział musi być chronologiczny — losowy podział (jak w klasycznym ML) przecieka informację z przyszłości przez autokorelację zmienności.

import pandas as pd

df = pd.read_parquet("data/BTCUSDT_1h.parquet")

_# Chronologicznie: trening -> walidacja (dobór hiperparametrów) -> test (dotykany RAZ)
train = df.loc[:"2023-12-31"]            # ~6 lat: nauka
val   = df.loc["2024-01-01":"2024-12-31"] # 1 rok: porównywanie modeli i strojenie
test  = df.loc["2025-01-01":]             # holdout: finalna ocena, JEDEN raz na końcu

for name, part in [("train", train), ("val", val), ("test", test)]:
    part.to_parquet(f"data/{name}.parquet")
    print(f"{name:5}: {len(part):>6} świec  {part.index[0].date()} - {part.index[-1].date()}")
Zbiór testowy to sejf

Każde spojrzenie na wynik z testu i „poprawka" modelu po nim zamienia test w drugą walidację — wynik przestaje być wiarygodny. Profesjonalna praktyka: test otwierasz raz, na samym końcu projektu, i raportujesz co wyszło — nawet jeśli wyszło źle. W module 7 zastąpimy ten prosty podział walk-forwardem.

Co musisz mieć po tym module

  • Katalog data/ z BTC/USDT 1h (pełna historia) i SPY 1d w Parquet.
  • Skrypt walidacji przechodzący bez czerwonych flag (albo z flagami, które rozumiesz).
  • Podział train/val/test i obietnicę, że testu nie dotkniesz do modułu 8.
Sprawdź się

Dlaczego dane dzielimy chronologicznie, a nie losowo jak w klasycznym ML?

Sąsiednie świece są skorelowane, więc losowo wrzucając próbki do train i test rozsiewasz „bliskich sąsiadów" po obu stronach — model widzi w treningu coś prawie identycznego z testem. Podział po czasie zachowuje strzałkę czasu, tak jak w realnym handlu.

Dlaczego trenujemy na zwrotach logarytmicznych zamiast na surowych cenach?

Model uczony na poziomach cen nie generalizuje, bo przyszłe ceny wychodzą poza zakres widziany w treningu. Zwroty logarytmiczne mają znacznie stabilniejszy rozkład i sumują się w czasie — wygodne przy nagrodach. Nie są zawsze dodatnie.

Dlaczego przy akcjach trzeba używać cen skorygowanych o splity i dywidendy?

Split 10:1 na surowych danych to skok ceny w dół o 90%, który nie odpowiada żadnej stracie posiadacza akcji. Agent potraktowałby go jak realny sygnał i nauczyłby się artefaktu. Ceny skorygowane usuwają ten cichy, klasyczny błąd.
Moduł 4 · Rynek i dane · ~60 min

Środowisko tradingowe: własny Gymnasium env

Cel: zbudować od zera środowisko zgodne z API Gymnasium — serce całego projektu, w którym zaszyta jest cała „fizyka" rynku: pozycje, koszty, poślizg.

Dlaczego środowisko jest ważniejsze niż algorytm

W RL finansowym 90% błędów siedzi w środowisku, nie w algorytmie. Jeśli env pozwala agentowi kupić po cenie, którą zna z przyszłości, albo nie nalicza prowizji — agent znajdzie tę dziurę i ją wyeksploatuje, bo od tego jest. Wynik: piękna krzywa w backteście, strata na żywo. Dlatego budujemy env sami, linia po linii, zamiast brać czarną skrzynkę.

Standard branżowy to API Gymnasium (następca OpenAI Gym): środowisko implementuje reset() i step(action), dzięki czemu każdy algorytm (Twój własny, Stable-Baselines3, cokolwiek) może z nim rozmawiać.

uv pip install gymnasium

Decyzje projektowe (v1)

  • Obserwacja: okno ostatnich 32 zwrotów logarytmicznych + bieżąca pozycja. Celowo minimalistycznie — cechy dołożymy w module 6.
  • Akcje: dyskretne {0: flat, 1: long, 2: short}. Docelowa pozycja, nie „kup/sprzedaj" — prostsza księgowość, brak dźwigni.
  • Egzekucja: decyzja na zamknięciu świecy t, wykonanie po cenie otwarcia t+1. To jedna linijka, która eliminuje najczęstszy lookahead bias.
  • Koszty: prowizja + poślizg naliczane od każdej zmiany pozycji.
  • Nagroda: zwrot logarytmiczny pozycji netto po kosztach (punkt wyjścia — alternatywy w module 6).
"""Minimalne, ale UCZCIWE środowisko tradingowe (Gymnasium API).
Uczciwe = decyzja w t, egzekucja w t+1, koszty od zmiany pozycji."""
import numpy as np
import gymnasium as gym
from gymnasium import spaces

class TradingEnv(gym.Env):
    def __init__(self, prices: np.ndarray, window: int = 32,
                 fee: float = 0.0004, slippage: float = 0.0002):
        """prices: wektor cen close; fee: 0.04% (taker Binance z BNB);
        slippage: 0.02% - konserwatywny dodatek za poślizg na płynnym rynku."""
        super().__init__()
        self.prices   = prices.astype(np.float64)
        self.log_ret  = np.diff(np.log(self.prices))   # log_ret[t] = zwrot od t do t+1
        self.window   = window
        self.cost     = fee + slippage                 # łączny koszt zmiany pozycji o 1

        # Przestrzeń akcji: docelowa pozycja {flat, long, short}
        self.action_space = spaces.Discrete(3)
        # Obserwacja: `window` ostatnich zwrotów + aktualna pozycja = window+1 liczb
        self.observation_space = spaces.Box(-np.inf, np.inf,
                                            shape=(window + 1,), dtype=np.float32)

    def _obs(self):
        """Stan: zwroty z okna [t-window, t) oraz pozycja. Skalowanie x100:
        zwroty 1h to ~0.008 - bez skalowania sieć widzi 'prawie zera'."""
        r = self.log_ret[self.t - self.window : self.t] * 100.0
        return np.append(r, self.position).astype(np.float32)

    def reset(self, seed=None, options=None):
        super().reset(seed=seed)
        self.t        = self.window       # start po nagromadzeniu pełnego okna
        self.position = 0                 # -1 short, 0 flat, +1 long
        self.equity   = 0.0               # skumulowany log-zwrot strategii
        return self._obs(), {}

    def step(self, action: int):
        target = {0: 0, 1: 1, 2: -1}[int(action)]

        # KOLEJNOŚĆ MA ZNACZENIE (anty-lookahead):
        # 1) płacimy koszt zmiany pozycji (wykonanej na otwarciu t+1),
        # 2) NOWA pozycja zarabia zwrot świecy t -> t+1.
        trade_cost = abs(target - self.position) * self.cost
        self.position = target
        reward = self.position * self.log_ret[self.t] - trade_cost

        self.equity += reward
        self.t      += 1
        terminated   = self.t >= len(self.log_ret)     # koniec danych
        info = {"equity": self.equity, "position": self.position}
        return (self._obs() if not terminated else
                np.zeros(self.observation_space.shape, np.float32),
                float(reward), terminated, False, info)

Test środowiska — zawsze, zanim cokolwiek wytrenujesz

Środowisko testujemy jak każdy inny kod. Trzy obowiązkowe sanity checki:

import numpy as np, pandas as pd
from trading_env import TradingEnv

prices = pd.read_parquet("data/train.parquet").close.to_numpy()
env = TradingEnv(prices)

_# 1. Zgodność z API Gymnasium (kształty, typy, zakresy)
from gymnasium.utils.env_checker import check_env
check_env(env)   # brak wyjątku = OK

_# 2. Agent losowy MUSI tracić ~koszty transakcyjne (nie zarabiać!)
_#    Jeśli losowe akcje zarabiają, środowisko przecieka przyszłość.
obs, _ = env.reset()
total, done = 0.0, False
while not done:
    obs, r, done, _, info = env.step(env.action_space.sample())
    total += r
print(f"Agent losowy, log-equity: {total:.3f}")   # oczekiwane: wyraźnie ujemne

_# 3. Buy&hold w env == zwrot z danych (księgowość się zgadza)
obs, _ = env.reset()
total, done = 0.0, False
while not done:
    obs, r, done, _, _ = env.step(1)              # zawsze long
    total += r
expected = np.log(prices[-1] / prices[env.window]) - env.cost
print(f"B&H w env: {total:.4f}  vs z cen: {expected:.4f}")   # różnica < 1e-9
Test agenta losowego to najtańszy wykrywacz lookahead

Zapamiętaj tę heurystykę na zawsze: jeśli losowa polityka systematycznie zarabia w Twoim środowisku, środowisko jest zepsute. Losowość nie ma przewagi informacyjnej — może co najwyżej płacić koszty.

Losowe okna startowe: pierwsza obrona przed przeuczeniem

Nasz env v1 ma subtelną wadę: reset() zawsze startuje w tym samym miejscu i przechodzi całą historię. Dla algorytmu on-policy (PPO) oznacza to, że agent w kółko ogląda jedną, identyczną trajektorię — idealne warunki do nauczenia się jej na pamięć, czyli dokładnie tego overfittingu, z którym walczy pół kursu. Standardowa poprawka: każdy epizod to losowe okno z danych treningowych.

_# Epizod = losowe ~60 dni zamiast całej historii. Dopisz do TradingEnv:
MAX_STEPS = 24 * 60      # długość epizodu w świecach 1h

def reset(self, seed=None, options=None):
    super().reset(seed=seed)                        # inicjalizuje self.np_random
    hi = len(self.log_ret) - MAX_STEPS - 1
    self.t0 = int(self.np_random.integers(self.window, max(self.window + 1, hi)))
    self.t  = self.t0                               # losowy początek epizodu
    self.position, self.equity = 0, 0.0
    return self._obs(), {}

_# ...i w step() epizod kończy się po MAX_STEPS krokach:
terminated = (self.t >= self.t0 + MAX_STEPS) or (self.t >= len(self.log_ret))

Bonus: krótsze epizody to częstsze resety i lepsze wykorzystanie wektoryzacji środowisk (moduł 15). Do ewaluacji (walidacja, walk-forward) używaj oczywiście wariantu deterministycznego — od początku do końca okna, żeby wyniki były porównywalne.

Zobacz problem na własne oczy

Poniższa symulacja pokazuje, czemu trading jest trudniejszy niż gridworld. Ten sam Q-learning co w module 2, stan = znak ostatnich zwrotów (prymitywna dyskretyzacja). Na cenie z trendem agent szybko odkrywa sensowną politykę. Na błądzeniu losowym — nie ma czego się nauczyć, a koszty robią swoje.

Zanim uruchomisz

Ten sam Q-learning co na gridworldzie dostanie cenę typu „błądzenie losowe" (zero sygnału) i będzie płacić prowizję. Czego się spodziewasz po treningu?

Nauczenie się „nie handluj" wymaga rozpoznania, że sygnału nie ma — a to trudniejsze, niż się wydaje: algorytm widzi w szumie pozorne wzorce i eksploatuje je, płacąc koszty przy każdej zmianie pozycji. Zobaczysz to po przełączeniu reżimu na błądzenie losowe.
Symulacja: tabularny agent w środowisku tradingowym interaktywne
cena (syntetyczna) kapitał agenta (po kosztach) buy & hold
0
epizody treningu
+0.0%
agent
+0.0%
buy & hold

Przełącz reżim na „błądzenie losowe" i zauważ: krzywa agenta dryfuje w dół mimo treningu — to koszty transakcyjne zjadają szum, który agent bierze za sygnał. Lekcja: RL nie wyczaruje sygnału, którego nie ma w danych.

Ćwiczenia

  1. Dodaj do info licznik transakcji i wypisuj go po epizodzie — będzie potrzebny w module 8.
  2. Zepsuj celowo środowisko: użyj self.log_ret[self.t] w obserwacji (przeciek przyszłego zwrotu!). Zobacz, że agent losowy dalej traci, ale zachłanny względem obserwacji nagle „zarabia". Tak wygląda lookahead od środka.
  3. Zaimplementuj wariant z akcją ciągłą (spaces.Box(-1, 1)): pozycja ułamkowa. Przyda się przy SAC w module 5.
  4. Dołóż do obserwacji niezrealizowany PnL bieżącej pozycji (log-zwrot od ceny wejścia; 0 przy pozycji flat). Bez tego agent nie może nauczyć się polityk typu „zamknij stratną" — decyzja o wyjściu wymaga wiedzy, ile trzymasz. Porównaj na walidacji z wersją bez PnL.
Sprawdź się

Agent losowy systematycznie ZARABIA w Twoim środowisku. Co to najpewniej znaczy?

To najtańszy wykrywacz lookaheadu: losowa polityka nie zna przyszłości, więc bez przecieku powinna tracić mniej więcej koszty. Systematyczny zysk oznacza, że env gdzieś udostępnia przyszłą informację, którą nawet losowe akcje „przypadkiem" łapią.

Dlaczego decyzję podejmujemy na zamknięciu świecy t, a egzekwujemy po cenie otwarcia t+1?

Decyzja opiera się na danych domkniętych do t, ale samej ceny zamknięcia t użyć do egzekucji nie możesz, bo w praktyce poznajesz ją dokładnie w momencie zamknięcia. Wykonanie po otwarciu t+1 to jedna linijka, która eliminuje najczęstszy przeciek.

Dlaczego mówi się, że w RL finansowym środowisko jest ważniejsze niż algorytm?

Agent jest optymalizatorem: znajdzie każdą dziurę w env, bo od tego jest. Zepsute środowisko produkuje spektakularne, ale fałszywe wyniki niezależnie od algorytmu. Dlatego budujemy env sami, linia po linii, zamiast ufać czarnej skrzynce.
Moduł 5 · Deep RL · ~75 min

Deep RL: DQN, PPO i SAC na Twoim M3

Cel: zrozumieć, jak sieci neuronowe zastępują tabelę Q, poznać trzy rodziny algorytmów i wytrenować pierwszego agenta PPO na prawdziwych danych.

Od tabeli do sieci: DQN

Pomysł jest prosty: zamiast tabeli Q(s,a) — sieć neuronowa Qθ(s), która dla stanu zwraca wektor wartości wszystkich akcji. Uczymy ją minimalizując kwadrat błędu TD. Ale naiwne połączenie „sieć + Q-learning" jest niestabilne; DQN (DeepMind, 2015) dodał dwa stabilizatory, które musisz rozumieć:

  • Replay buffer: przejścia (s, a, r, s′) trafiają do bufora, a uczymy na losowych minibatchach z niego. Łamie to korelację kolejnych próbek (sąsiednie świece są silnie skorelowane!) i wielokrotnie używa drogich danych. Możliwe dzięki temu, że Q-learning jest off-policy.
  • Sieć docelowa (target network): cel TD liczymy zamrożoną kopią sieci, aktualizowaną co N kroków. Bez tego cel „ucieka" razem z siecią i uczenie się rozbiega.
Strata DQN L(θ) = 𝔼(s,a,r,s′)∼bufor [ ( r + γ·maxa′ Qθ⁻(s′,a′) − Qθ(s,a) )² ]

Trzy rodziny — którą wybrać

AlgorytmRodzinaAkcjeMocne stronyW tradingu
DQN (+Double, Dueling)value-based, off-policydyskretneEfektywność próbkowa (replay), prostota pojęciowaDobre wejście: long/flat/short; wrażliwy na szum i hiperparametry
PPOpolicy gradient (actor-critic), on-policydyskretne i ciągłeStabilny, odporny, mało strojenia — koń roboczy całej branżyDomyślny wybór tego kursu; entropia = wbudowana eksploracja
SACactor-critic, off-policyciągłeMaks. entropii = mocna eksploracja, efektywny próbkowoNajlepszy do ciągłego sizingu pozycji [−1, 1]

Intuicja różnicy: value-based (DQN) uczy się „ile warta jest każda akcja" i wybiera najlepszą; policy gradient (PPO) bezpośrednio poprawia rozkład π(a|s), zwiększając prawdopodobieństwo akcji, które wypadły lepiej niż oczekiwał krytyk (funkcja przewagi A(s,a)). PPO dodatkowo przycina (clip) wielkość zmiany polityki w jednym kroku — stąd „Proximal" — co czyni go wyjątkowo stabilnym na zaszumionych nagrodach. Dokładnie dlatego jest domyślnym wyborem w finansach.

PPO pod maską: przycinanie i GAE

Skoro PPO jest koniem roboczym kursu, zobaczmy jego dwa mechanizmy naprawdę — bo ich hiperparametry (clip_range, gae_lambda) będziesz stroić. PPO porównuje nową politykę ze starą przez iloraz prawdopodobieństw ρt(θ) = πθ(at|st) / πθ,stara(at|st) i maksymalizuje przycięty cel:

Cel PPO (clipped surrogate) LCLIP(θ) = 𝔼t [ min( ρt·At,  clip(ρt, 1−ε, 1+ε)·At ) ]

At to przewaga (advantage): o ile akcja wypadła lepiej, niż oczekiwał krytyk V(s). Kluczowa asymetria: gdy akcja była dobra (A>0), clip odcina zachętę do zwiększania jej prawdopodobieństwa ponad (1+ε) — „nie szarżuj po jednym sukcesie". Gdy była zła (A<0), min zostawia karę nieprzyciętą od dołu — błędy można korygować mocno. Na rynkowym szumie, gdzie pojedynczy epizod bywa czystym fartem, ta ostrożność to być albo nie być. Zobacz to na wykresie:

PPO: co przycięcie robi ze współczynnikiem prawdopodobieństwa ρ interaktywne
bez clipu: ρ·A cel PPO (z clipem) strefa przycięta
ρ bez kary
okno przycięcia
która strona odcięta

Clip odcina zachętę do dużych zmian polityki — ale tylko w JEDNĄ stronę zależną od znaku przewagi A. Dla A>0 (akcja lepsza niż średnia) cel spłaszcza się powyżej 1+ε: nie opłaca się rozpędzać prawdopodobieństwa w nieskończoność. Dla A<0 spłaszcza się poniżej 1−ε. Ta asymetria to cały sekret stabilności PPO na zaszumionych nagrodach rynkowych — jeden pechowy batch nie wywróci polityki.

Samą przewagę At PPO estymuje przez GAE (Generalized Advantage Estimation) — wykładniczo ważoną sumę błędów TD:

GAE — estymata przewagi AtGAE(γ,λ) = Σk≥0 (γλ)k δt+k,   δt = rt+1 + γV(st+1) − V(st)

λ to pokrętło bias–wariancja: λ=0 ufa tylko krytykowi (niska wariancja, duży bias — a krytyk na zaszumionych rynkach bywa słaby), λ=1 to surowe zwroty Monte Carlo (bez biasu, ogromna wariancja). Domyślne λ=0.95 działa dobrze; przy bardzo szarpanych krzywych uczenia zejście do 0.9 często stabilizuje trening kosztem tempa. To najczęściej pomijany hiperparametr, który realnie pomaga na danych finansowych.

VecNormalize kontra Twoja funkcja nagrody

VecNormalize(norm_reward=True) przeskalowuje nagrody krocząco do jednostkowej wariancji. Wygodne przy gołym log-zwrocie — ale jeśli w module 6 wepniesz nagrodę z ręcznie wyważoną karą (λ·pnl², kara za obrót, DSR), normalizacja zniekształci Twoje proporcje i „czemu moja kara nic nie robi" stanie się zagadką na tydzień. Reguła: własna, starannie wyskalowana nagroda → norm_reward=False; prosty log-zwrot → True.

Setup: Stable-Baselines3 na Apple Silicon

_# PyTorch (z natywnym wsparciem Metal/MPS) + Stable-Baselines3 + TensorBoard
uv pip install torch stable-baselines3 tensorboard

_# Szybki test, czy PyTorch widzi GPU Twojego M3:
python -c "import torch; print('MPS dostępne:', torch.backends.mps.is_available())"
_# MPS dostępne: True
CPU vs MPS w RL — nieintuicyjna prawda

Dla małych sieci MLP (a takich używamy) trening RL bywa szybszy na CPU niż na GPU — wąskim gardłem jest pętla środowiska i tysiące małych forward-passów, a narzut transferu do GPU przewyższa zysk. Reguła praktyczna: MLP do ~3 warstw × 256 neuronów → device="cpu"; sieci CNN/LSTM/transformer albo duże batche → device="mps". Zmierz oba — na M3 masz dużo szybkich rdzeni CPU.

Pierwszy agent PPO na prawdziwych danych

"""Trening PPO na BTC/USDT 1h. Walidacja na osobnym roku danych."""
import numpy as np, pandas as pd
from stable_baselines3 import PPO
from stable_baselines3.common.vec_env import DummyVecEnv, VecNormalize
from stable_baselines3.common.monitor import Monitor
from trading_env import TradingEnv

train_p = pd.read_parquet("data/train.parquet").close.to_numpy()
val_p   = pd.read_parquet("data/val.parquet").close.to_numpy()
WINDOW  = 32                # jedno źródło prawdy - używane też przy ewaluacji

_# VecNormalize: krocząca standaryzacja obserwacji i nagród.
_# Dla RL na rynkach to często różnica między "uczy się" a "nie uczy się".
env = DummyVecEnv([lambda: Monitor(TradingEnv(train_p, window=WINDOW))])
env = VecNormalize(env, norm_obs=True, norm_reward=True, clip_obs=10.0)

model = PPO(
    "MlpPolicy", env,
    learning_rate = 3e-4,       # standard; przy niestabilności zejdź do 1e-4
    n_steps       = 2048,       # długość rolloutu przed każdą aktualizacją
    batch_size    = 256,
    gamma         = 0.99,       # horyzont ~100 świec = ~4 dni na danych 1h
    ent_coef      = 0.01,       # premia za entropię: nie pozwala polityce
                                # zapaść się przedwcześnie w jedną akcję
    policy_kwargs = dict(net_arch=[128, 128]),   # mały MLP wystarczy
    device        = "cpu",      # patrz ramka: MLP -> CPU szybsze na M3
    tensorboard_log = "runs/",
    seed          = 42,
    verbose       = 1,
)
model.learn(total_timesteps=500_000)   # ~10-20 min na M3
model.save("models/ppo_btc_v1")
env.save("models/vecnorm_v1.pkl")      # statystyki normalizacji - KONIECZNE do ewaluacji

_# --- Ewaluacja na walidacji (dane, których model nie widział) ---
def run(model, prices, vecnorm):
    env = DummyVecEnv([lambda: TradingEnv(prices)])
    env = VecNormalize.load(vecnorm, env)
    env.training, env.norm_reward = False, False   # zamrażamy statystyki!
    obs, done, eq = env.reset(), False, []
    while not done:
        act, _ = model.predict(obs, deterministic=True)
        obs, r, dones, infos = env.step(act)
        done = dones[0]; eq.append(infos[0]["equity"])
    return np.array(eq)

eq = run(model, val_p, "models/vecnorm_v1.pkl")
bh = np.log(val_p[-1] / val_p[WINDOW])   # nie "magiczne 32" - patrz WINDOW wyżej
print(f"Agent (walidacja): {eq[-1]:+.3f} log-ret | B&H: {bh:+.3f}")
python 06_train_ppo.py

_# W drugim terminalu - podgląd krzywych uczenia na żywo:
tensorboard --logdir runs/
_# otwórz http://localhost:6006 i patrz na rollout/ep_rew_mean

Jak czytać krzywe uczenia RL

  • rollout/ep_rew_mean — średnia nagroda epizodu. Powinna rosnąć, ale na danych rynkowych będzie bardzo szarpana. Patrz na trend z 50+ punktów.
  • train/entropy_loss — entropia polityki. Zbyt szybki spadek do zera = przedwczesna pewność siebie (podnieś ent_coef). Brak spadku = agent nie zbiega.
  • train/approx_kl — wielkość zmian polityki. Skoki = niestabilność (zmniejsz learning rate).
  • Najważniejsze: krzywa treningowa w górę przy płaskiej/spadającej walidacji = overfitting. W RL na rynkach to stan domyślny, nie wyjątek.
Wariancja między ziarnami jest OGROMNA

Ten sam kod, te same dane, inne seed — i wynik potrafi się różnić o kilkadziesiąt punktów procentowych. Nigdy nie wyciągaj wniosków z jednego przebiegu. Standard: trenuj min. 5 ziaren i raportuj medianę + rozrzut. To nie pedanteria — pojedynczy „udany" seed to najczęstszy sposób, w jaki ludzie oszukują samych siebie (i czytelników na GitHubie).

SAC dla ciągłego sizingu — szkic

_# Wymaga wariantu env z akcją ciągłą (ćwiczenie z modułu 4):
_#   action_space = spaces.Box(-1.0, 1.0, shape=(1,))  # docelowa pozycja ułamkowa
from stable_baselines3 import SAC

model = SAC(
    "MlpPolicy", env,
    learning_rate = 3e-4,
    buffer_size   = 200_000,   # replay buffer (off-policy!)
    batch_size    = 256,
    tau           = 0.005,     # miękka aktualizacja sieci docelowej
    ent_coef      = "auto",    # SAC sam stroi entropię - jego supermoc
    device        = "cpu",
    seed          = 42,
)
model.learn(total_timesteps=300_000)
_# Uwaga: SAC z akcją ciągłą + koszty transakcyjne = agent uczy się też
_# NIE handlować zbyt często, bo drobne korekty pozycji kosztują. To pożądane.

Ćwiczenia

  1. Wytrenuj PPO na 5 ziarnach (pętla po seed). Zapisz wyniki walidacyjne wszystkich — zobacz rozrzut na własne oczy.
  2. Porównaj device="cpu" vs "mps" (czas 100k kroków). Zanotuj wynik — to Twoja lokalna prawda o M3.
  3. Zamień PPO na DQN (from stable_baselines3 import DQN). Porównaj stabilność krzywych uczenia z PPO.
Sprawdź się

Po co w DQN replay buffer?

Kolejne przejścia są mocno skorelowane (sąsiednie świece), a uczenie na skorelowanych batchach destabilizuje sieć. Losowe próbkowanie z bufora dekoreluje dane i pozwala użyć każdego przejścia wielokrotnie. Nie ma to nic wspólnego z entropią ani z przechowywaniem wag docelowych.

Po co osobna, zamrożona sieć docelowa (target network) w DQN?

Cel TD zawiera Q(s′,·). Jeśli liczy go ta sama, aktualizowana co krok sieć, gonimy ruchomy cel i uczenie się rozbiega. Zamrożona kopia, odświeżana co N kroków, daje stabilny punkt odniesienia. Nie chodzi o eksplorację ani wydajność.

Ten sam kod i dane, inny seed — wynik walidacyjny różni się o kilkadziesiąt p.p. Co z tego wynika?

Ogromna wariancja między ziarnami jest normalna w RL na rynkach, nie objaw buga. Wybieranie najlepszego seeda to najczęstszy sposób samooszukiwania — raportuje się medianę i rozrzut z kilku przebiegów, żeby oddzielić realny sygnał od szczęścia.
Moduł 6 · Deep RL · ~60 min

Inżynieria cech i projektowanie nagrody

Cel: nauczyć się dwóch dźwigni, które realnie decydują o wyniku — co agent widzi (stan) i za co jest chwalony (nagroda).

Stan: co agent powinien widzieć

Deep RL teoretycznie „sam wyciągnie cechy" — na rynkach to mit. Przy stosunku sygnału do szumu bliskim zera dobre cechy to połowa sukcesu. Zasady:

  • Stacjonarność przede wszystkim: nigdy surowe ceny; zwroty, relacje (cena/średnia − 1), rangi percentylowe.
  • Normalizacja zmiennością: zwrot ÷ krocząca zmienność. Ten sam ruch 1% znaczy co innego przy VIX 12 i 40.
  • Wielo-skalowość: cechy z okien 24h, 7d, 30d — rynek ma wiele rytmów.
  • Pozycja i PnL w stanie: agent musi wiedzieć, co trzyma — inaczej decyzja o wyjściu jest niemożliwa do nauczenia.
  • Mniej znaczy więcej: każda cecha to wymiar do przeszukania i okazja do overfittingu. Zaczynaj od 10–20, nie 200.
"""Zestaw cech v2 - stacjonarnych i znormalizowanych zmiennością.
Wszystkie liczone WYŁĄCZNIE z danych dostępnych w chwili t (rolling, nigdy center)."""
import numpy as np, pandas as pd

def make_features(df: pd.DataFrame) -> pd.DataFrame:
    f = pd.DataFrame(index=df.index)
    ret = np.log(df.close / df.close.shift(1))

    # Zmienność krocząca (EWMA ~1 tydzień na danych 1h) - mianownik normalizacji
    vol = ret.ewm(span=168).std()

    # 1) Zwroty znormalizowane zmiennością, kilka horyzontów
    for h in (1, 6, 24, 72):
        f[f"zret_{h}"] = (np.log(df.close / df.close.shift(h))
                          / (vol * np.sqrt(h))).clip(-5, 5)

    # 2) Pozycja ceny względem średnich (trend) - relacja, nie poziom
    for w in (24, 168):
        f[f"trend_{w}"] = (df.close / df.close.rolling(w).mean() - 1) / (vol * np.sqrt(w))

    # 3) Reżim zmienności: bieżąca vs długoterminowa (log-stosunek)
    f["vol_regime"] = np.log(vol / vol.rolling(720).mean())

    # 4) RSI przeskalowane do [-1, 1] (klasyk, ale znormalizowany)
    delta = df.close.diff()
    up  = delta.clip(lower=0).ewm(span=14).mean()
    dn  = (-delta.clip(upper=0)).ewm(span=14).mean()
    f["rsi"] = (up / (up + dn + 1e-12) - 0.5) * 2

    # 5) Względny wolumen (aktywność rynku)
    f["rvol"] = np.log(df.volume / df.volume.rolling(168).mean() + 1e-12).clip(-3, 3)

    # 6) Rytm dobowy - krypto ma wyraźną sezonowość śróddzienną
    hour = df.index.hour
    f["hod_sin"] = np.sin(2 * np.pi * hour / 24)
    f["hod_cos"] = np.cos(2 * np.pi * hour / 24)

    return f.dropna()   # początek serii nie ma pełnych okien - odcinamy
Lookahead w cechach — lista kontrolna

Każda cecha musi być policzalna o godz. t z danych ≤ t. Zakazane: rolling(..., center=True), normalizacja statystykami z całego zbioru (fit scalera na train+test!), wskaźniki „repaintujące" (ZigZag, niektóre pivoty), łączenie danych o różnym opóźnieniu publikacji. Scaler fitujesz na train, aplikujesz na resztę — albo używasz statystyk kroczących jak wyżej.

Nagroda: najważniejsza decyzja projektowa w całym systemie

Agent maksymalizuje dokładnie to, co mu każesz — łącznie z lukami w Twojej specyfikacji. Ten efekt (reward hacking) w tradingu objawia się np. tak: nagroda za niezrealizowany PnL → agent trzyma stratne pozycje w nieskończoność; brak kary za obrót → agent handluje co świecę.

Funkcja nagrodyDefinicjaZaletyRyzyka
Log-zwrot netto (nasza v1)pozycja · rt − kosztyProsta, gęsta, addytywnaIgnoruje ryzyko: agent może lubić wariancję
Zwrot − λ·ryzykoj.w. − λ·(pozycja·rtKara za zmienność wyników; λ to Twój apetyt na ryzykoDobór λ wymaga eksperymentów (start: sweep 0.05–0.5)
Differential Sharpe Ratioprzyrostowa aktualizacja Sharpe'a (Moody & Saffell, 1998)Bezpośrednio optymalizuje risk-adjusted returnBardziej złożona; wrażliwa na parametr zapominania
Kara za drawdownzwrot − β·max(0, przyrost DD)Agent unika głębokich obsunięćRzadki, opóźniony sygnał — trudniejsze uczenie
_# Wariant A: kara za wariancję (risk-aversion) - jedna linijka w step():
pnl    = self.position * self.log_ret[self.t]
reward = pnl - trade_cost - self.risk_lambda * pnl**2

_# Wariant B: dodatkowa kara za obrót (uczy cierpliwości):
reward = pnl - trade_cost - self.turnover_penalty * abs(target - prev_position)

_# Wariant C: differential Sharpe ratio (Moody & Saffell) - stan A,B w env:
_#   A_t = A + eta*(r - A);  B_t = B + eta*(r^2 - B)
_#   DSR ~ (B*(r - A) - 0.5*A*(r^2 - B)) / (B - A^2)**1.5
eta = 0.01
dA, dB = r - self.A, r**2 - self.B
denom  = max((self.B - self.A**2) ** 1.5, 1e-9)
reward = (self.B * dA - 0.5 * self.A * dB) / denom
self.A += eta * dA;  self.B += eta * dB
Metodologia porównywania nagród

Zmieniaj jedną rzecz naraz i porównuj na walidacji tą samą metryką zewnętrzną (np. Sharpe po kosztach z modułu 8) — nie wartością nagrody, bo różne nagrody mają różne skale. Prowadź dziennik eksperymentów: data, commit, config, seed’y, wynik. Bez tego po dwóch tygodniach nie będziesz wiedział, co już próbowałeś.

Overfitting: zobacz go, zanim Cię ograbi

Poniżej najważniejsza symulacja kursu. Dopasowujemy model o rosnącej złożoności do zaszumionej serii „zwrotów". Błąd na treningu spada monotonicznie — na teście spada, a potem rośnie. RL z milionami kroków na tych samych danych to maszyna do przejeżdżania tego minimum.

Zanim przesuniesz suwak

Zwiększasz złożoność modelu od 1 do 15. Co stanie się z błędem na danych TESTOWYCH?

To krzywa w kształcie litery U: za prosty model nie łapie sygnału (niedouczenie), za złożony dopasowuje się do szumu treningu (przeuczenie) i przegrywa na świeżych danych. Cały warsztat modułów 7–8 istnieje po to, żeby znajdować to minimum uczciwie.
Symulacja: złożoność modelu vs błąd train/test interaktywne
błąd na treningu błąd na teście prawdziwy sygnał

Przesuwaj złożoność. Zielona linia to prawdziwy (prosty!) sygnał, punkty to sygnał + szum. Model o dużej złożoności „wyjaśnia" szum treningowy i przegrywa na świeżych danych. W RL odpowiednikami suwaka są: rozmiar sieci, liczba cech, liczba kroków treningu i… liczba pomysłów, które przetestowałeś na tej samej walidacji.

Ćwiczenia

  1. Wepnij features.py do środowiska (obserwacja = wektor cech + pozycja) i porównaj z gołym oknem zwrotów na walidacji, 5 ziaren.
  2. Przetestuj λ ∈ {0, 0.1, 0.3} w nagrodzie z karą za wariancję. Jak zmienia się liczba transakcji i drawdown?
  3. W symulacji overfittingu ustaw złożoność 15 i klikaj „Nowa próbka szumu" — zobacz, jak wynik testowy skacze. Tak samo skaczą backtesty przeuczonych agentów.
Sprawdź się

Nagroda = niezrealizowany PnL, bez żadnej kary za trzymanie stratnej pozycji. Jaki patologiczny efekt?

Agent maksymalizuje dokładnie to, co zapisano w nagrodzie, łącznie z lukami. Skoro strata liczy się dopiero po realizacji, „optymalne" jest nigdy jej nie realizować i przeczekiwać. To klasyczny reward hacking wynikający z niedomkniętej specyfikacji nagrody.

Dlaczego lepiej zacząć od 10–20 cech niż od 200?

Sieć przyjmie dowolnie wiele wejść i policzysz je szybko — problemem jest statystyka. Im więcej cech, tym łatwiej model dopasuje się do szumu treningowego zamiast do słabego sygnału. „Mniej znaczy więcej" chroni przed przeuczeniem.

Jak poprawnie porównać dwie różne funkcje nagrody?

Nie można porównywać wprost wartości różnych nagród — mają inne jednostki i skale, więc „większa" nic nie znaczy. Trzeba wspólnej, zewnętrznej miary na walidacji i dyscypliny zmieniania jednej rzeczy naraz plus dziennika eksperymentów.
Moduł 7 · Rzetelność · ~60 min

Backtesting: jak nie okłamać samego siebie

Cel: poznać wszystkie kanały, którymi przyszłość przecieka do backtestu, i zbudować walk-forward — jedyny uczciwy protokół oceny na szeregach czasowych.

Katalog przecieków i błędów

PułapkaMechanizmObrona
Lookahead biasDecyzja korzysta z danych z przyszłości: egzekucja po close świecy, którą dopiero „widzimy", wskaźnik z center=True, scaler fitowany na całościEgzekucja na open t+1; audyt każdej cechy; test agenta losowego (moduł 4)
Survivorship biasTrening tylko na spółkach/coinach, które przetrwały — LUNA, FTT i tysiące akcji znikły z indeksówDane point-in-time; w krypto trenuj też na okresach delistingów
Ignorowanie kosztówProwizja + spread + poślizg + (krypto) funding. Strategia 1h robiąca 500 transakcji/rok: 0.06% kosztu na trade = −3 p.p. rocznieKoszty w środowisku od pierwszego dnia; testuj wrażliwość na 2× koszty
Data snooping / p-hackingTestujesz 100 wariantów na tej samej walidacji — najlepszy jest najlepszy przypadkiemLicz wykonane testy; deflated Sharpe (moduł 8); świeży holdout na koniec
Dobór okresuBacktest 2020–2021 na BTC „udowodni" każdą strategię long-onlyTestuj przez pełny cykl: hossa, krach, flauta (dla BTC: min. 2017–2025)
Przeżycie jednego seedaRaportujesz najlepszy z 20 przebiegów RLMediana + IQR z ≥5 ziaren, zawsze

Walk-forward: standard branżowy

Pojedynczy podział train/val/test ma wadę: oceniasz model na jednym okresie, a rynek ma reżimy. Walidacja walk-forward naśladuje rzeczywistą eksploatację: trenuj na oknie przeszłości, testuj na kolejnym kawałku przyszłości, przesuń okno, powtórz. Wynik = sklejka wyników wyłącznie z okien testowych. Dodatkowo między treningiem a testem zostawiamy przerwę (embargo) — sąsiednie świece są skorelowane (zmienność!), więc bez przerwy informacja przecieka przez samą bliskość czasową. To samo rozumowanie prowadzi do purged k-fold CV z książki Lópeza de Prado, a w wersji najmocniejszej — do CPCV (Combinatorial Purged Cross-Validation): zamiast jednego przebiegu przez historię testujesz model na wielu kombinacjach bloków czasowych, więc każdy fragment danych trafia do testu wielokrotnie. Sam López de Prado przestrzega, że pojedynczy walk-forward też da się przeoptymalizować (to wciąż jedna ścieżka przez historię) — dlatego traktuj go jako minimum, a CPCV jako poziom docelowy.

Symulacja: okna walk-forward z embargiem interaktywne
okno treningowe embargo okno testowe (OOS)

Każdy pasek to jeden fold: model trenowany od zera (albo dostrajany) na niebieskim, oceniany na zielonym. Sklejone zielone segmenty tworzą krzywą out-of-sample — jedyną, którą wolno Ci pokazać komukolwiek. Rozjazd wyników między foldami mówi więcej niż średnia: to miara wrażliwości na reżim.

Implementacja walk-forward dla agenta RL

"""Walk-forward dla PPO: trening od zera w każdym foldzie, wynik = sklejka OOS."""
import numpy as np, pandas as pd
from stable_baselines3 import PPO
from stable_baselines3.common.vec_env import DummyVecEnv, VecNormalize
from trading_env import TradingEnv

df      = pd.read_parquet("data/BTCUSDT_1h.parquet")
prices  = df.close.to_numpy()
N       = len(prices)

TEST_LEN  = 24 * 90        # okno testowe: 90 dni świec 1h
EMBARGO   = 24 * 7         # 7 dni przerwy między train a test
MIN_TRAIN = 24 * 365 * 2   # minimum 2 lata treningu (okno rosnące)
SEEDS     = [0, 1, 2, 3, 4]

oos_curves = []            # krzywe out-of-sample z każdego folda
fold, start_test = 0, MIN_TRAIN + EMBARGO
while start_test + TEST_LEN <= N:
    tr = prices[: start_test - EMBARGO]          # trening: wszystko do embarga
    te = prices[start_test : start_test + TEST_LEN]

    fold_eq = []
    for seed in SEEDS:                           # >=5 ziaren - patrz moduł 5
        env = DummyVecEnv([lambda: TradingEnv(tr)])
        env = VecNormalize(env, clip_obs=10.0)
        m = PPO("MlpPolicy", env, seed=seed, device="cpu", verbose=0,
                policy_kwargs=dict(net_arch=[128, 128]))
        m.learn(total_timesteps=300_000)

        # ewaluacja na oknie testowym z zamrożoną normalizacją
        tenv = DummyVecEnv([lambda: TradingEnv(te)])
        tenv = VecNormalize(tenv, training=False, norm_reward=False)
        tenv.obs_rms = env.obs_rms               # statystyki WYŁĄCZNIE z treningu
        obs, done, eq = tenv.reset(), False, 0.0
        while not done:
            a, _ = m.predict(obs, deterministic=True)
            obs, r, d, infos = tenv.step(a)
            done = d[0]; eq = infos[0]["equity"]
        fold_eq.append(eq)

    med = float(np.median(fold_eq))
    print(f"fold {fold}: mediana OOS = {med:+.4f}  (rozrzut {np.ptp(fold_eq):.4f})")
    oos_curves.append(med)
    start_test += TEST_LEN                       # przesuwamy okno o długość testu
    fold += 1

print(f"\nŁączny OOS log-return: {sum(oos_curves):+.4f} w {fold} foldach")
print("Foldy dodatnie:", sum(c > 0 for c in oos_curves), "/", fold)
Ile to kosztuje obliczeniowo

6 foldów × 5 ziaren × 300k kroków = 9M kroków treningu. Na M3 to rząd wielkości kilku–kilkunastu godzin na CPU. Praktyki: puszczaj na noc (caffeinate -i python 08_walkforward.py — nie pozwala Macowi zasnąć), zrównolegl foldy przez multiprocessing (M3 ma dużo rdzeni, a 128 GB RAM pomieści wiele środowisk naraz), zapisuj wyniki do CSV po każdym foldzie.

Test wrażliwości: strategia musi przeżyć stress

_# Uczciwa strategia nie umiera od drobnej zmiany założeń. Przetestuj macierz:
for fee_mult in (1.0, 1.5, 2.0):        # a jeśli koszty są 2x wyższe?
    for delay in (0, 1):                 # a jeśli egzekucja spóźnia się o świecę?
        for slip in (0.0002, 0.0005):    # a przy gorszym poślizgu?
            run_backtest(fee=BASE_FEE * fee_mult, exec_delay=delay, slippage=slip)
_# Jeśli zysk znika przy fee_mult=1.5 - nie masz strategii, masz artefakt kosztowy.

Checklist przed uwierzeniem we własny backtest

  1. Agent losowy w env traci ~koszty? ✓
  2. Egzekucja po open t+1, cechy bez przyszłości, scaler z train? ✓
  3. Koszty: prowizja + poślizg (+ funding dla perpetuali)? ✓
  4. Walk-forward min. 5 foldów, w tym co najmniej jeden kryzysowy okres? ✓
  5. ≥5 ziaren, raportujesz medianę? ✓
  6. Wynik przeżywa 2× koszty i 1 świecę opóźnienia? ✓
  7. Policzyłeś, ile konfiguracji przetestowałeś (do deflated Sharpe)? ✓
Sprawdź się

Po co przerwa (embargo) między oknem treningowym a testowym w walk-forward?

Nawet przy poprawnym podziale w czasie, próbki tuż przy granicy są niemal identyczne z tymi po drugiej stronie z powodu autokorelacji. Embargo wycina ten pas, żeby test nie „podglądał" treningu przez bliskość. To ta sama logika, co purged k-fold u Lópeza de Prado.

Testujesz 100 wariantów strategii na tej samej walidacji i wybierasz najlepszy. Na czym polega problem?

Przy wielu próbach na tym samym zbiorze rekordzista często wygrywa szumem, nie realną przewagą — to p-hacking. Sama walidacja przestaje być niezależna. Ratunkiem jest liczenie prób, korekta Sharpe'a i nietknięty holdout otwierany raz na końcu.

Którą krzywą wolno Ci pokazać jako wynik strategii?

Wiarygodny jest tylko wynik na danych, których model nie widział podczas uczenia ani strojenia — czyli sklejone okna OOS. Krzywa treningowa i „najlepsza z wielu" są skażone przeuczeniem i selekcją, a wykres z całości miesza dane in-sample z testem.
Moduł 8 · Rzetelność · ~45 min

Ewaluacja: metryki, benchmarki i DCA

Cel: mierzyć strategię jak profesjonalista — ryzykiem, nie tylko zwrotem — i zawsze porównywać z tanimi benchmarkami, które trzeba najpierw pokonać.

Metryki, które musisz liczyć

Sharpe ratio (annualizowany) SR = ( μr − rf ) / σr · √K    (K = liczba okresów w roku: 252 dla dziennych, 8760 dla 1h w krypto)
MetrykaCo mierzyInterpretacja praktyczna
CAGRŚrednioroczny zwrot składanyBez kontekstu ryzyka — nigdy solo
SharpeZwrot na jednostkę zmienności<0.5 słabo · 0.5–1 OK · 1–2 dobrze · >2 w backteście = podejrzane
SortinoJak Sharpe, ale karze tylko zmienność w dółLepszy przy asymetrycznych strategiach
Max drawdownNajwiększe obsunięcie od szczytuCzy przeżyjesz psychicznie i kapitałowo? −50% wymaga +100%, by wyjść
CalmarCAGR / |MaxDD|Zwrot na jednostkę bólu; >1 to solidnie
Obrót (turnover)Suma |zmian pozycji| na rokGłówny mnożnik kosztów; porównuj strategie przy tym samym obrocie
Deflated Sharpe (Bailey & López de Prado)Sharpe po korekcie na liczbę prób i nienormalność zwrotówAntidotum na p-hacking: przy 100 testowanych wariantach najlepszy Sharpe 1.5 może być statystycznie niczym
"""Komplet metryk z prostych log-zwrotów strategii. K = okresów w roku."""
import numpy as np

def metrics(log_rets: np.ndarray, K: int = 8760) -> dict:
    eq   = np.exp(np.cumsum(log_rets))            # krzywa kapitału (start = 1.0)
    years = len(log_rets) / K
    cagr = eq[-1] ** (1 / years) - 1

    mu, sd = log_rets.mean(), log_rets.std(ddof=1)
    sharpe = mu / (sd + 1e-12) * np.sqrt(K)

    # Downside deviation (kanonicznie): pierwiastek ŚREDNIEJ kwadratów zwrotów
    # poniżej celu 0, uśrednianej po WSZYSTKICH okresach. Popularny skrót
    # log_rets[log_rets<0].std() zaniża Sortino i daje NaN przy braku strat.
    downside = np.sqrt(np.mean(np.minimum(log_rets, 0.0) ** 2))
    sortino  = mu / (downside + 1e-12) * np.sqrt(K)

    peak = np.maximum.accumulate(eq)              # dotychczasowy szczyt
    dd   = eq / peak - 1                          # krzywa "podwodna"
    mdd  = dd.min()

    return dict(CAGR=cagr, Sharpe=sharpe, Sortino=sortino,
                MaxDD=mdd, Calmar=cagr / abs(mdd + 1e-12),
                n_periods=len(log_rets))

Deflated Sharpe: ile jest wart najlepszy wynik z N prób

Obiecaliśmy w module 7, że dowieziemy antidotum na p-hacking — oto ono, w całości. Idea Baileya i Lópeza de Prado: jeśli przetestowałeś N wariantów strategii na tych samych danych, najlepszy Sharpe jest zawyżony przez samo maksimum z N losowań. Deflated Sharpe Ratio odpowiada na pytanie: „jakie jest prawdopodobieństwo, że prawdziwy Sharpe najlepszego wariantu jest większy od zera, biorąc pod uwagę, ile razy próbowałem i jak nienormalne są zwroty?"

"""Deflated Sharpe Ratio (Bailey & López de Prado, 2014).
Wymaga: uv pip install scipy. Wszystkie Sharpe'y PER OKRES (nieanualizowane!)."""
import numpy as np
from scipy.stats import norm, skew, kurtosis

EULER = 0.5772156649                    # stała Eulera-Mascheroniego

def deflated_sharpe(rets_best: np.ndarray, sr_all: np.ndarray) -> float:
    """rets_best: zwroty NAJLEPSZEGO wariantu; sr_all: Sharpe'y (per okres)
    WSZYSTKICH N testowanych wariantów. Zwraca P(prawdziwy Sharpe > 0)."""
    T  = len(rets_best)
    sr = rets_best.mean() / rets_best.std(ddof=1)          # Sharpe per okres
    n  = len(sr_all)

    # 1) Benchmark: oczekiwane MAKSIMUM Sharpe'a z n losowań czystego szumu
    #    o rozrzucie takim, jak rozrzut Twoich wyników (wzór na E[max] z EVT)
    sr0 = np.std(sr_all, ddof=1) * (
        (1 - EULER) * norm.ppf(1 - 1/n) + EULER * norm.ppf(1 - 1/(n*np.e)))

    # 2) Probabilistic Sharpe Ratio względem tego benchmarku,
    #    z korektą na skośność i kurtozę zwrotów (grube ogony!)
    g3, g4 = skew(rets_best), kurtosis(rets_best, fisher=False)
    num = (sr - sr0) * np.sqrt(T - 1)
    den = np.sqrt(1 - g3*sr + (g4 - 1)/4 * sr**2)
    return float(norm.cdf(num / den))

_# Przykład: 100 wariantów agenta, najlepszy ma anualizowany Sharpe 1.5 na 1 roku 1h
_#   dsr = deflated_sharpe(best_rets, np.array(all_sharpes))
_# Interpretacja: dsr > 0.95 - edge prawdopodobnie prawdziwy;
_#                dsr ~ 0.5  - najlepszy wariant nieodróżnialny od szczęścia.
Licz każdą próbę — również te „nieudane"

N to liczba wszystkich konfiguracji, które oceniłeś na tej samej walidacji: seedy, architektury, funkcje nagrody, zestawy cech, „szybkie sprawdzenia". Dziennik eksperymentów z modułu 6 właśnie po to istnieje. Typowy szok: przy N=100 i rocznych danych 1h oczekiwane maksimum Sharpe'a z czystego szumu przekracza 1 — Twój „świetny" wynik może być dokładnie tym.

Drawdown: poznaj swojego wroga

Drawdown to metryka, którą czujesz. Strategia z CAGR 30% i drawdownem 60% jest w praktyce nieużywalna — porzucisz ją (albo zbankrutujesz na dźwigni) na dnie obsunięcia, tuż przed odbiciem. Poeksploruj krzywą kapitału poniżej: najedź myszką, zobacz głębokość i długość obsunięć (czas pod wodą bywa gorszy niż głębokość).

Eksplorator: krzywa kapitału i drawdown interaktywne
kapitał szczyt kroczący drawdown (pod wodą)
max drawdown
najdłużej pod wodą
sharpe

Suwak zmienności pokazuje brutalną prawdę: przy tym samym średnim zwrocie wyższa zmienność = głębsze doły i niższy kapitał końcowy (koszt wariancji: exp(μ−σ²/2)). To dlatego profesjonaliści optymalizują Sharpe'a, nie zwrot.

Benchmarki: tani przeciwnik, którego musisz pobić

Wynik agenta RL bez punktu odniesienia jest bez znaczenia. Zanim powiesz „agent zarobił 40%", odpowiedz: a ile zarobiłby idiota? Trzy obowiązkowe benchmarki:

  • Buy & hold — kup pierwszego dnia, nic nie rób. Zero kosztów, zero pracy. Na rynkach z długim dryfem w górę (akcje US, BTC historycznie) to zaskakująco mocny przeciwnik.
  • DCA (dollar-cost averaging) — kupuj za stałą kwotę co interwał (np. co tydzień), niezależnie od ceny. Uśrednia cenę wejścia, redukuje ryzyko złego timingu i wariancję wyniku względem wejścia jednorazowego. To benchmark „rozsądnego pasywnego inwestora" — i strategia, którą realnie warto stosować prywatnie, kiedy Twoje modele jeszcze nie są gotowe.
  • Momentum SMA — long, gdy cena nad średnią 200-okresową, flat pod nią. Reprezentuje „najprostszą aktywną strategię z sensem". Jeśli RL z tygodniami treningu nie bije 3 linijek kodu — po co Ci RL?
"""Trzy benchmarki na tych samych danych i kosztach co agent. Zawsze."""
import numpy as np, pandas as pd
from metrics import metrics

df   = pd.read_parquet("data/test.parquet")      # dopiero teraz, RAZ (moduł 3!)
ret  = np.log(df.close / df.close.shift(1)).dropna().to_numpy()
COST = 0.0006                                    # te same założenia co w env

_# 1) Buy & hold: pełna ekspozycja od pierwszej świecy
bh = ret.copy();  bh[0] -= COST

_# 2) DCA: co tydzień (168h) dokupujemy 1/N docelowej pozycji.
_#    Ekspozycja rośnie schodkowo 0 -> 1, więc wczesne zwroty ważą mniej.
N_TRANCHES = 52
expo = np.minimum(np.arange(len(ret)) // 168 + 1, N_TRANCHES) / N_TRANCHES
dca  = expo * ret
dca[::168][:N_TRANCHES] -= COST / N_TRANCHES     # koszt każdej transzy

_# 3) Momentum: long nad SMA200 (liczoną z danych DO t, pozycja działa od t+1)
sma    = pd.Series(df.close).rolling(200).mean().to_numpy()
signal = (df.close.to_numpy() > sma).astype(float)[:-1]     # sygnał w t...
signal = np.nan_to_num(signal)
mom    = signal * ret[len(ret)-len(signal):]                 # ...zwrot t->t+1
mom   -= np.abs(np.diff(signal, prepend=0.0)) * COST         # koszt zmian pozycji

for name, r in [("Buy&Hold", bh), ("DCA 52 transze", dca), ("SMA200 momentum", mom)]:
    m = metrics(r)
    print(f"{name:16} CAGR {m['CAGR']:+7.1%}  Sharpe {m['Sharpe']:5.2f}  "
          f"MaxDD {m['MaxDD']:6.1%}  Calmar {m['Calmar']:5.2f}")
DCA — intuicja i granice

DCA nie zwiększa oczekiwanego zwrotu (matematycznie na rynku z dodatnim dryfem wejście od razu ma wyższą średnią) — DCA redukuje wariancję żalu: nigdy nie wchodzisz całością na szczycie. Dla agenta RL to świetny benchmark, bo reprezentuje wynik osiągalny bez żadnej informacji o rynku. Twój agent ma dostęp do informacji — jeśli nie bije DCA po kosztach, informacji nie wykorzystuje.

Benchmarki na TYM SAMYM okresie co agent

Kod wyżej liczy benchmarki na pojedynczym holdoucie — dla uproszczenia. Gdy przejdziesz na walk-forward (moduł 7), benchmarki musisz policzyć na sklejce tych samych okien OOS, na której oceniasz agenta. Porównanie „agent na OOS vs B&H na całej historii" to porównanie dwóch różnych rynków — i kolejny sposób na okłamanie siebie.

Zanim policzysz to na własnych danych, poczuj mechanikę na symulacji — ile edge'u naprawdę trzeba, żeby po kosztach pobić „głupie" benchmarki:

Zanim uruchomisz

Agent trafia kierunek w 51% kroków (edge 1%), handluje co krok i płaci 5 bps na transakcję. Pobije buy&hold?

Edge 1% na krok daje ~0.01·σ_krok oczekiwanego zwrotu, a 5 bps kosztu płacisz przy każdej zmianie pozycji — na danych dziennych koszt przewyższa przewagę kilkukrotnie. Dlatego obrót (turnover) jest w mianowniku każdej realnej strategii.
Agent RL kontra proste benchmarki (po kosztach) interaktywne
Buy & Hold DCA (20 transz) SMA-momentum 50 agent RL
Buy&Hold · Sharpe / MaxDD
DCA · Sharpe / MaxDD
SMA · Sharpe / MaxDD
agent · Sharpe / MaxDD

Przy edge 0 agent PRZEGRYWA dokładnie o koszty — krzywa osypuje się liniowo w skali log. Pokręć suwakiem edge i sprawdź, ile realnej przewagi trzeba, by po 5 bps kosztu pobić choćby DCA. To jest poprzeczka całego kursu: nie „czy agent zarabia", tylko „czy zarabia więcej niż tępy benchmark PO KOSZTACH".

Werdykt: jak porównać agenta z benchmarkami

  1. Te same dane, te same koszty, ten sam okres OOS (sklejka walk-forward).
  2. Porównuj Sharpe i Calmar, nie sam zwrot — agent może „wygrywać" zwrotem, biorąc 3× większe ryzyko.
  3. Policz deflated Sharpe kodem z tego modułu, z uczciwym N (dziennik eksperymentów!). Poniżej 0.95 nie masz podstaw twierdzić, że edge istnieje.
  4. Sprawdź stabilność między foldami: agent lepszy w 5/6 foldów > agent lepszy średnio dzięki jednemu foldowi.
Sprawdź się

Agent zarobił 40%, benchmark 30%. Dlaczego to jeszcze nie znaczy, że agent jest lepszy?

Wyższy zwrot osiągnięty przy 3× większej zmienności czy głębszym drawdownie nie jest przewagą — jest lewarem. Dlatego porównuje się metryki uwzględniające ryzyko: Sharpe i Calmar. Benchmarki liczymy zresztą przy tych samych kosztach co agenta.

Po co liczyć deflated Sharpe zamiast zwykłego Sharpe'a?

Deflated Sharpe to antidotum na p-hacking: im więcej konfiguracji przetestowałeś, tym wyższy Sharpe może paść czystym przypadkiem, więc korygujemy próg o liczbę prób i grube ogony. Annualizacja i odejmowanie stopy wolnej to zwykłe elementy podstawowego Sharpe'a.

Twój agent po kosztach nie bije DCA. Co to mówi?

DCA nie zna rynku — kupuje mechanicznie za stałą kwotę. Jeśli agent z dostępem do cech i historii go nie bije po kosztach, znaczy to, że nie zamienia informacji na przewagę. Dłuższy trening bez sygnału tego nie naprawi.
Moduł 9 · Produkcja · ~50 min

Specyfika krypto: perpetuale, funding, testnet

Cel: zrozumieć mikrostrukturę rynku krypto — i przenieść agenta z backtestu na paper trading na testnecie Binance.

Czym krypto różni się od akcji (dla modelu)

CechaAkcjeKryptoKonsekwencja dla RL
Godziny handluSesje + luki nocne/weekendowe24/7/365Brak luk = czystsze dane; ale reżimy azjatycki/US w cyklu dobowym (cechy hod_sin/cos!)
ShortPożyczka akcji, koszty, ograniczeniaPerpetual futures — natywnieAkcja short jest realistyczna; ale dochodzi funding
KosztyProwizje niskie, spread wąski (large cap)Taker 0.04–0.1%, maker bywa ujemny; spread zależny od coinaModel kosztów MUSI być per-giełda i per-instrument
Zmienność~15–25% rocznie (indeksy)60–100%+ (BTC), więcej na altachGrubsze ogony; sizing pozycji ważniejszy niż kierunek
ManipulacjeRegulowanePump&dump, wash trading na małych coinachTrzymaj się top płynności: BTC, ETH; wolumen altów bywa fikcją

Funding rate: koszt, o którym zapominają wszyscy

Perpetual futures nie wygasają — ich cena jest wiązana ze spotem przez funding: co 8h (Binance) posiadacze pozycji po „przeciążonej" stronie płacą drugiej stronie. Historycznie funding BTC jest przeważnie dodatni (longi płacą shortom) i wynosi ~0.01%/8h — to ~11% rocznie kosztu trzymania longa w spokojnych warunkach, a w euforii potrafi być wielokrotnie wyższy. Strategia long-only na perpetualach bez uwzględnienia fundingu to systematyczne zawyżanie backtestu.

"""Pobierz historię funding rate i dolicz do środowiska."""
import ccxt, pandas as pd

ex = ccxt.binanceusdm()                        # rynek USD-M futures
rows, since = [], ex.parse8601("2020-01-01T00:00:00Z")
while True:
    batch = ex.fetch_funding_rate_history("BTC/USDT:USDT", since=since, limit=1000)
    if not batch: break
    rows += batch
    since = batch[-1]["timestamp"] + 1

f = pd.DataFrame([{"ts": r["timestamp"], "rate": r["fundingRate"]} for r in rows])
f["ts"] = pd.to_datetime(f.ts, unit="ms", utc=True)
f.set_index("ts").to_parquet("data/funding_btc.parquet")
print(f"Średni funding/8h: {f.rate.mean():.5%}  => rocznie ~{f.rate.mean()*3*365:.1%}")

_# W środowisku (step, co 8. świeca 1h):
_#   reward -= self.position * funding_rate_t   # long płaci przy dodatnim fundingu

Open interest, imbalance i basis: mikrostruktura jako cechy

Krypto daje za darmo dane, za które na akcjach płaci się krocie. Trzy najtańsze do policzenia, o realnej wartości informacyjnej:

  • Open interest (OI) — liczba otwartych kontraktów, czyli ile lewarowanego kapitału siedzi w rynku (ex.fetch_open_interest_history()). Rosnące OI + skrajny funding = rynek przegrzany i „naładowany" pod kaskadę likwidacji. Jako cecha: log-zmiana OI, znormalizowana jak wszystko w module 6.
  • Orderbook imbalance (OBI) — (Vbid − Vask) / (Vbid + Vask) na szczycie książki (lub kilku poziomach). Krótkoterminowy predyktor kierunku — im bardziej jednostronna presja, tym bardziej prawdopodobny ruch w jej stronę. Wymaga zbierania snapshotów L2 na bieżąco (nie kupisz historii za darmo) — dobry projekt „po kursie".
  • Basis — różnica cen perpetual − spot. Wysoki dodatni basis = euforia lewaru; ujemny = strach. Tani wskaźnik reżimu sentymentu, komplementarny do vol_regime z modułu 6.

Kaskady likwidacji: anatomia 10 października 2025

Likwidacja to przymusowe zamknięcie pozycji, gdy strata zjada margines. Problem w tym, że likwidacje pchają cenę w stronę kolejnych progów likwidacyjnych — reakcja łańcuchowa. Studium przypadku, które powinieneś znać na pamięć: 10.10.2025, po zapowiedzi 100% ceł USA na Chiny, w ciągu doby zlikwidowano ponad 19 mld USD pozycji na ponad 1.6 mln kont. W kulminacyjnych 40 minutach — prawie 7 mld USD. BTC spadł o ~14.5%, niektóre alty chwilowo o 40%+. Kluczowa obserwacja dla Twoich modeli: głębokość szczytu książki zleceń spadła o ponad 90%, a spready z pojedynczych punktów bazowych urosły do dwucyfrowych procentów.

  • Lekcja 1 — model kosztów zależny od reżimu: stały poślizg 0.02% z modułu 4 jest OK w spokoju i absurdalnie optymistyczny w stresie. Minimalny krok: poślizg proporcjonalny do bieżącej zmienności (slippage = k·σt), wpięty w env.
  • Lekcja 2 — płynność znika, gdy jej potrzebujesz: stop-loss „rynkowy" w kaskadzie wykonuje się o procenty od ceny wyzwolenia. Risk layer (moduł 10) musi zakładać scenariusz „nie wyjdę po dobrej cenie".
  • Lekcja 3 — cross-margin wiąże Cię z najsłabszym aktywem: strukturalnym paliwem 10.10 był unified margin — pod stresem cały portfel jest tak mocny, jak najbardziej lewarowana noga. Preferuj margines izolowany, dopóki nie wiesz dokładnie, co robisz.

Sezonowość dobowa: rynek ma rytm

Pamiętasz cechy hod_sin/hod_cos z modułu 6? To nie dekoracja. Rynek krypto działa 24/7, ale kapitał nie: badania sezonowości BTC pokazują koncentrację najsilniejszych zwrotów w oknie 21:00–23:00 UTC (po zamknięciu sesji US) oraz wyraźne efekty przełomu tygodnia (niedziela ~23:00 UTC, otwarcie Azji w poniedziałek). Zmienność też ma rytm dobowy: minimum w nocy azjatyckiej, szczyt na otwarciu US. Wniosek praktyczny: godzina i dzień tygodnia to tanie, legalne (bez lookahead) cechy — a jeśli Twój agent nauczył się „handlować tylko o 21 UTC", wiedz, że odkrył znaną anomalię, nie magię.

Paper trading na testnecie Binance

Zanim jakiekolwiek prawdziwe środki: testnet — pełne API Binance Futures na wirtualnych środkach. Załóż konto na testnet.binancefuture.com, wygeneruj klucze API i wpnij agenta w pętlę na żywo:

_# Klucze trzymaj w zmiennych środowiskowych, NIGDY w kodzie / repo
export BINANCE_TESTNET_KEY="twoj_klucz"
export BINANCE_TESTNET_SECRET="twoj_sekret"
"""Pętla paper-tradingu: co godzinę obserwacja -> decyzja agenta -> zlecenie na testnet.
Uruchamiaj pod launchd/cron albo w tmux; loguj WSZYSTKO."""
import os, time, logging, ccxt, numpy as np, pandas as pd
from stable_baselines3 import PPO

logging.basicConfig(filename="paper.log", level=logging.INFO,
                    format="%(asctime)s %(message)s")

ex = ccxt.binanceusdm({
    "apiKey": os.environ["BINANCE_TESTNET_KEY"],
    "secret": os.environ["BINANCE_TESTNET_SECRET"],
})
ex.set_sandbox_mode(True)                      # <- przełącznik na testnet

model  = PPO.load("models/ppo_btc_v1")
SYMBOL, POS_USD = "BTC/USDT:USDT", 1000        # stała wielkość pozycji w USD

def build_obs():
    """Ta sama transformacja co w treningu - co do bitu.
    Rozjazd trening/produkcja w preprocessing to klasyczna cicha śmierć."""
    # PUŁAPKA: fetch_ohlcv zwraca bieżącą, wciąż OTWARTĄ świecę jako ostatnią.
    # Budzimy się o :00:05, więc jej close ~ open i ostatni zwrot byłby ~0
    # w KAŻDEJ obserwacji - czego w treningu nie było. Bierzemy 41 i tniemy.
    ohlcv = ex.fetch_ohlcv(SYMBOL, "1h", limit=41)
    close = np.array([c[4] for c in ohlcv], dtype=np.float64)[:-1]
    r = np.diff(np.log(close))[-32:] * 100.0
    pos = get_current_position()
    return np.append(r, pos).astype(np.float32)

def get_current_position() -> int:
    for p in ex.fetch_positions([SYMBOL]):
        amt = float(p["info"].get("positionAmt", 0))
        if amt: return int(np.sign(amt))
    return 0

def set_position(target: int, price: float):
    current = get_current_position()
    if target == current: return               # nic do zrobienia
    qty = round(POS_USD / price, 3)
    if current != 0:                            # najpierw zamknij obecną
        side = "sell" if current > 0 else "buy"
        ex.create_order(SYMBOL, "market", side, qty, params={"reduceOnly": True})
    if target != 0:                             # potem otwórz docelową
        ex.create_order(SYMBOL, "market", "buy" if target > 0 else "sell", qty)
    logging.info(f"pozycja {current} -> {target} @ {price}")

while True:
    try:
        obs = build_obs()
        action, _ = model.predict(obs, deterministic=True)
        price  = ex.fetch_ticker(SYMBOL)["last"]
        target = {0: 0, 1: 1, 2: -1}[int(action)]
        set_position(target, price)
    except Exception as e:                      # sieć/API padnie - to pewne
        logging.exception(f"błąd pętli: {e}")
    # śpimy do pełnej godziny + 5 s (świeca musi się domknąć)
    time.sleep(3600 - time.time() % 3600 + 5)
Minimum 4–8 tygodni paper tradingu

Porównuj co tydzień: wynik paper vs wynik z backtestu na tych samych świecach. Rozjazd > kosztów transakcyjnych = masz błąd w pipeline (najczęściej: inna normalizacja obserwacji, inna cena egzekucji, strefa czasowa). Dopiero zgodność paper↔backtest daje moralne prawo myśleć o realnych środkach — małych.

A co z wielkością pozycji i dźwignią?

Sizing, kryterium Kelly'ego, vol targeting, kill-switche i matematyka likwidacji dostały własny moduł 10 — Risk management i sizing. To nie jest dodatek do strategii, tylko warstwa, która decyduje, czy przeżyjesz własnego agenta.

Sprawdź się

Strategia long-only na perpetualach, backtest bez fundingu. Co jest nie tak?

Perpetuale są wiązane ze spotem przez funding płacony co 8h między stronami. Historycznie na BTC longi zwykle płacą, więc trzymanie longa kosztuje kilkanaście procent rocznie. Backtest, który to pomija, sztucznie podnosi wynik strategii long-only.

Wynik paper tradingu rozjeżdża się z backtestem na tych samych świecach o więcej niż koszty. Najczęstsza przyczyna?

Jeśli te same świece dają różne wyniki, problem nie leży w treningu, lecz w tym, że produkcja liczy obserwację lub egzekucję inaczej niż backtest. Dopiero zgodność paper↔backtest daje moralne prawo myśleć o realnych środkach.

Krypto ma zmienność 60–100%+ i grubsze ogony niż akcje. Praktyczna konsekwencja dla agenta?

Grube ogony znaczą, że rzadkie, ekstremalne ruchy dominują wynik, więc to jak dużo ryzykujesz waży bardziej niż to, czy zgadłeś kierunek. Koszty nadal się liczą, a zwroty nie są normalne — dlatego samo Sharpe nie oddaje ryzyka ogona.
Moduł 10 · Produkcja · ~45 min

Risk management i sizing: warstwa nad agentem

Cel: zbudować osobną, testowalną warstwę kodu, która decyduje ILE ryzykować i kiedy zablokować agenta — bo o przetrwaniu decyduje sizing, nie kierunek.

Filozofia: agent proponuje, risk layer dysponuje

Do tej pory agent był całym systemem: obserwacja wchodzi, akcja wychodzi, akcja to pozycja. To wystarczało w backteście. W produkcji to przepis na katastrofę, bo model nie ma pojęcia o kapitale, o dzisiejszej stracie ani o tym, że właśnie zwariował. Profesjonalny system rozdziela dwie decyzje, które amator skleja w jedną: w którą stronę (to należy do agenta) i ile (to należy do warstwy ryzyka).

Risk layer to osobny kawałek kodu, który stoi między agentem a giełdą. Dostaje surową akcję modelu i stan portfela, a zwraca akcję przeskalowaną i przyciętą — albo blokadę. Kluczowa własność: jest prosty, deterministyczny i pokryty testami. Agent to czarna skrzynka, której nie ufasz do końca. Warstwa ryzyka to biała skrzynka, którą czytasz linia po linii i której ufasz właśnie dlatego, że nic się w niej nie uczy.

Dlaczego to musi być osobna warstwa

Gdybyś próbował „nauczyć" agenta ryzyka przez funkcję nagrody, dostałbyś ryzyko probabilistyczne — działa średnio, zawodzi w ogonie. A ogon to jedyne, co Cię zabija. Twarde ograniczenia muszą być twarde: egzekwuje je kod, którego model nie kontroluje i nie może obejść.

Volatility targeting: stała ekspozycja na ryzyko, nie na kapitał

Największy błąd początkującego to stała wielkość pozycji („zawsze 1000 USD"). Problem: ta sama pozycja niesie zupełnie inne ryzyko, gdy BTC robi 1% dziennie, a inne, gdy robi 8%. Vol targeting odwraca logikę: ustalasz docelową zmienność portfela i skalujesz ekspozycję tak, by ją utrzymać.

Skalowanie ekspozycji skala = σtarget / σrealized

Przykład: celujesz w 10% zmienności rocznej, a rynek realizuje właśnie 15%. Wtedy skala = 10/15 ≈ 0.67 — trzymasz 67% nominalnej pozycji. Gdy rynek się uspokoi do 5%, skala = 10/5 = 2.0 i zwiększasz ekspozycję. Efekt jest kontrintuicyjnie dobry: automatycznie chowasz się w chaosie i odważasz w spokoju, bez żadnej prognozy.

To nie jest folklor — to jeden z niewielu efektów w finansach, który powtarza się na dekadach danych. Na indeksie S&P 500 nałożenie vol-targetingu podniosło Sharpe z 0.43 do 0.61 i obniżyło maksymalne obsunięcie z 52% do 24% — czyli poprawiło zwrot na jednostkę ryzyka i jednocześnie o połowę ścięło najgorszy scenariusz. Powód jest strukturalny: zmienność jest silnie autoskorelowana (skupia się w klastry), więc wczorajsza wysoka zmienność realnie przewiduje jutrzejszą. Kierunku nie umiesz przewidzieć, ale skalę — tak.

Kalibracja targetu do klasy aktywów

Typowe targety to 8–12% rocznie dla akcji. Dla BTC, którego surowa zmienność to 60–100% rocznie, celowanie w 10% oznacza trzymanie ułamka nominalnej pozycji przez większość czasu — i o to chodzi. Nie przenoś targetu z akcji na krypto bezmyślnie: ustaw go świadomie znacznie poniżej nominalnej zmienności instrumentu, bo inaczej vol-targeting nic nie ogranicza.

"""Volatility targeting: skaluj ekspozycję do docelowej zmienności.
Zmienność szacujemy EWMA - reaguje szybciej niż okno kroczące,
bo nowszym zwrotom daje większą wagę."""
import numpy as np

def realized_vol_ewma(log_ret: np.ndarray, halflife: int = 20,
                       periods_per_year: int = 24 * 365) -> float:
    """Zannualizowana zmienność z EWMA na zwrotach log.
    halflife w krokach (dla świec 1h: 20 = ~20h 'pamięci').
    periods_per_year: 24*365 dla 1h krypto, 252 dla dziennych akcji."""
    if len(log_ret) < 2:
        return np.nan
    alpha = 1.0 - 0.5 ** (1.0 / halflife)          # waga najnowszej obserwacji
    weights = (1 - alpha) ** np.arange(len(log_ret))[::-1]
    weights /= weights.sum()
    mean = np.average(log_ret, weights=weights)
    var  = np.average((log_ret - mean) ** 2, weights=weights)
    return np.sqrt(var * periods_per_year)         # sigma per krok -> roczna

def vol_target_scale(log_ret: np.ndarray, sigma_target: float = 0.10,
                     max_scale: float = 3.0) -> float:
    """Zwraca mnożnik ekspozycji w [0, max_scale].
    sigma_target: docelowa zmienność ROCZNA (0.10 = 10%).
    max_scale: twardy cap, by w martwym rynku nie wziąć absurdalnej dźwigni."""
    sigma_real = realized_vol_ewma(log_ret)
    if not np.isfinite(sigma_real) or sigma_real <= 0:
        return 0.0                                 # brak wiarygodnej estymaty = brak pozycji
    scale = sigma_target / sigma_real
    return float(np.clip(scale, 0.0, max_scale))

if __name__ == "__main__":
    _# Symulacja: 500 zwrotów 1h o zmienności ~15% rocznej
    rng = np.random.default_rng(0)
    sigma_step = 0.15 / np.sqrt(24 * 365)          # 15% rocznie -> na krok 1h
    r = rng.normal(0, sigma_step, size=500)
    print(f"Zmienność realizowana: {realized_vol_ewma(r):.1%} rocznie")
    print(f"Skala przy targecie 10%: {vol_target_scale(r, 0.10):.2f}")
    _# oczekiwane ~0.67: realized ~15%, target 10% -> 10/15

Kryterium Kelly'ego: teoretyczne optimum, którego nie chcesz grać w całości

Vol targeting mówi, jak skalować pozycję z ryzykiem. Kelly odpowiada na głębsze pytanie: jaki ułamek kapitału maksymalizuje długoterminowe tempo wzrostu? Dla ciągłego przypadku odpowiedź jest zwodniczo prosta:

Ułamek Kelly'ego (ciągły) f* = μ / σ²

gdzie μ to oczekiwany zwrot nadwyżkowy, a σ² to jego wariancja. Intuicja: stawiaj tym więcej, im większa przewaga (μ) i im mniejsza niepewność (σ²). Kelly maksymalizuje oczekiwany logarytm kapitału — czyli geometryczne tempo wzrostu — i w długim biegu bije każdą inną strategię sizingu. Brzmi jak święty Graal. Nie jest.

Haczyk siedzi w tym, że μ i σ nie znasz — estymujesz je z hałaśliwych danych. A Kelly jest brutalnie wrażliwy na błąd w μ, bo wchodzi liniowo do licznika. Jeśli przeszacujesz przewagę dwukrotnie (banalnie łatwe na rynku), grasz podwójny Kelly. A krzywa tempa wzrostu w funkcji ułamka Kelly'ego jest odwróconą parabolą: rośnie do f*, po czym spada, i przecina zero dokładnie przy 2× Kelly. Grając pełnym Kelly'm na przeszacowanej przewadze lądujesz przy zerowym wzroście przy potwornej zmienności — a wszystko powyżej to ruina z prawdopodobieństwem rosnącym do 1.

Dlatego praktycy grają ułamkiem Kelly'ego — najczęściej połówką. Matematyka half-Kelly jest wyjątkowo łaskawa: tniesz zmienność portfela o połowę, a oddajesz tylko ~25% tempa wzrostu. To jedna z najlepszych wymian ryzyka na zwrot, jaką znajdziesz — kupujesz gigantyczny spokój za drobny ułamek potencjału. Poniższe demo pozwala poczuć tę parabolę na własnej myszce: przesuwasz ułamek Kelly'ego i patrzysz, jak zmienia się mediana kapitału i ryzyko bankructwa w wielu symulowanych ścieżkach.

Kalkulator Kelly'ego: ile stawiać przy przewadze interaktywne
mediana kapitału (40 trajektorii) pojedyncze trajektorie tempo wzrostu g(f)
frakcja Kelly'ego f*
mediana kapitału końc.
% trajektorii ze stratą >50%

Nota dydaktyczna: przesuń „frakcję Kelly'ego" do 50% — połowa zmienności za ok. 3/4 wzrostu (half-Kelly). Powyżej 100% mediana rośnie wolniej, a odsetek „ruiny" gwałtownie skacze; przy ~200% krzywa g(f) schodzi poniżej zera — pełny Kelly na PRZESZACOWANYM edge to matematyczna gwarancja katastrofy.

Reguła kciuka

Na własnej strategii nigdy nie graj więcej niż half-Kelly, a jeśli ufasz swoim estymatom przewagi mniej niż w pełni (a powinieneś) — ćwierć-Kelly. Vol targeting i Kelly nie są konkurencją: vol targeting utrzymuje stałe ryzyko w czasie, ułamek Kelly'ego ustala jego globalny poziom.

Dźwignia i cena likwidacji: arytmetyka, która kończy konta

Na perpetualach dźwignia jest zawsze pod ręką i zawsze kusi. Zanim jej dotkniesz, musisz umieć policzyć w głowie jedną rzecz: jak blisko jest likwidacja. Przy dźwigni L i marginesie izolowanym cena likwidacji leży w przybliżeniu o ruch ~1/L przeciw Twojej pozycji (pomijając prowizje i margin utrzymaniowy, które przybliżają ją jeszcze bardziej).

Przybliżony ruch do likwidacji ruchlikwidacja ≈ 1 / L

Przy dźwigni 10× wystarczy ruch 10% przeciw Tobie — na BTC to kwestia jednego nerwowego popołudnia. Przy 20× to 5%, czyli szum wewnątrzdzienny. Dźwignia nie zwiększa Twojej przewagi ani o jotę — mnoży wyłącznie zmienność i przybliża ścianę, o którą się rozbijasz. Demo poniżej pokazuje, jak cena likwidacji zaciska się wokół ceny wejścia wraz z rosnącą dźwignią.

Zanim uruchomisz

Long BTC z dźwignią 20×, zmienność dzienna 3%. Jakie jest ryzyko likwidacji w ciągu 2 tygodni?

Próg likwidacji przy 20× to ledwie −5%. Przy σ=3%/dzień odchylenie po 14 dniach to ~11% — ruch −5% w którymkolwiek momencie jest niemal pewny. Dźwignia nie zwiększa edge, zwiększa prawdopodobieństwo wypadnięcia z gry.
Dźwignia i likwidacja: ile ruchu wystarczy interaktywne
ścieżki, które przeżyły ścieżki zlikwidowane próg likwidacji
cena likwidacji (od wejścia)
% ścieżek zlikwidowanych
mediana PnL przeżywających

Nota dydaktyczna: przy 20× próg likwidacji to raptem −5%, a dzienna zmienność BTC to 2–4% — likwidacja jest kwestią dni, nie pecha. Zmniejsz dźwignię do 3–5×, żeby zobaczyć, jak odsetek zlikwidowanych ścieżek spada niemal do zera. Kaskady z 10.10.2025 (19 mld USD w dobę) zaczynały się dokładnie tak.

Izolowany kontra cross margin

W trybie izolowanym likwidacja jednej pozycji zjada tylko margin do niej przypisany — reszta portfela żyje. W trybie cross cały kapitał konta jest wspólnym zabezpieczeniem, więc jedna pozycja idąca pod wodę może pociągnąć za sobą całe konto. Cross wiąże cały portfel z losem najsłabszego aktywa. Domyślnie handluj izolowanym, a dźwignię traktuj jak narzędzie sizingu (mniejszy margin na tę samą ekspozycję), nie jak sposób na większą ekspozycję.

Kill-switch i twarde limity: ostatnia linia obrony

Vol targeting i Kelly to sizing „na co dzień". Kill-switch to bezpiecznik na dzień, w którym coś pójdzie fundamentalnie nie tak: bug w danych, model trafi na reżim spoza treningu, albo giełda zwróci śmieci. Twarde limity to nie sugestie — to progi, po przekroczeniu których system przestaje handlować, kropka:

  • Max daily loss — np. 2% equity. Spadłeś dziś o 2%? Koniec handlu do jutra. To ochrona przed serią, w której model uparcie powtarza ten sam błąd.
  • Max drawdown vs historyczny — jeśli bieżące obsunięcie przebija najgorsze z backtestu, to nie pech, to sygnał zmiany reżimu. Zatrzymaj i zbadaj, zanim stracisz więcej.
  • Position limits — twardy cap na notional niezależny od tego, co proponuje sizing. Nawet gdy vol targeting policzy skalę 3.0, kod nie przepuści pozycji większej niż ustalony sufit.
  • Sanity checks akcji — akcja to NaN albo poza zakresem? Zleceń więcej niż X na godzinę? Zamykasz pozycję, więc zlecenie musi być reduce-only? Każdy z tych warunków blokuje albo koryguje akcję, zanim dotrze do giełdy.
"""Warstwa ryzyka: stoi między agentem a giełdą.
Bierze surową akcję modelu i stan portfela, zwraca akcję bezpieczną lub BLOCK.
Cała 'inteligencja' jest tu deterministyczna i testowalna - w tym rzecz."""
from dataclasses import dataclass
import numpy as np

BLOCK = None   # sygnał: nie wysyłaj żadnego zlecenia

@dataclass
class RiskLimits:
    max_daily_loss: float = 0.02      # 2% equity - dzienny stop
    max_drawdown:   float = 0.20      # 20% - przebicie = zmiana reżimu
    max_notional:   float = 1.0       # cap ekspozycji jako ułamek equity
    max_orders_per_hour: int = 6      # zbyt częste zlecenia = coś się zapętliło

class RiskLayer:
    def __init__(self, limits: RiskLimits = RiskLimits()):
        self.lim = limits
        self.orders_this_hour = 0

    def check(self, action, state: dict):
        """action: docelowa pozycja z agenta (np. -1..1 po sizingu).
        state: {'equity', 'day_start_equity', 'peak_equity',
                'current_pos', 'closing'}.
        Zwraca akcję (być może przyciętą) albo BLOCK."""
        eq = state["equity"]

        _# 1. Sanity check samej akcji - NaN/inf/poza zakresem nie idzie dalej
        if action is None or not np.isfinite(action):
            return BLOCK
        action = float(np.clip(action, -1.0, 1.0))

        _# 2. Dzienny stop: strata od otwarcia dnia przebiła limit
        daily_pnl = (eq - state["day_start_equity"]) / state["day_start_equity"]
        if daily_pnl <= -self.lim.max_daily_loss:
            return self._flatten_only(action, state)

        _# 3. Drawdown od szczytu equity przebił limit historyczny
        drawdown = (state["peak_equity"] - eq) / state["peak_equity"]
        if drawdown >= self.lim.max_drawdown:
            return self._flatten_only(action, state)

        _# 4. Cap na notional - niezależny od sizingu agenta
        action = float(np.clip(action, -self.lim.max_notional, self.lim.max_notional))

        _# 5. Limit częstotliwości zleceń (chroni przed pętlą wysyłającą w kółko)
        if action != state["current_pos"]:
            if self.orders_this_hour >= self.lim.max_orders_per_hour:
                return BLOCK
            self.orders_this_hour += 1

        _# 6. reduce-only przy zamykaniu: nie pozwól odwrócić pozycji 'przy okazji'
        if state.get("closing") and np.sign(action) == -np.sign(state["current_pos"]):
            action = 0.0                  # domykamy do zera, nie flipujemy

        return action

    def _flatten_only(self, action, state):
        """Tryb awaryjny: dozwolone tylko domykanie pozycji do zera."""
        if state["current_pos"] == 0:
            return BLOCK                  # brak pozycji - nic nie robimy
        return 0.0                        # jest pozycja - domknij ją

    def reset_hour(self):
        self.orders_this_hour = 0

Warstwa ryzyka jest tak ważna, że testujesz ją jak kod lotniczy — każda gałąź ma test. Poniżej minimalny zestaw pytest; w produkcji dopiszesz po jednym teście na każdy limit i każdy tryb awaryjny.

"""Testy warstwy ryzyka. Uruchom: pytest test_risk_layer.py -v"""
import numpy as np
from risk_layer import RiskLayer, RiskLimits, BLOCK

def base_state(**kw):
    s = {"equity": 100.0, "day_start_equity": 100.0, "peak_equity": 100.0,
         "current_pos": 0.0, "closing": False}
    s.update(kw)
    return s

def test_nan_action_is_blocked():
    rl = RiskLayer()
    assert rl.check(np.nan, base_state()) is BLOCK

def test_action_is_clipped_to_notional_cap():
    rl = RiskLayer(RiskLimits(max_notional=0.5))
    _# agent chce 1.0, cap to 0.5 -> oczekujemy 0.5
    assert rl.check(1.0, base_state()) == 0.5

def test_daily_loss_forces_flatten():
    rl = RiskLayer(RiskLimits(max_daily_loss=0.02))
    _# strata 3% dnia, mamy longa -> wolno tylko domknąć do zera
    st = base_state(equity=97.0, current_pos=1.0)
    assert rl.check(1.0, st) == 0.0

def test_daily_loss_with_no_position_blocks_new_trades():
    rl = RiskLayer(RiskLimits(max_daily_loss=0.02))
    st = base_state(equity=97.0, current_pos=0.0)
    assert rl.check(1.0, st) is BLOCK       # po stopie nie otwieramy nowych

def test_drawdown_breach_forces_flatten():
    rl = RiskLayer(RiskLimits(max_drawdown=0.20))
    st = base_state(equity=75.0, peak_equity=100.0, current_pos=-1.0)
    assert rl.check(-1.0, st) == 0.0

def test_order_frequency_limit():
    rl = RiskLayer(RiskLimits(max_orders_per_hour=2))
    st = base_state(current_pos=0.0)
    assert rl.check(1.0, st) == 1.0         # 1. zlecenie ok
    assert rl.check(-1.0, base_state(current_pos=1.0)) == -1.0  # 2. ok
    assert rl.check(1.0, base_state(current_pos=-1.0)) is BLOCK # 3. zablokowane

def test_closing_flag_prevents_flip():
    rl = RiskLayer()
    _# zamykamy longa, agent proponuje shorta -> domknij do zera, nie flipuj
    st = base_state(current_pos=1.0, closing=True)
    assert rl.check(-1.0, st) == 0.0
Model kiedyś zwariuje — to nie „jeśli", to „kiedy"

Rynek wejdzie w reżim spoza danych treningowych, feed zwróci błędną świecę, albo w wagach zadziała coś, czego nikt nie przewidział — i agent wystawi absurdalną akcję. To pewne jak podatki. Jedyne pytanie brzmi, czy w tym momencie między modelem a Twoimi pieniędzmi stoi warstwa kodu, której model nie kontroluje i nie może obejść. Kill-switch, którego nie napisałeś, zawsze wygląda na niepotrzebny — aż do jednego dnia, w którym jest jedyną rzeczą, która ratuje konto.

Sprawdź się

Twoja strategia realizuje zmienność 20% rocznie, a Ty celujesz w vol targeting na poziomie 8%. Jaką część nominalnej pozycji powinieneś trzymać?

Skala = σ_target / σ_realized = 8% / 20% = 0.4. Rynek jest bardziej zmienny niż Twój cel, więc redukujesz ekspozycję do 40% nominalnej. Gdyby rynek się uspokoił poniżej 8%, skala przekroczyłaby 1 i zwiększałbyś pozycję.

Dlaczego praktycy grają half-Kelly zamiast pełnego Kelly'ego, mimo że pełny Kelly maksymalizuje tempo wzrostu?

Krzywa wzrostu w funkcji ułamka Kelly'ego to odwrócona parabola: powyżej f* wzrost spada, a przy 2× Kelly osiąga zero. Ponieważ μ estymujesz z szumu, łatwo przeszacować przewagę i nieświadomie grać za mocno. Half-Kelly kupuje ogromną redukcję zmienności za drobny ułamek potencjału wzrostu — świetna wymiana.

Model wystawił akcję będącą NaN. Gdzie ten problem powinien zostać zatrzymany?

Ryzyko wyuczone przez nagrodę jest probabilistyczne — zawodzi dokładnie w ogonie, który Cię zabija. Twarde ograniczenia muszą być deterministyczne i egzekwowane przez kod, którego model nie kontroluje. Sanity check akcji w warstwie ryzyka wychwytuje NaN, wartości poza zakresem i nadmierną częstotliwość zleceń, zanim cokolwiek trafi na giełdę.
Moduł 11 · Produkcja · ~50 min

Ostatnia mila: architektura systemu live

Cel: zamienić skrypt z pętlą while True w system, który przeżyje reconnect, timeout i restart bez zgubienia albo zdublowania zlecenia — i wie, kiedy się wyłączyć.

Trzy pętle zamiast jednej

Skrypt paper-tradingu z modułu 9 miał jedną pętlę: pobierz świece, policz akcję, wyślij zlecenie, śpij. To działa na testnecie w dobry dzień. W produkcji ta monolityczna pętla pęka, bo skleja trzy niezależne procesy o zupełnie różnym tempie. Profesjonalny system live rozdziela je na trzy osobne pętle:

  • Data feed — słucha WebSocketa giełdy, składa z tików bary i cechy, utrzymuje aktualny obraz rynku. Działa cały czas, niezależnie od decyzji.
  • Inference — budzi się raz na zamknięcie bara, bierze gotowe cechy, odpytuje agenta i przekazuje docelową pozycję przez warstwę ryzyka. Dla agenta na świecach 1h to jedno wywołanie na godzinę.
  • Order manager — pilnuje cyklu życia zleceń: wysyła, śledzi wypełnienia, obsługuje częściowe fille i anulacje. Działa w swoim rytmie, między inference a giełdą.

Do feedu i zleceń służy CCXT Pro — warstwa WebSocket nad CCXT z metodami watch_order_book, watch_trades, watch_orders. Zwróć uwagę na proporcje: dla agenta decydującego raz na godzinę latencja jest nieistotna — kilkaset milisekund w tę czy tamtą stronę nic nie zmienia. Kluczowa jest architektura: to, że system poprawnie odtwarza stan po awarii, a nie to, że robi to o 20 ms szybciej. Odwrotnie niż w HFT, gdzie liczy się każda mikrosekunda, a architektura jest prosta.

Rozdzielenie pętli to nie over-engineering

Gdy feed, decyzja i zlecenia siedzą w jednym while True, timeout przy wysyłce zlecenia blokuje odbiór danych, a błąd w budowaniu cech zabija cały system. Rozdzielone pętle upadają niezależnie i restartują niezależnie — a to, jak przeżywają restart, jest treścią całego tego modułu.

Reconciliation: prawdą jest giełda, nie Twój stan lokalny

Najgroźniejszy błąd w tradingu live to rozjazd między tym, co system myśli, że ma, a tym, co faktycznie jest na giełdzie. Powstaje zawsze tak samo: proces padł tuż po wysłaniu zlecenia, ale przed zapisaniem tego faktu; albo reconnect zgubił zdarzenie o wypełnieniu. Po każdym restarcie i po każdym reconnect obowiązuje jedna zasada: nie ufaj pamięci, odtwórz prawdę z giełdy i uzgodnij ją ze stanem lokalnym.

"""Reconciliation: po starcie/reconnect odtwórz prawdę z giełdy.
Rozjazd stanu lokalnego z giełdą = STOP i alarm, nie 'jakoś to będzie'."""
import ccxt, logging

def reconcile(ex, local_state: dict, symbol: str) -> dict:
    """Zwraca prawdziwy stan z giełdy; przy rozjeździe podnosi alarm.
    local_state: to, co system myślał, że ma (z pliku/bazy)."""
    open_orders = ex.fetch_open_orders(symbol)
    positions   = ex.fetch_positions([symbol])
    balance     = ex.fetch_balance()

    real_pos = 0.0
    for p in positions:
        amt = float(p["info"].get("positionAmt", 0) or 0)
        if amt:
            real_pos = amt

    _# Porównaj prawdę z giełdy z tym, co mieliśmy zapisane lokalnie
    if abs(real_pos - local_state.get("position", 0.0)) > 1e-8:
        logging.error(
            f"ROZJAZD POZYCJI: lokalnie {local_state.get('position')} "
            f"vs giełda {real_pos} - STOP")
        raise SystemExit("Reconciliation failed: rozjazd pozycji")

    if open_orders:
        logging.warning(f"Znaleziono {len(open_orders)} wiszących zleceń po starcie")

    return {"position": real_pos,
            "open_orders": open_orders,
            "free_usdt": balance["USDT"]["free"]}
Rozjazd to STOP, nie automatyczna korekta

Kusi, by po wykryciu rozjazdu „naprawić" pozycję dosyłką zlecenia. Nie rób tego automatycznie. Rozjazd oznacza, że jedno z Twoich założeń o świecie jest fałszywe — a handel na fałszywych założeniach to jak łatanie dziury, której nie widzisz. Zatrzymaj system, podnieś alarm, obejrzyj to ludzkim okiem. Automatyczna korekta na złej diagnozie potrafi podwoić szkodę.

Idempotencja przez clientOrderId: nigdy dwa razy tego samego zlecenia

Wysyłasz zlecenie, mija timeout — i nie wiesz, czy giełda go nie dostała, czy dostała, ale zgubiła się odpowiedź. Naiwny odruch „wyślę jeszcze raz" to najkrótsza droga do podwójnej pozycji. Rozwiązanie: nadawaj każdemu zleceniu własny clientOrderId i po timeoucie najpierw sprawdź, czy takie zlecenie już istnieje, zanim cokolwiek wyślesz ponownie.

"""Idempotentne wysyłanie zlecenia: na timeout NIE dubluj - sprawdź stan.
clientOrderId nadany przez nas jest kluczem, po którym rozpoznajemy 'to samo'."""
import time, uuid, logging, ccxt

def place_order_idempotent(ex, symbol, side, qty, coid=None,
                           max_retries=4):
    """Zwraca zlecenie. Bezpieczne do ponowienia: ten sam clientOrderId
    nigdy nie utworzy drugiego zlecenia."""
    coid = coid or f"bot-{uuid.uuid4().hex[:16]}"    # nasz stały identyfikator

    for attempt in range(max_retries):
        try:
            return ex.create_order(
                symbol, "market", side, qty,
                params={"clientOrderId": coid})
        except ccxt.RequestTimeout:
            _# Nie wiemy, czy dotarło. Sprawdź, ZANIM wyślesz ponownie.
            logging.warning(f"timeout (proba {attempt+1}), sprawdzam clientOrderId={coid}")
            try:
                existing = ex.fetch_order(coid, symbol,
                                          params={"clientOrderId": coid})
                if existing:                         # jednak dotarło - gotowe
                    logging.info(f"zlecenie {coid} istnieje - nie dubluję")
                    return existing
            except ccxt.OrderNotFound:
                pass                                 # nie dotarło - można ponowić
        except ccxt.NetworkError as e:
            logging.warning(f"blad sieci: {e}")

        time.sleep(2 ** attempt)                     # exponential backoff: 1,2,4,8 s

    raise RuntimeError(f"nie udało się wystawić zlecenia {coid} po {max_retries} próbach")

Typy zleceń: wolny agent może wybrać taniej

Agent decydujący raz na godzinę ma luksus, którego nie ma HFT: czas. Może złożyć zlecenie pasywne i poczekać na wypełnienie, zamiast płacić za natychmiastowość. Na perpetualach Binance różnica jest konkretna: maker 0.02%, taker 0.05%. Przy setkach transakcji rocznie ta różnica jest jedną z największych dźwigni na Twój wynik netto — większą niż niejedna „poprawka modelu".

Typ zleceniaKiedy używaćKoszt / ryzyko
marketTrzeba wejść/wyjść natychmiast (stop, kill-switch)Taker 0.05% + poślizg; pewność wykonania
limitMasz cenę graniczną i akceptujesz brak wykonaniaMoże się nie wypełnić; kontrola ceny
post-onlyChcesz gwarancji maker fee — wolny agent może poczekaćOdrzucone, gdyby weszło jako taker; najtaniej
reduce-onlyZamykanie/zmniejszanie pozycji na perp — obowiązkowy safetyNigdy nie odwróci pozycji „przy okazji"

reduce-only na perpetualach nie jest opcją — to obowiązek. Gwarantuje, że zlecenie domykające co najwyżej sprowadzi pozycję do zera, ale nigdy jej nie odwróci, jeśli źle policzyłeś wielkość. To ten sam odruch, co flaga closing w warstwie ryzyka z modułu 10 — dwie warstwy pilnujące tej samej rzeczy.

Częściowe wypełnienia: zlecenie to nie zdarzenie zero-jedynkowe

Skrypt z modułu 9 zakładał, że zlecenie market albo się wykona w całości, albo nie. W rzeczywistości giełda zwraca filled i remaining: część weszła, część czeka. System musi wiedzieć, ile realnie ma pozycji, i świadomie zdecydować o reszcie:

  • Dopłać marketem — gdy zależy na pełnej ekspozycji i akceptujesz koszt taker na resztę.
  • Przestaw limit — gdy możesz poczekać i chcesz dokończyć po cenie maker.
  • Anuluj resztę — gdy częściowa pozycja jest wystarczająca albo warunki się zmieniły.

Cokolwiek wybierzesz, filled jest jedyną prawdą o Twojej pozycji — nie to, co zamierzałeś, tylko to, co giełda potwierdziła jako wypełnione.

Poprawiona pętla: świeca, która jeszcze się nie domknęła

Wróćmy do build_obs() z modułu 9 — bo kryje się w nim błąd, który zabija zgodność trening↔produkcja tak cicho, że łatwo go przeoczyć. CCXT przez fetch_ohlcv zwraca jako ostatnią świecę bieżącą, jeszcze otwartą. Jej close to aktualna cena, a nie cena zamknięcia — więc ostatni policzony zwrot jest zawsze bliski zera i zmienia się w każdej sekundzie. W treningu takiej świecy nie było: tam wszystkie świece były domknięte.

def build_obs():
    """Ta sama transformacja co w treningu - co do bitu.
    POPRAWKA vs modul 9: pobieramy 41 świec i ODCINAMY ostatnią (otwartą),
    bo jej 'close' to cena bieżąca, nie zamknięcie - inaczej ostatni zwrot
    w obserwacji live jest zawsze ~0 i mamy rozjazd trening/produkcja."""
    ohlcv = ex.fetch_ohlcv(SYMBOL, "1h", limit=41)   # 41, nie 40
    close = np.array([c[4] for c in ohlcv], dtype=np.float64)[:-1]  # odetnij otwartą
    r = np.diff(np.log(close))[-32:] * 100.0         # dokładnie 32 zwroty z domkniętych świec
    pos = get_current_position()
    return np.append(r, pos).astype(np.float32)
To dokładnie ta „cicha śmierć" z modułu 9

Rozjazd preprocessingu trening↔produkcja nie rzuca wyjątku i nie pojawia się w logach — po prostu agent w live widzi inny świat niż ten, na którym się uczył, i jego decyzje przestają mieć sens. Efekt: paper trading powoli rozjeżdża się z backtestem, a Ty szukasz błędu w modelu, choć siedzi on w jednej niedomkniętej świecy. Zawsze porównuj obserwację zbudowaną live z tą z backtestu na tym samym timestampie — muszą być identyczne co do bitu.

Monitoring i MLOps: rynek to ciągły dryf

W klasycznym ML rozróżniamy data drift (zmienia się rozkład wejść) i concept drift (zmienia się relacja wejście→wyjście). Na rynku concept drift to nie awaria, to stan domyślny — strategia, która działała w hossie 2021, przestaje działać w bessie 2022, bo zmieniła się sama gra. Dlatego monitoring nie pyta „czy model się zdezaktualizował", tylko „jak bardzo i czy już czas reagować". Konkretne triggery:

  • Przebicie historycznego maxDD — bieżące obsunięcie głębsze niż najgorsze z backtestu (jak w warstwie ryzyka z modułu 10).
  • Rolling Sharpe live poza przedziałem z backtestu — jeśli krocząca jakość spadła poniżej dolnego progu z ewaluacji, model nie gra już tej gry, na której się uczył.

Zamiast wyłączać model na zgadanie, profesjonalny zespół prowadzi go równolegle:

  • Shadow trading — nowy model liczy decyzje na żywo, ale bez wysyłania zleceń. Zbierasz jego „papierowy" wynik na realnych danych, zanim powierzysz mu grosz.
  • Champion/challenger — challenger dostaje ułamek kapitału i konkuruje z championem na realnych transakcjach; awansuje dopiero, gdy udowodni przewagę.
  • Retraining wyzwalany dryfem, nie kalendarzem — „przetrenuj co miesiąc" to zabobon. Trenuj, gdy monitoring pokaże dryf, bo to on, a nie data w kalendarzu, mówi, że model stracił kontakt z rynkiem.
  • Logowanie KAŻDEJ decyzji — stan, akcja, zlecenie, fill, PnL. Bez tego nie odtworzysz, dlaczego bot zrobił to, co zrobił — a ten log jest pierwszą rzeczą, której będziesz szukał po każdym złym dniu.
"""Najprostszy użyteczny monitor dryfu: kroczący Sharpe live vs próg z backtestu.
Gdy jakość spada poniżej dolnej granicy - sygnał do shadow tradingu / retreningu."""
import numpy as np
from collections import deque

class DriftMonitor:
    def __init__(self, window: int = 24 * 30,        # ~30 dni świec 1h
                 sharpe_floor: float = 0.0,          # dolny próg z backtestu
                 periods_per_year: int = 24 * 365):
        self.returns = deque(maxlen=window)
        self.floor = sharpe_floor
        self.ppy = periods_per_year

    def update(self, step_return: float) -> dict:
        """Dokłada zwrot z ostatniego kroku, zwraca stan monitoringu."""
        self.returns.append(step_return)
        if len(self.returns) < self.returns.maxlen // 2:
            return {"ready": False, "sharpe": None, "alert": False}

        r = np.array(self.returns)
        sd = r.std()
        sharpe = 0.0 if sd == 0 else r.mean() / sd * np.sqrt(self.ppy)
        alert = sharpe < self.floor                  # jakość poniżej progu backtestu
        return {"ready": True, "sharpe": float(sharpe), "alert": bool(alert)}
Appendix formalny — podatki (Polska, stan na 2026)

Zyski z krypto rozliczasz w Polsce jako przychód z kapitałów pieniężnych: 19% PIT na formularzu PIT-38. Kluczowa zasada: wymiana krypto↔krypto jest neutralna podatkowo — zdarzenie podatkowe powstaje dopiero przy zamianie na FIAT (albo zapłacie krypto za towar/usługę). Handel BTC↔ETH↔USDT w kółko nie generuje podatku, dopóki nie wyjdziesz do złotówek. Giełdy krypto nie wystawiają PIT-8C — rozliczasz się sam z własnej historii transakcji. Koszty nabycia nieodliczone w danym roku przenosisz na lata kolejne. Od 1 stycznia 2026 giełdy raportują dane o użytkownikach do administracji skarbowej (unijna dyrektywa DAC8) — fiskus i tak zobaczy Twoje ruchy, więc rozliczenie musi się zgadzać.

Appendix formalny — regulacje i ryzyko kontrahenta

MiCA / licencja CASP dotyczy firm świadczących usługi krypto (giełd, brokerów, custodianów), a nie osoby fizycznej handlującej na własny rachunek własnym kapitałem. Boty na własne konto Cię nie licencjonują. Osobna, poważniejsza sprawa to ryzyko kontrahenta: FTX, Celsius i Voyager pokazały, że „środki na giełdzie" to nie Twoje środki, tylko roszczenie wobec giełdy. Proof of Reserves nie ratuje — to zdjęcie aktywów w jednym momencie, bez informacji o zobowiązaniach off-chain, więc dowodzi istnienia rezerw, a nie wypłacalności. Praktyczna obrona: self-custody środków, które nie pracują, na giełdzie trzymaj tylko kapitał roboczy, i dywersyfikuj między giełdy, by upadek jednej nie zabrał wszystkiego.

To nie jest porada podatkowa ani prawna — to orientacyjny obraz stanu na 2026 r. Przed realnymi decyzjami skonsultuj się z doradcą podatkowym i sprawdź aktualne przepisy.

Sprawdź się

Wysłałeś zlecenie market, dostałeś timeout i nie wiesz, czy dotarło. Co robisz?

Timeout nie mówi, czy zlecenie dotarło — mogła zginąć tylko odpowiedź. Ślepe ponowienie grozi podwójną pozycją. Dzięki własnemu clientOrderId możesz najpierw odpytać fetch_order: jeśli zlecenie istnieje, jesteś gotów; jeśli nie (OrderNotFound), bezpiecznie ponawiasz z exponential backoff. To jest właśnie idempotencja.

Po restarcie systemu okazuje się, że stan lokalny mówi „pozycja 0", a giełda pokazuje otwartego longa. Co powinien zrobić system?

Prawdą jest zawsze giełda, nie pamięć lokalna, więc reconciliation słusznie wykrył rozjazd. Ale automatyczna „korekta" działa na diagnozie, której nie znasz — coś zawiodło, i dopóki nie wiesz co, każda akcja może pogłębić szkodę. Bezpieczna reakcja to STOP i alarm, a nie dosyłka zlecenia.

W obserwacji live ostatni zwrot logarytmiczny jest prawie zawsze bliski zera, choć w backteście był normalną liczbą. Najbardziej prawdopodobna przyczyna?

Ostatnia świeca z fetch_ohlcv jest jeszcze otwarta — jej close to aktualna cena, blisko poprzedniego zamknięcia, więc ostatni zwrot wychodzi ~0. W treningu wszystkie świece były domknięte, stąd rozjazd. Poprawka: pobierz limit=41 i odetnij ostatnią świecę (close[:-1]), by liczyć zwroty tylko z domkniętych barów. To klasyczny cichy rozjazd trening↔produkcja.
Moduł 12 · Zaawansowane · ~35 min

Market making i optymalna egzekucja: gdzie RL naprawdę wygrywa

Cel: zrozumieć dwa zastosowania, w których RL ma najmocniejsze umocowanie teoretyczne i empiryczne — kwotowanie market makera i rozłożenie dużego zlecenia w czasie — oraz nauczyć się klasycznych baseline'ów (Avellaneda–Stoikov, Almgren–Chriss), na których RL buduje.

Dlaczego akurat tu RL działa najlepiej

Systematyczny przegląd 167 prac o RL w finansach (2017–2025) policzył coś, co warto zapamiętać na całe życie z tym tematem: „premię RL" — o ile agent RL bije silny baseline klasyczny — dla każdej domeny osobno. Market making zajął pierwsze miejsce z premią 0.488, wyprzedzając egzekucję, zarządzanie portfelem i — na samym końcu — kierunkową spekulację. To nie przypadek.

  • Problem jest dobrze zdefiniowany. Market making i egzekucja to zadania sterowania (control), nie prognozowania. Nie zgadujesz, dokąd pójdzie rynek — reagujesz na przepływ zleceń i zarządzasz zapasem/harmonogramem. Sygnał (mikrostruktura książki) jest silniejszy i stabilniejszy niż kierunek ceny.
  • Nagroda jest gęsta. Market maker inkasuje spread przy niemal każdej transakcji — feedback przychodzi co sekundy, nie co tygodnie. Gęsta nagroda to marzenie RL; rzadka nagroda (spekulacja) to jego przekleństwo.
  • Jest mocny baseline teoretyczny. Avellaneda–Stoikov i Almgren–Chriss dają zamknięte wzory na optymalne kwotowanie i harmonogram. RL nie zaczyna od zera — startuje od rozwiązania analitycznego i uczy się je korygować tam, gdzie założenia klasyki nie trzymają.
Odwróć intuicję z internetu

YouTube i Medium sprzedają RL jako maszynę do przewidywania ceny BTC. To najtrudniejsze i najsłabiej umocowane zastosowanie. Najłatwiejsze do obronienia — i to, czym faktycznie żyją fundusze — to egzekucja i market making. Jeśli chcesz, żeby Twój projekt RL miał sens naukowy, celuj w problem sterowania z gęstą nagrodą, a nie w wyrocznię kierunku.

Avellaneda–Stoikov: market making na jednej kartce

Model Avellanedy i Stoikova (2008) to punkt odniesienia całego market makingu. Wyobraź sobie, że wystawiasz jednocześnie zlecenie kupna (bid) poniżej ceny i sprzedaży (ask) powyżej. Zarabiasz na spreadzie za każdym razem, gdy ktoś „przejedzie" oba Twoje kwotowania. Ryzyko: gdy rynek idzie w jedną stronę, wypełnia Ci się tylko jedna strona i zostajesz z niechcianą pozycją (inventory), która traci na wartości.

Sedno modelu to cena rezerwacji r — nie środek rynku, lecz środek przesunięty proporcjonalnie do Twojego zapasu. Masz za dużo longa? Cena rezerwacji spada, więc kwotujesz niżej, zachęcając rynek, by odkupił od Ciebie zapas:

Cena rezerwacji r = s − q · γ · σ² · (T − t)

Symbol po symbolu: s — bieżąca cena środkowa (mid); q — Twój zapas (dodatni = long, ujemny = short); γ — awersja do ryzyka (jak bardzo boisz się zapasu); σ² — wariancja ceny; (T−t) — czas do końca sesji (im bliżej końca, tym mniej ryzyka niesie otwarty zapas, więc korekta maleje). Wokół tej ceny rezerwacji rozkładasz symetrycznie bid i ask, a łączny optymalny spread wynosi:

Optymalny spread (bid + ask) δ+ + δ = γσ²(T − t) + (2/γ)·ln(1 + γ/k)

Nowy symbol: k — intensywność napływu zleceń rynkowych (jak głęboko w książkę „sięgają" transakcje; wysokie k = płynny, aktywny rynek). Czytaj ten wzór jako dwa napięcia. Pierwszy człon rośnie z ryzykiem (γσ²(T−t)) — im groźniejszy zapas, tym szerzej kwotujesz, żeby dostać większą premię za jego trzymanie. Drugi człon ((2/γ)·ln(1+γ/k)) to kompromis między szansą wypełnienia a marżą: kwotując zbyt szeroko, zarabiasz dużo na transakcję, ale rzadko; zbyt wąsko — często, ale grosze.

Cała gra market makera w jednym zdaniu

Zysk = spread, ryzyko = zapas. Wąski spread → dużo transakcji, ale mikra marża i szybko rosnący zapas. Szeroki spread → tłusta marża, ale rzadkie wypełnienia. Awersja γ i przesunięcie ceny rezerwacji to Twoje dwa pokrętła do zarządzania tym napięciem. Zrozum je na baseline'ie, zanim wpuścisz do gry sieć neuronową.

Ten kompromis najlepiej poczuć rękami. W symulatorze poniżej ustawiasz awersję γ i szerokość kwotowań, a strumień zleceń rynkowych (proces Poissona) wypełnia Twoje bid/ask. Zobacz na własne oczy, jak agresywne, wąskie kwotowanie pęcznieje od zapasu w trendzie i jak przesunięcie ceny rezerwacji „ściąga" pozycję z powrotem do zera — kosztem części zarobionego spreadu.

Market maker: spread, inwentarz i awersja γ interaktywne
cena mid bid ask inwentarz q PnL skumulowany
0.00
PnL łączny
0
inwentarz q
0
liczba filli
0.00
PnL ze spreadu
0.00
PnL z inwentarza

Nota dydaktyczna: ustaw γ=0 i wąski spread — dużo filli, PnL ze spreadu rośnie, ale inwentarz dryfuje i jeden trend zabiera wszystko przez „PnL z inwentarza". Podnieś γ: kwotowania są spychane przeciw pozycji, zysk mniejszy, lecz inwentarz trzyma się blisko zera. To jest sedno market makingu i powód, dla którego RL uczy się modulować γ zamiast trzymać stałą.

RL nie zastępuje Avellanedy — on ją koryguje

Kluczowe nieporozumienie: ludzie próbują trenować agenta market makera „od zera", z gołą siecią, i dziwią się, że przegrywa z prostym wzorem. Kanoniczny, działający wzorzec jest odwrotny — RL jako warstwa korekcyjna nad Avellanedą–Stoikovem. Klasyka zakłada stałe γ, stałą intensywność k i geometryczny ruch ceny; realny rynek łamie wszystkie trzy założenia. Sieć uczy się modulować parametry kwotowania (najczęściej efektywne γ albo bezpośrednie przesunięcia bid/ask) w zależności od stanu rynku, którego wzór nie widzi: zmienności realizowanej, nierównowagi książki (order-book imbalance), ostatniego przepływu.

Praca w PLOS ONE (2022) pokazała dokładnie ten schemat: agent RL nauczony modulować kwotowania A–S obniżył ryzyko zapasu przy zachowanym zysku ze spreadu — poprawa szła nie z „lepszego przewidywania", lecz z adaptacyjnego zarządzania zapasem. Nowszy front to market making wieloagentowy (arXiv:2510.25929), gdzie kilku agentów konkuruje o ten sam przepływ — naturalne odwzorowanie realnego rynku, na którym nie jesteś jedynym market makerem. To wciąż teren badawczy, ale kierunek jest zdrowy: RL dokłada adaptacyjność tam, gdzie klasyka ma sztywne założenia.

Optymalna egzekucja: problem, który każdy fundusz musi rozwiązać

Masz do sprzedania 100 000 akcji. Wrzucisz wszystko jednym zleceniem rynkowym — zjesz całą płynność po drodze i dostaniesz fatalną średnią cenę (market impact). Rozłożysz na tydzień — zredukujesz impact, ale wystawiasz się na ryzyko, że cena ucieknie, zanim skończysz. To jest cała optymalna egzekucja: trade-off między wpływem na cenę a ryzykiem czasowym.

Model Almgrena–Chrissa (2000) daje na to zamknięte rozwiązanie. Koszt egzekucji dzieli na dwie części. Impact trwały (permanent) — Twoja sprzedaż na stałe obniża cenę dla wszystkich, proporcjonalnie do tempa. Impact tymczasowy (temporary) — chwilowe „wgniecenie" ceny, gdy jesteś zbyt agresywny, które zanika po Tobie. Optymalna trajektoria to kompromis: agent bardziej awersyjny do ryzyka sprzedaje szybciej z przodu (płaci więcej impactu, ale mniej ryzykuje), agent obojętny na ryzyko rozkłada równo (TWAP). Trajektoria ma kształt wykładniczy, a jej „stromość" reguluje właśnie awersja do ryzyka.

Koszt egzekucji (Almgren–Chriss, szkic) E[koszt] =  γ·Σ xk·(nk/2)  +  η·Σ (nk²/τ)   (impact trwały + tymczasowy)

Gdzie nk to ilość sprzedana w kroku k, xk to pozostały zapas, γ i η — współczynniki impactu trwałego i tymczasowego, τ — długość kroku. Nie ucz się tego wzoru na pamięć — poniżej zaimplementujemy trajektorię wprost i porównamy ją z naiwnym TWAP (równy podział na kroki).

"""Optymalna trajektoria egzekucji Almgrena-Chrissa vs naiwny TWAP.
Sprzedajemy X akcji w N krokach. Porownujemy oczekiwany koszt i ryzyko."""
import numpy as np

_# --- Parametry problemu ---
X      = 100_000     # laczny zapas do sprzedania (akcje)
N      = 20          # liczba krokow (np. 20 interwalow sesji)
T      = 1.0         # laczny horyzont (znormalizowany)
tau    = T / N       # dlugosc pojedynczego kroku
sigma  = 0.02        # zmiennosc ceny na krok
eta    = 2.5e-6      # wspolczynnik impactu TYMCZASOWEGO
gamma  = 2.5e-7      # wspolczynnik impactu TRWALEGO
lam    = 1e-6        # awersja do ryzyka (lambda): 0 = obojetny -> TWAP

def ac_trajectory(lam: float) -> np.ndarray:
    """Optymalna trajektoria ZAPASU x_k wg Almgrena-Chrissa.
    Dla lam=0 wychodzi liniowy spadek (TWAP). Im wieksza lam,
    tym bardziej wypukla krzywa = szybsza sprzedaz na poczatku."""
    # kappa: parametr stromosci trajektorii wykladniczej
    kappa_sq = lam * sigma**2 / eta
    kappa = np.arccosh(kappa_sq * tau**2 / 2 + 1) / tau if kappa_sq > 0 else 0.0
    k = np.arange(N + 1)
    if kappa == 0.0:
        return X * (1 - k / N)                      # TWAP: liniowo do zera
    # trajektoria wykladnicza (sinh) - klasyczna postac A-C
    return X * np.sinh(kappa * (T - k * tau)) / np.sinh(kappa * T)

def expected_cost(x: np.ndarray) -> tuple[float, float]:
    """Oczekiwany koszt (impact) i wariancja (ryzyko) dla trajektorii zapasu x."""
    n = -np.diff(x)                                 # sprzedaz w kazdym kroku
    temp = eta * np.sum(n**2) / tau                 # impact tymczasowy
    perm = 0.5 * gamma * np.sum(n**2)               # impact trwaly (uproszczony)
    var  = sigma**2 * np.sum(x[1:]**2) * tau        # ryzyko: wariancja kosztu
    return temp + perm, var

_# --- Porownanie: TWAP (lam=0) vs agent awersyjny ---
for name, L in [("TWAP (lam=0)", 0.0), ("A-C awersyjny", lam)]:
    x = ac_trajectory(L)
    cost, var = expected_cost(x)
    print(f"{name:16s} | koszt(impact)={cost:10.1f} | ryzyko(std)={np.sqrt(var):10.1f}")

_# Typowy wynik: agent awersyjny placi WIECEJ impactu, ale ma MNIEJSZE ryzyko.
_# To jest cala istota egzekucji - nie ma darmowego obiadu, jest granica efektywna.

Uruchom to i pobaw się parametrem lam. Zobaczysz „granicę efektywną" egzekucji: każdy punkt na niej to inny kompromis koszt–ryzyko, a wybór na niej to Twoja decyzja o awersji. Tu wchodzi RL. Almgren–Chriss zakłada stałe, znane współczynniki impactu i brak sygnału o kierunku. Realny rynek daje sygnały: nierównowagę książki, chwilową płynność, momentum. Agent RL w symulatorze książki zleceń (LOB) z zleceniami rynkowymi i limit — uczy się odchylać od harmonogramu A–C, gdy mikrostruktura mu sprzyja: przyspieszyć, gdy płynność jest tania, wstrzymać się, gdy książka jest cienka. W symulacjach RL bije VWAP i statyczne heurystyki (arXiv:2507.06345, arXiv:2411.06389). Anegdotycznie: system LOXM banku JPMorgan to właśnie produkcyjna egzekucja oparta o uczenie maszynowe — publicznych liczb brak, ale kierunek branży jest jednoznaczny.

Prawo pierwiastka: dlaczego duże zlecenia bolą nieliniowo

Skąd w ogóle bierze się koszt egzekucji? Z empirycznej regularności, która jest jedną z najsolidniejszych w całej mikrostrukturze rynku — prawo pierwiastka (square-root law). Wpływ zlecenia na cenę rośnie nie liniowo, lecz z pierwiastkiem udziału w wolumenie:

Market impact (square-root law) I(q) = η · σ · √(q / V)

Gdzie q to wielkość Twojego zlecenia, V — dzienny wolumen instrumentu, σ — zmienność, η — stała rzędu jedności. Konsekwencja praktyczna: podwojenie zlecenia nie podwaja kosztu — zwiększa go tylko o √2 ≈ 1,41×. Dlatego opłaca się dzielić zlecenia i dlatego duzi gracze tak bardzo pilnują swojego udziału w wolumenie. Do środowiska treningowego wpinasz to jako realistyczny model kosztu wypełnienia:

_# Realistyczny koszt wykonania: polowa spreadu + impact wg prawa pierwiastka.
_# Wpinasz to w metode step() swojego TradingEnv zamiast stalej prowizji.
import numpy as np

def execution_cost(qty: float, adv: float, sigma: float,
                   half_spread: float, eta: float = 1.0) -> float:
    """qty        - wielkosc zlecenia (w tych samych jednostkach co adv)
    adv        - sredni dzienny wolumen (Average Daily Volume)
    sigma      - dzienna zmiennosc instrumentu
    half_spread- polowa aktualnego spreadu (koszt 'przejscia' przez ksiazke)
    Zwraca koszt jako ulamek wartosci - do odjecia od nagrody."""
    participation = abs(qty) / adv                  # udzial w wolumenie
    impact = eta * sigma * np.sqrt(participation)   # prawo pierwiastka
    return half_spread + impact                     # spread placisz zawsze, impact rosnie z q

_# Przyklad: to samo zlecenie w normalnym rynku i w stresie (cienka ksiazka)
for regime, hs in [("spokojny", 0.0005), ("stres", 0.0050)]:
    c = execution_cost(qty=5_000, adv=1_000_000, sigma=0.02, half_spread=hs)
    print(f"rynek {regime:9s}: koszt = {c*100:.3f}% wartosci zlecenia")
Koszt musi zależeć od reżimu — inaczej backtest kłamie

Największy błąd w modelowaniu kosztów to traktowanie ich jako stałej prowizji. W stresie rynkowym płynność na szczycie książki (top-of-book depth) potrafi spaść o ponad 90% — obserwowano to m.in. w gwałtownych ruchach z 10 października 2025. Twój model kosztów musi zależeć od reżimu (zmienność, spread, głębokość), bo dokładnie wtedy, gdy najbardziej potrzebujesz wyjść, egzekucja jest najdroższa. Backtest ze stałym kosztem systematycznie przeszacowuje wynik strategii aktywnych.

Uczciwa ramka: to warsztat, nie plan biznesowy

Trzeba to powiedzieć wprost. Jako samodzielny trader z małym kapitałem nie wygrasz z firmami HFT w market makingu na płynnych parach — one mają kolokację, dostęp do surowej książki i latencję w mikrosekundach, a Ty API z opóźnieniem setek milisekund. Nie o to chodzi. Ten moduł uczysz się jako warsztatu inżynierskiego: intuicji zapasu i ryzyka, mikrostruktury, modelowania kosztów, kompromisu impact–ryzyko. To są umiejętności, które podnoszą jakość każdej strategii, którą zbudujesz — także tej wolniejszej, w której masz szansę. Traktuj Avellanedę i Almgrena jako gimnastykę myślenia o egzekucji, nie jako biznesplan na konkurowanie z Citadelem.

Sprawdź się

W modelu Avellanedy–Stoikova market maker ma duży dodatni zapas (long). Co robi z jego kwotowaniami cena rezerwacji?

Cena rezerwacji r = s − q·γ·σ²·(T−t). Dodatni zapas q obniża r, więc market maker kwotuje niżej. To skłania rynek do kupna od niego i „ściąga" niechciany zapas z powrotem ku zeru — sedno zarządzania inventory risk.

Zgodnie z prawem pierwiastka (square-root law), jak zmieni się market impact, jeśli podwoisz wielkość zlecenia?

I(q) = η·σ·√(q/V) — impact rośnie z pierwiastkiem wielkości zlecenia. Podwojenie q mnoży impact przez √2 ≈ 1,41, a nie przez 2. Dlatego dzielenie dużych zleceń w czasie realnie się opłaca.

Dlaczego market making osiągnął najwyższą „premię RL" (0.488) w przeglądzie 167 prac, a kierunkowa spekulacja najniższą?

Market making to zadanie sterowania (reakcja na przepływ), z nagrodą przychodzącą co transakcję i solidnym baseline'em (Avellaneda–Stoikov), który RL koryguje. Spekulacja kierunkowa ma słaby sygnał, rzadką nagrodę i wymaga prognozy — najtrudniejszy profil dla RL.
Moduł 13 · Zaawansowane · ~40 min

Zaawansowany warsztat: ryzyko w funkcji celu, offline RL, dane syntetyczne

Cel: poznać techniki z frontu badań, które mają realny, mierzalny sens w tradingu — rozkładowe RL i CVaR w celu, uczenie z zastanych danych, generowanie danych syntetycznych i dyscyplinę anty-overfittingową — bez wpadania w hype.

Zacznij od pokory: algorytm to nie jest to, co Cię uratuje

Ten sam przegląd 167 prac, który w module 12 dał market makingowi koronę, sprawdził też coś, co powinno przewietrzyć Ci głowę: wybór rodziny algorytmu ma statystycznie znikomy wpływ na wynik (p = 0.640). PPO kontra SAC kontra DQN kontra najnowszy błyszczący akronim — to szum. Co decyduje? Jakość i realizm danych, uczciwe modelowanie kosztów i rygor metodologiczny (walk-forward, wiele ziaren, brak wycieku informacji). Zapamiętaj to, zanim zaczniesz gonić za każdą nową architekturą z arXiv.

Mimo to trzy grupy technik z frontu badań naprawdę coś wnoszą — nie dlatego, że są modne, lecz dlatego, że atakują konkretne, mierzalne słabości standardowego RL na rynkach: ślepotę na ryzyko ogonowe, kosztowną eksplorację on-line i głód danych.

Rozkładowe RL i CVaR: ucz się całego rozkładu, nie średniej

Standardowy agent estymuje oczekiwaną wartość akcji — jedną liczbę. Ale w tradingu dwie strategie o tej samej średniej mogą mieć diametralnie różny profil ryzyka: jedna równo zarabia, druga zbiera drobne zyski i raz na jakiś czas obrywa katastrofalnie. Średnia tego nie widzi. Rozkładowe RL (C51, QR-DQN, IQN) uczy się całego rozkładu zwrotu Z(s,a), a nie tylko jego wartości oczekiwanej. Z rozkładu dostajesz za darmo miary ogonowe — w tym CVaR (Conditional Value at Risk), czyli średnią stratę w najgorszych p% przypadków.

_# QR-DQN uczy sie rozkladu zwrotu przez kwantyle. Z kwantyli liczysz CVaR
_# bez zadnego dodatkowego modelu. Dostepny w sb3-contrib.
from sb3_contrib import QRDQN
import numpy as np

model = QRDQN(
    "MlpPolicy", env,
    n_quantiles = 50,          # ile kwantyli rozkladu estymujemy
    learning_rate = 1e-4,
    buffer_size = 100_000,
    device = "cpu",
    seed = 42,
)
model.learn(total_timesteps=300_000)

_# --- CVaR wprost z wyuczonych kwantyli akcji ---
def cvar_from_quantiles(quantiles: np.ndarray, alpha: float = 0.05) -> float:
    """Srednia z najgorszych alpha (np. 5%) kwantyli = CVaR_5%.
    Bardziej ujemna wartosc = grubszy lewy ogon = wieksze ryzyko."""
    q_sorted = np.sort(quantiles)
    cutoff = max(1, int(alpha * len(q_sorted)))
    return float(q_sorted[:cutoff].mean())

_# W ewaluacji: dla kazdego stanu porownaj akcje nie po sredniej,
_# lecz po CVaR - wybieraj te o akceptowalnym ogonie, nie tylko wysokiej sredniej.

Idziemy krok dalej: skoro potrafimy zmierzyć ogon, wpiszmy go do celu uczenia. CVaR-PPO (CPPO) dokłada do funkcji celu PPO karę za lewy ogon strat. Agent nie maksymalizuje już samego zwrotu — maksymalizuje zwrot pomniejszony o ryzyko ogonowe. W praktyce (m.in. w FinRL-DeepSeek) daje to mierzalnie płytszy drawdown przy niewielkim koszcie średniego zwrotu. To jeden z najlepszych „kursów wymiany" w całym RL na rynkach: oddajesz odrobinę zysku, kupujesz realną kontrolę nad katastrofą.

Ryzyko należy do funkcji celu, nie do przypisu

Klasyczny RL optymalizuje zwrot, a ryzykiem zajmuje się „gdzieś obok" (limity, stop-lossy). Rozkładowe RL i CVaR-PPO wnoszą ryzyko do środka optymalizowanej funkcji. Dla tradingu, gdzie przetrwanie drawdownu decyduje o wszystkim, to filozoficznie właściwe miejsce. Jeśli masz wybrać jedną „zaawansowaną" technikę z tego modułu do wdrożenia — zacznij od kary CVaR w celu.

Offline RL: ucz się z historii, nie pal pieniędzy na eksplorację

Standardowy RL uczy się przez interakcję — próbuje, dostaje nagrodę, poprawia się. Na rynku „próba" to prawdziwe zlecenie za prawdziwe pieniądze, a eksploracja on-line to dosłownie palenie kapitału na sprawdzanie złych akcji. Offline RL (CQL, IQL) rozwiązuje to inaczej: uczy się wyłącznie z zastanego zbioru trajektorii — Twoich historycznych transakcji, logów paper tradingu, danych z backtestów — bez ani jednej nowej interakcji z rynkiem. Kluczowa sztuczka to konserwatyzm: algorytm celowo zaniża wartość akcji, których nie ma w danych, żeby agent nie „fantazjował" o zyskownych ruchach, których nikt nigdy nie zaobserwował.

"""Offline RL (CQL) na trajektoriach z wlasnego TradingEnv, biblioteka d3rlpy.
Nie ma tu treningu on-line - uczymy sie WYLACZNIE z zebranych przejsc."""
import numpy as np
from d3rlpy.algos import DiscreteCQLConfig
from d3rlpy.dataset import MDPDataset

_# --- 1. Zbierz trajektorie z istniejacej polityki (np. z paper tradingu) ---
_#    Kazde przejscie to (obserwacja, akcja, nagroda, koniec_epizodu).
_#    Tutaj laduje sie je z wczesniej zapisanych tablic NumPy.
obs        = np.load("data/offline_obs.npy")        # (T, dim_stanu)
actions    = np.load("data/offline_actions.npy")    # (T,) dyskretne akcje
rewards    = np.load("data/offline_rewards.npy")    # (T,)
terminals  = np.load("data/offline_terminals.npy")  # (T,) 1 na koncu epizodu

_# --- 2. Konwersja do standardowego formatu MDPDataset ---
dataset = MDPDataset(
    observations = obs,
    actions      = actions,
    rewards      = rewards,
    terminals    = terminals,
)

_# --- 3. Trening konserwatywnego agenta (CQL) - zero interakcji z rynkiem ---
cql = DiscreteCQLConfig(
    alpha = 1.0,        # sila konserwatyzmu: wyzej = mocniej karze akcje spoza danych
).create(device="cpu")
cql.fit(dataset, n_steps=100_000)
cql.save_model("models/cql_offline.pt")

_# Standard datasetow offline RL: Minari (Farama) - jesli chcesz dzielic sie
_# danymi lub korzystac z gotowych zbiorow, konwertuj do formatu Minari.

Wariant, o którym warto wiedzieć, bo pasuje do profilu danych rynkowych: Decision Transformer (DT). Zamiast uczyć wartości, traktuje RL jak modelowanie sekwencji — podajesz mu ciąg (zwrot-do-uzyskania, stan, akcja, zwrot-do-uzyskania, stan, akcja, …) i uczysz transformer przewidywać kolejną akcję warunkowaną pożądanym zwrotem. Badanie z ICLR 2024 pokazało ważny kompromis: DT potrzebuje więcej danych niż CQL, ale jest odporniejszy przy rzadkiej nagrodzie i słabej jakości danych — a to dokładnie profil danych rynkowych. W tradingu testowano warianty typu GPT-2 z dostrajaniem LoRA jako DT (arXiv:2411.17900).

Uczciwie: obiecujące i dobrze umocowane, ale nie udowodnione

Offline RL i Decision Transformery mają mocne teoretyczne uzasadnienie i świetne wyniki w benchmarkach sterowania (D4RL, robotyka). Ale nie ma publicznych, przekonujących wyników out-of-sample na realnym rynku. Traktuj je jako „obiecujące i dobrze umocowane", nie jako „udowodnione, że zarabiają". To najlepszy stan wiedzy na 2026 — nie pozwól, by ładna architektura zastąpiła Ci dowód.

Dane syntetyczne: więcej rynków, niż dała Ci historia

Fundamentalny problem RL na rynkach: masz jedną historię. Jeden ciąg cen BTC, jedną hossę 2021, jeden krach 2022. Agent, który widział jedną trajektorię, uczy się jej na pamięć. Odpowiedź to generowanie dodatkowych, wiarygodnych przebiegów. Trzy poziomy, od najtańszego do najbardziej badawczego:

(a) Bootstrap blokowy / stacjonarny — najtańszy i zaskakująco skuteczny. Zamiast losować pojedyncze zwroty (co niszczy autokorelację), losujesz bloki kolejnych zwrotów, zachowując lokalną strukturę czasową. To standardowy sposób na sprawdzenie, czy Twoja strategia jest odporna, czy tylko dopasowała się do jednej ścieżki historii:

"""Stationary bootstrap (Politis-Romano): resampluj zwroty blokami o losowej
dlugosci, zachowujac autokorelacje. Rozklad wynikow na wielu resamplach
mowi Ci, czy strategia jest odporna, czy to fuks jednej sciezki."""
import numpy as np

def stationary_bootstrap(returns: np.ndarray, p: float = 0.1,
                         rng=None) -> np.ndarray:
    """p - prawdopodobienstwo zakonczenia biezacego bloku (1/p = srednia dlugosc).
    Zwraca nowy szereg zwrotow tej samej dlugosci co oryginal."""
    rng = rng or np.random.default_rng()
    n = len(returns)
    out = np.empty(n)
    i = rng.integers(n)                             # losowy start pierwszego bloku
    for t in range(n):
        out[t] = returns[i]
        if rng.random() < p:
            i = rng.integers(n)                     # zakoncz blok: skok w nowe miejsce
        else:
            i = (i + 1) % n                         # kontynuuj blok: nastepny zwrot
    return out

_# --- Rozklad wyniku backtestu na 500 resamplach ---
real_returns = np.load("data/returns.npy")
rng = np.random.default_rng(42)
finals = []
for _ in range(500):
    sim = stationary_bootstrap(real_returns, p=0.1, rng=rng)
    finals.append(np.exp(np.cumsum(sim))[-1] - 1)   # koncowy zwrot strategii B&H na resamplu
finals = np.array(finals)
print(f"Zwrot: mediana={np.median(finals):+.2%} | "
      f"5-95 pct=[{np.percentile(finals,5):+.2%}, {np.percentile(finals,95):+.2%}]")
_# Szeroki, silnie ujemny 5. percentyl = strategia krucha. Patrz na ROZKLAD, nie na jeden wynik.

(b) Modele dyfuzyjne — nowszy front. Te same modele, które generują obrazy, generują też realistyczne szeregi finansowe i biją GAN-y: mają mniejszy mode collapse i lepiej odtwarzają grube ogony oraz skupianie zmienności (arXiv:2410.18897). Sensowne do augmentacji zbioru treningowego RL. (c) World models — agent uczy się wewnętrznego modelu dynamiki rynku i trenuje „we śnie", w wyobrażonym środowisku. W finansach to wciąż czysto badawczy teren, bez dowodów rynkowych — wzmiankuję dla kompletności mapy, nie jako narzędzie na dziś.

Lab anty-overfittingowy: przeuczenie jako test hipotezy

Cały kurs powtarza: RL na rynkach domyślnie się przeucza. Jest na to dyscyplina metodologiczna mocniejsza niż „patrz na walidację". Praca arXiv:2209.05559 proponuje sformułować wykrywanie przeuczenia jako formalny test hipotezy: trenujesz N agentów (różne ziarna), estymujesz rozkład ich wyników walidacyjnych i odrzucasz tych, którzy wyglądają na przeuczonych względem tego rozkładu — zanim w ogóle dopuścisz ich do danych testowych.

"""Szkic procedury anty-overfittingowej: trenuj komitet, odrzuc przeuczonych.
Idea: agent, ktory swietnie radzi sobie na treningu, ale slabo na walidacji,
jest statystycznie odrzucany PRZED dopuszczeniem do testu."""
import numpy as np

def filter_overfit(train_scores: np.ndarray, val_scores: np.ndarray,
                   z_thresh: float = 1.0) -> np.ndarray:
    """train_scores, val_scores - wyniki N agentow na treningu i walidacji.
    Zwraca maske agentow NIEprzeuczonych.
    Kryterium: luka train-val nie moze byc razaco wieksza od typowej."""
    gap = train_scores - val_scores                 # miara przeuczenia kazdego agenta
    # standaryzujemy luke wzgledem rozkladu w komitecie
    z = (gap - gap.mean()) / (gap.std() + 1e-9)
    keep = z < z_thresh                              # odrzuc agentow z rzucajaca sie w oczy luka
    # dodatkowy warunek: wynik walidacyjny musi byc dodatni
    keep &= val_scores > 0
    return keep

_# --- Uzycie: N agentow z rozna kombinacja seed/hiperparametry ---
train_scores = np.load("runs/train_scores.npy")     # (N,)
val_scores   = np.load("runs/val_scores.npy")       # (N,)
keep = filter_overfit(train_scores, val_scores)
print(f"Zachowano {keep.sum()}/{len(keep)} agentow (odrzucono przeuczonych)")
_# Dopiero z tego przefiltrowanego komitetu budujesz ensemble (modul 10) i idziesz na TEST.

W badaniu na 10 kryptowalutach, przeprowadzonym przez krach 2022, agenci mniej przeuczeni (przefiltrowani tą procedurą) pobili zarówno agentów przeuczonych, jak i portfel równoważony (equal-weight) oraz benchmark. To rzadki przypadek, gdy „mniej dopasowany" empirycznie wygrywa — bo test przeszedł przez reżim, którego trening nie widział. Dokładnie o to chodzi w niestacjonarnym rynku.

Hybrydy i LLM: rynek badawczy już zagłosował

Najsilniejszy sygnał z całego pola: udział podejść hybrydowych (RL jako warstwa decyzyjna nad klasycznymi sygnałami quant, a nie surowe end-to-end) wzrósł z 15% (2020) do 42% (2025), a czyste end-to-end DRL spadło z 85% do 58%. To nie moda — to zbiorowa odpowiedź badaczy na niestacjonarność: dawaj sieci najlepsze cechy, jakie umiesz policzyć, zamiast każać jej odkrywać wszystko od zera na zaszumionych danych. Dokładnie ta sama lekcja, co w module 6, potwierdzona statystyką całej dziedziny.

Drugi kierunek: LLM jako źródło cech (nie jako trader). FinRL-DeepSeek (arXiv:2502.07393) wyciąga z newsów sygnały ryzyka i sentymentu modelem językowym (DeepSeek V3) i dokłada je do stanu agenta CPPO handlującego Nasdaq-100. Uczciwy bilans: sygnały z LLM potrafią podnieść zwrot, ale kontrola drawdownu pozostaje problemem, a trwałej przewagi out-of-sample nikt nie udowodnił.

Rozróżnij: „LLM-agent trader" to hype, „LLM-cechy" to sensowny kierunek

Pomysł, że wrzucisz GPT wykresy i newsy, a on będzie tradował — to marketing, nie metoda. Nie ma na to żadnych wiarygodnych wyników. Natomiast używanie LLM do ekstrakcji cech (sentyment, ryzyko, zdarzenia z tekstu), które potem trafiają jako liczby do sprawdzonego agenta RL — to badawczo umocowany, testowalny kierunek. Różnica jest fundamentalna: w pierwszym LLM „decyduje", w drugim tylko „czyta". Buduj to drugie.

Na koniec dwa kierunki warte świadomości, ale nie fundamentu. Hierarchical RL rozdziela decyzję na piętra — agent wysokiego poziomu wybiera aktywa, niskiego zajmuje się egzekucją (arXiv:2410.14927). Multi-agent RL jest naturalny wszędzie tam, gdzie modelujesz rynek jako grę wielu graczy — market making i symulatory książki zleceń. Oba to obecnie proof-of-concept: ciekawe, ale nie buduj na nich systemu produkcyjnego.

Sprawdź się

Przegląd 167 prac wykazał, że wybór rodziny algorytmu (PPO vs SAC vs DQN) ma na wynik wpływ statystycznie znikomy (p = 0.640). Jaki jest praktyczny wniosek?

Skoro rodzina algorytmu nie różnicuje wyniku, dźwignie leżą gdzie indziej: jakość i realizm danych, uczciwe koszty, rygor (walk-forward, wiele ziaren, brak wycieku). To nie znaczy, że metodologia jest zbędna — przeciwnie, staje się jedynym miejscem, gdzie realnie wygrywasz.

Czym różni się offline RL (CQL) od standardowego RL na rynku i dlaczego to ważne?

Eksploracja on-line na rynku to strata pieniędzy na sprawdzaniu złych akcji. Offline RL (CQL/IQL) uczy się z gotowego zbioru trajektorii i celowo zaniża wartość akcji nieobecnych w danych, żeby agent nie fantazjował o niezaobserwowanych zyskach.

Po co stosować CVaR-PPO (karę za lewy ogon strat w funkcji celu) zamiast zwykłego PPO?

CVaR-PPO wpina średnią stratę w najgorszych p% przypadków wprost do funkcji celu. Efekt (m.in. w FinRL-DeepSeek): płytszy drawdown kosztem odrobiny zwrotu. Nie eliminuje strat — czyni je płytszymi, bo ryzyko przestaje być przypisem, a staje się częścią tego, co agent optymalizuje.
Moduł 14 · Zaawansowane · ~35 min

Profesjonalny stack: co robią fundusze, czego nie robi internet

Cel: poznać techniki poziomu produkcyjnego — ensembles, detekcję reżimów, offline RL — i architekturę kompletnego systemu.

Gdzie RL naprawdę działa w finansach

Warto wiedzieć, gdzie profesjonaliści faktycznie stosują RL — bo rzadko jest to „przewidywanie kierunku BTC":

  • Optymalna egzekucja: jak rozłożyć duże zlecenie w czasie, by zminimalizować wpływ na cenę (następca algorytmów typu Almgren–Chriss). Sygnał: mikrostruktura książki zleceń — silniejszy i stabilniejszy niż kierunek rynku. To najbardziej udane zastosowanie RL w finansach.
  • Market making: ustawianie kwotowań bid/ask; nagroda = spread minus koszty inwentarza. Środowisko dobrze zdefiniowane, feedback szybki.
  • Zarządzanie portfelem: RL jako warstwa alokacji nad sygnałami z innych modeli — akcja to wagi portfela, nie „kup/sprzedaj".
  • Hedging opcji: deep hedging — RL uczy się replikować wypłaty z kosztami transakcyjnymi, gdzie klasyczna teoria (Black–Scholes) zakłada ich brak.
Dwa pierwsze punkty rozkładamy na części

Market making i optymalna egzekucja — jedyne obszary, gdzie RL ma naprawdę twarde dowody przewagi — dostały własny moduł 12: od wzorów Avellanedy–Stoikova i Almgrena–Chrissa po RL jako korektę klasyki.

Ensembles: mediana zamiast bohatera

Skoro wariancja między ziarnami jest ogromna (moduł 5), profesjonalna odpowiedź brzmi: nie wybieraj najlepszego — uśrednij komitet. Trenuj 5–10 agentów (różne ziarna, można też różne architektury/nagrody) i zagreguj decyzje:

_# Głosowanie komitetu: pozycja = mediana pozycji docelowych agentów.
_# Redukuje wariancję pojedynczego seeda i wygładza obrót.
import numpy as np
from stable_baselines3 import PPO

models = [PPO.load(f"models/ppo_seed{s}") for s in range(5)]

def ensemble_action(obs) -> int:
    # {0: flat, 1: long, 2: short} -> pozycje {-1, 0, +1}
    pos = [{0: 0, 1: 1, 2: -1}[int(m.predict(obs, deterministic=True)[0])]
           for m in models]
    return int(np.sign(np.median(pos)))   # mediana głosów komitetu

_# Wariant pro (FinRL): co okres walk-forwardu wybieraj do komitetu tylko
_# agentów z dodatnim wynikiem na OSTATNIM oknie walidacyjnym.

Detekcja reżimów: nie jedna polityka, lecz mapa polityk

Rynek przełącza się między reżimami (trend/flauta, niska/wysoka zmienność), a polityka optymalna w jednym bywa fatalna w innym. Dwa podejścia:

  • Jawnie: klasyfikator reżimu (np. HMM na zwrotach i zmienności, biblioteka hmmlearn) + osobny agent per reżim, albo reżim jako cecha w stanie.
  • Niejawnie: sieć rekurencyjna (RecurrentPPO z pakietu sb3-contrib, polityka LSTM) — agent sam utrzymuje wewnętrzną „pamięć reżimu". Kosztuje więcej treningu, ale nie wymaga ręcznej definicji reżimów.

Offline RL, ryzyko w funkcji celu, dane syntetyczne

Trzy techniki z frontu badań, które mają już realne, mierzalne zastosowania — offline RL (uczenie z zastanych trajektorii bez kosztownej eksploracji), distributional RL z CVaR (ryzyko ogonowe wprost w funkcji celu) i syntetyczne dane rynkowe — rozwijamy z kodem w module 13. Tutaj wystarczy wiedzieć, że istnieją i kiedy po nie sięgnąć: offline RL, gdy masz dużo danych historycznych i zero ochoty płacić za eksplorację; CVaR, gdy drawdown boli bardziej niż cieszy zwrot; syntetyki, gdy walk-forward daje za mało niezależnych okien.

Trend 2025/2026: hybrydy wygrywają z czystym RL

W literaturze i praktyce wyraźnie rośnie udział podejść hybrydowych (RL jako warstwa decyzyjna nad klasycznymi cechami i sygnałami quant) kosztem czystego end-to-end RL — hybrydy dają stabilniejsze wyniki na niestacjonarnych rynkach. Drugi kierunek: LLM jako źródło cech — np. FinRL-DeepSeek dokłada do stanu agenta sygnały ryzyka/sentymentu wyciągnięte z newsów przez model językowy. Dla Ciebie praktyczny wniosek jest ten sam, co w module 6: nie każ sieci odkrywać wszystkiego — dawaj jej najlepsze cechy, jakie umiesz policzyć.

Architektura kompletnego systemu

Tak wygląda minimalny system produkcyjny — każdy prostokąt to osobny, testowalny moduł:

WarstwaOdpowiedzialnośćNarzędzia z tego kursu
DanePobieranie, walidacja, magazyn point-in-timeccxt + Parquet + skrypt walidacji (moduł 3)
CechyJedna implementacja dla treningu i produkcjifeatures.py (moduł 6) — importowana przez env i pętlę live
BadaniaTrening, walk-forward, rejestr eksperymentówSB3 + TensorBoard + CSV/MLflow (moduły 5, 7)
EgzekucjaPętla live, zlecenia, stan pozycjiccxt + testnet (moduł 9)
RyzykoLimity twarde, kill-switch — NAD modelemOsobna klasa, testowana jednostkowo
MonitoringLogi, alerty, porównanie live vs backtestlogging + prosty dashboard / powiadomienia
Zasada dwóch implementacji

Wszystko, co dotyka pieniędzy, licz dwiema niezależnymi drogami: PnL według Twojego systemu vs PnL według giełdy; pozycja według stanu wewnętrznego vs fetch_positions(). Rozjazd → stop i alarm. Fundusze mają całe zespoły rekoncyliacji; Ty potrzebujesz przynajmniej jednego asserta.

Czego ten kurs świadomie nie pokrył (dalsze horyzonty)

  • Dane alternatywne: sentyment (X/Reddit), dane on-chain (Glassnode), przepływy giełdowe — potencjalne cechy o realnej wartości informacyjnej.
  • Symulacja książki zleceń (LOB): dla strategii HFT/market-making środowisko musi symulować kolejkę zleceń — rząd wielkości więcej pracy (ABIDES, własny symulator).
  • Multi-asset: portfel wielu instrumentów — akcja to wektor wag; zaczynając, ogranicz się do 2–5 płynnych aktywów.
  • Meta-labeling (López de Prado): model pierwotny daje kierunek, ML decyduje tylko „grać czy nie grać" — często lepsze niż end-to-end RL.
Sprawdź się

Skoro wariancja między ziarnami jest ogromna, jaka jest profesjonalna odpowiedź?

Wybór najlepszego seeda to selekcja pod szum — dokładnie to, przed czym ostrzega moduł o wariancji. Komitet uśrednia idiosynkrazje pojedynczych przebiegów i wygładza obrót. Ziarna nigdy nie zbiegną do jednego wyniku, bo to natura RL na rynkach.

Które zastosowanie RL w finansach jest najbardziej udane i dlaczego?

RL działa najlepiej tam, gdzie sygnał jest mocny i szybki, a feedback natychmiastowy — to egzekucja i market making, następcy algorytmów typu Almgren–Chriss. Przewidywanie kierunku ceny ma sygnał bliski szumowi, więc jest znacznie trudniejsze.

Po co detekcja reżimów (trend/flauta, niska/wysoka zmienność)?

Rynek przełącza dynamikę, a strategia dobra w trendzie potrafi tracić w flaucie. Reżim można podać jawnie (np. HMM plus agent per reżim albo jako cechę) lub niejawnie przez politykę rekurencyjną. Nie ma to nic wspólnego z rozmiarem sieci ani zerowaniem kosztów.
Moduł 15 · Warsztat · ~30 min

Setup: Mac M3 ze 128 GB jako stacja badawcza

Cel: kompletna, powtarzalna konfiguracja maszyny — od Homebrew po nocne treningi — z realistycznymi oczekiwaniami co do wydajności.

Co ten sprzęt daje w RL

  • 128 GB unified memory to luksus: cały zbiór minutowy BTC (lata) mieści się w RAM wielokrotnie; możesz trzymać dziesiątki równoległych środowisk i ogromne replay buffery bez dotykania dysku.
  • Rdzenie CPU M3 są bardzo szybkie w pojedynczym wątku — a pętla RL (env → mały MLP → env) jest właśnie single-thread-bound. To dlatego CPU wygrywa z MPS dla małych sieci.
  • GPU (MPS) włączasz dla CNN/LSTM/transformerów i dużych batchy. MLX (framework ML Apple) jest świetny do LLM, ale ekosystem RL stoi na PyTorchu — zostań przy PyTorch + MPS.
  • Czego nie zrobisz lokalnie: masywne sweepy tysięcy konfiguracji (to domena klastrów). Ale 5 ziaren × 6 foldów × PPO? Spokojnie, przez noc.

Instalacja od zera — kompletny skrypt

_# 1. Narzędzia bazowe
xcode-select --install                       # kompilatory (wymagane przez część pakietów)
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
brew install uv git htop

_# 2. Projekt i środowisko (Python 3.11 - najszersza kompatybilność RL)
mkdir -p ~/dev/rl-trading && cd ~/dev/rl-trading && git init
uv venv --python 3.11 && source .venv/bin/activate

_# 3. Pełny stack kursu
uv pip install numpy pandas pyarrow matplotlib \
    ccxt yfinance \
    gymnasium stable-baselines3 sb3-contrib tensorboard \
    torch \
    hmmlearn d3rlpy \
    pytest ruff

_# 4. Weryfikacja instalacji
python - <<'EOF'
import torch, stable_baselines3, gymnasium, ccxt
print("PyTorch:", torch.__version__, "| MPS:", torch.backends.mps.is_available())
print("SB3:", stable_baselines3.__version__, "| Gymnasium:", gymnasium.__version__)
EOF

_# 5. Higiena repo: nie commituj danych, modeli ani sekretów
printf "data/\nmodels/\nruns/\n.venv/\n*.log\n.env\n" > .gitignore
git add -A && git commit -m "init: środowisko RL trading"

Nocne treningi i równoległość

_# caffeinate: Mac nie zaśnie w trakcie treningu (klapa może być zamknięta przy zasilaniu)
caffeinate -i python 08_walkforward.py

_# Trening w tle z logiem i możliwością wylogowania:
nohup caffeinate -i python 08_walkforward.py > wf.log 2>&1 &
tail -f wf.log                                # podgląd postępu

_# Monitorowanie zasobów podczas treningu:
htop                                          # obciążenie rdzeni CPU
sudo powermetrics --samplers gpu_power -i 2000   # czy GPU faktycznie pracuje (dla MPS)
"""Walk-forward z foldami w osobnych procesach. M3 + 128 GB RAM: 6 foldów
naraz to nic. UWAGA: ogranicz wątki BLAS per proces, inaczej procesy
będą się dusić nawzajem o rdzenie."""
import os
os.environ["OMP_NUM_THREADS"] = "2"      # PRZED importem numpy/torch!

from multiprocessing import Pool
from my_walkforward import run_fold      # funkcja: (fold_id) -> wynik

if __name__ == "__main__":
    with Pool(processes=6) as pool:      # 6 foldów jednocześnie
        results = pool.map(run_fold, range(6))
    print(results)

Wektoryzacja środowisk — darmowe przyspieszenie

_# SubprocVecEnv: N kopii środowiska w osobnych procesach; PPO zbiera
_# doświadczenie N razy szybciej. Na M3 sweet spot to zwykle 8-12 kopii.
from stable_baselines3.common.vec_env import SubprocVecEnv

def make_env(seed_offset):
    def _f():
        env = TradingEnv(train_prices)
        env.reset(seed=seed_offset)
        return env
    return _f

if __name__ == "__main__":
    env = SubprocVecEnv([make_env(i) for i in range(8)])
    model = PPO("MlpPolicy", env, n_steps=256, device="cpu")  # 256*8=2048 kroków/rollout
    model.learn(2_000_000)

Orientacyjne czasy na M3 (rząd wielkości)

ZadanieCzasUwagi
Pobranie pełnej historii BTC 1h~2 minlimit API, nie łącze
PPO 500k kroków, MLP 2×128, 1 env10–20 minCPU, single-thread bound
PPO 2M kroków, 8 env równolegle~30–60 minSubprocVecEnv
Walk-forward 6 foldów × 5 ziarennoczrównoleglone po foldach
Sweep 30 konfiguracji hiperparametrówweekendtu kończy się laptop, zaczyna chmura
Sprawdź się

Dlaczego mały MLP w RL bywa szybszy na CPU niż na GPU (MPS) na M3?

GPU wygrywa przy dużych, równoległych obliczeniach — a tu dominuje sekwencyjna pętla env i mikroskopijne przejścia przez sieć. Koszt przerzucania danych na MPS zjada korzyść. Dla CNN/LSTM albo dużych batchy sytuacja się odwraca i wtedy warto MPS.

Co realnie daje 128 GB unified memory w tym kursie?

Duża, wspólna pamięć to luksus przepustowości danych: trzymasz historię i wiele środowisk naraz w RAM. Nie zamienia laptopa w maszynę do LLM-ów ani nie znosi metodologii — walk-forward jest wymogiem rzetelności, nie ograniczeniem sprzętu.

Uruchamiasz 6 foldów walk-forward równolegle przez multiprocessing. Dlaczego ustawić OMP_NUM_THREADS na małą wartość PRZED importem numpy/torch?

Domyślnie każdy proces bierze tyle wątków BLAS, ile jest rdzeni, więc 6 procesów walczy o te same rdzenie i wszystko zwalnia. Ograniczenie do 2 wątków na proces daje czysty podział zasobów. Zmienną trzeba ustawić przed importem, bo biblioteki czytają ją przy starcie.
Moduł 16 · Synteza · ~20 min

Roadmapa 16 tygodni i biblioteka zasobów

Cel: zamienić ten kurs w plan tygodniowy z jasnymi kryteriami „zaliczenia" każdego etapu.

Plan tygodniowy

Tydzień 1–2 · v0

Fundamenty RL

Moduły 1–2. Bandyta i Q-learning z pamięci, ćwiczenia z gridworlda.

Zaliczenie: Twój Q-learning osiąga śr. zwrot ≥ 8.5 na gridworldzie; umiesz wyjaśnić Bellmana bliskiej osobie.

Tydzień 3 · v1

Pipeline danych

Moduł 3. Pobieranie, walidacja, podział chronologiczny, BTC 1h + SPY 1d.

Zaliczenie: 03_download.py działa idempotentnie; raport jakości danych bez niezrozumiałych anomalii.

Tydzień 4–5 · v1

Środowisko

Moduł 4. TradingEnv + testy (API check, agent losowy, zgodność B&H).

Zaliczenie: wszystkie trzy sanity checki przechodzą; rozumiesz każdą linię step().

Tydzień 6–7 · v2

Deep RL

Moduł 5. PPO na train, ewaluacja na val, 5 ziaren, TensorBoard, benchmark CPU vs MPS.

Zaliczenie: tabela wyników 5 ziaren z medianą; potrafisz wskazać overfitting na krzywych.

Tydzień 8 · v2

Cechy i nagroda

Moduł 6. Wektor cech v2, eksperymenty z λ ryzyka, dziennik eksperymentów.

Zaliczenie: porównanie „okno zwrotów vs cechy v2" na 5 ziarnach, wnioski na piśmie.

Tydzień 9–10 · v3

Walk-forward i ewaluacja

Moduły 7–8. Pełny walk-forward z embargiem, metryki, deflated Sharpe, benchmarki B&H / DCA / SMA, stress test kosztów.

Zaliczenie: raport OOS: Sharpe, Calmar, MaxDD agenta vs 3 benchmarki, wrażliwość na 2× koszty. Uwaga: wynik „agent przegrywa z DCA" to też zaliczenie — nauczyłeś się prawdy.

Tydzień 11 · v3

Risk layer

Moduł 10. Vol targeting, ułamkowy Kelly, kill-switch i limity jako osobna, testowana jednostkowo warstwa nad agentem.

Zaliczenie: RiskLayer z testami pytest; symulacja, w której risk layer ratuje kapitał przed celowo zepsutym agentem.

Tydzień 12–13 · v3

Paper trading i architektura live

Moduły 9 i 11. Pętla na testnecie Binance z reconciliation i idempotencją zleceń, logowanie każdej decyzji, monitoring dryfu.

Zaliczenie: 2+ tygodnie nieprzerwanej pracy pętli (z co najmniej jednym restartem, po którym stan uzgodnił się sam); rozjazd paper↔backtest wyjaśniony co do przyczyny.

Tydzień 14 · v4

Mikrostruktura i realistyczne koszty

Moduły 9 i 12. Funding w środowisku, poślizg zależny od zmienności (√-prawo), OI/basis jako cechy.

Zaliczenie: ten sam agent przetestowany w env v1 (koszty stałe) i v2 (koszty reżimowe) — raport różnic. Zwykle boli.

Tydzień 15–16 · v4

Zaawansowany warsztat

Moduł 13. Offline RL (CQL na trajektoriach z Twojego env) albo CVaR-PPO — jedno z dwóch, porządnie. Lab anty-overfittingowy na własnych agentach.

Zaliczenie: porównanie z bazowym PPO na tych samych oknach OOS, z deflated Sharpe i uczciwym N.

Dalej

Iteracja bez końca

Moduł 14: ensembles, detekcja reżimów. Więcej instrumentów, lepsze cechy, dłuższy paper trading. Realne środki — najwcześniej po miesiącach zgodnego paper tradingu i wyłącznie kwotą, której utratę akceptujesz z góry.

Biblioteka

Książki — kanon

  • Sutton & Barto, „Reinforcement Learning: An Introduction" (2. wyd.) — biblia RL, darmowy PDF na stronie autorów. Rozdziały 1–6 pokrywają moduły 1–2.
  • Marcos López de Prado, „Advances in Financial Machine Learning" — obowiązkowa: purged CV / CPCV, deflated Sharpe, meta-labeling, fractional differentiation. Krótsza kontynuacja: „Machine Learning for Asset Managers" (2020). Przeczytaj też jego esej „10 Reasons Most ML Funds Fail".
  • Stefan Jansen, „Machine Learning for Algorithmic Trading" (2. wyd.) — szeroki podręcznik praktyczny z rozdziałem o deep RL; kod na GitHubie (stefan-jansen/machine-learning-for-trading).
  • Ernest Chan, „Algorithmic Trading" — klasyka o strategiach i ich psuciu się; zdrowy sceptycyzm.

Narzędzia i repozytoria

  • Stable-Baselines3 (DLR-RM/stable-baselines3) — implementacje referencyjne PPO/DQN/SAC; czytaj ich dokumentację „RL Tips and Tricks" — złoto.
  • sb3-contrib — RecurrentPPO (LSTM), QR-DQN i inne rozszerzenia.
  • FinRL (AI4Finance-Foundation/FinRL) — framework RL-dla-finansów: gotowe środowiska (akcje, krypto, portfele), tutoriale i coroczny FinRL Contest z aktualnymi benchmarkami. Świetny do czytania i inspiracji; jakość kodu bywa nierówna i API się zmienia, więc traktuj jako baseline, nie fundament — do nauki i tak zbuduj własny env (wiesz już czemu).
  • gym-trading-env — lekkie, dobrze udokumentowane środowisko tradingowe Gymnasium; dobre drugie spojrzenie po Twoim własnym.
  • vectorbt — błyskawiczne wektorowe backtesty (NumPy+Numba; miliony zleceń w ułamku sekundy). Idealny do triage: zanim wydasz noc GPU na RL, sprawdź w minutę, czy w sygnale w ogóle jest alpha.
  • NautilusTrader — profesjonalna platforma event-driven (rdzeń w Rust): realistyczna egzekucja (poślizg, częściowe fille) i ten sam kod w backteście i live. Wyższy próg wejścia; naturalny następca Twojej własnej pętli z modułu 9. Rekomendowany workflow: vectorbt (przesiew) → Nautilus (realistyczna weryfikacja i live).
  • backtrader / zipline-reloaded — starsze silniki event-driven; backtrader od 2023 tylko w trybie utrzymania (bez nowych funkcji), zipline-reloaded to żywy fork do badań na akcjach. TensorTrade omijaj — projekt w stagnacji.
  • d3rlpy — offline RL (CQL, IQL, Decision Transformer) z prostym API; do tego Minari (Farama) jako standard datasetów offline. Warsztat do modułu 13.
  • ccxt — jednolite API do 100+ giełd krypto; Twoja warstwa danych i egzekucji.

Kursy i materiały online

  • David Silver, „RL Course" (UCL/DeepMind, YouTube) — najlepszy wykład teorii RL.
  • OpenAI „Spinning Up in Deep RL" — zwięzłe, precyzyjne wprowadzenie do deep RL z kodem.
  • Hugging Face „Deep RL Course" — darmowy, praktyczny, na Gymnasium i SB3.
  • QuantStart, Quantopian lectures (archiwum) — metodologia badań kwantowych.

Kluczowe prace (frontier 2022–2026)

  • „A Systematic Review of RL in Financial Decision Making" (2025, arXiv:2512.10913) — przegląd 167 prac; wniosek-kręgosłup: rodzina algorytmu prawie nie ma znaczenia, metodologia i dane — tak.
  • Bailey & López de Prado, „The Deflated Sharpe Ratio" (2014) — matematyka z modułu 8: ile wart jest najlepszy wynik z N prób.
  • „DRL for Cryptocurrency Trading: Addressing Backtest Overfitting" (2022, arXiv:2209.05559) — wykrywanie przeuczenia jako test hipotezy; podstawa labu w module 13.
  • „RL for Trade Execution with Market and Limit Orders" (2025, arXiv:2507.06345) i RL + Avellaneda–Stoikov (PLOS ONE 2022) — dowody z modułu 12.
  • „When should we prefer Decision Transformers for Offline RL?" (ICLR 2024) — kiedy DT bije CQL (rzadka nagroda, słabe dane) i odwrotnie.
  • FinRL Contests (arXiv:2504.02281) i FinRL-DeepSeek (arXiv:2502.07393) — żywe benchmarki i LLM-cechy z modułu 13.

Słowo na koniec

Jeżeli po 12 tygodniach Twój agent przegrywa z DCA — jesteś w większości, która mówi prawdę. Prawdziwa wygrana tego kursu to umiejętności: budowa środowisk, dyscyplina walidacyjna, sceptycyzm wobec własnych wyników. To one są rzadkie i cenne — na rynku pracy bardziej niż niejeden „działający" backtest. A jeśli kiedyś znajdziesz przewagę: będziesz miał warsztat, żeby ją zweryfikować, zanim jej zaufasz.