feat: V2 prompt system with benchmarks and CoT for Demo Day

- Add investment_advice_v2.py (educational content)
- Add market_benchmark.py (0050.TW/SPY real-time data)
- Update main.py (CoT parameter support)
- Update llm_service.py (V2 integration)
- Update result_view.html (CoT toggle, marked.js)
- Add render.yaml (deployment config)
- Update .gitignore (protect .env, node_modules, logs)
data-init-fixes
Eric0801 1 month ago
parent 1c16cb4321
commit cbd580bb12
  1. 14
      .gitignore
  2. 3
      README.md
  3. 3
      cursor.md
  4. 2
      data_init/config.py
  5. 4
      data_init/data_clear&update_tw_v0.py
  6. 8
      data_init/data_clear&update_us_v0.py
  7. 6
      data_init/data_init_tw_v0.py
  8. 8
      data_init/data_init_us_v0.py
  9. 35
      data_init/update_data_daily.py
  10. 2
      docker-compose.yml
  11. 69
      llm_service.py
  12. 12
      main.py
  13. 259
      market_benchmark.py
  14. 53
      prompts/investment_advice.py
  15. 630
      prompts/investment_advice_v2.py
  16. 46
      render.yaml
  17. 238
      templates/result_view.html

14
.gitignore vendored

@ -3,9 +3,23 @@
__pycache__/
.DS_Store
.vscode/
*.pyc
# Environment and secrets
.env
.env.local
# Dependencies
node_modules/
tpm/
# Logs
*.log
# IDE
.idea/
.claude/
# Distribution / packaging
main.old.py
postgres_runner.py

@ -69,3 +69,6 @@ docker compose up -d --build --force-recreate
> 更詳細的協作規範請見 `cursor.md`

@ -55,3 +55,6 @@
---
本文件供 AI/協作工具與開發者遵循,若有必要變更,請先起草 RFC 並在 PR 中說明動機與回滾方案。

