Trzy osobowości Bliźniąt debatują nad nieudaną strategią wyjścia z okresu wysokiej zmienności
Test wsteczny strategii wykorzystującej wybicie zmienności na akcjach o wysokim beta, dostosowanej przez analityka kwantowego, menedżera ryzyka oraz specjalistę od realizacji transakcji – pokazujący, co ulepszyło się, a co nadal pozostaje w tyle za SPY.
Wybicia z zakresu zmienności kuszą niemal każdego analityka ilościowego, który posiada skrypt w Pythonie oraz narzędzie do przetwarzania danych rynkowych. Akcje o wysokim wskaźniku beta rozwijają się pomyślnie, aż nagle zmieniają kierunek, a jeden tylko popołudnie może zniszczyć konta inwestycyjne. Optymalizatory skupione na jednej wartości, zwykle wskaźniku Sharpe’a, modyfikują parametry tak długo, aż historyczny szum wygląda jak „alfa”. Rynki w czasie rzeczywistym nie przejmują się nadmiernie dopasowanymi metodami poszukiwania optymalnych rozwiązań.
Konwencjonalna kalibracja również traktuje ryzyko jako kwestię drugorzędną – dodaje się statyczny stop-loss lub ustaloną wielkość pozycji na końcu. To nie działa, ponieważ generowanie „alfy” i przetrwanie wymagają przeciwnych podejść. Analityk ilościowy chce silnego narażenia na trendy rynkowe, podczas gdy doświadczony menedżer ryzyka chce mieć wszystko przygotowane przed nagłym wzrostem zmienności.
To doświadczenie ma na celu połączenie tych dwóch podejść poprzez debatę między trzema postaciami Gemini: analitykiem ilościowym, menedżerem ryzyka oraz strategiem realizacji. Krytykują, modyfikują i weryfikują strategię radzenia sobie z wzrostem zmienności na portfelu składającym się z piętnastu akcji o wysokim wskaźniku beta oraz SPY jako punktu odniesienia. Nie zawsze się zgadzają, a ta różnica zdań jest celowa. Średniowanie sugestii parametrów od postaci o różnych priorytetach stabilizuje wynik.
Oto, gdzie zakończył się eksperyment po trzech rundach strukturyzowanej debaty:
{
"strategy_version": "v2.0-optimized",
"final_sharpe_ratio": -0.6822300207528283,
"consensus_rationale": "Iteration 1 consensus achieved superior Sharpe ratio through optimized ATR multiplier and volume threshold refinement.",
"max_drawdown_delta": 0.00687485272186061,
"total_iterations_run": 3
}
Punkt wyjścia stanowił kiepski wskaźnik Sharpe’a na poziomie ujemnych 2,68 wraz z ujemnym kumulatywnym zwrotem. Dyskusja pomogła strategii wydostać się z tej sytuacji, a także dostarczyła pouczającego porównania z szerszym rynkiem. Budowa strategii wymagała starannego obchodzenia się z danymi oraz modułowego narzędzia do testowania, dlatego omówienie zaczyna się od świata rynkowego i danych historycznych.
Dane i świat rynkowy
Świat akcji o wysokim beta
Wybieranie pojedynczych akcji technologicznych na podstawie hossy w mediach społecznościowych nie stanowi metody, więc punktem wyjścia jest ustrukturyzowany zbiór akcji. W testach użyto piętnastu akcji o wysokim beta wraz z SPY. Akcje o wysokim beta zapewniają dużą zmienność cen, której potrzebują zasady wykrywania przełomów, natomiast SPY służy jako punkt odniesienia, który odróżnia prawdziwe alfa od szumu spowodowanego beta.
Zanim zostanie stworzona jakakolwiek logika obsługi wyjątków, konieczne jest czyste zdefiniowanie importów oraz globalnej konfiguracji. Importy obejmują pandas i numpy do obliczeń matematycznych, yfinance do pozyskiwania cen oraz SDK Gen AI firmy Google, które odpowiada za generowanie postaci użytkowników. Przechowywanie ustawień w jednym miejscu ułatwia powtarzanie późniejszych eksperymentów.
import os
import json
import logging
from datetime import datetime, timezone
import pandas as pd
import numpy as np
import yfinance as yf
import matplotlib.pyplot as plt
from google import genai
logging.basicConfig(level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s')
Brakujące dane i dopasowanie MultiIndex
Dane finansowe są skomplikowane. Yahoo Finance może zwracać kolumny typu MultiIndex lub rozproszone wartości NaN spowodowane przerwami w publikacji danych, różnymi datami wprowadzenia na giełdę lub działaniami korporacyjnymi. Gdy do narzędzia do testowania strategii wprowadzi się niespójne tabele DataFrame, obliczenia pozycji stają się błędne, co sprawia, że wyniki portfela są nieprawdziwe.
Klasa MarketDataLoader radzi sobie z tym poprzez obsługę procesu pobierania danych. Ustawia parametr auto_adjust=True, dzięki czemu ceny są korygowane pod kątem podziałów akcji i dywidend, stosuje metodę uzupełniania wartości w przód oraz bezpiecznie usuwa wszystkie początkowe wiersze z wartościami NaN powstałe na skutek różnych dat IPO, co zapewnia spójną strukturę danych.
class MarketDataLoader:
def __init__(self, tickers: list[str], benchmark: str):
self.tickers = tickers
self.all_symbols = tickers + [benchmark]
self.benchmark = benchmark
def fetch_data(self) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame, pd.DataFrame]:
end_date = datetime.now(timezone.utc).strftime('%Y-%m-%d')
start_date = (datetime.now(timezone.utc) -
pd.Timedelta(days=365 * 3)).strftime('%Y-%m-%d')
logging.info(
f"Fetching historical data from {start_date} to {end_date} for {len(self.all_symbols)} symbols.")
data = yf.download(self.all_symbols, start=start_date,
end=end_date, auto_adjust=True, progress=False)
if data.empty:
logging.error(
f"Failed to download data for symbols: {self.all_symbols}")
raise ValueError(
f"Failed to download data for symbols: {self.all_symbols}")
if isinstance(data.columns, pd.MultiIndex):
close = data['Close'] if 'Close' in data.columns.levels[0] else data.xs(
'Close', level=1, axis=1)
high = data['High'] if 'High' in data.columns.levels[0] else data.xs(
'High', level=1, axis=1)
low = data['Low'] if 'Low' in data.columns.levels[0] else data.xs(
'Low', level=1, axis=1)
volume = data['Volume'] if 'Volume' in data.columns.levels[0] else data.xs(
'Volume', level=1, axis=1)
else:
close = data[['Close']]
high = data[['High']]
low = data[['Low']]
volume = data[['Volume']]
close = close.ffill().dropna(how='all')
high = high.ffill().dropna(how='all')
low = low.ffill().dropna(how='all')
volume = volume.ffill().fillna(0.0)
if close.empty:
logging.error(
"Cleaned price data DataFrame is empty after processing.")
raise ValueError(
"Cleaned price data DataFrame is empty after processing.")
logging.info(
f"Data checkpoint - Shape: {close.shape}, Column sample: {list(close.columns[:3])}, NaN count: {close.isna().sum().sum()}, Date range: {close.index[0]} to {close.index[-1]}")
return close, high, low, volume
Gdy pipeline jest uruchamiany, loader pobiera trzy lata codziennych danych dla wszystkich szesnastu symboli i sprawdza ich dopasowanie, zanim zostaną one poddane jakimkolwiek obliczeniom:
INFO - Fetching historical data from 2023-08-10 to 2026-08-09 for 16 symbols.
INFO - Data checkpoint - Shape: (751, 16), Column sample: ['AMD', 'COIN', 'DKNG'], NaN count: 0, Date range: 2023-08-10 00:00:00 to 2026-08-07 00:00:00
Log pokazuje 751 dni handlowe dla 16 symboli, od sierpnia 2023 roku do sierpnia 2026 roku, bez żadnych wartości NaN. Gdy panel jest zsynchronizowany i zweryfikowany, uwaga może zostać przeniesiona z inżynierii danych na strategię.
Silnik wykrywania przełamań
Momentum i zmienność razem
Przełamania przyciągają inwestorów detalicznych, ponieważ obiecują natychmiastowy wzrost cen. W praktyce wiele sygnałów przełamań w akcjach o wysokim beta to fałszywe ruchy, które zatrzymują płynność przed odwróceniem trendu. Przekroczenie określonego progu cenowego niewiele znaczy bez wzrostu wolumenu i skalowania zmienności, które to potwierdzą.
Dlatego niezawodny backtester musi badać, w jaki sposób cena oddziałuje na zmieniający się wolumen oraz przedziały zmienności. Gdy cena przekracza poprzedni rekord, a wolumen przewyższa swoją średnią kroczącą, sygnał ma rzeczywistą wagę. W przeciwnym razie jest to tylko szum.
Simulowanie zwrotów portfela
Klasa VolatilityStrategyBacktester zawiera tę logikę. Bierze czyste dane o cenach i wolumenach z modułu ładowania, oblicza wskaźniki oraz symuluje dzienną stopę zwrotu portfela na podstawie konfigurowalnych parametrów, takich jak okna analizy historycznej i progi zmienności. Poniższa implementacja przeprowadza symulacje dla portfela składającego się z wielu symboli, bez wycieku stanu pomiędzy poszczególnymi rundami.
class VolatilityStrategyBacktester:
def __init__(self, close_df: pd.DataFrame, high_df: pd.DataFrame, low_df: pd.DataFrame, volume_df: pd.DataFrame, benchmark: str):
self.close_df = close_df
self.high_df = high_df
self.low_df = low_df
self.volume_df = volume_df
self.benchmark = benchmark
self.asset_columns = [c for c in close_df.columns if c != benchmark]
def backtest(self, params: dict) -> tuple[pd.Series, pd.Series]:
lookback = int(params.get("lookback", 20))
atr_mult = float(params.get("atr_multiplier", 2.0))
vol_thresh = float(params.get("volume_threshold", 1.5))
asset_returns_dict = {}
for ticker in self.asset_columns:
if ticker not in self.close_df.columns:
continue
c = self.close_df[ticker]
h = self.high_df[ticker]
l = self.low_df[ticker]
v = self.volume_df[ticker]
tr1 = h - l
tr2 = (h - c.shift(1)).abs()
tr3 = (l - c.shift(1)).abs()
tr = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1)
atr = tr.rolling(window=lookback).mean()
rolling_high = c.rolling(window=lookback).max()
avg_vol = v.rolling(window=lookback).mean()
breakout = (c > rolling_high.shift(1) + atr_mult *
atr) & (v > vol_thresh * avg_vol)
asset_ret = c.pct_change()
strat_ret = asset_ret * breakout.shift(1).fillna(False)
trades = breakout & (~breakout.shift(1).fillna(False))
strat_ret = strat_ret - (trades.astype(float) * 0.001)
asset_returns_dict[ticker] = strat_ret
strategy_df = pd.DataFrame(asset_returns_dict)
portfolio_returns = strategy_df.mean(axis=1).fillna(0.0)
benchmark_returns = self.close_df[self.benchmark].pct_change().fillna(
0.0)
return portfolio_returns, benchmark_returns
Zastosowanie go do piętnastu indeksów giełdowych oraz SPY przed udziałem jakiegokolwiek agenta, przy użyciu nieskalibrowanych parametrów, daje surowy wynik:
INFO - Baseline Metrics: Sharpe=-2.68, Return=-2.79%
Wartość Sharpe’a na poziomie -2,68 oraz kumulatywny zwrot na poziomie -2,79 procent pokazują, jak szybko statyczne parametry w portfelu o wysokim wskaźniku beta mogą zniszczyć konto inwestycyjne. To właśnie tę awarię ma naprawić architektura debaty, po tym jak naiwny przeszukiwacz siatki tylko pogorszył sytuację.
Projektowanie persony i pętla debaty
Wykorzystanie modelu jako narzędzia rozumowania, a nie orakula
Wiele prób zastosowania modeli LLM w finansach ilościowych kończy się niepowodzeniem, ponieważ model jest traktowany jak orakul, od którego oczekuje się przewidywania jutrzejszej ceny, co jest szybkim sposobem na utratę kapitału. Tutaj model pełni rolę narzędzia rozumowania językowego w ramach ustrukturyzowanej pętli optymalizacji. Ocenia on wrażliwość parametrów i nie próbuje przewidywać cen.
Klasa konfiguracji zawiera podstawowe hiperparametry oraz identyfikatory modelu niezbędne do połączenia z Google Gen AI SDK:
class Config:
TICKERS = [
"TSLA", "NVDA", "AMD", "PLTR", "COIN",
"MARA", "RIOT", "MSTR", "HOOD", "ROKU",
"UBER", "SHOP", "SNAP", "PTON", "DKNG"
]
BENCHMARK = "SPY"
MAX_ITERATIONS = 3
INITIAL_PARAMS = {
"lookback": 20,
"atr_multiplier": 2.0,
"volume_threshold": 1.5,
"stop_loss_pct": 0.05
}
Aby debata była produktywna, tworzone są trzy specjalistyczne role. Analityk ilościowy obserwuje rozpad alfa oraz wrażliwość na progi. Menedżer ryzyka skupia się na limitach spadków wartości i wskaźnikach zmienności. Strateg realizacji analizuje stopę rotacji oraz straty wynikające z odchyłek cenowych.
class GeminiAgentPersona:
def __init__(self, name: str, role: str, system_prompt: str):
self.name = name
self.role = role
self.system_prompt = system_prompt
self.client = genai.Client()
def evaluate(self, current_params: dict, metrics: dict, iteration: int) -> dict:
prompt = f"""
You are {self.name}, a specialized {self.role} in a multi-agent quantitative trading system.
Current Iteration: {iteration}
Current Strategy Parameters: {json.dumps(current_params, indent=2)}
Current Backtest Performance Metrics: {json.dumps(metrics, indent=2)}
Analyze the performance and parameters from your professional perspective. Provide your critique and propose specific parameter adjustments (lookback, atr_multiplier, volume_threshold, stop_loss_pct) to improve the Sharpe ratio and reduce max drawdown.
You MUST return a valid JSON object with keys:
- "critique": Detailed analytical critique from your persona's perspective.
- "proposed_adjustment": Dictionary of recommended parameter changes (e.g., ).
- "confidence_score": Float between 0 and 1 representing your confidence in this adjustment.
"""
response = self.client.models.generate_content(
model="gemini-3.5-flash-lite",
contents=prompt
)
text = response.text
clean_text = text.strip()
if clean_text.startswith("```json"):
clean_text = clean_text[7:]
if clean_text.endswith("```"):
clean_text = clean_text[:-3]
try:
parsed = json.loads(clean_text.strip())
except json.JSONDecodeError:
parsed = {
"critique": text[:500],
"proposed_adjustment": current_params,
"confidence_score": 0.6
}
return parsed
Koordynator debaty
Gdy postacie te się spotykają, nie tylko wymieniają opinie. PersonaDebateCoordinator uruchamia uporządkowany cykl krytyki. Bierze wyniki testów z poprzedniej iteracji, przekazuje metryki poszczególnym postaciom i zbiera ustrukturyzowane odpowiedzi w formacie JSON zawierające krytykę oraz proponowane zmiany parametrów.
Aby zapobiec temu, by model wymyślał zbyt duże wielkości pozycji lub nieskończone okna analizy, koordynator stosuje ścisłe ograniczenia parametrów. Ponadto oblicza średnią z rekomendacji poszczególnych postaci, co ułatwia konwergencję pomiędzy iteracjami.
class PersonaDebateCoordinator:
def __init__(self, backtester: VolatilityStrategyBacktester, initial_params: dict, max_iterations: int):
self.backtester = backtester
self.current_params = initial_params.copy()
self.max_iterations = max_iterations
self.quant = GeminiAgentPersona(
"QuantAnalyst",
"Quantitative Strategist",
"Focuses on alpha generation, indicator sensitivity, and statistical edge."
)
self.risk = GeminiAgentPersona(
"RiskManager",
"Risk Officer",
"Focuses on drawdowns, tail risk, position sizing, and volatility caps."
)
self.execution = GeminiAgentPersona(
"ExecutionStrategist",
"Execution Trading Specialist",
"Focuses on liquidity, volume confirmation, slippage, and practical execution friction."
)
self.audit_log = []
self.parameter_history = []
def run_iterative_loop(self) -> dict:
best_sharpe = -999.0
consensus_rationale = "Initial baseline strategy evaluation."
p_ret, b_ret = self.backtester.backtest(self.current_params)
baseline_metrics = calculate_performance_metrics(p_ret, b_ret)
current_metrics = baseline_metrics
logging.info(
f"Baseline Metrics: Sharpe={baseline_metrics['sharpe_ratio']:.2f}, Return={baseline_metrics['cumulative_return']*100:.2f}%")
for i in range(1, self.max_iterations + 1):
logging.debug(
f"--- Starting Debate Iteration {i}/{self.max_iterations} ---")
q_eval = self.quant.evaluate(
self.current_params, current_metrics, i)
r_eval = self.risk.evaluate(
self.current_params, current_metrics, i)
e_eval = self.execution.evaluate(
self.current_params, current_metrics, i)
for persona_name, eval_data in [("QuantAnalyst", q_eval), ("RiskManager", r_eval), ("ExecutionStrategist", e_eval)]:
self.audit_log.append({
"iteration_number": i,
"persona_name": persona_name,
"critique_text": eval_data.get("critique", ""),
"proposed_adjustment": eval_data.get("proposed_adjustment", {}),
"confidence_score": eval_data.get("confidence_score", 0.8)
})
q_adj = q_eval.get("proposed_adjustment", {})
r_adj = r_eval.get("proposed_adjustment", {})
e_adj = e_eval.get("proposed_adjustment", {})
new_lookback = int(np.mean([
float(q_adj.get("lookback",
self.current_params["lookback"])),
float(r_adj.get("lookback",
self.current_params["lookback"])),
float(e_adj.get("lookback",
self.current_params["lookback"]))
]))
new_atr = float(np.mean([
float(q_adj.get("atr_multiplier",
self.current_params["atr_multiplier"])),
float(r_adj.get("atr_multiplier",
self.current_params["atr_multiplier"])),
float(e_adj.get("atr_multiplier",
self.current_params["atr_multiplier"]))
]))
new_vol_thresh = float(np.mean([
float(q_adj.get("volume_threshold",
self.current_params["volume_threshold"])),
float(r_adj.get("volume_threshold",
self.current_params["volume_threshold"])),
float(e_adj.get("volume_threshold",
self.current_params["volume_threshold"]))
]))
new_sl = float(np.mean([
float(q_adj.get("stop_loss_pct",
self.current_params["stop_loss_pct"])),
float(r_adj.get("stop_loss_pct",
self.current_params["stop_loss_pct"])),
float(e_adj.get("stop_loss_pct",
self.current_params["stop_loss_pct"]))
]))
self.current_params = {
"lookback": max(5, min(60, new_lookback)),
"atr_multiplier": max(1.0, min(4.0, new_atr)),
"volume_threshold": max(1.0, min(3.0, new_vol_thresh)),
"stop_loss_pct": max(0.01, min(0.15, new_sl))
}
p_ret_new, b_ret_new = self.backtester.backtest(
self.current_params)
current_metrics = calculate_performance_metrics(
p_ret_new, b_ret_new)
logging.debug(
f"Iteration {i} Results: Sharpe={current_metrics['sharpe_ratio']:.2f}, Return={current_metrics['cumulative_return']*100:.2f}%, Params={self.current_params}")
self.parameter_history.append({
"iteration": i,
"params": self.current_params.copy(),
"metrics": current_metrics.copy()
})
if current_metrics["sharpe_ratio"] > best_sharpe:
best_sharpe = current_metrics["sharpe_ratio"]
consensus_rationale = f"Iteration {i} consensus achieved superior Sharpe ratio through optimized ATR multiplier and volume threshold refinement."
summary_result = {
"strategy_version": "v2.0-optimized",
"final_sharpe_ratio": float(best_sharpe),
"consensus_rationale": consensus_rationale,
"max_drawdown_delta": float(current_metrics["max_drawdown"] - baseline_metrics["max_drawdown"]),
"total_iterations_run": self.max_iterations,
"parameter_history": self.parameter_history
}
return summary_result
Gdy koordynator przechodzi przez kolejne debaty oraz sporządza dzienniki audytu i podsumowania wydajności, zakończa się proces optymalizacji:
INFO - Successfully generated output JSON files.
Wyniki
Krok po kroku
Testowanie wsteczne bez wiarygodnych metryk to nic innego jak opowiadanie historii, dlatego obserwuje się postęp na przestrzeni kolejnych iteracji. Funkcja metryk wydajności standaryzuje roczną wartość zwrotów oraz korektę ryzyka.
def calculate_performance_metrics(portfolio_returns: pd.Series, benchmark_returns: pd.Series) -> dict:
valid_idx = portfolio_returns.index.intersection(benchmark_returns.index)
p_ret = portfolio_returns.loc[valid_idx]
b_ret = benchmark_returns.loc[valid_idx]
trading_days = 252
cum_return = (1 + p_ret).prod() - 1
annualized_return = (1 + cum_return) ** (trading_days /
len(p_ret)) - 1 if len(p_ret) > 0 else 0.0
annualized_vol = p_ret.std() * np.sqrt(trading_days)
risk_free_rate = 0.03
sharpe_ratio = (annualized_return - risk_free_rate) / \
annualized_vol if annualized_vol > 0 else 0.0
cum_wealth = (1 + p_ret).cumprod()
peak = cum_wealth.cummax()
drawdown = (cum_wealth - peak) / peak
max_drawdown = drawdown.min()
b_cum_return = (1 + b_ret).prod() - 1
outperformance = cum_return - b_cum_return
return {
"cumulative_return": float(cum_return),
"annualized_return": float(annualized_return),
"annualized_volatility": float(annualized_vol),
"sharpe_ratio": float(sharpe_ratio),
"max_drawdown": float(max_drawdown),
"benchmark_cumulative_return": float(b_cum_return),
"outperformance": float(outperformance)
}
W trakcie eksperymentu pierwsza iteracja osiągnęła optymalny wynik. Zgoda pomiędzy trzema personami przyniosła pozytywną stopę zwrotu oraz znacznie lepszy wskaźnik Sharpe’a, umożliwiając uniknięcie negatywnych skutków wyjścia od punktu bazowego. Wyniki debat oraz historia parametrów wyglądają w ten sposób:
{
"strategy_version": "v2.0-optimized",
"final_sharpe_ratio": -0.6822300207528283,
"consensus_rationale": "Iteration 1 consensus achieved superior Sharpe ratio through optimized ATR multiplier and volume threshold refinement.",
"parameter_history": [
{
"iteration": 1,
"params": {
"lookback": 14,
"atr_multiplier": 1.5,
"volume_threshold": 1.4666,
"stop_loss_pct": 0.025
},
"metrics": {
"cumulative_return": 0.0399,
"annualized_return": 0.0132,
"sharpe_ratio": -0.6822,
"max_drawdown": -0.0344
}
}
]
}
W porównaniu z benchmarkiem
Zyski strategii zawsze są porównywane z prostym benchmarkiem opartym na strategii kup i trzymaj. Optymalizacja z udziałem wielu agentów faktycznie odwróciła negatywne wyniki bazowe, jednak w tym reżimie rynkowym radziła sobie bardzo słabo w porównaniu z SPY. Portfele technologiczne o wysokim wskaźniku beta przechodziły przez okresy niestabilności i powrotów do średniej, w których zasady oparte na śledzeniu trendów powodowały częste błędy decyzyjne. Wykres porównuje wzrost strategii z wynikami benchmarku na przestrzeni kolejnych iteracji.
Cały proces obejmuje ładowanie danych, testy wsteczne oraz koordynację różnych elementów w jednym punkcie wejścia:
def main():
logging.info(
"Starting Multi-Agent Volatility Breakout Optimization Pipeline.")
loader = MarketDataLoader(Config.TICKERS, Config.BENCHMARK)
close_df, high_df, low_df, volume_df = loader.fetch_data()
backtester = VolatilityStrategyBacktester(
close_df, high_df, low_df, volume_df, Config.BENCHMARK)
# Run initial baseline backtest
baseline_p_ret, baseline_b_ret = backtester.backtest(Config.INITIAL_PARAMS)
coordinator = PersonaDebateCoordinator(
backtester, Config.INITIAL_PARAMS, Config.MAX_ITERATIONS)
summary_results = coordinator.run_iterative_loop()
# Write output files exactly as specified
with open("volatility_debate_summary.json", "w") as f:
json.dump(summary_results, f, indent=4)
with open("iteration_debate_audit_log.json", "w") as f:
json.dump(coordinator.audit_log, f, indent=4)
logging.info("Successfully generated output JSON files.")
# Generate annotated plot
fig, ax = plt.subplots(figsize=(10, 6))
optimized_p_ret, _ = backtester.backtest(coordinator.current_params)
wealth_baseline = (1 + baseline_p_ret).cumprod()
wealth_optimized = (1 + optimized_p_ret).cumprod()
wealth_benchmark = (1 + baseline_b_ret).cumprod()
ax.plot(wealth_baseline.index, wealth_baseline,
label="Baseline Strategy", color="gray", linestyle="--")
ax.plot(wealth_optimized.index, wealth_optimized,
label="Optimized Multi-Agent Strategy", color="blue", linewidth=2)
ax.plot(wealth_benchmark.index, wealth_benchmark,
label="SPY Benchmark", color="orange", alpha=0.7)
# Annotate final peak / convergence
max_idx = wealth_optimized.idxmax()
max_val = wealth_optimized.max()
ax.annotate(f"Optimized Peak: {max_val:.2f}x",
xy=(max_idx, max_val),
xytext=(max_idx - pd.Timedelta(days=90), max_val * 0.9),
arrowprops=dict(facecolor='blue', shrink=0.05, width=1, headwidth=6))
ax.set_title("Multi-Agent Volatility Breakout Portfolio vs. SPY Benchmark",
fontsize=12, fontweight='bold')
ax.set_xlabel("Date", fontsize=10)
ax.set_ylabel("Growth of 1.00", fontsize=10)
ax.legend(loc="upper left")
ax.grid(True, linestyle=":", alpha=0.6)
plt.tight_layout()
plt.show()
logging.info("Successfully generated strategy performance plot.")
if __name__ == "__main__":
main()
Gdy skrypt się zakończy, zapisuje logi audytu oraz wizualizację wydajności:
INFO - Successfully generated strategy performance plot.
Te wyniki przypominają nam, że systemy LLM oparte na wielu agentach są skuteczne w nawigacji po parametrach i ocenie ryzyka, ale nie potrafią wykryć makrotrendów na rynku o niewielkich fluktuacjach. Przeniesienie tych badań do rzeczywistego środowiska handlowego wymagałoby również uwagi na opóźnienia w realizacji zleceń oraz ograniczenia w ich kierowaniu.
Co pokazuje eksperyment
System handlu ilościowego wymaga czegoś więcej niż po prostu podania danych historycznych do optymalizatora i liczenia na najlepszy wynik. Struktura debaty funkcjonowała jak typowe spotkania zarządu zajmującego się handlem ilościowym, podczas których specjalistyczne postacie Gemini sprawdzały efektywność generowania profitów, limity spadków wartości portfela oraz trudności w realizacji zleceń.
Pętla krytyczna faktycznie naprawiła bazową wartość. W miarę iteracji wskaźnik Sharpe’a wzrósł z -2,68 do -0,68, co nadal jest wartością ujemną, natomiast kumulatywne zwroty przeszły z poziomu ujemnych 2,79 procenta do obszaru dodatniego. Średnienie parametrów w połączeniu z ścisłym ograniczaniem zapobiegło temu, by modele popadły w niekontrolowaną matematyczną fantazję, dzięki czemu każda iteracja pozostała powiązana z rzeczywistymi wynikami testów wstępnych.
Wyniki przyniosły również pouczającą lekcję. Nawet przy użyciu trzech skutecznych modeli do dostosowywania progów wejścia, strategia o wysokim poziomie ryzyka znacznie ustępowała wskaźnikowi SPY typu „kup i trzymaj” w tym reżimie. Zasady oparte na dynamice tracą znaczenie, gdy trend się zatrzymuje, a ceny zaczynają wracać do swoich średnich wartości. Żadne nocne analizy modelu nie mogą stworzyć korzystnych warunków makroekonomicznych, których sam rynek nie zapewnia. Jedyne, co poprawia się w wyniku dyskusji, to wewnętrzna spójność strategii, natomiast zewnętrzny reżim rynkowy pozostaje poza zasięgiem.
Prawdziwy plan działania w kierunku handlu na żywo zacząłby się od podziału pracy na trzy etapy ulepszeń. Polegałyby one na zastąpieniu historycznych paneli pandas rzeczywistymi danymi przesyłanymi przez WebSocket z API brokera, dodaniu do narzędzia backtesting dynamicznych modeli slippage i opłat, a także wprowadzeniu czwartego typu agenta – agenta oceniającego nastroje rynkowe – który może całkowicie zmienić strategię, gdy zmienność wymaga trzymania gotówki.
Rynki nie płacą za elegancki kod ani sprytnie sformułowane polecenia. Płacą za umiejętność przetrwania w obliczu nieustannej niepewności.