Skip to main content

hyper_agent_ai/
adjuster_prompt.rs

1use serde::{Deserialize, Serialize};
2
3// ---------------------------------------------------------------------------
4// Types
5// ---------------------------------------------------------------------------
6
7/// All context needed to build a market-context prompt for Claude strategy
8/// adjuster. Fields are kept as simple, serializable types so the caller
9/// can assemble them from heterogeneous sources without coupling to internal
10/// domain structs.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct AdjusterContext {
13    /// Trading symbol, e.g. "BTC-PERP".
14    pub symbol: String,
15    /// Latest mark / last price (None if not yet available).
16    pub current_price: Option<f64>,
17    /// Snapshot of technical indicators as free-form JSON.
18    /// Expected keys: rsi, macd, bb, adx, volume (truncated to essentials).
19    pub indicators: Option<serde_json::Value>,
20    /// Current market regime label, e.g. "trending_up".
21    pub current_regime: String,
22    /// How long the current regime has been held, in seconds.
23    pub regime_duration_secs: u64,
24    /// Last N signal summaries (capped to 10 by caller).
25    pub recent_signals: Vec<String>,
26    /// Last N trade summaries (capped to 5 by caller).
27    pub recent_trades: Vec<String>,
28    /// Open position summaries (human-readable lines).
29    pub current_positions: Vec<String>,
30    /// Cumulative realised + unrealised P&L.
31    pub total_pnl: f64,
32    /// P&L for the current calendar day.
33    pub daily_pnl: f64,
34    /// Current StrategyGroup serialized as JSON.
35    pub strategy_params: serde_json::Value,
36    /// Hard cap on any single position size (USDC).
37    pub max_position_usdc: f64,
38}
39
40// ---------------------------------------------------------------------------
41// System prompt
42// ---------------------------------------------------------------------------
43
44/// Build the system prompt that tells Claude how to behave as a strategy
45/// optimizer. `max_position_usdc` is embedded as a hard constraint.
46pub 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
99// ---------------------------------------------------------------------------
100// User message
101// ---------------------------------------------------------------------------
102
103/// Build the user message containing full market + strategy context.
104/// Sections are ordered for readability and token efficiency.
105pub fn build_user_message(ctx: &AdjusterContext) -> String {
106    let mut sections: Vec<String> = Vec::with_capacity(10);
107
108    // --- Current Price ---
109    sections.push(build_price_section(ctx));
110
111    // --- Technical Indicators ---
112    sections.push(build_indicators_section(ctx));
113
114    // --- Regime ---
115    sections.push(build_regime_section(ctx));
116
117    // --- Current Strategy Parameters ---
118    sections.push(build_strategy_section(ctx));
119
120    // --- Recent Performance ---
121    sections.push(build_performance_section(ctx));
122
123    // --- Recent Signals ---
124    sections.push(build_signals_section(ctx));
125
126    // --- Recent Trades ---
127    if !ctx.recent_trades.is_empty() {
128        sections.push(build_trades_section(ctx));
129    }
130
131    // --- Open Positions ---
132    sections.push(build_positions_section(ctx));
133
134    // --- Task ---
135    sections.push(build_task_section());
136
137    sections.join("\n\n")
138}
139
140// ---------------------------------------------------------------------------
141// Section builders
142// ---------------------------------------------------------------------------
143
144fn 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            // Render in a deterministic, readable order for the key indicators.
158            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            // Any remaining keys not in the preferred order.
165            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
183/// Format a single indicator JSON value into a compact human-readable string.
184fn 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
256// ---------------------------------------------------------------------------
257// Helpers
258// ---------------------------------------------------------------------------
259
260/// Format a duration in seconds into a compact human-readable string.
261fn 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// ---------------------------------------------------------------------------
286// Tests
287// ---------------------------------------------------------------------------
288
289#[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    // --- build_system_prompt tests ---
341
342    #[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    // --- build_user_message with full context ---
372
373    #[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        // Should be a reasonable size (not gigantic).
406        assert!(msg.len() < 10_000, "prompt should be token-efficient");
407    }
408
409    // --- build_user_message with minimal context ---
410
411    #[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    // --- Section-level tests ---
454
455    #[test]
456    fn indicators_section_renders_object_values() {
457        let ctx = full_context();
458        let section = build_indicators_section(&ctx);
459        // MACD is an object, should expand its keys.
460        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        // signal_9 should appear, signal_10 should not.
489        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}