Skip to content
← Wszystkie wpisy
5 min czytania Michał Smykowski

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.