1use hyper_market::market_data::PositionInfo;
2use hyper_strategy::rule_engine::SignalSummary;
3use hyper_strategy::strategy_config::Playbook;
4
5#[derive(Debug, Clone)]
11pub struct PromptParams {
12 pub current_regime: String,
14 pub regime_duration_secs: u64,
16 pub regime_changed: bool,
18 pub previous_regime: Option<String>,
20 pub market_data: String,
22 pub technical_summary: String,
24 pub signal_summary: SignalSummary,
26 pub positions: Vec<PositionInfo>,
28 pub playbook: Playbook,
30 pub funding_rate: Option<FundingRateInfo>,
32}
33
34#[derive(Debug, Clone)]
36pub struct FundingRateInfo {
37 pub rate: f64,
39 pub annualized_rate: f64,
41}
42
43const BASE_SYSTEM_PROMPT: &str = "你是一個加密貨幣交易 AI 助手。";
48
49pub 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
59pub fn build_user_prompt(params: &PromptParams) -> String {
65 let mut sections: Vec<String> = Vec::with_capacity(8);
66
67 sections.push(build_regime_section(params));
69
70 sections.push(format!("## 市場數據\n{}", params.market_data));
72
73 sections.push(format!("## 技術指標\n{}", params.technical_summary));
75
76 sections.push(build_signals_section(params));
78
79 if let Some(ref fr) = params.funding_rate {
81 sections.push(build_funding_rate_section(fr));
82 }
83
84 sections.push(build_positions_section(¶ms.positions));
86
87 sections.push(build_risk_section(params));
89
90 sections.push("請給出你的交易決策。".to_string());
92
93 sections.join("\n\n")
94}
95
96fn 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 ¶ms.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
210fn 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#[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 #[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 #[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 #[test]
411 fn test_regime_section_normal() {
412 let params = sample_prompt_params();
413 let section = build_regime_section(¶ms);
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(¶ms);
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(¶ms);
434 assert!(section.contains("高波動"));
435 assert!(section.contains("風控"));
436 }
437
438 #[test]
441 fn test_signals_section_with_triggers() {
442 let params = sample_prompt_params();
443 let section = build_signals_section(¶ms);
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(¶ms);
455 assert!(section.contains("沒有觸發任何規則訊號"));
456 assert!(section.contains("自行判斷"));
457 }
458
459 #[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 #[test]
497 fn test_risk_section_full() {
498 let params = sample_prompt_params();
499 let section = build_risk_section(¶ms);
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(¶ms);
511 assert!(section.contains("最大持倉: 500"));
512 assert!(!section.contains("止損"));
513 assert!(!section.contains("止盈"));
514 }
515
516 #[test]
519 fn test_user_prompt_contains_all_sections() {
520 let params = sample_prompt_params();
521 let prompt = build_user_prompt(¶ms);
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(¶ms);
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(¶ms);
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(¶ms);
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(¶ms);
563 assert!(prompt.contains("SMA20=64000"));
564 }
565
566 #[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(¶ms);
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(¶ms);
613 assert!(section.contains("overbought, strong_trend"));
614 }
615
616 #[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(¶ms);
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(¶ms);
686 assert!(!prompt.contains("資金費率"));
687 }
688}