1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct AdjusterContext {
13 pub symbol: String,
15 pub current_price: Option<f64>,
17 pub indicators: Option<serde_json::Value>,
20 pub current_regime: String,
22 pub regime_duration_secs: u64,
24 pub recent_signals: Vec<String>,
26 pub recent_trades: Vec<String>,
28 pub current_positions: Vec<String>,
30 pub total_pnl: f64,
32 pub daily_pnl: f64,
34 pub strategy_params: serde_json::Value,
36 pub max_position_usdc: f64,
38}
39
40pub fn build_system_prompt(max_position_usdc: f64) -> String {
47 format!(
48 r#"You are a **strategy parameter optimizer** for an automated crypto trading system.
49You are NOT a trader -- you do not place orders. Your sole job is to suggest
50small, incremental adjustments to the strategy configuration so that the
51system can adapt to changing market conditions.
52
53## Output format
54
55Return a JSON object conforming to the `StrategyAdjustment` schema:
56
57```json
58{{
59 "reasoning": "string — explain WHY you are making changes",
60 "regime_rules": null | [...],
61 "default_regime": null | "string",
62 "hysteresis": null | {{ "min_hold_secs": u64, "confirmation_count": u32 }},
63 "playbook_overrides": null | {{
64 "<regime_name>": {{
65 "rules": null | [...],
66 "max_position_size": null | f64,
67 "stop_loss_pct": null | f64,
68 "take_profit_pct": null | f64
69 }}
70 }}
71}}
72```
73
74- Omit any field you do not want to change (set it to `null`).
75- If the strategy is performing well and no changes are needed, return an
76 empty object `{{}}`.
77- Always include a `"reasoning"` field when you *do* propose changes.
78
79## Constraints
80
81- **max_position_size** must not exceed {max_position_usdc:.2} USDC.
82- **stop_loss_pct** must be between 0.5 and 50.0 (percent).
83- **take_profit_pct** must be between 0.5 and 200.0 (percent).
84- **min_hold_secs** (hysteresis) must be between 60 and 86400.
85- **confirmation_count** must be between 1 and 20.
86
87## Philosophy
88
89- Prefer **small, incremental** changes over large swings.
90- Be **conservative** -- only adjust when the data clearly supports it.
91- If the strategy is profitable and volatility is normal, **do nothing**.
92- Never chase short-term noise; focus on regime-level signals.
93- When in doubt, tighten risk (lower position size, tighter SL) rather than
94 loosen it."#,
95 max_position_usdc = max_position_usdc,
96 )
97}
98
99pub fn build_user_message(ctx: &AdjusterContext) -> String {
106 let mut sections: Vec<String> = Vec::with_capacity(10);
107
108 sections.push(build_price_section(ctx));
110
111 sections.push(build_indicators_section(ctx));
113
114 sections.push(build_regime_section(ctx));
116
117 sections.push(build_strategy_section(ctx));
119
120 sections.push(build_performance_section(ctx));
122
123 sections.push(build_signals_section(ctx));
125
126 if !ctx.recent_trades.is_empty() {
128 sections.push(build_trades_section(ctx));
129 }
130
131 sections.push(build_positions_section(ctx));
133
134 sections.push(build_task_section());
136
137 sections.join("\n\n")
138}
139
140fn build_price_section(ctx: &AdjusterContext) -> String {
145 match ctx.current_price {
146 Some(price) => format!("## Current Price\n{}: ${:.2}", ctx.symbol, price),
147 None => format!("## Current Price\n{}: (unavailable)", ctx.symbol),
148 }
149}
150
151fn build_indicators_section(ctx: &AdjusterContext) -> String {
152 let mut lines = vec!["## Technical Indicators".to_string()];
153
154 match ctx.indicators {
155 Some(ref val) if val.is_object() => {
156 let obj = val.as_object().unwrap();
157 let preferred_order = ["rsi", "macd", "bb", "adx", "volume"];
159 for &key in &preferred_order {
160 if let Some(v) = obj.get(key) {
161 lines.push(format!("- {}: {}", key.to_uppercase(), format_indicator(v)));
162 }
163 }
164 for (k, v) in obj {
166 let lower = k.to_lowercase();
167 if !preferred_order.contains(&lower.as_str()) {
168 lines.push(format!("- {}: {}", k.to_uppercase(), format_indicator(v)));
169 }
170 }
171 if lines.len() == 1 {
172 lines.push("(no indicator data)".to_string());
173 }
174 }
175 _ => {
176 lines.push("(no indicator data available)".to_string());
177 }
178 }
179
180 lines.join("\n")
181}
182
183fn format_indicator(val: &serde_json::Value) -> String {
185 match val {
186 serde_json::Value::Number(n) => format!("{}", n),
187 serde_json::Value::String(s) => s.clone(),
188 serde_json::Value::Object(map) => {
189 let parts: Vec<String> = map.iter().map(|(k, v)| format!("{}={}", k, v)).collect();
190 parts.join(", ")
191 }
192 other => other.to_string(),
193 }
194}
195
196fn build_regime_section(ctx: &AdjusterContext) -> String {
197 format!(
198 "## Current Regime\nRegime: {} (held for {})",
199 ctx.current_regime,
200 format_duration(ctx.regime_duration_secs),
201 )
202}
203
204fn build_strategy_section(ctx: &AdjusterContext) -> String {
205 let pretty = serde_json::to_string_pretty(&ctx.strategy_params).unwrap_or_default();
206 format!("## Current Strategy Parameters\n```json\n{}\n```", pretty)
207}
208
209fn build_performance_section(ctx: &AdjusterContext) -> String {
210 format!(
211 "## Recent Performance\nDaily P&L: ${:.2}\nTotal P&L: ${:.2}",
212 ctx.daily_pnl, ctx.total_pnl,
213 )
214}
215
216fn build_signals_section(ctx: &AdjusterContext) -> String {
217 let mut lines = vec!["## Recent Signals (last 10)".to_string()];
218 if ctx.recent_signals.is_empty() {
219 lines.push("(none)".to_string());
220 } else {
221 for s in ctx.recent_signals.iter().take(10) {
222 lines.push(format!("- {}", s));
223 }
224 }
225 lines.join("\n")
226}
227
228fn build_trades_section(ctx: &AdjusterContext) -> String {
229 let mut lines = vec!["## Recent Trades (last 5)".to_string()];
230 for t in ctx.recent_trades.iter().take(5) {
231 lines.push(format!("- {}", t));
232 }
233 lines.join("\n")
234}
235
236fn build_positions_section(ctx: &AdjusterContext) -> String {
237 let mut lines = vec!["## Open Positions".to_string()];
238 if ctx.current_positions.is_empty() {
239 lines.push("(no open positions)".to_string());
240 } else {
241 for p in &ctx.current_positions {
242 lines.push(format!("- {}", p));
243 }
244 }
245 lines.join("\n")
246}
247
248fn build_task_section() -> String {
249 "## Your Task\n\
250 Analyze the market conditions and strategy performance above.\n\
251 Suggest parameter adjustments as a JSON `StrategyAdjustment` object, \
252 or `{}` if no changes are needed."
253 .to_string()
254}
255
256fn format_duration(secs: u64) -> String {
262 if secs < 60 {
263 format!("{}s", secs)
264 } else if secs < 3600 {
265 format!("{} minutes", secs / 60)
266 } else if secs < 86400 {
267 let h = secs / 3600;
268 let m = (secs % 3600) / 60;
269 if m == 0 {
270 format!("{} hours", h)
271 } else {
272 format!("{}h {}m", h, m)
273 }
274 } else {
275 let d = secs / 86400;
276 let h = (secs % 86400) / 3600;
277 if h == 0 {
278 format!("{} days", d)
279 } else {
280 format!("{}d {}h", d, h)
281 }
282 }
283}
284
285#[cfg(test)]
290mod tests {
291 use super::*;
292 use serde_json::json;
293
294 fn full_context() -> AdjusterContext {
295 AdjusterContext {
296 symbol: "BTC-PERP".to_string(),
297 current_price: Some(67344.0),
298 indicators: Some(json!({
299 "rsi": 62.3,
300 "macd": {"value": 150.2, "signal": 120.1, "hist": 30.1},
301 "bb": {"upper": 68000, "mid": 66500, "lower": 65000},
302 "adx": {"adx": 28.5, "plus_di": 35.2, "minus_di": 18.7},
303 "volume": "1.2M"
304 })),
305 current_regime: "trending_up".to_string(),
306 regime_duration_secs: 2700,
307 recent_signals: vec![
308 "trending_up | OrderPlaced buy 0.001 BTC".to_string(),
309 "trending_up | None (hold)".to_string(),
310 ],
311 recent_trades: vec![
312 "buy 0.001 BTC @ $67000".to_string(),
313 "sell 0.001 BTC @ $67200".to_string(),
314 ],
315 current_positions: vec!["BTC-PERP long 0.001 entry=$67000 pnl=+$3.44".to_string()],
316 total_pnl: 125.50,
317 daily_pnl: -30.0,
318 strategy_params: json!({"name": "momentum_v1", "interval_secs": 300}),
319 max_position_usdc: 10000.0,
320 }
321 }
322
323 fn minimal_context() -> AdjusterContext {
324 AdjusterContext {
325 symbol: "ETH-PERP".to_string(),
326 current_price: None,
327 indicators: None,
328 current_regime: "neutral".to_string(),
329 regime_duration_secs: 0,
330 recent_signals: vec![],
331 recent_trades: vec![],
332 current_positions: vec![],
333 total_pnl: 0.0,
334 daily_pnl: 0.0,
335 strategy_params: json!({}),
336 max_position_usdc: 5000.0,
337 }
338 }
339
340 #[test]
343 fn system_prompt_contains_key_constraints() {
344 let prompt = build_system_prompt(10000.0);
345 assert!(
346 prompt.contains("10000.00"),
347 "should embed max_position_usdc"
348 );
349 assert!(prompt.contains("strategy parameter optimizer"));
350 assert!(prompt.contains("StrategyAdjustment"));
351 assert!(prompt.contains("conservative"));
352 assert!(prompt.contains("small, incremental"));
353 assert!(
354 prompt.contains("{}"),
355 "should mention empty object for no changes"
356 );
357 assert!(prompt.contains("reasoning"));
358 assert!(prompt.contains("stop_loss_pct"));
359 assert!(prompt.contains("take_profit_pct"));
360 }
361
362 #[test]
363 fn system_prompt_varies_with_limit() {
364 let a = build_system_prompt(5000.0);
365 let b = build_system_prompt(20000.0);
366 assert!(a.contains("5000.00"));
367 assert!(b.contains("20000.00"));
368 assert!(!a.contains("20000.00"));
369 }
370
371 #[test]
374 fn user_message_full_context_contains_all_sections() {
375 let ctx = full_context();
376 let msg = build_user_message(&ctx);
377
378 assert!(msg.contains("## Current Price"));
379 assert!(msg.contains("BTC-PERP: $67344.00"));
380 assert!(msg.contains("## Technical Indicators"));
381 assert!(msg.contains("RSI"));
382 assert!(msg.contains("MACD"));
383 assert!(msg.contains("## Current Regime"));
384 assert!(msg.contains("trending_up"));
385 assert!(msg.contains("45 minutes"));
386 assert!(msg.contains("## Current Strategy Parameters"));
387 assert!(msg.contains("momentum_v1"));
388 assert!(msg.contains("## Recent Performance"));
389 assert!(msg.contains("Daily P&L: $-30.00"));
390 assert!(msg.contains("Total P&L: $125.50"));
391 assert!(msg.contains("## Recent Signals"));
392 assert!(msg.contains("OrderPlaced buy 0.001 BTC"));
393 assert!(msg.contains("## Recent Trades"));
394 assert!(msg.contains("buy 0.001 BTC @ $67000"));
395 assert!(msg.contains("## Open Positions"));
396 assert!(msg.contains("BTC-PERP long 0.001"));
397 assert!(msg.contains("## Your Task"));
398 }
399
400 #[test]
401 fn user_message_full_context_is_non_empty() {
402 let ctx = full_context();
403 let msg = build_user_message(&ctx);
404 assert!(!msg.is_empty());
405 assert!(msg.len() < 10_000, "prompt should be token-efficient");
407 }
408
409 #[test]
412 fn user_message_minimal_context_does_not_panic() {
413 let ctx = minimal_context();
414 let msg = build_user_message(&ctx);
415 assert!(!msg.is_empty());
416 }
417
418 #[test]
419 fn user_message_minimal_context_handles_none_indicators() {
420 let ctx = minimal_context();
421 let msg = build_user_message(&ctx);
422 assert!(msg.contains("no indicator data"));
423 }
424
425 #[test]
426 fn user_message_minimal_context_handles_none_price() {
427 let ctx = minimal_context();
428 let msg = build_user_message(&ctx);
429 assert!(msg.contains("(unavailable)"));
430 }
431
432 #[test]
433 fn user_message_minimal_context_handles_empty_signals() {
434 let ctx = minimal_context();
435 let msg = build_user_message(&ctx);
436 assert!(msg.contains("(none)"));
437 }
438
439 #[test]
440 fn user_message_minimal_context_handles_empty_positions() {
441 let ctx = minimal_context();
442 let msg = build_user_message(&ctx);
443 assert!(msg.contains("no open positions"));
444 }
445
446 #[test]
447 fn user_message_minimal_context_omits_trades_section() {
448 let ctx = minimal_context();
449 let msg = build_user_message(&ctx);
450 assert!(!msg.contains("## Recent Trades"));
451 }
452
453 #[test]
456 fn indicators_section_renders_object_values() {
457 let ctx = full_context();
458 let section = build_indicators_section(&ctx);
459 assert!(section.contains("MACD"));
461 assert!(section.contains("signal"));
462 }
463
464 #[test]
465 fn indicators_section_with_empty_object() {
466 let mut ctx = minimal_context();
467 ctx.indicators = Some(json!({}));
468 let section = build_indicators_section(&ctx);
469 assert!(section.contains("no indicator data"));
470 }
471
472 #[test]
473 fn format_duration_ranges() {
474 assert_eq!(format_duration(30), "30s");
475 assert_eq!(format_duration(120), "2 minutes");
476 assert_eq!(format_duration(2700), "45 minutes");
477 assert_eq!(format_duration(3600), "1 hours");
478 assert_eq!(format_duration(5400), "1h 30m");
479 assert_eq!(format_duration(86400), "1 days");
480 assert_eq!(format_duration(90000), "1d 1h");
481 }
482
483 #[test]
484 fn signals_capped_at_ten() {
485 let mut ctx = full_context();
486 ctx.recent_signals = (0..20).map(|i| format!("signal_{}", i)).collect();
487 let msg = build_user_message(&ctx);
488 assert!(msg.contains("signal_9"));
490 assert!(!msg.contains("signal_10"));
491 }
492
493 #[test]
494 fn trades_capped_at_five() {
495 let mut ctx = full_context();
496 ctx.recent_trades = (0..10).map(|i| format!("trade_{}", i)).collect();
497 let msg = build_user_message(&ctx);
498 assert!(msg.contains("trade_4"));
499 assert!(!msg.contains("trade_5"));
500 }
501}