Skip to content

Commit f6db929

Browse files
committed
Add GA optimizer example for Hyperliquid strategies
1 parent 1402fb4 commit f6db929

2 files changed

Lines changed: 334 additions & 0 deletions

File tree

Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ thiserror = "1.0"
4646

4747
[dev-dependencies]
4848
chrono = { version = "0.4", default-features = false, features = ["clock", "std"] }
49+
anyhow = "1"
50+
rayon = "1.10"
51+
rand = "0.8"
52+
tokio = { version = "1", features = ["full"] }
53+
hyperliquid_rust_sdk = { git = "https://github.com/hyperliquid-dex/hyperliquid-rust-sdk" }
4954

5055
[[example]]
5156
name = "mode_reporting_example"

examples/ga_optimize.rs

Lines changed: 329 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,329 @@
1+
// examples/ga_optimize.rs
2+
// GA-оптимизатор гиперпараметров поверх hyperliquid-backtest.
3+
//
4+
// Оптимизируем параметры простой стратегии (например, enhanced_sma_cross):
5+
// - fast_ma: u32 [5..50]
6+
// - slow_ma: u32 [10..150], slow > fast
7+
// - risk_mult: f64 [0.5..3.0] — условный множитель риска/позиции (пример)
8+
//
9+
// Цель: максимизировать Total Return и Sharpe, минимизировать Max Drawdown.
10+
// Для простоты — скалируем в единый скоринг (можно заменить на Pareto/NSGA-II позже).
11+
//
12+
// Требования:
13+
// cargo run --example ga_optimize
14+
//
15+
// Основано на рабочем примере получения данных и бэктеста из README/docs.
16+
// См. API: prelude, HyperliquidData::with_ohlc_data, HyperliquidBacktest, enhanced_sma_cross.
17+
// Docs: https://docs.rs/hyperliquid-backtest (см. prelude, Quick Start) и README репозитория.
18+
//
19+
20+
use anyhow::{Context, Result};
21+
use chrono::{Duration, FixedOffset, TimeZone, Utc};
22+
use hyperliquid_backtest::prelude::*;
23+
use hyperliquid_backtest::{
24+
backtest::{HyperliquidBacktest, HyperliquidCommission},
25+
data::HyperliquidData,
26+
errors::HyperliquidBacktestError,
27+
strategies::{enhanced_sma_cross, FundingAwareConfig},
28+
};
29+
use hyperliquid_rust_sdk::{types::Candle, BaseUrl, InfoClient};
30+
use rand::rngs::StdRng;
31+
use rand::{Rng, SeedableRng};
32+
use rayon::prelude::*;
33+
34+
// -------------------------------
35+
// Параметры стратегии (хромосома)
36+
// -------------------------------
37+
#[derive(Clone, Debug)]
38+
struct Params {
39+
fast_ma: u32,
40+
slow_ma: u32,
41+
risk_mult: f64,
42+
}
43+
44+
impl Params {
45+
fn random<R: Rng>(rng: &mut R) -> Self {
46+
let mut fast = rng.gen_range(5..=50);
47+
let mut slow = rng.gen_range(10..=150);
48+
if slow <= fast {
49+
slow = fast + rng.gen_range(5..=50).min(150 - fast);
50+
}
51+
let risk_mult = rng.gen_range(0.5..=3.0);
52+
Self {
53+
fast_ma: fast,
54+
slow_ma: slow.max(fast + 1),
55+
risk_mult,
56+
}
57+
}
58+
59+
fn mutate<R: Rng>(&mut self, rng: &mut R) {
60+
if rng.gen_bool(0.3) {
61+
let delta: i32 = rng.gen_range(-5..=5);
62+
self.fast_ma = (self.fast_ma as i32 + delta).clamp(5, 80) as u32;
63+
}
64+
if rng.gen_bool(0.3) {
65+
let delta: i32 = rng.gen_range(-10..=10);
66+
self.slow_ma = (self.slow_ma as i32 + delta).clamp(10, 200) as u32;
67+
}
68+
if self.slow_ma <= self.fast_ma {
69+
self.slow_ma = self.fast_ma + rng.gen_range(1..=10);
70+
}
71+
if rng.gen_bool(0.3) {
72+
let delta = rng.gen_range(-0.3..=0.3);
73+
self.risk_mult = (self.risk_mult + delta).clamp(0.3, 5.0);
74+
}
75+
}
76+
77+
fn crossover<R: Rng>(&self, other: &Self, rng: &mut R) -> Self {
78+
let fast = if rng.gen_bool(0.5) {
79+
self.fast_ma
80+
} else {
81+
other.fast_ma
82+
};
83+
let slow = if rng.gen_bool(0.5) {
84+
self.slow_ma
85+
} else {
86+
other.slow_ma
87+
};
88+
let risk = if rng.gen_bool(0.5) {
89+
self.risk_mult
90+
} else {
91+
other.risk_mult
92+
};
93+
let mut child = Self {
94+
fast_ma: fast,
95+
slow_ma: slow,
96+
risk_mult: risk,
97+
};
98+
if child.slow_ma <= child.fast_ma {
99+
child.slow_ma = child.fast_ma + 1;
100+
}
101+
child
102+
}
103+
}
104+
105+
// -------------------------------
106+
// Метрики fitness
107+
// -------------------------------
108+
#[derive(Clone, Debug)]
109+
struct Metrics {
110+
total_return: f64, // 0.25 = 25%
111+
sharpe: f64,
112+
max_drawdown: f64, // положительное число, напр. 0.12 = 12%
113+
}
114+
115+
fn score(m: &Metrics) -> f64 {
116+
let w1 = 1.0;
117+
let w2 = 0.8;
118+
let w3 = 0.7;
119+
w1 * m.total_return + w2 * m.sharpe - w3 * m.max_drawdown
120+
}
121+
122+
// -------------------------------------------
123+
// Запуск одного бэктеста и получение метрик
124+
// -------------------------------------------
125+
fn evaluate_once(
126+
base_data: &HyperliquidData,
127+
base_currency: &str,
128+
params: &Params,
129+
initial_capital: f64,
130+
) -> Result<Metrics, HyperliquidBacktestError> {
131+
let mut data = base_data.clone();
132+
data.symbol = base_currency.to_string();
133+
134+
let strategy = enhanced_sma_cross(
135+
data.to_rs_backtester_data(),
136+
params.fast_ma as usize,
137+
params.slow_ma as usize,
138+
FundingAwareConfig::default(),
139+
);
140+
141+
let mut backtest = HyperliquidBacktest::new(
142+
data,
143+
strategy,
144+
initial_capital * params.risk_mult,
145+
HyperliquidCommission::default(),
146+
)?;
147+
148+
backtest.initialize_base_backtest()?;
149+
backtest.calculate_with_funding()?;
150+
151+
let report = backtest.enhanced_report()?;
152+
153+
Ok(Metrics {
154+
total_return: report.total_return,
155+
sharpe: report.sharpe_ratio,
156+
max_drawdown: report.max_drawdown.abs(),
157+
})
158+
}
159+
160+
// -------------------------------
161+
// Простой GA-движок
162+
// -------------------------------
163+
#[derive(Clone)]
164+
struct Individual {
165+
params: Params,
166+
metrics: Option<Metrics>,
167+
fitness: f64,
168+
}
169+
170+
fn tournament<'a>(population: &'a [Individual], rng: &mut StdRng, k: usize) -> &'a Individual {
171+
let mut best = rng.gen_range(0..population.len());
172+
let mut best_score = population[best].fitness;
173+
for _ in 1..k {
174+
let idx = rng.gen_range(0..population.len());
175+
if population[idx].fitness > best_score {
176+
best = idx;
177+
best_score = population[idx].fitness;
178+
}
179+
}
180+
&population[best]
181+
}
182+
183+
fn evaluate_population(
184+
population: &mut [Individual],
185+
base_data: &HyperliquidData,
186+
base_currency: &str,
187+
initial_capital: f64,
188+
) -> Result<(), HyperliquidBacktestError> {
189+
population
190+
.par_iter_mut()
191+
.try_for_each(|ind| -> Result<(), HyperliquidBacktestError> {
192+
let metrics = evaluate_once(base_data, base_currency, &ind.params, initial_capital)?;
193+
ind.fitness = score(&metrics);
194+
ind.metrics = Some(metrics);
195+
Ok(())
196+
})
197+
}
198+
199+
fn candles_to_data(
200+
candles: &[Candle],
201+
symbol: &str,
202+
) -> Result<HyperliquidData, HyperliquidBacktestError> {
203+
let mut datetime = Vec::with_capacity(candles.len());
204+
let mut open = Vec::with_capacity(candles.len());
205+
let mut high = Vec::with_capacity(candles.len());
206+
let mut low = Vec::with_capacity(candles.len());
207+
let mut close = Vec::with_capacity(candles.len());
208+
let mut volume = Vec::with_capacity(candles.len());
209+
210+
let tz = FixedOffset::east_opt(0).unwrap();
211+
212+
for candle in candles {
213+
let ts = Utc
214+
.timestamp_millis_opt(candle.time_open as i64)
215+
.single()
216+
.ok_or_else(|| {
217+
HyperliquidBacktestError::conversion_error(format!(
218+
"Invalid timestamp: {}",
219+
candle.time_open
220+
))
221+
})?;
222+
223+
datetime.push(ts.with_timezone(&tz));
224+
open.push(candle.open.parse::<f64>().unwrap_or(0.0));
225+
high.push(candle.high.parse::<f64>().unwrap_or(0.0));
226+
low.push(candle.low.parse::<f64>().unwrap_or(0.0));
227+
close.push(candle.close.parse::<f64>().unwrap_or(0.0));
228+
volume.push(candle.vlm.parse::<f64>().unwrap_or(0.0));
229+
}
230+
231+
HyperliquidData::with_ohlc_data(symbol.to_string(), datetime, open, high, low, close, volume)
232+
}
233+
234+
async fn fetch_candles() -> Result<Vec<Candle>> {
235+
let client = InfoClient::new(None, Some(BaseUrl::Mainnet))
236+
.await
237+
.context("Failed to create Hyperliquid InfoClient")?;
238+
let end = Utc::now();
239+
let start = end - Duration::days(14);
240+
241+
let candles = client
242+
.candles_snapshot(
243+
"BTC".to_string(),
244+
"1h".to_string(),
245+
start.timestamp_millis() as u64,
246+
end.timestamp_millis() as u64,
247+
)
248+
.await
249+
.context("Failed to fetch candles snapshot")?;
250+
251+
if candles.is_empty() {
252+
anyhow::bail!("No candles received from Hyperliquid API");
253+
}
254+
255+
Ok(candles)
256+
}
257+
258+
#[tokio::main]
259+
async fn main() -> Result<()> {
260+
init_logger();
261+
262+
println!("🔍 Fetching BTC/USDC 1h candles for the last 14 days...");
263+
let candles = fetch_candles().await?;
264+
println!(" ✅ Loaded {} candles", candles.len());
265+
266+
let base_currency = "BTC";
267+
let base_data = candles_to_data(&candles, base_currency)?;
268+
269+
let pop_size = 64usize;
270+
let elitism = 4usize;
271+
let generations = 25usize;
272+
let initial_capital = 10_000.0_f64;
273+
let seed = 42u64;
274+
275+
let mut rng = StdRng::seed_from_u64(seed);
276+
277+
let mut population: Vec<Individual> = (0..pop_size)
278+
.map(|_| Individual {
279+
params: Params::random(&mut rng),
280+
metrics: None,
281+
fitness: f64::NEG_INFINITY,
282+
})
283+
.collect();
284+
285+
evaluate_population(&mut population, &base_data, base_currency, initial_capital)
286+
.context("Failed to evaluate initial population")?;
287+
288+
population.sort_by(|a, b| b.fitness.partial_cmp(&a.fitness).unwrap());
289+
println!(
290+
"Gen 0 | best score={:.4}, params={:?}, metrics={:?}",
291+
population[0].fitness, population[0].params, population[0].metrics
292+
);
293+
294+
for gen in 1..=generations {
295+
let mut next: Vec<Individual> = population.iter().take(elitism).cloned().collect();
296+
297+
while next.len() < pop_size {
298+
let parent1 = tournament(&population, &mut rng, 3);
299+
let parent2 = tournament(&population, &mut rng, 3);
300+
let mut child = Individual {
301+
params: parent1.params.crossover(&parent2.params, &mut rng),
302+
metrics: None,
303+
fitness: f64::NEG_INFINITY,
304+
};
305+
child.params.mutate(&mut rng);
306+
next.push(child);
307+
}
308+
309+
evaluate_population(&mut next, &base_data, base_currency, initial_capital)
310+
.with_context(|| format!("Failed to evaluate generation {}", gen))?;
311+
312+
next.sort_by(|a, b| b.fitness.partial_cmp(&a.fitness).unwrap());
313+
population = next;
314+
315+
let best = &population[0];
316+
println!(
317+
"Gen {} | best score={:.4}, params={:?}, metrics={:?}",
318+
gen, best.fitness, best.params, best.metrics
319+
);
320+
}
321+
322+
let best = &population[0];
323+
println!("\n=== BEST ===");
324+
println!("Score: {:.6}", best.fitness);
325+
println!("Params: {:?}", best.params);
326+
println!("Metrics: {:?}", best.metrics);
327+
328+
Ok(())
329+
}

0 commit comments

Comments
 (0)