@ -9,7 +9,7 @@ CONFIGS = {
SQL_CONFIG = dict(
database="portfolio_platform",
user="postgres",
host="db",
host="localhost",
port="5432",
password="thiispassword1qaz!QAZ"
)

@ -8,7 +8,7 @@ from tqdm import tqdm
with open('assets_tw.json') as f:
data_tw = json.load(f)
#SQL setting
SQL_CONFIG = dict(database="portfolio_platform", user='postgres', password='thiispassword1qaz!QAZ', host='localhost',port ='5432')
SQL_CONFIG = dict(database="portfolio_platform", user='postgres', password='thiispassword1qaz!QAZ', host='db', port='5432')
# TW Stocks
conn = psycopg2.connect(**SQL_CONFIG)
@ -22,7 +22,7 @@ for ticker in tqdm(data_tw):
price_col = 'Close' if 'Close' in df.columns else ('Adj Close' if 'Adj Close' in df.columns else None)
if price_col is None:
continue
value = [(ticker, df.index[i], float(df[price_col].iloc[i])) for i in range(len(df))]
value = [(ticker, row.name.strftime('%Y-%m-%d'), float(row[price_col])) for _, row in df.iterrows()]
with conn:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as curs:
sql = "insert into stock_price_tw (ticker, date, price) values %s"

@ -7,23 +7,23 @@ import psycopg2
from psycopg2.extras import execute_values
from tqdm import tqdm
with open('assets_us.json') as f:
data_tw = json.load(f)
data_us = json.load(f)
#SQL setting
SQL_CONFIG = dict(database="portfolio_platform", user='postgres', password='thiispassword1qaz!QAZ', host='localhost',port ='5432')
SQL_CONFIG = dict(database="portfolio_platform", user='postgres', password='thiispassword1qaz!QAZ', host='db', port='5432')
# TW Stocks
conn = psycopg2.connect(**SQL_CONFIG)
cursor = conn.cursor()
cursor.execute("TRUNCATE TABLE stock_price")
print("US stock price cleared")
for ticker in tqdm(data_tw):
for ticker in tqdm(data_us):
df = yf.download(ticker, start="2007-01-01", progress=False, threads=False)
if df is None or df.empty:
continue
price_col = 'Close' if 'Close' in df.columns else ('Adj Close' if 'Adj Close' in df.columns else None)
if price_col is None:
continue
value = [(ticker, df.index[i], float(df[price_col].iloc[i])) for i in range(len(df))]
value = [(ticker, row.name.strftime('%Y-%m-%d'), float(row[price_col])) for _, row in df.iterrows()]
with conn:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as curs:
sql = "insert into stock_price (ticker, date, price) values %s"

@ -9,7 +9,7 @@ with open('data_init/assets_tw.json') as f:
data_tw = json.load(f)
#SQL setting
SQL_CONFIG = dict(database="portfolio_platform", user='postgres', password='thiispassword1qaz!QAZ', host='localhost',port ='5432')
SQL_CONFIG = dict(database="portfolio_platform", user='postgres', password='thiispassword1qaz!QAZ', host='db', port='5432')
# TW Stocks
@ -25,11 +25,11 @@ if row_count <= 0 :
price_col = 'Close' if 'Close' in df.columns else ('Adj Close' if 'Adj Close' in df.columns else None)
if price_col is None:
continue
value = [(ticker, row.name.strftime('%Y-%m-%d'), row['Close']) for _, row in df.iterrows()]
value = [(ticker, row.name.strftime('%Y-%m-%d'), float(row[price_col])) for _, row in df.iterrows()]
with conn:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as curs:
sql = "insert into stock_price_tw (ticker, date, price) values %s"
execute_values(curs, sql, value)
print("Finish initialize")
else:
print("DB already have data")
print("DB already have data")

@ -6,10 +6,10 @@ import psycopg2
from psycopg2.extras import execute_values
from tqdm import tqdm
with open('data_init/assets_us.json') as f:
data_tw = json.load(f)
data_us = json.load(f)
#SQL setting
SQL_CONFIG = dict(database="portfolio_platform", user='postgres', password='thiispassword1qaz!QAZ', host='localhost',port ='5432')
SQL_CONFIG = dict(database="portfolio_platform", user='postgres', password='thiispassword1qaz!QAZ', host='db', port='5432')
# TW Stocks
conn = psycopg2.connect(**SQL_CONFIG)
@ -17,14 +17,14 @@ cursor = conn.cursor()
cursor.execute("SELECT COUNT(*) FROM stock_price;")
row_count = cursor.fetchone()[0]
if row_count <= 0 :
for ticker in tqdm(data_tw):
for ticker in tqdm(data_us):
df = yf.download(ticker, start="2007-01-01", progress=False, threads=False)
if df is None or df.empty:
continue
price_col = 'Close' if 'Close' in df.columns else ('Adj Close' if 'Adj Close' in df.columns else None)
if price_col is None:
continue
value = [(ticker, row.name.strftime('%Y-%m-%d'), row['Close']) for _, row in df.iterrows()]
value = [(ticker, row.name.strftime('%Y-%m-%d'), float(row[price_col])) for _, row in df.iterrows()]
with conn:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as curs:
sql = "insert into stock_price (ticker, date, price) values %s"

@ -11,11 +11,34 @@ import time
from config import SQL_CONFIG
#SQL setting
# Quick status: print the latest available dates in DB
def print_last_update():
try:
conn = psycopg2.connect(**SQL_CONFIG)
cursor = conn.cursor()
cursor.execute("SELECT MAX(date) FROM stock_price")
us_last = cursor.fetchone()[0]
cursor.execute("SELECT MAX(date) FROM stock_price_tw")
tw_last = cursor.fetchone()[0]
print(f"Latest US date in DB: {us_last}" if us_last else "Latest US date in DB: None")
print(f"Latest TW date in DB: {tw_last}" if tw_last else "Latest TW date in DB: None")
cursor.close()
conn.close()
except Exception as e:
print(f"Check last update failed: {e}")
def update_data():
# Connect to the database
conn = psycopg2.connect(**SQL_CONFIG)
cursor = conn.cursor()
print("Stock prices US updating")
# Show current latest date in table for quick verification
try:
cursor.execute("SELECT MAX(date) FROM stock_price")
last_overall = cursor.fetchone()[0]
print(f"US table current latest date: {last_overall}")
except Exception:
pass
# Get the list of tickers
cursor.execute("SELECT DISTINCT ticker FROM stock_price")
tickers = [row[0] for row in cursor.fetchall()]
@ -44,6 +67,13 @@ def update_data_tw():
conn = psycopg2.connect(**SQL_CONFIG)
cursor = conn.cursor()
print("Stock prices US updating")
# Show current latest date in table for quick verification
try:
cursor.execute("SELECT MAX(date) FROM stock_price_tw")
last_overall = cursor.fetchone()[0]
print(f"TW table current latest date: {last_overall}")
except Exception:
pass
# Get the list of tickers
cursor.execute("SELECT DISTINCT ticker FROM stock_price_tw")
tickers = [row[0] for row in cursor.fetchall()]
@ -67,10 +97,13 @@ def update_data_tw():
sql = "insert into stock_price_tw (ticker, date, price) values %s"
execute_values(curs, sql, value)
print("Stock prices TW updated")
# Print once on startup
print_last_update()
# Schedule the task
schedule.every().day.at("17:00").do(update_data)
schedule.every().day.at("18:00").do(update_data_tw)
# Keep the script running
while True:
schedule.run_pending()
time.sleep(1)
time.sleep(1)

@ -47,7 +47,7 @@ services:
- LLM_TEMPERATURE=0.6
- LLM_MAX_RETRIES=3
- LLM_RETRY_DELAY=2
- MOCK_LLM=true
- MOCK_LLM=false
volumes:
- flask-data:/flask_run
depends_on:

@ -80,15 +80,46 @@ class PromptManager:
- 市場類型{'台灣市場' if strategy_data.get('tw', True) else '美國市場'}
"""
def get_investment_advice_prompt(self, strategy_data: Dict[str, Any]) -> str:
"""生成結構化投資建議報告的完整Prompt - 使用外部模板"""
def get_investment_advice_prompt(self, strategy_data: Dict[str, Any],
use_cot: bool = False,
use_v2: bool = True,
risk_tolerance: str = "moderate") -> str:
"""生成結構化投資建議報告的完整Prompt - 使用外部模板
Args:
strategy_data: 策略數據
use_cot: 是否使用 Chain-of-Thought 模式預設 False
use_v2: 是否使用 V2 Context-Aware Prompt預設 True推薦
risk_tolerance: 風險承受度 (conservative/moderate/aggressive)
Returns:
完整的 prompt 字串
"""
try:
# 導入 prompts 模組
from prompts.investment_advice import get_comprehensive_analysis_prompt
return get_comprehensive_analysis_prompt(strategy_data)
except ImportError:
# V2 Context-Aware Prompt (推薦,解決評價不一致問題)
if use_v2:
from prompts.investment_advice_v2 import get_context_aware_prompt, get_simplified_context_prompt
logger.info(f"Using V2 Context-Aware Prompt (CoT={use_cot}, risk={risk_tolerance})")
if use_cot:
return get_context_aware_prompt(strategy_data, risk_tolerance=risk_tolerance, use_cot=True)
else:
# 簡化版,但仍有 context
return get_simplified_context_prompt(strategy_data)
# V1 Prompt (舊版,保留向後兼容)
elif use_cot:
# 使用 CoT 版本
from prompts.investment_advice_cot import get_cot_analysis_prompt
logger.info("Using V1 Chain-of-Thought prompt")
return get_cot_analysis_prompt(strategy_data)
else:
# 使用原版(結構化輸出)
from prompts.investment_advice import get_comprehensive_analysis_prompt
logger.info("Using V1 standard prompt")
return get_comprehensive_analysis_prompt(strategy_data)
except ImportError as e:
# 回退到內建模板
logger.warning("Could not import prompts.investment_advice, using built-in template")
logger.warning(f"Could not import prompts module ({e}), using built-in template")
return self._get_builtin_prompt(strategy_data)
def _get_builtin_prompt(self, strategy_data: Dict[str, Any]) -> str:
@ -172,30 +203,42 @@ class LLMInvestmentAdvisor:
"""檢查快取是否有效"""
return time.time() - cache_time < self.cache_timeout
def generate_advice(self, strategy_id: str, strategy_data: Dict[str, Any]) -> str:
def generate_advice(self, strategy_id: str, strategy_data: Dict[str, Any],
use_cot: bool = False,
use_v2: bool = True,
risk_tolerance: str = "moderate") -> str:
"""生成投資建議
Args:
strategy_id: 策略ID
strategy_data: 策略資料字典
use_cot: 是否使用 Chain-of-Thought 模式預設 False
use_v2: 是否使用 V2 Context-Aware Prompt預設 True
risk_tolerance: 風險承受度 (conservative/moderate/aggressive)
Returns:
str: 投資建議文本
"""
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}"
# 快取鍵包含版本和風險承受度資訊
cache_key = f"advice_{strategy_id}_{cache_digest}_v{2 if use_v2 else 1}_cot{use_cot}_risk{risk_tolerance}"
# 檢查快取
if cache_key in self.cache:
advice, cache_time = self.cache[cache_key]
if self._is_cache_valid(cache_time):
logger.info(f"Returning cached advice for strategy {strategy_id}")
logger.info(f"Returning cached advice for strategy {strategy_id} (V2={use_v2}, CoT={use_cot}, risk={risk_tolerance})")
return advice
try:
# 構建prompt
prompt = self.prompt_manager.get_investment_advice_prompt(strategy_data)
# 構建prompt(根據參數選擇版本)
prompt = self.prompt_manager.get_investment_advice_prompt(
strategy_data,
use_cot=use_cot,
use_v2=use_v2,
risk_tolerance=risk_tolerance
)
# Mock 模式:不呼叫外部API
if self.mock_mode:
@ -207,7 +250,7 @@ class LLMInvestmentAdvisor:
# 快取結果
self.cache[cache_key] = (response, time.time())
logger.info(f"Generated new advice for strategy {strategy_id}")
logger.info(f"Generated new advice for strategy {strategy_id} (CoT={use_cot})")
return response
except Exception as e:

@ -532,6 +532,8 @@ def llm_advice(strategy_id):
"""LLM投資建議API
接收策略ID返回AI生成的投資建議
Query parameters:
- cot (optional): 'true' to enable Chain-of-Thought reasoning
"""
try:
# 驗證登入
@ -541,6 +543,9 @@ def llm_advice(strategy_id):
'error': '請先登入'
}), 401
# 獲取CoT參數
use_cot = request.args.get('cot', 'false').lower() == 'true'
# 查詢策略資料
sql = "SELECT * FROM strategy WHERE id = %s;"
conn = psycopg2.connect(**SQL_CONFIG)
@ -564,13 +569,14 @@ def llm_advice(strategy_id):
# 獲取LLM顧問實例
llm_advisor = get_llm_advisor()
# 生成投資建議
advice = llm_advisor.generate_advice(str(strategy_id), strategy_dict)
# 生成投資建議(支援CoT模式)
advice = llm_advisor.generate_advice(str(strategy_id), strategy_dict, use_cot=use_cot)
return jsonify({
'success': True,
'advice': advice,
'strategy_id': strategy_id
'strategy_id': strategy_id,
'cot_enabled': use_cot
})
except Exception as e:

@ -0,0 +1,259 @@
"""
市場基準資料模組
從資料庫取得實際的市場基準資料台股加權指數S&P 500
用於 Context Engineering 的市場環境背景
"""
import psycopg2
import pandas as pd
import numpy as np
from datetime import datetime, timedelta
from typing import Dict, Any, Optional
import logging
logger = logging.getLogger(__name__)
# 從 config 匯入資料庫設定
try:
from config import SQL_CONFIG
except ImportError:
# Fallback 設定
SQL_CONFIG = {
"database": "portfolio_platform",
"user": "postgres",
"host": "db",
"port": "5432",
"password": "thiispassword1qaz!QAZ"
}
class MarketBenchmark:
"""市場基準資料類別"""
def __init__(self):
"""初始化市場基準資料"""
self.cache = {}
self.cache_timeout = 3600 # 1小時快取
self.cache_time = {}
def _is_cache_valid(self, key: str) -> bool:
"""檢查快取是否有效"""
if key not in self.cache_time:
return False
return (datetime.now().timestamp() - self.cache_time[key]) < self.cache_timeout
def get_market_context(self, tw: bool = True, force_refresh: bool = False) -> Dict[str, Any]:
"""
獲取市場環境背景從資料庫計算實際數據
Args:
tw: True=台灣市場False=美國市場
force_refresh: 強制重新計算不使用快取
Returns:
市場環境背景字典
"""
cache_key = f"market_{'tw' if tw else 'us'}"
# 檢查快取
if not force_refresh and self._is_cache_valid(cache_key):
logger.info(f"Using cached market context for {'TW' if tw else 'US'}")
return self.cache[cache_key]
try:
if tw:
context = self._get_tw_market_context()
else:
context = self._get_us_market_context()
# 更新快取
self.cache[cache_key] = context
self.cache_time[cache_key] = datetime.now().timestamp()
logger.info(f"Calculated market context for {'TW' if tw else 'US'}: {context}")
return context
except Exception as e:
logger.error(f"Error getting market context: {e}")
# Fallback 到靜態資料
return self._get_fallback_context(tw)
def _get_tw_market_context(self) -> Dict[str, Any]:
"""取得台灣市場基準資料(從資料庫計算)"""
conn = psycopg2.connect(**SQL_CONFIG)
try:
# 取得 0050.TW 近期資料
query = """
SELECT date, price
FROM stock_price_tw
WHERE ticker = '0050.TW'
ORDER BY date DESC
LIMIT 1260 -- 約5年交易日
"""
df = pd.read_sql(query, conn)
df = df.sort_values('date')
df['return'] = df['price'].pct_change()
# 計算各項指標
latest_price = df['price'].iloc[-1]
year_start_idx = max(0, len(df) - 252) # 今年開始(約252交易日)
ytd_return = (latest_price / df['price'].iloc[year_start_idx]) - 1
# 近5年年化報酬
total_return = (latest_price / df['price'].iloc[0]) - 1
years = len(df) / 252
avg_5y_return = (1 + total_return) ** (1 / years) - 1
# 年化波動率
volatility = df['return'].std() * np.sqrt(252)
# 市場情緒判斷(基於近期趨勢)
recent_returns = df['return'].iloc[-63:].sum() # 最近3個月
if recent_returns > 0.05:
sentiment = "bull"
elif recent_returns < -0.05:
sentiment = "bear"
else:
sentiment = "neutral"
return {
"market_name": "台灣加權指數(0050.TW)",
"ytd_return": float(ytd_return),
"avg_5y_return": float(avg_5y_return),
"current_price": float(latest_price),
"volatility": float(volatility),
"sentiment": sentiment,
"last_update": df['date'].iloc[-1].strftime("%Y-%m-%d"),
"data_points": len(df)
}
finally:
conn.close()
def _get_us_market_context(self) -> Dict[str, Any]:
"""取得美國市場基準資料(從資料庫計算)"""
conn = psycopg2.connect(**SQL_CONFIG)
try:
# 取得 SPY 近期資料
query = """
SELECT date, price
FROM stock_price
WHERE ticker = 'SPY'
ORDER BY date DESC
LIMIT 1260 -- 約5年交易日
"""
df = pd.read_sql(query, conn)
df = df.sort_values('date')
df['return'] = df['price'].pct_change()
# 計算各項指標
latest_price = df['price'].iloc[-1]
year_start_idx = max(0, len(df) - 252)
ytd_return = (latest_price / df['price'].iloc[year_start_idx]) - 1
# 近5年年化報酬
total_return = (latest_price / df['price'].iloc[0]) - 1
years = len(df) / 252
avg_5y_return = (1 + total_return) ** (1 / years) - 1
# 年化波動率
volatility = df['return'].std() * np.sqrt(252)
# 市場情緒判斷
recent_returns = df['return'].iloc[-63:].sum()
if recent_returns > 0.05:
sentiment = "bull"
elif recent_returns < -0.05:
sentiment = "bear"
else:
sentiment = "neutral"
return {
"market_name": "S&P 500(SPY)",
"ytd_return": float(ytd_return),
"avg_5y_return": float(avg_5y_return),
"current_price": float(latest_price),
"volatility": float(volatility),
"sentiment": sentiment,
"last_update": df['date'].iloc[-1].strftime("%Y-%m-%d"),
"data_points": len(df)
}
finally:
conn.close()
def _get_fallback_context(self, tw: bool) -> Dict[str, Any]:
"""Fallback 靜態資料(資料庫查詢失敗時使用)"""
if tw:
return {
"market_name": "台灣加權指數",
"ytd_return": 0.18,
"avg_5y_return": 0.09,
"volatility": 0.15,
"sentiment": "neutral",
"last_update": "static",
"is_fallback": True
}
else:
return {
"market_name": "S&P 500",
"ytd_return": 0.22,
"avg_5y_return": 0.12,
"volatility": 0.14,
"sentiment": "bull",
"last_update": "static",
"is_fallback": True
}
# 單例模式
_market_benchmark_instance = None
def get_market_benchmark() -> MarketBenchmark:
"""獲取市場基準實例(單例)"""
global _market_benchmark_instance
if _market_benchmark_instance is None:
_market_benchmark_instance = MarketBenchmark()
return _market_benchmark_instance
# 便利函數(向後兼容)
def get_market_context(tw: bool = True) -> Dict[str, Any]:
"""
獲取市場環境背景
此函數與 prompts/investment_advice_v2.py 中的函數簽名相同
可直接替換使用
"""
benchmark = get_market_benchmark()
return benchmark.get_market_context(tw)
if __name__ == "__main__":
# 測試腳本
import json
logging.basicConfig(level=logging.INFO)
print("="*80)
print("測試市場基準資料模組")
print("="*80)
# 測試台灣市場
print("\n台灣市場基準:")
tw_context = get_market_context(tw=True)
print(json.dumps(tw_context, indent=2, ensure_ascii=False))
# 測試美國市場
print("\n美國市場基準:")
us_context = get_market_context(tw=False)
print(json.dumps(us_context, indent=2, ensure_ascii=False))
print("\n" + "="*80)
print("測試完成!")
print("="*80)

@ -53,43 +53,38 @@ def get_comprehensive_analysis_prompt(strategy_data: Dict[str, Any]) -> str:
投資組合配置
持有資產{', '.join(strategy_data.get('assets', []))}
權重配置{strategy_data.get('weight', {}).get('columns', [])}
請使用以下結構提供分析確保格式清晰易讀
分析要求
請從以下面向提供詳細分析和建議
## 📊 策略總評
*用2-3句話總結這個策略的核心特點與績效等級直接點出它適合哪一種類型的投資者*
1. **績效評估**
- 與市場基準比較台灣加權指數或S&P 500
- 歷史表現趨勢分析
- 相對表現評價
## 📈 關鍵指標深度解析
### 💰 報酬表現
- **年化報酬率**{strategy_data.get('annual_ret', 0):.2%} - *用生動比喻解釋此表現*
- **風險調整報酬**{strategy_data.get('annual_sr', 0):.2f} - *解釋夏普比率的意義*
2. **風險分析**
- 整體風險水平評估
- 主要風險來源識別
- 風險調整後報酬分析
### ⚠ 風險評估
- **波動率**{strategy_data.get('vol', 0):.2%} - *評估風險水平*
- **最大回落**{strategy_data.get('mdd', 0):.2%} - *解釋最大虧損風險*
3. **市場適配性**
- 當前市場環境適配程度
- 經濟週期位置評估
- 市場波動性影響分析
## 🎯 策略優劣分析
### 👍 亮點優勢
- [列出至少2個優點例如特定市場環境表現優異風險控制得當等]
4. **投資建議**
- 資產配置優化建議
- 風險管理改進方案
- 定期調整和再平衡建議
### 🤔 潛在風險
- [列出至少2個風險點例如資產過於集中對利率變化敏感等]
## 💡 具體投資建議
### 1. 核心觀點
**建議**[明確指出繼續持有考慮調整尋找替代方案]
### 2. 優化方向
- [提出1-2項具體優化建議例如可考慮納入 OOO 類型資產以分散風險]
### 3. 風險管理
- [提醒投資者應該注意的市場訊號或事件]
## 🔮 未來展望
*提供前瞻性總結並附上簡短的免責聲明*
5. **未來展望**
- 未來3-6個月投資展望
- 潛在風險預警
- 操作建議和時機點
---
請用繁體中文回答確保報告結構完整語氣專業且易於理解使用適當的emoji和格式化讓內容更生動
請用繁體中文回答確保報告結構完整語氣專業且易於理解使用和格式化讓內容更生動
"""

@ -0,0 +1,630 @@
"""
投資建議 Prompt V2 - Context Engineering + Prompt Engineering
核心改進
1. Context Engineering: 加入用戶背景市場環境評分標準
2. Prompt Engineering: 強制一致性邏輯嚴格輸出格式
3. 前端整合: 支援 Markdown 渲染
"""
from typing import Dict, Any
from datetime import datetime
# ===== Context Engineering =====
def get_market_context(tw: bool = True) -> Dict[str, Any]:
"""
獲取市場環境背景從資料庫計算實際數據
整合
- 資料庫 stock_price_tw (0050.TW) stock_price (SPY) 計算
- 快取1小時 TTL 避免重複計算
- Fallback資料庫查詢失敗時使用靜態資料
"""
try:
# 嘗試使用資料庫計算的實際市場數據
from market_benchmark import get_market_benchmark
benchmark = get_market_benchmark()
context = benchmark.get_market_context(tw)
# 如果是 fallback,記錄警告
if context.get('is_fallback'):
import logging
logging.warning(f"Using fallback market data for {'TW' if tw else 'US'}")
return context
except ImportError:
# market_benchmark 模組不存在,使用靜態資料
import logging
logging.warning("market_benchmark module not found, using static data")
return _get_static_market_context(tw)
except Exception as e:
# 其他錯誤,使用靜態資料
import logging
logging.error(f"Error loading market context: {e}, using static data")
return _get_static_market_context(tw)
def _get_static_market_context(tw: bool) -> Dict[str, Any]:
"""靜態市場資料(Fallback)"""
if tw:
return {
"market_name": "台灣加權指數(0050.TW)",
"ytd_return": 0.18,
"avg_5y_return": 0.09,
"volatility": 0.15,
"sentiment": "neutral",
"is_fallback": True
}
else:
return {
"market_name": "S&P 500(SPY)",
"ytd_return": 0.22,
"avg_5y_return": 0.12,
"volatility": 0.14,
"sentiment": "bull",
"is_fallback": True
}
def get_investor_profile(risk_tolerance: str = "moderate") -> Dict[str, Any]:
"""
投資人背景設定 (未來可從用戶資料庫讀取)
Args:
risk_tolerance: conservative/moderate/aggressive
"""
profiles = {
"conservative": {
"label": "保守型",
"max_acceptable_mdd": -0.15, # 最大可接受回落
"target_sharpe": 1.5, # 目標夏普比率
"description": "重視資本保全,追求穩定收益"
},
"moderate": {
"label": "穩健型",
"max_acceptable_mdd": -0.25,
"target_sharpe": 1.0,
"description": "平衡風險與報酬"
},
"aggressive": {
"label": "積極型",
"max_acceptable_mdd": -0.40,
"target_sharpe": 0.8,
"description": "追求高報酬,願意承擔較高風險"
}
}
return profiles.get(risk_tolerance, profiles["moderate"])
def get_evaluation_criteria() -> Dict[str, Any]:
"""
明確的評分標準 (解決評價不一致問題)
"""
return {
"sharpe_ratio": {
"excellent": 2.0, # >= 2.0
"good": 1.5, # >= 1.5
"acceptable": 1.0, # >= 1.0
"poor": 0.5, # >= 0.5
"very_poor": 0.0 # < 0.5
},
"annual_return": {
"tw": {"excellent": 0.15, "good": 0.10, "acceptable": 0.08, "poor": 0.05},
"us": {"excellent": 0.18, "good": 0.12, "acceptable": 0.10, "poor": 0.06}
},
"mdd": {
"low_risk": -0.15, # < -15% 算低風險
"medium_risk": -0.25, # -15% ~ -25%
"high_risk": -0.40, # -25% ~ -40%
"extreme_risk": -0.40 # > -40%
},
"beta": {
"defensive": 0.8, # < 0.8
"balanced": 1.2, # 0.8 ~ 1.2
"aggressive": 1.2 # > 1.2
}
}
# ===== Prompt Engineering =====
def get_context_aware_prompt(strategy_data: Dict[str, Any],
risk_tolerance: str = "moderate",
use_cot: bool = True) -> str:
"""
Context-Aware Prompt with Strict Evaluation Logic
Args:
strategy_data: 策略數據
risk_tolerance: 風險承受度 (conservative/moderate/aggressive)
use_cot: 是否使用 Chain-of-Thought
"""
# 提取數據
annual_ret = strategy_data.get('annual_ret', 0)
vol = strategy_data.get('vol', 0)
sharpe = strategy_data.get('annual_sr', 0)
mdd = strategy_data.get('mdd', 0)
alpha = strategy_data.get('alpha', 0)
beta = strategy_data.get('beta', 0)
var10 = strategy_data.get('var10', 0)
r2 = strategy_data.get('r2', 0)
assets = strategy_data.get('assets', [])
name = strategy_data.get('name', 'N/A')
role = strategy_data.get('role', 'N/A')
tw = strategy_data.get('tw', True)
# Context Engineering
market_ctx = get_market_context(tw)
investor_profile = get_investor_profile(risk_tolerance)
criteria = get_evaluation_criteria()
# 構建評分邏輯
evaluation_logic = f"""
## 評分標準與邏輯 (CRITICAL: 必須嚴格遵守)
### 1. 夏普比率評級
```python
if sharpe >= {criteria['sharpe_ratio']['excellent']}:
rating = "優秀"
description = "風險調整後報酬表現卓越"
elif sharpe >= {criteria['sharpe_ratio']['good']}:
rating = "良好"
description = "風險調整後報酬表現良好"
elif sharpe >= {criteria['sharpe_ratio']['acceptable']}:
rating = "可接受"
description = "風險調整後報酬尚可"
elif sharpe >= {criteria['sharpe_ratio']['poor']}:
rating = "待改善"
description = "風險調整後報酬偏低"
else:
rating = "不佳"
description = "風險調整後報酬表現不佳"
```
**本策略夏普比率: {sharpe:.2f}** 請依上述邏輯給予評級並保持一致性
### 2. 報酬率評級 (相對於市場基準)
- 市場基準: {market_ctx['market_name']} 近5年平均 {market_ctx['avg_5y_return']:.1%}
- 本策略: {annual_ret:.2%}
- 相對表現: {((annual_ret / market_ctx['avg_5y_return']) - 1) * 100:+.1f}%
### 3. 風險評級
- 最大回落: {mdd:.2%}
- 投資人可接受範圍: {investor_profile['max_acceptable_mdd']:.1%}
- 超出程度: {((abs(mdd) / abs(investor_profile['max_acceptable_mdd'])) - 1) * 100:+.1f}%
### 4. 市場相關性
- Beta: {beta:.2f}
- 類型: {"防禦型 (< 0.8)" if beta < criteria['beta']['defensive'] else "均衡型 (0.8-1.2)" if beta < criteria['beta']['balanced'] else "進攻型 (> 1.2)"}
- 預期波動: 市場變動 1%策略預期變動 {beta:.2f}%
### 5. 超額收益能力
- Alpha: {alpha:.4f}
- 解釋: {"創造超額收益 ✓" if alpha > 0.01 else "與市場同步 ≈" if alpha >= -0.01 else "落後市場 ✗"}
"""
# 一致性邏輯檢查
consistency_check = f"""
## 一致性邏輯檢查 (MUST FOLLOW)
**禁止自相矛盾**
1. 如果夏普比率評級為優秀整體評價不能說表現一般
2. 如果報酬率遠超市場風險評價不能忽略這個亮點
3. 如果 MDD 超出可接受範圍必須在風險警示中明確指出
**評價優先順序**
1. 首先看風險調整後報酬 (夏普比率) 這是最重要的指標
2. 其次看絕對報酬 vs 市場基準
3. 最後看風險承受度匹配性
📊 **本策略快速判讀**
- 夏普 {sharpe:.2f}: {"優秀 ✓" if sharpe >= 2.0 else "良好 ✓" if sharpe >= 1.5 else "可接受 ○" if sharpe >= 1.0 else "待改善 ✗"}
- 報酬 {annual_ret:.2%} vs 市場 {market_ctx['avg_5y_return']:.1%}: {"超越 ✓" if annual_ret > market_ctx['avg_5y_return'] * 1.2 else "接近 ○" if annual_ret > market_ctx['avg_5y_return'] * 0.8 else "落後 ✗"}
- 風險 {mdd:.2%}: {"可接受 ✓" if abs(mdd) <= abs(investor_profile['max_acceptable_mdd']) else "偏高 ⚠"}
"""
# CoT 推理步驟
cot_steps = """
## 推理步驟 (Chain-of-Thought)
請按以下順序思考並輸出
**步驟 1: 指標解讀**
- 先單獨解讀每個指標的絕對值
- 再與市場基準比較
- 最後與投資人預期比較
**步驟 2: 綜合評分**
- 根據評分標準給出各維度評級
- 注意保持一致性不要有矛盾
- 找出最突出的優勢和劣勢
**步驟 3: 情境分析**
- 假設投入 100 萬資金
- 最好情況能賺多少(基於年化報酬)
- 最壞情況會虧多少(基於 MDD)
- 承受 1 單位風險能換多少報酬(基於夏普)
**步驟 4: 投資建議**
- 這個策略適合哪種投資人
- 在什麼市場環境下表現最好
- 需要做哪些調整才能更好
""" if use_cot else ""
# 嚴格禁止事項
strict_prohibitions = f"""
## ⛔ 嚴格禁止事項 (CRITICAL - MUST FOLLOW)
1. **禁止自己假設或生成市場數據**
- 禁止說假設台灣加權指數同期間年化報酬率為 12%...
- 禁止說為了更精確的比較我們需要...
- **必須使用已提供的市場基準數據**: {market_ctx['market_name']} 近5年平均 {market_ctx['avg_5y_return']:.1%}
2. **禁止模糊或矛盾的評價**
- 禁止說優秀的投資策略...風險調整後報酬表現一般
- 禁止說良好但待改善
- 必須根據夏普比率給出**明確且一致**的評級
3. **禁止使用模板化語言**
- 禁止說策略基本資訊回顧歷史表現趨勢分析等空泛標題
- 必須根據**實際數據**給出具體分析
4. **市場基準說明**
- 台灣市場: 使用 **0050.TW (元大台灣50)** 作為市場代理指標
- 理由: 追蹤台灣市值前50大公司涵蓋約70%市值流動性高
- 美國市場: 使用 **SPY (SPDR S&P 500 ETF)** 作為市場代理指標
- **直接使用提供的數據不要質疑或假設其他數據**
"""
# 輸出格式要求(專業深度版)
output_format = """
## 輸出格式要求 (STRICT)
**目標對象**專業投資人進階使用者
**字數控制**1500-2000
**特色**顯示完整推理過程技術性分析數學計算
請用以下 Markdown 格式輸出
```markdown
## 📊 策略總評
[一句話總結綜合評級 + 核心特性] (不超過 30 )
---
## 🎯 核心指標分析
### 風險調整後報酬
**夏普比率 {sharpe:.2f}** [優秀/良好/可接受/待改善]
```
推理過程
- 業界標準: 2.0 優秀, 1.5 良好, 1.0 可接受
- 本策略: {sharpe:.2f}
- 意義: 每承受 1% 風險獲得 {sharpe:.2f}% 超額報酬
結論: [具體評價]
```
### 報酬表現
**年化報酬 {annual_ret:.2%}** vs **市場基準 {market_ctx['avg_5y_return']:.1%}**
```
推理過程:
- 絕對報酬: {annual_ret:.2%}
- 相對市場: {((annual_ret / market_ctx['avg_5y_return']) - 1) * 100:+.1f}%
- 情境假設: 投入 100 一年後預期變為 {100 * (1 + annual_ret):.1f}
結論: [超越/接近/落後]市場表現
```
### 風險評估
**最大回落 {mdd:.2%}** | **波動率 {vol:.2%}**
```
推理過程:
- 歷史最大虧損: {mdd:.2%}
- 投資人可接受範圍: {investor_profile['max_acceptable_mdd']:.1%}
- 情境假設: 投入 100 最壞情況虧損 {abs(100 * mdd):.1f}
結論: 風險水平 [//][適合/不適合] {investor_profile['label']}投資人
```
---
## 💡 投資建議
### ✅ 策略亮點
1. [最突出的優勢用數據支持]
2. [第二個優勢]
### ⚠ 風險提示
1. [最主要的風險量化說明]
2. [次要風險]
### 🎯 適合對象
- **投資人類型**: [保守/穩健/積極]
- **投資期限**: [短期/中期/長期]
- **市場環境**: [牛市/震盪/熊市]
### 🔧 優化建議
[1-2 個具體的改進方向]
---
## 📈 未來展望
[基於當前市場環境 {market_ctx['sentiment']}給出 3-6 個月展望]
---
*本分析基於歷史數據回測過往績效不代表未來表現建議搭配即時市場分析與專業投資顧問*
```
**重要**
1. 禁止使用模糊詞彙優秀但一般良好卻待改善
2. 每個評價必須有數據支持
3. 推理過程要顯示計算邏輯
4. 結論必須與推理過程一致
"""
# 組合完整 Prompt
full_prompt = f"""
你是一位資深投資顧問CFA, 20+ 年經驗請基於以下背景與數據提供專業投資分析
## 投資人背景
- 風險承受度**{investor_profile['label']}** ({investor_profile['description']})
- 目標夏普比率 {investor_profile['target_sharpe']:.1f}
- 可接受最大回落{investor_profile['max_acceptable_mdd']:.1%}
## 市場環境
- 市場{market_ctx['market_name']}
- 今年表現{market_ctx['ytd_return']:.1%}
- 近5年平均{market_ctx['avg_5y_return']:.1%}
- 市場情緒{market_ctx['sentiment']}
## 策略資訊
- 名稱{name}
- 目標{role}
- 持股{', '.join(assets[:5])}{"..." if len(assets) > 5 else ""} ( {len(assets)} )
## 績效數據
| 指標 | 數值 |
|------|------|
| 年化報酬率 | {annual_ret:.1%} |
| 年化波動率 | {vol:.1%} |
| 夏普比率 | {sharpe:.1f} |
| 最大回落 (MDD) | {mdd:.2%} |
| Alpha | {alpha:.1f} |
| Beta | {beta:.2f} |
| VaR (95%) | {var10:.2%} |
| | {r2:.2%} |
{strict_prohibitions}
{evaluation_logic}
{consistency_check}
{cot_steps}
{output_format}
現在請開始分析記住**數據驅動邏輯一致評價明確禁止假設市場數據**
"""
return full_prompt
def get_simplified_context_prompt(strategy_data: Dict[str, Any]) -> str:
"""
教育性詳細版 Context-Aware Prompt為普羅大眾設計預設模式
目標對象投資新手一般散戶
特色
- 白話文解釋每個指標的意義
- 舉例說明投入100萬的情境
- 說明為什麼這個數字重要
- 提供具體優化建議
- 字數控制在 1000-1500
適用於use_cot=False預設模式不顯示推理步驟但內容詳盡
"""
annual_ret = strategy_data.get('annual_ret', 0)
sharpe = strategy_data.get('annual_sr', 0)
vol = strategy_data.get('vol', 0)
mdd = strategy_data.get('mdd', 0)
beta = strategy_data.get('beta', 0)
alpha = strategy_data.get('alpha', 0)
var10 = strategy_data.get('var10', 0)
r2 = strategy_data.get('r2', 0)
tw = strategy_data.get('tw', True)
assets = strategy_data.get('assets', [])
name = strategy_data.get('name', 'N/A')
market_ctx = get_market_context(tw)
criteria = get_evaluation_criteria()
# 評分邏輯
sharpe_rating = "優秀" if sharpe >= 2.0 else "良好" if sharpe >= 1.5 else "可接受" if sharpe >= 1.0 else "待改善"
return_vs_market = ((annual_ret / market_ctx['avg_5y_return']) - 1) * 100
return f"""
你是一位資深投資教育講師專門為**投資新手和普羅大眾**解釋投資策略
你的任務是
1. **用白話文**解釋每個績效指標的意義
2. **說明為什麼**這個指標重要
3. **給予具體的優化建議**
4. **避免使用過多專業術語**如果必須使用請解釋清楚
## 策略基本資訊
- **策略名稱**: {name}
- **投資市場**: {'🇹🇼 台灣股市' if tw else '🇺🇸 美國股市'}
- **持股標的**: {', '.join(assets[:10])}{"..." if len(assets) > 10 else ""} ( {len(assets)} )
- **對比基準**: {market_ctx['market_name']} (近5年平均 {market_ctx['avg_5y_return']:.1%})
## 📊 核心績效對比表(你的策略 vs 市場)
| 指標 | 你的策略 | 市場基準 | 差異 | 評價 |
|------|---------|---------|------|------|
| 📈 年化報酬率 | {annual_ret:.2%} | {market_ctx['avg_5y_return']:.1%} | {return_vs_market:+.1f}% | {'🚀 大勝' if return_vs_market > 50 else '✅ 超越' if return_vs_market > 0 else '❌ 落後'} |
| 夏普比率 | {sharpe:.2f} | ~1.0 | {(sharpe - 1.0):+.2f} | {sharpe_rating} |
| 📊 波動率 | {vol:.2%} | {market_ctx.get('volatility', 0.20):.1%} | {(vol - market_ctx.get('volatility', 0.20)) * 100:+.1f}% | {' 較高' if vol > market_ctx.get('volatility', 0.20) * 1.2 else '✅ 適中'} |
| 最大回落 | {mdd:.2%} | ~-30% | {'更深' if abs(mdd) > 0.30 else '相近'} | {'❌ 風險高' if abs(mdd) > 0.30 else '✅ 可控'} |
## 🎯 主動管理能力
| 指標 | 數值 | 說明 |
|------|------|------|
| 🎯 Alpha (超額報酬) | {alpha:.4f} | {'✅ 打敗市場' if alpha > 0.01 else '≈ 跟隨市場' if alpha >= -0.01 else '❌ 落後市場'} |
| Beta (市場相關性) | {beta:.2f} | {'🔥 進攻型 (Beta > 1.2)' if beta > 1.2 else ' 均衡型 (Beta 0.8-1.2)' if beta > 0.8 else '🛡 防禦型 (Beta < 0.8)'} |
| 📐 (可解釋度) | {r2:.2%} | 策略中 {r2:.1%} 的波動來自市場影響 |
| 📉 VaR (風險值) | {var10:.2%} | 95%信心水準下的最大單日損失 |
## ⛔ 重要指示
1. **禁止假設數據** - 市場基準 {market_ctx['avg_5y_return']:.1%} 已提供直接使用
2. **評級必須一致** - 夏普 {sharpe:.2f} = {sharpe_rating}
3. **用白話文** - 避免過多專業術語
4. **解釋每個指標** - 說明它的意義和為什麼重要
## 📝 輸出格式(教育性詳細版)
請用 1200-1600 包含以下內容
```markdown
## 📊 一句話總評
[根據夏普比率和報酬率給一個清晰的總評30字內]
---
## 📋 你選了哪些股票?
{', '.join(assets[:15])}{"..." if len(assets) > 15 else ""} {len(assets)}
**組合特性**
- **產業分布**: [分析持股集中在哪些產業例如科技股佔50%金融股佔30%]
- **風險分散度**: [評估是否分散例如持股{len(assets)}{'分散良好' if len(assets) >= 8 else '偏集中' if len(assets) >= 5 else '非常集中'}]
- **代表性個股**: [列出前3-5檔重要持股簡述其特色]
**💡 持股建議**
[根據持股數量和類型給予是否需要調整的建議]
---
## 📊 績效表現分析(vs 市場基準)
### 1 賺錢能力:年化報酬率 {annual_ret:.2%}
**白話文解釋**
假設你投入 100 一年後預期變成 **{100 * (1 + annual_ret):.1f} **{'' if annual_ret > 0 else ''} {abs(100 * annual_ret):.1f}
**vs 市場表現**
- 📈 你的策略{annual_ret:.2%}
- 📊 市場平均{market_ctx['avg_5y_return']:.1%}{market_ctx['market_name']}
- 🎯 相對表現{return_vs_market:+.1f}%
**評價** {'🚀 遠超市場表現!' if return_vs_market > 50 else '✅ 打敗市場' if return_vs_market > 20 else '✓ 略勝市場' if return_vs_market > 0 else '❌ 落後市場'}
**為什麼重要**
[解釋報酬率是投資最直觀的指標但不能只看報酬率還要考慮風險]
---
### 2 投資CP值:夏普比率 {sharpe:.2f} → {sharpe_rating}
**白話文解釋**
CP值就是夏普比率每承受 1 元風險你能賺 {sharpe:.2f} 元報酬
**評級**
- 優秀 ( 2.0) - CP值超高
- 良好 ( 1.5) - CP值不錯
- 可接受 ( 1.0) - CP值及格 {'你在這裡' if 1.0 <= sharpe < 1.5 else ''}
- 待改善 (< 1.0) - CP值偏低
**為什麼重要**
[解釋夏普比率是同時考慮報酬和風險的指標比單看報酬率更全面]
---
### 3 最大虧損風險:最大回落 {mdd:.2%}
**白話文解釋**
投入 100 最慘的時候會虧到剩 **{100 * (1 + mdd):.1f} **虧損 {abs(100 * mdd):.1f}
**vs 市場風險**
- 你的策略{mdd:.2%}
- 一般市場 -30%
- 評價{'❌ 風險較高' if abs(mdd) > 0.30 else '✅ 風險可控'}
**心理測試**
[問投資人是否能接受這個程度的虧損]
---
### 4 主動選股價值:Alpha {alpha:.4f} & Beta {beta:.2f}
**Alpha 超額報酬** {'✅ +{alpha:.2%} 打敗市場' if alpha > 0.01 else '≈ 跟隨市場' if alpha >= -0.01 else '❌ 落後市場'}
**Beta 市場連動** {'🔥 進攻型' if beta > 1.2 else ' 均衡型' if beta > 0.8 else '🛡 防禦型'}
**白話文解釋**
- Alpha扣除市場影響後你的選股能力創造的額外報酬
- Beta當市場漲 1%你的策略預期漲 {beta:.2f}%
**為什麼重要**
[解釋主動選股的價值以及不同市場環境下的策略表現]
---
## 🎯 給投資人的建議
### ✅ 這個策略的優勢
[列出2-3個具體優點用數據支持]
### ⚠ 需要注意的風險
[列出2-3個具體風險量化說明]
### 🔧 如何優化這個策略?
[給3個具體的優化建議]
1. [建議1例如分散持股調整權重等]
2. [建議2例如設定停損點]
3. [建議3例如定期再平衡]
### 💼 適合什麼樣的投資人?
- **風險承受度**: [保守/穩健/積極]
- **投資期限**: [建議持有多久]
- **市場環境**: [適合牛市/震盪/熊市]
- **資金用途**: [閒錢/退休金/教育基金]
---
## 📈 總結
[用2-3句話總結這個策略的核心特點並給出明確的投資建議]
---
*💡 小提醒過去績效不代表未來表現投資一定有風險建議搭配專業顧問意見*
```
**輸出要求**
1. **字數**: 1000-1500 教育性內容不能太少
2. **語氣**: 親切易懂像在跟朋友聊天
3. **避免**: 策略基本資訊回顧等空泛標題
4. **必須**: 解釋每個指標的意義和重要性
5. **必須**: 給出具體的優化建議
"""

@ -0,0 +1,46 @@
services:
# Flask Web Service
- type: web
name: tpm-flask
env: docker
dockerfilePath: ./Dockerfile
plan: free
healthCheckPath: /
envVars:
- key: DATABASE_URL
fromDatabase:
name: tpm-db
property: connectionString
- key: REDIS_URL
fromService:
name: tpm-redis
type: redis
property: connectionString
- key: OPENROUTER_API_KEY
sync: false
- key: OPENROUTER_MODEL
value: google/gemini-2.0-flash-exp:free
- key: MOCK_LLM
value: false
- key: LLM_TIMEOUT
value: 60
- key: LLM_MAX_TOKENS
value: 1500
- key: LLM_TEMPERATURE
value: 0.6
# PostgreSQL Database
- type: pserv
name: tpm-db
env: docker
plan: free
disk:
name: postgres-data
mountPath: /var/lib/postgresql/data
sizeGB: 1
# Redis Cache
- type: redis
name: tpm-redis
plan: free
maxmemoryPolicy: allkeys-lru

@ -112,6 +112,67 @@
.popover-body {
padding: 12px;
}
/* Markdown 代碼塊樣式 */
.llm-advice-content pre {
background: #f6f8fa;
border: 1px solid #e1e4e8;
border-radius: 6px;
padding: 16px;
overflow-x: auto;
margin: 16px 0;
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
font-size: 0.9em;
line-height: 1.45;
}
.llm-advice-content code {
background: #f6f8fa;
padding: 2px 6px;
border-radius: 3px;
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
font-size: 0.9em;
color: #24292e;
}
.llm-advice-content pre code {
background: transparent;
padding: 0;
border-radius: 0;
}
/* 表格樣式 */
.llm-advice-content table {
border-collapse: collapse;
width: 100%;
margin: 16px 0;
}
.llm-advice-content table th,
.llm-advice-content table td {
border: 1px solid #dfe2e5;
padding: 8px 12px;
}
.llm-advice-content table th {
background: #f6f8fa;
font-weight: 600;
}
/* 分隔線 */
.llm-advice-content hr {
border: none;
border-top: 2px solid #eaecef;
margin: 24px 0;
}
/* 引用塊 */
.llm-advice-content blockquote {
border-left: 4px solid #dfe2e5;
padding-left: 16px;
margin: 16px 0;
color: #6a737d;
}
</style>
{% endblock %}
@ -255,14 +316,22 @@
</div>
<!-- 重新生成按鈕組 -->
<div class="d-flex gap-2 mt-3">
<div class="d-flex gap-2 mt-3 align-items-center flex-wrap">
<button id="refresh-advice" class="btn btn-outline-primary btn-sm" onclick="refreshLLMAdvice()">
🔄 重新生成建議
</button>
<!-- Popover 選項按鈕 -->
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-toggle="popover"
data-bs-placement="top" data-bs-html="true"
<!-- CoT 開關 -->
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="cot-toggle" checked>
<label class="form-check-label" for="cot-toggle">
🧠 思考鏈推理 (CoT)
</label>
</div>
{% comment %} <!-- Popover 選項按鈕 -->
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-toggle="popover"
data-bs-placement="top" data-bs-html="true"
data-bs-content="<div class='mb-2'><strong>選擇分析深度:</strong></div>
<div class='d-grid gap-1'>
<button class='btn btn-sm btn-outline-info' onclick='generateAdviceWithMode(\"simple\")'>📊 快速分析</button>
@ -270,7 +339,7 @@
<button class='btn btn-sm btn-outline-danger' onclick='generateAdviceWithMode(\"risk\")'> 風險分析</button>
</div>">
分析選項
</button>
</button> {% endcomment %}
</div>
</div>
</div>
@ -299,6 +368,11 @@
{% block script %}
<script src="https://cdn.plot.ly/plotly-latest.min.js"></script>
<!-- Markdown 解析庫 -->
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<!-- 代碼高亮庫 (可選) -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css">
<script type="text/javascript">
const wlayout = {
'autosize': true,
@ -352,30 +426,42 @@
async function refreshLLMAdvice() {
const container = document.getElementById('llm-advice-container');
const button = document.getElementById('refresh-advice');
const cotToggle = document.getElementById('cot-toggle');
try {
// 禁用按鈕
button.disabled = true;
button.innerHTML = '<span class="spinner-border spinner-border-sm"></span> 正在生成...';
// 獲取CoT開關狀態
const useCot = cotToggle.checked;
const cotLabel = useCot ? '(使用思考鏈推理)' : '';
// 顯示loading狀態
container.innerHTML = `
<div class="text-center text-muted">
<div class="spinner-border spinner-border-sm" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-2">正在生成AI投資建議...</p>
<p class="mt-2">正在生成AI投資建議${cotLabel}...</p>
</div>
`;
// 調用API
const response = await fetch(`/api/llm_advice/${strategyId}`);
// 調用API,加上cot參數
const url = `/api/llm_advice/${strategyId}${useCot ? '?cot=true' : ''}`;
const response = await fetch(url);
const result = await response.json();
if (result.success) {
// 格式化並顯示建議
const formattedAdvice = formatLLMAdvice(result.advice);
container.innerHTML = formattedAdvice;
// 如果啟用CoT,顯示標記
const cotBadge = result.cot_enabled
? '<span class="badge bg-info mb-2">🧠 Chain-of-Thought 推理模式</span><br>'
: '';
container.innerHTML = cotBadge + formattedAdvice;
} else {
container.innerHTML = `
<div class="alert alert-warning">
@ -401,47 +487,105 @@
}
function formatLLMAdvice(advice) {
// 將LLM回應格式化為HTML
const lines = advice.split('\n');
let html = '';
for (let line of lines) {
line = line.trim();
if (!line) {
html += '<br>';
continue;
}
// 標題處理
if (line.match(/^#{1,3}\s+/)) {
const level = line.match(/^#+/)[0].length;
const text = line.replace(/^#+\s+/, '');
html += `<h${level + 4} class="mt-3 mb-2">${text}</h${level + 4}>`;
}
// 列表項目
else if (line.startsWith('-') || line.startsWith('•')) {
html += `<li>${line.substring(1).trim()}</li>`;
}
// 數字列表
else if (line.match(/^\d+\./)) {
html += `<li>${line}</li>`;
}
// 粗體文本
else if (line.includes('**') && line.split('**').length >= 3) {
const parts = line.split('**');
html += `<p>${parts[0]}<strong>${parts[1]}</strong>${parts[2]}</p>`;
// 使用 marked.js 解析 Markdown
if (typeof marked !== 'undefined') {
// 配置 marked
marked.setOptions({
breaks: true, // 支援換行
gfm: true, // GitHub Flavored Markdown
headerIds: false, // 不生成 header ID
mangle: false // 不編碼 email
});
try {
const html = marked.parse(advice);
// 包裝在容器中
return `
<div class="llm-advice-content markdown-body">
${html}
</div>
`;
} catch (e) {
console.error('Markdown parsing error:', e);
// Fallback to simple formatting
return `
<div class="llm-advice-content">
<pre style="white-space: pre-wrap; font-family: inherit;">${advice}</pre>
</div>
`;
}
// 普通段落
else {
html += `<p>${line}</p>`;
} else {
// Fallback: 簡單的 Markdown 解析 (如果 marked.js 沒載入)
const lines = advice.split('\n');
let html = '';
let inCodeBlock = false;
let codeContent = '';
for (let line of lines) {
// 代碼塊處理
if (line.trim().startsWith('```')) {
if (inCodeBlock) {
// 結束代碼塊
html += `<pre class="code-block"><code>${escapeHtml(codeContent)}</code></pre>`;
codeContent = '';
inCodeBlock = false;
} else {
// 開始代碼塊
inCodeBlock = true;
}
continue;
}
if (inCodeBlock) {
codeContent += line + '\n';
continue;
}
line = line.trim();
if (!line) {
html += '<br>';
continue;
}
// Markdown 處理
line = line.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>'); // 粗體
line = line.replace(/\*([^*]+)\*/g, '<em>$1</em>'); // 斜體
line = line.replace(/`([^`]+)`/g, '<code>$1</code>'); // 行內代碼
// 標題
if (line.match(/^#{1,3}\s+/)) {
const level = line.match(/^#+/)[0].length;
const text = line.replace(/^#+\s+/, '');
html += `<h${level + 4} class="mt-3 mb-2">${text}</h${level + 4}>`;
}
// 列表
else if (line.startsWith('-') || line.startsWith('•') || line.startsWith('*')) {
html += `<li>${line.substring(1).trim()}</li>`;
}
// 數字列表
else if (line.match(/^\d+\./)) {
html += `<li>${line}</li>`;
}
// 普通段落
else {
html += `<p>${line}</p>`;
}
}
return `
<div class="llm-advice-content">
${html}
</div>
`;
}
}
return `
<div class="llm-advice-content" style="line-height: 1.6;">
${html}
</div>
`;
// HTML 轉義函數
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// 生成指定模式的建議

Loading…
Cancel
Save