Skip to main content

hyper_agent_ai/
prompt_builder.rs

1use hyper_market::market_data::PositionInfo;
2use hyper_strategy::rule_engine::SignalSummary;
3use hyper_strategy::strategy_config::Playbook;
4
5// ---------------------------------------------------------------------------
6// Types
7// ---------------------------------------------------------------------------
8
9/// All the inputs needed to build the user prompt for Claude.
10#[derive(Debug, Clone)]
11pub struct PromptParams {
12    /// Current market regime (e.g., "bull", "bear", "high_vol").
13    pub current_regime: String,
14    /// How long the current regime has been active, in seconds.
15    pub regime_duration_secs: u64,
16    /// Whether the regime just changed this iteration.
17    pub regime_changed: bool,
18    /// Previous regime name (only meaningful when `regime_changed` is true).
19    pub previous_regime: Option<String>,
20    /// Formatted market data string (from `format_context_for_claude`).
21    pub market_data: String,
22    /// Formatted technical indicators string (from `format_technical_summary`).
23    pub technical_summary: String,
24    /// Signal summary from the rule engine.
25    pub signal_summary: SignalSummary,
26    /// Current open positions.
27    pub positions: Vec<PositionInfo>,
28    /// The active playbook for the current regime.
29    pub playbook: Playbook,
30    /// Optional funding rate info (pre-fetched for perpetual contracts).
31    pub funding_rate: Option<FundingRateInfo>,
32}
33
34/// Pre-fetched funding rate information for a perpetual contract.
35#[derive(Debug, Clone)]
36pub struct FundingRateInfo {
37    /// Current funding rate as a decimal (e.g., 0.0001 = 0.01%).
38    pub rate: f64,
39    /// Annualized funding rate as a decimal.
40    pub annualized_rate: f64,
41}
42
43// ---------------------------------------------------------------------------
44// System prompt
45// ---------------------------------------------------------------------------
46
47const BASE_SYSTEM_PROMPT: &str = "你是一個加密貨幣交易 AI 助手。";
48
49/// Build the system prompt by combining the base prompt with the playbook's
50/// custom system prompt.
51pub fn build_system_prompt(playbook: &Playbook) -> String {
52    if playbook.system_prompt.is_empty() {
53        BASE_SYSTEM_PROMPT.to_string()
54    } else {
55        format!("{}\n{}", BASE_SYSTEM_PROMPT, playbook.system_prompt)
56    }
57}
58
59// ---------------------------------------------------------------------------
60// User prompt
61// ---------------------------------------------------------------------------
62
63/// Build the full user prompt from all available context.
64pub fn build_user_prompt(params: &PromptParams) -> String {
65    let mut sections: Vec<String> = Vec::with_capacity(8);
66
67    // --- Regime section ---
68    sections.push(build_regime_section(params));
69
70    // --- Market data ---
71    sections.push(format!("## 市場數據\n{}", params.market_data));
72
73    // --- Technical indicators ---
74    sections.push(format!("## 技術指標\n{}", params.technical_summary));
75
76    // --- Signals ---
77    sections.push(build_signals_section(params));
78
79    // --- Funding rate ---
80    if let Some(ref fr) = params.funding_rate {
81        sections.push(build_funding_rate_section(fr));
82    }
83
84    // --- Positions ---
85    sections.push(build_positions_section(&params.positions));
86
87    // --- Risk constraints ---
88    sections.push(build_risk_section(params));
89
90    // --- Final instruction ---
91    sections.push("請給出你的交易決策。".to_string());
92
93    sections.join("\n\n")
94}
95
96// ---------------------------------------------------------------------------
97// Section builders
98// ---------------------------------------------------------------------------
99
100fn build_regime_section(params: &PromptParams) -> String {
101    let mut lines = Vec::new();
102    lines.push("## 當前市場狀態".to_string());
103    lines.push(format!(
104        "Regime: {} (持續 {})",
105        params.current_regime,
106        format_duration(params.regime_duration_secs),
107    ));
108
109    if params.regime_changed {
110        if let Some(ref prev) = params.previous_regime {
111            lines.push(format!("!! 剛從 {} 切換,注意過渡期風險", prev,));
112        }
113    }
114
115    if params.current_regime == "high_vol" {
116        lines.push("!! 當前處於高波動 regime,請特別注意風控,減少倉位規模".to_string());
117    }
118
119    lines.join("\n")
120}
121
122fn build_signals_section(params: &PromptParams) -> String {
123    let mut lines = Vec::new();
124    lines.push(format!(
125        "## 規則引擎訊號({} playbook)",
126        params.current_regime
127    ));
128
129    if params.signal_summary.triggered_signals.is_empty() {
130        lines.push("目前沒有觸發任何規則訊號,請根據上述市場數據和技術指標自行判斷。".to_string());
131    } else {
132        for eval in &params.signal_summary.evaluations {
133            let status = if eval.triggered {
134                "TRIGGERED"
135            } else {
136                "not triggered"
137            };
138            lines.push(format!(
139                "- {} {} {}: value={:.4} [{}]",
140                eval.rule.indicator,
141                eval.rule.condition,
142                eval.rule.threshold,
143                eval.current_value,
144                status,
145            ));
146        }
147        lines.push(format!(
148            "\n觸發的訊號: {}",
149            params.signal_summary.triggered_signals.join(", "),
150        ));
151    }
152
153    lines.join("\n")
154}
155
156fn build_positions_section(positions: &[PositionInfo]) -> String {
157    let mut lines = Vec::new();
158    lines.push("## 當前持倉".to_string());
159
160    if positions.is_empty() {
161        lines.push("目前沒有持倉。".to_string());
162    } else {
163        for pos in positions {
164            let mut entry = format!(
165                "- {}: {} {:.6} @ ${:.2}, PnL: ${:.2}, Leverage: {:.1}x",
166                pos.symbol, pos.side, pos.size, pos.entry_price, pos.unrealized_pnl, pos.leverage,
167            );
168            if let Some(liq) = pos.liquidation_price {
169                entry.push_str(&format!(", Liq: ${:.2}", liq));
170            }
171            lines.push(entry);
172        }
173    }
174
175    lines.join("\n")
176}
177
178fn build_funding_rate_section(fr: &FundingRateInfo) -> String {
179    let mut lines = Vec::new();
180    lines.push("## 資金費率 (Funding Rate)".to_string());
181    lines.push(format!("- 當前費率: {:.4}%", fr.rate * 100.0));
182    lines.push(format!("- 年化費率: {:.2}%", fr.annualized_rate * 100.0));
183    if fr.rate > 0.0 {
184        lines.push("- 方向: 多頭支付空頭(看多情緒偏高)".to_string());
185    } else if fr.rate < 0.0 {
186        lines.push("- 方向: 空頭支付多頭(看空情緒偏高)".to_string());
187    } else {
188        lines.push("- 方向: 中性".to_string());
189    }
190    if fr.rate.abs() > 0.0005 {
191        lines.push("!! 資金費率偏高,持倉過夜成本顯著,請納入考量".to_string());
192    }
193    lines.join("\n")
194}
195
196fn build_risk_section(params: &PromptParams) -> String {
197    let mut lines = Vec::new();
198    lines.push(format!("## 風控限制({} 設定)", params.current_regime));
199    lines.push(format!("- 最大持倉: {}", params.playbook.max_position_size));
200    if let Some(sl) = params.playbook.stop_loss_pct {
201        lines.push(format!("- 止損: {}%", sl));
202    }
203    if let Some(tp) = params.playbook.take_profit_pct {
204        lines.push(format!("- 止盈: {}%", tp));
205    }
206
207    lines.join("\n")
208}
209
210// ---------------------------------------------------------------------------
211// Helpers
212// ---------------------------------------------------------------------------
213
214/// Format a duration in seconds into a human-readable string.
215fn format_duration(secs: u64) -> String {
216    if secs < 60 {
217        format!("{}s", secs)
218    } else if secs < 3600 {
219        format!("{}m", secs / 60)
220    } else if secs < 86400 {
221        let h = secs / 3600;
222        let m = (secs % 3600) / 60;
223        if m == 0 {
224            format!("{}h", h)
225        } else {
226            format!("{}h{}m", h, m)
227        }
228    } else {
229        let d = secs / 86400;
230        let h = (secs % 86400) / 3600;
231        if h == 0 {
232            format!("{}d", d)
233        } else {
234            format!("{}d{}h", d, h)
235        }
236    }
237}
238
239// ---------------------------------------------------------------------------
240// Tests
241// ---------------------------------------------------------------------------
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246    use hyper_strategy::rule_engine::{RuleEvaluation, SignalSummary};
247    use hyper_strategy::strategy_config::TaRule;
248
249    fn sample_playbook() -> Playbook {
250        Playbook {
251            rules: vec![],
252            entry_rules: vec![],
253            exit_rules: vec![],
254            system_prompt: "You are a bull-market trading agent.".to_string(),
255            max_position_size: 1000.0,
256            stop_loss_pct: Some(5.0),
257            take_profit_pct: Some(10.0),
258            timeout_secs: None,
259            side: None,
260        }
261    }
262
263    fn sample_playbook_no_prompt() -> Playbook {
264        Playbook {
265            rules: vec![],
266            entry_rules: vec![],
267            exit_rules: vec![],
268            system_prompt: String::new(),
269            max_position_size: 500.0,
270            stop_loss_pct: None,
271            take_profit_pct: None,
272            timeout_secs: None,
273            side: None,
274        }
275    }
276
277    fn sample_signal_summary_with_triggers() -> SignalSummary {
278        SignalSummary {
279            symbol: "BTC-USD".to_string(),
280            timestamp: 1700000000,
281            current_regime: "bull".to_string(),
282            regime_changed: false,
283            evaluations: vec![
284                RuleEvaluation {
285                    rule: TaRule {
286                        indicator: "RSI".to_string(),
287                        params: vec![14.0],
288                        condition: "gt".to_string(),
289                        threshold: 70.0,
290                        threshold_upper: None,
291                        signal: "overbought".to_string(),
292                        action: None,
293                    },
294                    current_value: 75.0,
295                    triggered: true,
296                    signal: "overbought".to_string(),
297                },
298                RuleEvaluation {
299                    rule: TaRule {
300                        indicator: "ADX".to_string(),
301                        params: vec![14.0],
302                        condition: "gt".to_string(),
303                        threshold: 25.0,
304                        threshold_upper: None,
305                        signal: "strong_trend".to_string(),
306                        action: None,
307                    },
308                    current_value: 20.0,
309                    triggered: false,
310                    signal: "strong_trend".to_string(),
311                },
312            ],
313            triggered_signals: vec!["overbought".to_string()],
314        }
315    }
316
317    fn sample_signal_summary_no_triggers() -> SignalSummary {
318        SignalSummary {
319            symbol: "BTC-USD".to_string(),
320            timestamp: 1700000000,
321            current_regime: "neutral".to_string(),
322            regime_changed: false,
323            evaluations: vec![],
324            triggered_signals: vec![],
325        }
326    }
327
328    fn sample_positions() -> Vec<PositionInfo> {
329        vec![PositionInfo {
330            symbol: "BTC-USD".to_string(),
331            side: "long".to_string(),
332            size: 0.5,
333            entry_price: 60000.0,
334            unrealized_pnl: 1500.0,
335            leverage: 5.0,
336            liquidation_price: Some(48000.0),
337        }]
338    }
339
340    fn sample_prompt_params() -> PromptParams {
341        PromptParams {
342            current_regime: "bull".to_string(),
343            regime_duration_secs: 7200,
344            regime_changed: false,
345            previous_regime: None,
346            market_data: "Mark Price: $65000.00".to_string(),
347            technical_summary: "Trend: SMA20=64000 EMA12=64500".to_string(),
348            signal_summary: sample_signal_summary_with_triggers(),
349            positions: sample_positions(),
350            playbook: sample_playbook(),
351            funding_rate: None,
352        }
353    }
354
355    // --- build_system_prompt tests ---
356
357    #[test]
358    fn test_system_prompt_with_playbook() {
359        let pb = sample_playbook();
360        let result = build_system_prompt(&pb);
361        assert!(result.contains(BASE_SYSTEM_PROMPT));
362        assert!(result.contains("bull-market trading agent"));
363    }
364
365    #[test]
366    fn test_system_prompt_without_playbook_prompt() {
367        let pb = sample_playbook_no_prompt();
368        let result = build_system_prompt(&pb);
369        assert_eq!(result, BASE_SYSTEM_PROMPT);
370    }
371
372    #[test]
373    fn test_system_prompt_base_always_present() {
374        let pb = sample_playbook();
375        let result = build_system_prompt(&pb);
376        assert!(result.starts_with(BASE_SYSTEM_PROMPT));
377    }
378
379    // --- format_duration tests ---
380
381    #[test]
382    fn test_format_duration_seconds() {
383        assert_eq!(format_duration(30), "30s");
384        assert_eq!(format_duration(0), "0s");
385        assert_eq!(format_duration(59), "59s");
386    }
387
388    #[test]
389    fn test_format_duration_minutes() {
390        assert_eq!(format_duration(60), "1m");
391        assert_eq!(format_duration(120), "2m");
392        assert_eq!(format_duration(3599), "59m");
393    }
394
395    #[test]
396    fn test_format_duration_hours() {
397        assert_eq!(format_duration(3600), "1h");
398        assert_eq!(format_duration(7200), "2h");
399        assert_eq!(format_duration(5400), "1h30m");
400    }
401
402    #[test]
403    fn test_format_duration_days() {
404        assert_eq!(format_duration(86400), "1d");
405        assert_eq!(format_duration(90000), "1d1h");
406    }
407
408    // --- build_regime_section tests ---
409
410    #[test]
411    fn test_regime_section_normal() {
412        let params = sample_prompt_params();
413        let section = build_regime_section(&params);
414        assert!(section.contains("bull"));
415        assert!(section.contains("2h"));
416        assert!(!section.contains("切換"));
417    }
418
419    #[test]
420    fn test_regime_section_with_change() {
421        let mut params = sample_prompt_params();
422        params.regime_changed = true;
423        params.previous_regime = Some("bear".to_string());
424        let section = build_regime_section(&params);
425        assert!(section.contains("剛從 bear 切換"));
426        assert!(section.contains("過渡期風險"));
427    }
428
429    #[test]
430    fn test_regime_section_high_vol_warning() {
431        let mut params = sample_prompt_params();
432        params.current_regime = "high_vol".to_string();
433        let section = build_regime_section(&params);
434        assert!(section.contains("高波動"));
435        assert!(section.contains("風控"));
436    }
437
438    // --- build_signals_section tests ---
439
440    #[test]
441    fn test_signals_section_with_triggers() {
442        let params = sample_prompt_params();
443        let section = build_signals_section(&params);
444        assert!(section.contains("bull playbook"));
445        assert!(section.contains("TRIGGERED"));
446        assert!(section.contains("overbought"));
447        assert!(section.contains("not triggered"));
448    }
449
450    #[test]
451    fn test_signals_section_no_triggers() {
452        let mut params = sample_prompt_params();
453        params.signal_summary = sample_signal_summary_no_triggers();
454        let section = build_signals_section(&params);
455        assert!(section.contains("沒有觸發任何規則訊號"));
456        assert!(section.contains("自行判斷"));
457    }
458
459    // --- build_positions_section tests ---
460
461    #[test]
462    fn test_positions_section_with_positions() {
463        let positions = sample_positions();
464        let section = build_positions_section(&positions);
465        assert!(section.contains("BTC-USD"));
466        assert!(section.contains("long"));
467        assert!(section.contains("60000"));
468        assert!(section.contains("Liq: $48000"));
469    }
470
471    #[test]
472    fn test_positions_section_empty() {
473        let section = build_positions_section(&[]);
474        assert!(section.contains("沒有持倉"));
475    }
476
477    #[test]
478    fn test_positions_section_no_liquidation() {
479        let positions = vec![PositionInfo {
480            symbol: "ETH-USD".to_string(),
481            side: "short".to_string(),
482            size: 2.0,
483            entry_price: 3000.0,
484            unrealized_pnl: -50.0,
485            leverage: 3.0,
486            liquidation_price: None,
487        }];
488        let section = build_positions_section(&positions);
489        assert!(section.contains("ETH-USD"));
490        assert!(section.contains("short"));
491        assert!(!section.contains("Liq:"));
492    }
493
494    // --- build_risk_section tests ---
495
496    #[test]
497    fn test_risk_section_full() {
498        let params = sample_prompt_params();
499        let section = build_risk_section(&params);
500        assert!(section.contains("bull 設定"));
501        assert!(section.contains("最大持倉: 1000"));
502        assert!(section.contains("止損: 5%"));
503        assert!(section.contains("止盈: 10%"));
504    }
505
506    #[test]
507    fn test_risk_section_no_stop_loss_or_take_profit() {
508        let mut params = sample_prompt_params();
509        params.playbook = sample_playbook_no_prompt();
510        let section = build_risk_section(&params);
511        assert!(section.contains("最大持倉: 500"));
512        assert!(!section.contains("止損"));
513        assert!(!section.contains("止盈"));
514    }
515
516    // --- build_user_prompt integration tests ---
517
518    #[test]
519    fn test_user_prompt_contains_all_sections() {
520        let params = sample_prompt_params();
521        let prompt = build_user_prompt(&params);
522        assert!(prompt.contains("## 當前市場狀態"));
523        assert!(prompt.contains("## 市場數據"));
524        assert!(prompt.contains("## 技術指標"));
525        assert!(prompt.contains("## 規則引擎訊號"));
526        assert!(prompt.contains("## 當前持倉"));
527        assert!(prompt.contains("## 風控限制"));
528        assert!(prompt.contains("請給出你的交易決策"));
529    }
530
531    #[test]
532    fn test_user_prompt_regime_change_with_high_vol() {
533        let mut params = sample_prompt_params();
534        params.current_regime = "high_vol".to_string();
535        params.regime_changed = true;
536        params.previous_regime = Some("bull".to_string());
537        let prompt = build_user_prompt(&params);
538        assert!(prompt.contains("剛從 bull 切換"));
539        assert!(prompt.contains("高波動"));
540    }
541
542    #[test]
543    fn test_user_prompt_no_positions_no_signals() {
544        let mut params = sample_prompt_params();
545        params.positions = vec![];
546        params.signal_summary = sample_signal_summary_no_triggers();
547        let prompt = build_user_prompt(&params);
548        assert!(prompt.contains("沒有持倉"));
549        assert!(prompt.contains("沒有觸發任何規則訊號"));
550    }
551
552    #[test]
553    fn test_user_prompt_market_data_included() {
554        let params = sample_prompt_params();
555        let prompt = build_user_prompt(&params);
556        assert!(prompt.contains("Mark Price: $65000.00"));
557    }
558
559    #[test]
560    fn test_user_prompt_technical_summary_included() {
561        let params = sample_prompt_params();
562        let prompt = build_user_prompt(&params);
563        assert!(prompt.contains("SMA20=64000"));
564    }
565
566    // --- Edge-case tests ---
567
568    #[test]
569    fn test_regime_changed_without_previous() {
570        let mut params = sample_prompt_params();
571        params.regime_changed = true;
572        params.previous_regime = None;
573        let section = build_regime_section(&params);
574        assert!(!section.contains("剛從"));
575    }
576
577    #[test]
578    fn test_multiple_positions() {
579        let positions = vec![
580            PositionInfo {
581                symbol: "BTC-USD".to_string(),
582                side: "long".to_string(),
583                size: 0.1,
584                entry_price: 60000.0,
585                unrealized_pnl: 200.0,
586                leverage: 3.0,
587                liquidation_price: Some(45000.0),
588            },
589            PositionInfo {
590                symbol: "ETH-USD".to_string(),
591                side: "short".to_string(),
592                size: 1.0,
593                entry_price: 3500.0,
594                unrealized_pnl: -80.0,
595                leverage: 5.0,
596                liquidation_price: None,
597            },
598        ];
599        let section = build_positions_section(&positions);
600        assert!(section.contains("BTC-USD"));
601        assert!(section.contains("ETH-USD"));
602        assert!(section.contains("long"));
603        assert!(section.contains("short"));
604    }
605
606    #[test]
607    fn test_multiple_triggered_signals() {
608        let mut params = sample_prompt_params();
609        params.signal_summary.triggered_signals =
610            vec!["overbought".to_string(), "strong_trend".to_string()];
611        params.signal_summary.evaluations[1].triggered = true;
612        let section = build_signals_section(&params);
613        assert!(section.contains("overbought, strong_trend"));
614    }
615
616    // --- Funding rate section tests ---
617
618    #[test]
619    fn test_funding_rate_section_positive() {
620        let fr = FundingRateInfo {
621            rate: 0.0001,
622            annualized_rate: 0.0001 * 3.0 * 365.0,
623        };
624        let section = build_funding_rate_section(&fr);
625        assert!(section.contains("資金費率"));
626        assert!(section.contains("0.0100%"));
627        assert!(section.contains("多頭支付空頭"));
628    }
629
630    #[test]
631    fn test_funding_rate_section_negative() {
632        let fr = FundingRateInfo {
633            rate: -0.0002,
634            annualized_rate: -0.0002 * 3.0 * 365.0,
635        };
636        let section = build_funding_rate_section(&fr);
637        assert!(section.contains("空頭支付多頭"));
638    }
639
640    #[test]
641    fn test_funding_rate_section_zero() {
642        let fr = FundingRateInfo {
643            rate: 0.0,
644            annualized_rate: 0.0,
645        };
646        let section = build_funding_rate_section(&fr);
647        assert!(section.contains("中性"));
648    }
649
650    #[test]
651    fn test_funding_rate_section_high_rate_warning() {
652        let fr = FundingRateInfo {
653            rate: 0.001,
654            annualized_rate: 0.001 * 3.0 * 365.0,
655        };
656        let section = build_funding_rate_section(&fr);
657        assert!(section.contains("費率偏高"));
658    }
659
660    #[test]
661    fn test_funding_rate_section_normal_no_warning() {
662        let fr = FundingRateInfo {
663            rate: 0.0001,
664            annualized_rate: 0.0001 * 3.0 * 365.0,
665        };
666        let section = build_funding_rate_section(&fr);
667        assert!(!section.contains("費率偏高"));
668    }
669
670    #[test]
671    fn test_user_prompt_with_funding_rate() {
672        let mut params = sample_prompt_params();
673        params.funding_rate = Some(FundingRateInfo {
674            rate: 0.00015,
675            annualized_rate: 0.00015 * 3.0 * 365.0,
676        });
677        let prompt = build_user_prompt(&params);
678        assert!(prompt.contains("資金費率"));
679        assert!(prompt.contains("0.0150%"));
680    }
681
682    #[test]
683    fn test_user_prompt_without_funding_rate() {
684        let params = sample_prompt_params();
685        let prompt = build_user_prompt(&params);
686        assert!(!prompt.contains("資金費率"));
687    }
688}