|
|
|
|
@ -12,11 +12,24 @@ import os |
|
|
|
|
import json |
|
|
|
|
import time |
|
|
|
|
import logging |
|
|
|
|
import hashlib |
|
|
|
|
from typing import Dict, Any, Optional, Tuple |
|
|
|
|
|
|
|
|
|
import openai |
|
|
|
|
from openai import OpenAI |
|
|
|
|
from config_openai import OPENAI_CONFIG, RATE_LIMITS |
|
|
|
|
try: |
|
|
|
|
from config_openai import OPENAI_CONFIG, RATE_LIMITS |
|
|
|
|
except Exception: |
|
|
|
|
OPENAI_CONFIG = { |
|
|
|
|
'api_key': os.environ.get('OPENAI_API_KEY') or os.environ.get('OPENROUTER_API_KEY', ''), |
|
|
|
|
'model': os.environ.get('OPENAI_MODEL', os.environ.get('OPENROUTER_MODEL', 'gpt-4')), |
|
|
|
|
'timeout': int(os.environ.get('LLM_TIMEOUT', '60')), |
|
|
|
|
'max_tokens': int(os.environ.get('LLM_MAX_TOKENS', '2000')), |
|
|
|
|
'temperature': float(os.environ.get('LLM_TEMPERATURE', '0.7')) |
|
|
|
|
} |
|
|
|
|
RATE_LIMITS = { |
|
|
|
|
'max_retries': int(os.environ.get('LLM_MAX_RETRIES', '3')), |
|
|
|
|
'retry_delay': int(os.environ.get('LLM_RETRY_DELAY', '2')) |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
# 設定日誌 |
|
|
|
|
logging.basicConfig(level=logging.INFO) |
|
|
|
|
@ -30,25 +43,18 @@ class PromptManager: |
|
|
|
|
self.system_prompt = self._get_system_prompt() |
|
|
|
|
|
|
|
|
|
def _get_system_prompt(self) -> str: |
|
|
|
|
"""系統提示詞""" |
|
|
|
|
return """你是一位經驗豐富的專業投資顧問,擁有超過15年的投資經驗和深厚的金融知識。 |
|
|
|
|
請基於提供的投資組合數據,提供專業、客觀且實用的投資建議。 |
|
|
|
|
|
|
|
|
|
請從以下幾個面向進行分析: |
|
|
|
|
1. 整體表現評估(年化報酬、風險指標) |
|
|
|
|
2. 風險收益特性分析(夏普比率、最大回落) |
|
|
|
|
3. 市場環境適配性評估 |
|
|
|
|
4. 具體的改進建議和操作建議 |
|
|
|
|
5. 風險管理和再平衡建議 |
|
|
|
|
|
|
|
|
|
請確保你的回答: |
|
|
|
|
- 專業且易懂,避免過度技術術語 |
|
|
|
|
- 基於數據事實,客觀分析 |
|
|
|
|
- 提供可操作的具體建議 |
|
|
|
|
- 考慮台灣市場的特殊性(如果適用)""" |
|
|
|
|
"""系統提示詞 - 定義基金經理人專家角色""" |
|
|
|
|
return """你是一位頂尖的基金經理人與投資策略師,擁有超過20年橫跨牛熊市的實戰經驗。你的專長是將複雜的金融數據轉化為清晰、易於理解的語言,為大眾投資者提供專業見解。 |
|
|
|
|
|
|
|
|
|
你的溝通風格: |
|
|
|
|
- **權威且親切**:你的語氣充滿自信與專業,但同時讓非專業人士感到安心與信賴。 |
|
|
|
|
- **教育家精神**:你會用生動的比喻來解釋關鍵指標,例如將「夏普比率」比喻為投資的「性價比」,或將「最大回落」形容為「最顛簸的一段路」。 |
|
|
|
|
- **客觀中立**:你總是基於數據進行分析,同時點出潛在的盲點與風險。 |
|
|
|
|
|
|
|
|
|
你的任務是根據接下來提供的策略回測數據,撰寫一份專業的投資策略分析報告。""" |
|
|
|
|
|
|
|
|
|
def build_strategy_context(self, strategy_data: Dict[str, Any]) -> str: |
|
|
|
|
"""將策略資料轉換為結構化context""" |
|
|
|
|
"""將策略資料轉換為結構化context,並附帶指標提示""" |
|
|
|
|
return f""" |
|
|
|
|
策略基本資訊: |
|
|
|
|
- 策略編號:{strategy_data.get('id', 'N/A')} |
|
|
|
|
@ -57,16 +63,17 @@ class PromptManager: |
|
|
|
|
- 建立時間:{strategy_data.get('date', 'N/A')} |
|
|
|
|
- 建立者:{strategy_data.get('username', 'N/A')} |
|
|
|
|
|
|
|
|
|
績效指標: |
|
|
|
|
- 年化報酬率:{strategy_data.get('annual_ret', 0):.2%} |
|
|
|
|
- 年化波動率:{strategy_data.get('vol', 0):.2%} |
|
|
|
|
- 年化夏普比率:{strategy_data.get('annual_sr', 0):.2f} |
|
|
|
|
- 最大回落(MDD):{strategy_data.get('mdd', 0):.2%} |
|
|
|
|
- Alpha值:{strategy_data.get('alpha', 0):.4f} |
|
|
|
|
- Beta值:{strategy_data.get('beta', 0):.4f} |
|
|
|
|
- VaR (10%):{strategy_data.get('var10', 0):.2%} |
|
|
|
|
- R-squared:{strategy_data.get('r2', 0):.4f} |
|
|
|
|
- Gamma值:{strategy_data.get('gamma', 0):.4f} |
|
|
|
|
核心績效指標: |
|
|
|
|
- 年化報酬率:{strategy_data.get('annual_ret', 0):.2%} (衡量平均每年賺取多少利潤) |
|
|
|
|
- 年化波動率:{strategy_data.get('vol', 0):.2%} (衡量資產價值的波動風險,越高代表起伏越大) |
|
|
|
|
- 年化夏普比率:{strategy_data.get('annual_sr', 0):.2f} (衡量每一單位風險能換來多少報酬,可視為「投資CP值」) |
|
|
|
|
- 最大回落(MDD):{strategy_data.get('mdd', 0):.2%} (衡量策略從最高點到最低點可能出現的最大虧損幅度) |
|
|
|
|
|
|
|
|
|
進階參考指標: |
|
|
|
|
- Alpha值:{strategy_data.get('alpha', 0):.4f} (相對於市場基準的超額報酬能力) |
|
|
|
|
- Beta值:{strategy_data.get('beta', 0):.4f} (與市場波動的關聯性,大於1代表比市場更敏感) |
|
|
|
|
- VaR (10天, 95%信心):{strategy_data.get('var10', 0):.2%} (預估在未來10天內,有95%的機率虧損不會超過此比例) |
|
|
|
|
- R-squared:{strategy_data.get('r2', 0):.4f} (策略表現有多大比例可由市場表現來解釋) |
|
|
|
|
|
|
|
|
|
投資組合配置: |
|
|
|
|
- 包含資產:{', '.join(strategy_data.get('assets', []))} |
|
|
|
|
@ -74,21 +81,52 @@ class PromptManager: |
|
|
|
|
""" |
|
|
|
|
|
|
|
|
|
def get_investment_advice_prompt(self, strategy_data: Dict[str, Any]) -> str: |
|
|
|
|
"""生成投資建議的完整Prompt""" |
|
|
|
|
"""生成結構化投資建議報告的完整Prompt - 使用外部模板""" |
|
|
|
|
try: |
|
|
|
|
# 導入 prompts 模組 |
|
|
|
|
from prompts.investment_advice import get_comprehensive_analysis_prompt |
|
|
|
|
return get_comprehensive_analysis_prompt(strategy_data) |
|
|
|
|
except ImportError: |
|
|
|
|
# 回退到內建模板 |
|
|
|
|
logger.warning("Could not import prompts.investment_advice, using built-in template") |
|
|
|
|
return self._get_builtin_prompt(strategy_data) |
|
|
|
|
|
|
|
|
|
def _get_builtin_prompt(self, strategy_data: Dict[str, Any]) -> str: |
|
|
|
|
"""內建模板作為回退方案""" |
|
|
|
|
context = self.build_strategy_context(strategy_data) |
|
|
|
|
|
|
|
|
|
return f"""{self.system_prompt} |
|
|
|
|
|
|
|
|
|
{context} |
|
|
|
|
|
|
|
|
|
請提供詳細的投資建議分析,包含: |
|
|
|
|
1. 整體表現評估:該策略的強項和弱點 |
|
|
|
|
2. 風險評估:當前風險水平的評價和建議 |
|
|
|
|
3. 市場適配性:該策略在當前市場環境下的適配程度 |
|
|
|
|
4. 改進建議:具體的可操作改進建議 |
|
|
|
|
5. 未來展望:未來3-6個月的投資建議 |
|
|
|
|
請嚴格遵循以下結構,為這份投資策略撰寫一份專業分析報告: |
|
|
|
|
|
|
|
|
|
--- |
|
|
|
|
|
|
|
|
|
### **【投資策略總評:給您的執行摘要】** |
|
|
|
|
*在這部分,請用2-3句話總結這個策略的核心特點與績效等級。直接點出它適合哪一種類型的投資者。* |
|
|
|
|
|
|
|
|
|
### **【績效深度解析:白話解讀關鍵指標】** |
|
|
|
|
*在這部分,請選擇2-3個最重要的指標(例如:夏普比率、最大回落),並用生動的比喻解釋它們在此策略中的意義。* |
|
|
|
|
- **指標1**:[指標名稱] - [用比喻解釋其表現] |
|
|
|
|
- **指標2**:[指標名稱] - [用比喻解釋其表現] |
|
|
|
|
|
|
|
|
|
### **【策略的亮點與潛在風險】** |
|
|
|
|
*客觀分析此策略的優缺點。* |
|
|
|
|
- **👍 亮點 (Strengths)**:[至少列出2點,例如:在特定市場環境下表現優異、風險控制得當等] |
|
|
|
|
- **🤔 潛在風險 (Weaknesses/Risks)**:[至少列出2點,例如:資產過於集中、對利率變化敏感等] |
|
|
|
|
|
|
|
|
|
### **【給您的具體投資建議】** |
|
|
|
|
*提供清晰、可執行的建議。* |
|
|
|
|
1. **核心觀點**:[明確指出「繼續持有」、「考慮調整」或「尋找替代方案」] |
|
|
|
|
2. **優化建議**:[提出1-2項具體優化方向,例如:「可考慮納入 OOO 類型的資產以分散風險」或「建議將再平衡頻率調整為 X 個月一次」] |
|
|
|
|
3. **風險管理**:[提醒投資者應該注意的市場訊號或事件] |
|
|
|
|
|
|
|
|
|
### **【未來展望與提醒】** |
|
|
|
|
*提供一個前瞻性的總結,並附上免責聲明。* |
|
|
|
|
|
|
|
|
|
--- |
|
|
|
|
|
|
|
|
|
請用繁體中文回答,結構清晰,建議具體可行。""" |
|
|
|
|
請用繁體中文回答,確保報告結構完整、語氣專業且易於理解。""" |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class LLMInvestmentAdvisor: |
|
|
|
|
@ -96,22 +134,39 @@ class LLMInvestmentAdvisor: |
|
|
|
|
|
|
|
|
|
def __init__(self, api_key: Optional[str] = None): |
|
|
|
|
"""初始化LLM服務""" |
|
|
|
|
self.api_key = api_key or OPENAI_CONFIG['api_key'] |
|
|
|
|
provider = os.environ.get('LLM_PROVIDER', 'openai').lower() |
|
|
|
|
base_url = None |
|
|
|
|
default_headers = None |
|
|
|
|
|
|
|
|
|
if provider == 'openrouter': |
|
|
|
|
self.api_key = api_key or os.environ.get('OPENROUTER_API_KEY') or OPENAI_CONFIG.get('api_key') |
|
|
|
|
base_url = os.environ.get('OPENROUTER_BASE_URL', 'https://openrouter.ai/api/v1') |
|
|
|
|
self.model = os.environ.get('OPENROUTER_MODEL', OPENAI_CONFIG.get('model', 'openrouter/auto')) |
|
|
|
|
default_headers = { |
|
|
|
|
'HTTP-Referer': os.environ.get('OPENROUTER_REFERER', ''), |
|
|
|
|
'X-Title': os.environ.get('OPENROUTER_TITLE', 'TPM') |
|
|
|
|
} |
|
|
|
|
else: |
|
|
|
|
self.api_key = api_key or os.environ.get('OPENAI_API_KEY') or OPENAI_CONFIG.get('api_key') |
|
|
|
|
self.model = os.environ.get('OPENAI_MODEL', OPENAI_CONFIG.get('model', 'gpt-4')) |
|
|
|
|
|
|
|
|
|
if not self.api_key or self.api_key == 'your-api-key-here': |
|
|
|
|
raise ValueError("OpenAI API key is required. Please set OPENAI_API_KEY environment variable.") |
|
|
|
|
raise ValueError("LLM API key is required. Please set OPENAI_API_KEY or OPENROUTER_API_KEY.") |
|
|
|
|
|
|
|
|
|
self.client = OpenAI( |
|
|
|
|
api_key=self.api_key, |
|
|
|
|
timeout=OPENAI_CONFIG.get('timeout', 30) |
|
|
|
|
base_url=base_url, |
|
|
|
|
timeout=OPENAI_CONFIG.get('timeout', 30), |
|
|
|
|
default_headers=default_headers |
|
|
|
|
) |
|
|
|
|
self.prompt_manager = PromptManager() |
|
|
|
|
self.model = OPENAI_CONFIG.get('model', 'gpt-4') |
|
|
|
|
self.max_tokens = OPENAI_CONFIG.get('max_tokens', 2000) |
|
|
|
|
self.temperature = OPENAI_CONFIG.get('temperature', 0.7) |
|
|
|
|
|
|
|
|
|
# 快取設定 |
|
|
|
|
self.cache: Dict[str, Tuple[str, float]] = {} |
|
|
|
|
self.cache_timeout = 3600 # 1小時快取 |
|
|
|
|
self.mock_mode = os.environ.get('MOCK_LLM', 'false').lower() in ('1', 'true', 'yes') |
|
|
|
|
|
|
|
|
|
def _is_cache_valid(self, cache_time: float) -> bool: |
|
|
|
|
"""檢查快取是否有效""" |
|
|
|
|
@ -127,7 +182,9 @@ class LLMInvestmentAdvisor: |
|
|
|
|
Returns: |
|
|
|
|
str: 投資建議文本 |
|
|
|
|
""" |
|
|
|
|
cache_key = f"advice_{strategy_id}_{hash(json.dumps(strategy_data, sort_keys=True))}" |
|
|
|
|
stable_payload = json.dumps(strategy_data, sort_keys=True).encode('utf-8') |
|
|
|
|
cache_digest = hashlib.sha256(stable_payload).hexdigest() |
|
|
|
|
cache_key = f"advice_{strategy_id}_{cache_digest}" |
|
|
|
|
|
|
|
|
|
# 檢查快取 |
|
|
|
|
if cache_key in self.cache: |
|
|
|
|
@ -140,8 +197,13 @@ class LLMInvestmentAdvisor: |
|
|
|
|
# 構建prompt |
|
|
|
|
prompt = self.prompt_manager.get_investment_advice_prompt(strategy_data) |
|
|
|
|
|
|
|
|
|
# 調用OpenAI API |
|
|
|
|
response = self._call_openai_with_retry(prompt) |
|
|
|
|
# Mock 模式:不呼叫外部API |
|
|
|
|
if self.mock_mode: |
|
|
|
|
logger.info("MOCK_LLM enabled, returning fallback advice without external API call") |
|
|
|
|
response = self._get_fallback_advice(strategy_data) |
|
|
|
|
else: |
|
|
|
|
# 調用LLM API |
|
|
|
|
response = self._call_openai_with_retry(prompt) |
|
|
|
|
|
|
|
|
|
# 快取結果 |
|
|
|
|
self.cache[cache_key] = (response, time.time()) |
|
|
|
|
@ -184,29 +246,15 @@ class LLMInvestmentAdvisor: |
|
|
|
|
|
|
|
|
|
return response.choices[0].message.content.strip() |
|
|
|
|
|
|
|
|
|
except openai.RateLimitError as e: |
|
|
|
|
if attempt < max_retries - 1: |
|
|
|
|
# 指數退避:1s, 2s, 4s |
|
|
|
|
wait_time = retry_delay ** attempt |
|
|
|
|
logger.warning(f"Rate limit exceeded, retrying in {wait_time}s... (attempt {attempt + 1}/{max_retries})") |
|
|
|
|
time.sleep(wait_time) |
|
|
|
|
continue |
|
|
|
|
else: |
|
|
|
|
logger.error(f"Rate limit exceeded after {max_retries} attempts") |
|
|
|
|
raise e |
|
|
|
|
|
|
|
|
|
except openai.APIError as e: |
|
|
|
|
if attempt < max_retries - 1: |
|
|
|
|
except Exception as e: |
|
|
|
|
status_code = getattr(e, 'status_code', None) |
|
|
|
|
is_rate_limited = status_code == 429 or 'rate limit' in str(e).lower() |
|
|
|
|
if attempt < max_retries - 1 and (is_rate_limited or True): |
|
|
|
|
wait_time = retry_delay ** attempt |
|
|
|
|
logger.warning(f"API error, retrying in {wait_time}s... (attempt {attempt + 1}/{max_retries}): {str(e)}") |
|
|
|
|
logger.warning(f"LLM API error, retrying in {wait_time}s... (attempt {attempt + 1}/{max_retries}): {str(e)}") |
|
|
|
|
time.sleep(wait_time) |
|
|
|
|
continue |
|
|
|
|
else: |
|
|
|
|
logger.error(f"API error after {max_retries} attempts: {str(e)}") |
|
|
|
|
raise e |
|
|
|
|
|
|
|
|
|
except Exception as e: |
|
|
|
|
logger.error(f"Unexpected error calling OpenAI: {str(e)}") |
|
|
|
|
logger.error(f"LLM API error after {max_retries} attempts: {str(e)}") |
|
|
|
|
raise e |
|
|
|
|
|
|
|
|
|
def _get_fallback_advice(self, strategy_data: Dict[str, Any]) -> str: |
|
|
|
|
@ -231,11 +279,7 @@ class LLMInvestmentAdvisor: |
|
|
|
|
*注意:此為預設建議,如需詳細分析請稍後再試。* |
|
|
|
|
""" |
|
|
|
|
|
|
|
|
|
def clear_cache(self): |
|
|
|
|
"""清除快取""" |
|
|
|
|
self.cache.clear() |
|
|
|
|
self.generate_advice.cache_clear() |
|
|
|
|
logger.info("LLM advice cache cleared") |
|
|
|
|
# Duplicate clear_cache removed |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# 創建全域實例 |
|
|
|
|
|