Zasady SOLID w systemie tradingowym
SOLID może brzmieć jak zbiór pustych akronimów, dopóki system nie urośnie na tyle, żeby ukarać cię za ich ignorowanie. Opowieść z praktyki o zastosowaniu tych pięciu zasad w systemie tradingowym — gdzie każda z nich naprawdę się sprawdziła, a dogmatyczne trzymanie się ich tylko by zaszkodziło.
SOLID — pięć zasad projektowania obiektowego — w teorii brzmi jak zbiór abstrakcyjnych akronimów. Ożywa dopiero wtedy, gdy budujesz coś na tyle złożonego, że ich ignorowanie zaczyna boleć. System tradingowy to dokładnie taki projekt: wiele strategii, wiele giełd, dane rynkowe, egzekucja zleceń, kontrola ryzyka — współpracujące elementy, które zmieniają się w różnym tempie, a na szali są prawdziwe pieniądze. To opowieść z praktyki o tym, jak SOLID sprawdził się w takim systemie — gdzie każda z zasad naprawdę zarobiła na swoje miejsce, a dogmatyczne trzymanie się ich byłoby błędem.
Single Responsibility: oddziel to, co zmienia się z różnych powodów
Zasada Single Responsibility mówi, że klasa powinna mieć jeden powód do zmiany. W systemie
tradingowym pokusą jest wszechogarniająca klasa TradingBot, która pobiera dane rynkowe,
uruchamia strategię, sprawdza ryzyko, składa zlecenia i zapisuje wyniki. Przez chwilę działa, a
potem staje się nieutrzymywalna — bo każdy z tych aspektów zmienia się z innego powodu:
strategię modyfikujesz, gdy masz nowy pomysł; egzekucję — gdy zmieni się API giełdy; kontrolę
ryzyka — gdy zmieni się twój apetyt na ryzyko.
Dlatego rozdzielasz to na osobne klasy: MarketDataFeed, Strategy, RiskCheck,
OrderExecutor, TradeRecorder. Każda ma jedno zadanie i jeden powód do zmiany. Korzyść jest
konkretna: gdy giełda zmienia API, ruszasz tylko executora; gdy poprawiasz strategię, ruszasz
tylko strategię; a każdy element można testować w izolacji (strategię wystarczy odpalić na
sfabrykowanych danych, bez łączenia z giełdą). SRP to tutaj nie abstrakcyjna dbałość o porządek —
to właśnie ona pozwala zmieniać jeden aspekt systemu obracającego pieniędzmi bez obawy, że coś
zepsujesz w innym miejscu.
Open/Closed: dodawaj strategie bez dotykania silnika
Zasada Open/Closed — otwarte na rozszerzenie, zamknięte na modyfikację — to ta, która opłaciła się najbardziej. Sensem systemu tradingowego jest ciągłe dodawanie i zmienianie strategii. Jeśli dodanie strategii wymaga grzebania w rdzeniu silnika, każdy nowy pomysł grozi zepsuciem tych, które już działają. Zamiast tego silnik zależy od interfejsu strategii, a każdą nową strategię dodajesz jako osobną klasę:
class Strategy # kontrakt, który każda strategia implementuje
def signal(market) = raise NotImplementedError # => :buy / :sell / :hold
end
class MeanReversion < Strategy
def signal(market) = market.price < market.moving_average ? :buy : :sell
end
class Momentum < Strategy
def signal(market) = market.trend_up? ? :buy : :hold
end
# silnik jest zamknięty na modyfikację, otwarty na nowe strategie
class Engine
def initialize(strategy) = @strategy = strategy
def tick(market) = execute(@strategy.signal(market))
end
Dodanie nowej strategii sprowadza się do napisania nowej klasy implementującej signal — silnik
pozostaje nietknięty, więc przetestowany, działający kod nie musi być ruszany, żeby wesprzeć nowy
pomysł. W systemie, którego głównym zajęciem jest eksperymentowanie ze strategiami, to różnica
między bezpiecznym iterowaniem a bazą kodu, która robi się coraz bardziej ryzykowna z każdą
kolejną zmianą.
Dependency Inversion: zależ od abstrakcji, podmieniaj giełdę
Zasada Dependency Inversion — zależ od abstrakcji, a nie od konkretów — to ona uchroniła system przed przyspawaniem go do jednej giełdy. Silnik i strategie nie mogą zależeć od konkretnej giełdy (Binance, Kraken, jakiejkolwiek innej); zależą od abstrakcyjnego interfejsu giełdy, a konkretne adaptery go implementują:
class Exchange # abstrakcja, od której zależy system
def ticker(pair) = raise NotImplementedError
def place_order(side:, amount:, price:) = raise NotImplementedError
end
class BinanceAdapter < Exchange; end # konkretne implementacje
class KrakenAdapter < Exchange; end
class PaperExchange < Exchange; end # atrapa do backtestingu i testów!
Engine.new(strategy).run(exchange: BinanceAdapter.new)
Dało to dwie ogromne korzyści. Po pierwsze, możesz obsługiwać wiele giełd bez ruszania rdzenia
systemu. Po drugie — i to jest bezcenne dla systemu tradingowego — możesz wstrzyknąć
PaperExchange (atrapę) do backtestingu i testów, uruchamiając cały system na symulowanych
danych rynkowych i zleceniach, bez ryzykowania prawdziwych pieniędzy. To właśnie DIP w ogóle
uczynił ten system testowalnym — bez niego każdy test uderzałby w prawdziwą giełdę. Zależność od
abstrakcji, a nie od konkretnej giełdy, to właśnie to, co pozwoliło bezpiecznie testować
najbardziej ryzykowne fragmenty systemu.
Interface Segregation i Liskov — w skrócie
Pozostałe dwie zasady dały mniejsze, ale wciąż realne korzyści. Interface Segregation (nie
zmuszaj klientów do zależności od metod, których nie używają) sprawiła, że konsument danych
rynkowych tylko-do-odczytu nie widział metod składania zleceń — strategia, która jedynie czyta
rynek, nie może przez przypadek złożyć zlecenia, bo jej interfejs po prostu tego nie zawiera.
Liskov Substitution (podtypy muszą być użyteczne wszędzie tam, gdzie działa typ bazowy)
sprawiła, że każdy adapter Exchange i każda Strategy naprawdę zachowywały się tak, jak
obiecywał ich interfejs — dzięki temu PaperExchange mógł z pełnym zaufaniem zastąpić prawdziwą
giełdę w testach. Gdyby atrapa nie dotrzymywała kontraktu, testy po prostu by kłamały.
Uczciwe zastrzeżenie: SOLID to przewodnik, nie religia
Skoro już je pochwaliłem, muszę dodać drugą stronę tej lekcji. SOLID stosowany dogmatycznie — interfejs do wszystkiego, każda klasa rozbita na atomy, abstrakcje nad rzeczami, które i tak zawsze będą miały jedną implementację — tworzy własny bałagan: mgławicę maleńkich klas i warstw pośrednich, równie trudną do prześledzenia jak klasa-moloch, której chciałeś uniknąć. System tradingowy skorzystał z SOLID tam, gdzie faktycznie żyła złożoność i zmiana — strategie (dużo, ciągle się zmieniają → OCP), giełdy (kilka, wymienne, muszą być podmienialne na atrapy → DIP), odrębne odpowiedzialności zmieniające się niezależnie (→ SRP). Nie skorzystałby natomiast z odwracania zależności, która ma i będzie miała dokładnie jedną implementację, ani z rozbijania spójnej klasy tylko dlatego, że tak każe reguła. Klucz — ten sam, który przewija się przez inne wpisy o architekturze — to stosować zasadę tam, gdzie zwraca się koszt wprowadzanej pośredniości, a nie tam, gdzie jest tylko ceremonią.
Podsumowanie
SOLID przestaje być zbiorem pustych akronimów w chwili, gdy system jest na tyle złożony, żeby ukarać cię za jego ignorowanie — a algorytmiczny system tradingowy, z wieloma strategiami, wieloma giełdami i pieniędzmi na szali, jest do tego idealnym nauczycielem. Single Responsibility pozwoliło każdej odpowiedzialności ewoluować niezależnie, bez zagrożenia dla pozostałych; Open/Closed umożliwiło dodawanie nowych strategii bez ruszania przetestowanego silnika; Dependency Inversion odseparowało system od jakiejkolwiek konkretnej giełdy i, co najważniejsze, uczyniło go testowalnym za pomocą paper exchange; a ISP i LSP pilnowały uczciwości interfejsów. Ale najgłębsza lekcja tkwi właśnie w tym zastrzeżeniu: SOLID to zbiór wskazówek, które stosuje się tam, gdzie naprawdę żyje złożoność i zmiana — a nie dogmat, który należy narzucać wszędzie. Zastosowane z takim wyczuciem, te pięć zasad zamieniło system, który musiał być jednocześnie elastyczny i wiarygodny, w taki, który naprawdę taki był — a gdy na każdej decyzji zależą prawdziwe pieniądze, o to właśnie chodzi.