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
13
modułów
7
interaktywnych symulacji
20+
plików kodu z komentarzami
12 tyg.
proponowana roadmapa
Moduł 0 · Orientacja

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 12 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, paper trading, monitoring7–12Wszystko, co odróżnia zabawkę od systemu

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 11 (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
Moduł 1 · Fundamenty

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.
Moduł 2 · Fundamenty

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: 12 kroków * (-0.1) + 10 = +8.8. Jeśli 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
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%.
Moduł 3 · Rynek i dane

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.

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())

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.
Moduł 4 · Rynek i dane

Ś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.

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.

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.
Moduł 5 · Deep RL

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.

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()

_# 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))])
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[32])
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.
Moduł 6 · Deep RL

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.

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.
Moduł 7 · Rzetelność

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)? ✓
Moduł 8 · Rzetelność

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 = log_rets[log_rets < 0].std(ddof=1) # odchylenie tylko strat
    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))

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.

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: uwzględnij, ile wariantów agenta przetestowałeś. Wzór i kod znajdziesz w książce Lópeza de Prado (rozdz. 14) — implementacja to ~20 linii.
  4. Sprawdź stabilność między foldami: agent lepszy w 5/6 foldów > agent lepszy średnio dzięki jednemu foldowi.
Moduł 9 · Produkcja

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

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ć."""
    ohlcv = ex.fetch_ohlcv(SYMBOL, "1h", limit=40)
    close = np.array([c[4] for c in ohlcv], dtype=np.float64)
    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.

Sizing i ryzyko: zanim pomyślisz o dźwigni

  • Vol targeting: wielkość pozycji = cel_zmienności / zmienność_bieżąca. Automatycznie zmniejsza ekspozycję w chaosie — najprostszy risk management, który realnie działa.
  • Kryterium Kelly'ego: teoretyczne optimum wielkości zakładu f* = μ/σ². W praktyce estymaty μ są tak niepewne, że gra się ułamkiem Kelly (¼–½). Pełny Kelly na błędnej estymacie = bankructwo.
  • Twarde limity poza modelem: max pozycja, max strata dzienna, kill-switch. Model może zwariować (i kiedyś zwariuje) — limity egzekwuje osobna warstwa kodu, nie agent.
Moduł 10 · Produkcja

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.

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 i decision transformers — frontier

Klasyczny RL uczy się przez interakcję; na rynku „interakcja" to strata pieniędzy. Offline RL (CQL, IQL) uczy się z zastanego zbioru trajektorii bez dalszej eksploracji — konserwatywnie zaniżając wartość akcji, których nie ma w danych. Decision Transformer idzie dalej: traktuje RL jak modelowanie sekwencji (stan, akcja, zwrot-do-uzyskania) i uczy się transformera warunkowanego pożądanym wynikiem. W finansach to obiecujący, ale wciąż badawczy teren — traktuj jako moduł „po kursie", nie fundament. Biblioteka do eksperymentów: d3rlpy (CQL/IQL, czyste API).

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.
Moduł 11 · Produkcja

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
Moduł 12 · Synteza

Roadmapa 12 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, 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–12 · v3

Paper trading

Moduł 9. Pętla na testnecie Binance, logowanie, cotygodniowa rekoncyliacja live vs backtest.

Zaliczenie: 2+ tygodnie nieprzerwanej pracy pętli; rozjazd paper↔backtest wyjaśniony co do przyczyny.

Dalej

Iteracja bez końca

Moduł 10: ensembles, reżimy, offline RL. 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) z prostym API.
  • 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.

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.