Compare commits

..

5 Commits

Author SHA1 Message Date
Eric0801 cbd580bb12 feat: V2 prompt system with benchmarks and CoT for Demo Day 4 months ago
Eric0801 1c16cb4321 [UX] Remove accordion, restore detailed prompt, keep popover options 4 months ago
Eric0801 14407e04d2 feat: improve LLM output styling and prompt structure 5 months ago
Eric0801 bfe88e4b80 docs: add README with setup, workflow, and guardrails; no code changes 5 months ago
Eric0801 3290b69d72 docs: add cursor.md guardrails to prevent tech debt; no code changes 5 months ago
  1. 14
      .gitignore
  2. 74
      README.md
  3. 60
      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. 4
      data_init/data_init_tw_v0.py
  8. 8
      data_init/data_init_us_v0.py
  9. 33
      data_init/update_data_daily.py
  10. 14
      docker-compose.yml
  11. 345
      llm_service.py
  12. 12
      main.py
  13. 259
      market_benchmark.py
  14. 5
      prompts/investment_advice.py
  15. 630
      prompts/investment_advice_v2.py
  16. 46
      render.yaml
  17. 415
      templates/result_view.html

14
.gitignore vendored

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

@ -0,0 +1,74 @@
### TPM – 投資組合大擂台
#### 1) 內容概要
- Flask + PostgreSQL + Redis 的投資策略平台,內含回測、圖表與 LLM 投資建議。
- 前端採 Jinja SSR + Bootstrap;LLM 透過 `llm_service.py` 封裝,可切換 OpenAI/OpenRouter/Mock。
#### 2) 技術棧(現況,請勿任意更換)
- Backend: Flask 2.2, psycopg2, Flask-Caching, Plotly
- DB/Cache: PostgreSQL, Redis
- Frontend: Jinja, Bootstrap 5, Bootstrap Icons(避免新增其他 CSS 框架)
- LLM: OpenAI SDK(可接 OpenRouter),支援 Mock
- Container: Docker, docker-compose
#### 3) 目錄重點
- `main.py`: 路由與頁面組裝;禁止塞商業邏輯
- `llm_service.py`: LLM 供應商、Prompt、重試、快取
- `portfolio_builder.py`: 投組演算法
- `templates/`: Jinja 模板(僅結構與少量初始化)
- `static/js/{components,pages}/`: 前端 JS 組件與頁面邏輯
- `sql_script/`: DB 初始化
- `data_init/`: 資料初始化與更新腳本
#### 4) 快速開始
1) 準備 `.env`(置於專案根目錄)
```
LLM_PROVIDER=openrouter
OPENROUTER_API_KEY=your_key
OPENROUTER_MODEL=google/gemini-2.0-flash-exp:free
LLM_TIMEOUT=60
LLM_MAX_TOKENS=1500
LLM_TEMPERATURE=0.6
MOCK_LLM=false
```
2) 啟動容器
```
docker compose up -d --build --force-recreate
```
3) 服務連線
- Web: http://localhost:8007
- Postgres: container 內部名稱 `db`
- Redis: container 內部名稱 `redis`
#### 5) 開發規範(避免技術債)
- 不動既有架構、Docker 設定與不相關功能。
- 僅在確定「已使用」時才把套件寫入 `requirements.txt`;未用到的要移除。
- 完成一個環節、測試通過才 commit;不要在同一個 commit 混雜多項變更。
- 前端:避免大型 inline JS;新邏輯放 `static/js/pages/*.js``static/js/components/*.js`
- 後端:商業邏輯放在服務檔案(如 `llm_service.py`),`main.py` 保持輕薄。
- LLM:僅經 `get_llm_advisor().generate_advice(strategy_id, strategy_dict)`;參數由 `.env` 控制。
#### 6) 測試
- 後端:可使用離線腳本(`MOCK_LLM=true`)進行測試。
- 任何變更建議附最小可重現測試或腳本(避免手動點擊測試)。
#### 7) 常見問題
- 500 + LLM 失敗:確認 `.env` 已注入容器;離線測試可先設 `MOCK_LLM=true`
- DB 連線錯誤:程式內部連線 host 應為 `db`
- KeyError(TSLA/AAPL):確認 `data_init` 成功寫入對應市場資料
#### 8) Git / PR 規範
- 分支命名:`feature/<area>-<short>`、`fix/<area>-<short>`
- PR 標題:`[TPM] <Title>`;內容包含「動機 / 變更 / 風險 / 測試方式」
- 小步提交、保持向後相容;前端改動請將 JS 抽出至 `static/js/`
#### 9) 設計原則
- 關注點分離:路由薄、服務厚;模板薄、JS 組件化
- 僅在既有層擴展功能;避免跨層耦合
- 可回退:大改以 feature flag 包裝,保持 simple 模式可用
> 更詳細的協作規範請見 `cursor.md`

@ -0,0 +1,60 @@
### Purpose
- 定義 AI 與人類協作的開發規範,避免分散、重複與難以維護的代碼。
- 嚴格限制可修改範圍與方式,降低技術債。
### Architecture Guardrails
- Backend: 維持 Flask(單應用)+ PostgreSQL + Redis + Plotly SSR。不得引入新後端框架(如 FastAPI、Django)。
- Frontend: 維持 Jinja SSR + Bootstrap 5。避免新增其他 CSS 框架;僅在必要時使用 jQuery(若頁面已依賴)。
- LLM: 只能透過 `llm_service.py` 的封裝介面呼叫;前端僅呼叫既有 API。
- DB: 目前採 `psycopg2` 直連 SQL。若要導入 ORM,需先提 RFC 並分層重構(Repository/Service 分離)。
- Realtime: 若需 Agentic 進度,優先 SSE;如需 WebSocket,需提 RFC。禁止同時混用兩者。
### Files Ownership
- `main.py`: 僅負責路由、參數與 Response 序列化;禁止寫演算法或外部 API 邏輯。
- `llm_service.py`: 唯一 LLM 入口;新增供應商、Prompt、重試、快取皆在此擴展。
- `portfolio_builder.py`: 僅放投組相關演算法。
- `templates/*.html`: 僅做結構與插值;避免大型 inline JS;頁面邏輯放 `static/js/pages/*.js`
- `static/js/components/*`: 前端可複用組件;不得引用非必要的全域物件。
- `docker-compose.yml`: 僅調整現有服務設定;新增服務需提 RFC。
- `requirements.txt`: 僅加入「已使用」的最小依賴;未使用須移除。
### Frontend Rules
- 禁止在模板中新增大型 inline JS。新邏輯放 `static/js/pages/<page>.js``static/js/components/<comp>.js`,模板只保留初始化。
- 樣式統一 Bootstrap 5;避免再引入其他 CSS 框架;圖標統一 Bootstrap Icons。
- Markdown:優先在後端使用 `markdown` 轉 HTML;不要寫自製 Markdown 解析器。
### LLM Rules
- 僅用 `get_llm_advisor().generate_advice(strategy_id, strategy_dict)` 封裝介面。
- Provider/Model/Token/Timeout 由 `.env``config.py` 管理;程式內不得硬編碼。
- 慢任務需提供 `mode=simple|agentic|auto`,預設 simple。
- Mock 測試:支援 `MOCK_LLM=true` 的離線路徑。
### Testing & Quality
- 後端:新增/修改路由與服務需附基本單元測試或離線測試腳本(可用 `MOCK_LLM`)。
- 前端:邏輯拆為可測的純函數;避免深層 DOM 操作耦合。
- 風格:PEP8;命名語義化;函式不超過 ~100 行;避免深巢狀。
- 禁留 TODO/死碼;必要時加簡短註解說明「為何存在」。
### Git / PR Workflow
- 分支:`feature/<area>-<short>`、`fix/<area>-<short>`。
- PR 標題:`[TPM] <Title>`;內容包含「動機 / 變更 / 雙向風險 / 測試」。
- 提交頻率:完成一個環節且測試通過才 commit;不要在同一 commit 混雜多項變更。
- 禁止無關變更(例如排版清理與邏輯修改混在一起)。
### Change Checklist (DoD)
- 有對應文件或內嵌註解簡述「為何」。
- 測試或腳本可重跑;離線可跑(如 LLM mock)。
- API 相容,不破壞現有 flow。
- 不新增全域副作用或跨層耦合。
### Out-of-Scope(需先提 RFC)
- 更換 Web 框架或引入大型基礎設施(如微服務、消息隊列)。
- 引入新前端框架或 CSS 系統。
- 重構資料層成 ORM。
---
本文件供 AI/協作工具與開發者遵循,若有必要變更,請先起草 RFC 並在 PR 中說明動機與回滾方案。

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

@ -8,7 +8,7 @@ from tqdm import tqdm
with open('assets_tw.json') as f: with open('assets_tw.json') as f:
data_tw = json.load(f) data_tw = json.load(f)
#SQL setting #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 # TW Stocks
conn = psycopg2.connect(**SQL_CONFIG) 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) price_col = 'Close' if 'Close' in df.columns else ('Adj Close' if 'Adj Close' in df.columns else None)
if price_col is None: if price_col is None:
continue 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:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as curs: with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as curs:
sql = "insert into stock_price_tw (ticker, date, price) values %s" sql = "insert into stock_price_tw (ticker, date, price) values %s"

@ -7,23 +7,23 @@ import psycopg2
from psycopg2.extras import execute_values from psycopg2.extras import execute_values
from tqdm import tqdm from tqdm import tqdm
with open('assets_us.json') as f: with open('assets_us.json') as f:
data_tw = json.load(f) data_us = json.load(f)
#SQL setting #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 # TW Stocks
conn = psycopg2.connect(**SQL_CONFIG) conn = psycopg2.connect(**SQL_CONFIG)
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute("TRUNCATE TABLE stock_price") cursor.execute("TRUNCATE TABLE stock_price")
print("US stock price cleared") 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) df = yf.download(ticker, start="2007-01-01", progress=False, threads=False)
if df is None or df.empty: if df is None or df.empty:
continue continue
price_col = 'Close' if 'Close' in df.columns else ('Adj Close' if 'Adj Close' in df.columns else None) price_col = 'Close' if 'Close' in df.columns else ('Adj Close' if 'Adj Close' in df.columns else None)
if price_col is None: if price_col is None:
continue 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:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as curs: with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as curs:
sql = "insert into stock_price (ticker, date, price) values %s" 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) data_tw = json.load(f)
#SQL setting #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 # TW Stocks
@ -25,7 +25,7 @@ if row_count <= 0 :
price_col = 'Close' if 'Close' in df.columns else ('Adj Close' if 'Adj Close' in df.columns else None) price_col = 'Close' if 'Close' in df.columns else ('Adj Close' if 'Adj Close' in df.columns else None)
if price_col is None: if price_col is None:
continue 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:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as curs: with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as curs:
sql = "insert into stock_price_tw (ticker, date, price) values %s" sql = "insert into stock_price_tw (ticker, date, price) values %s"

@ -6,10 +6,10 @@ import psycopg2
from psycopg2.extras import execute_values from psycopg2.extras import execute_values
from tqdm import tqdm from tqdm import tqdm
with open('data_init/assets_us.json') as f: with open('data_init/assets_us.json') as f:
data_tw = json.load(f) data_us = json.load(f)
#SQL setting #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 # TW Stocks
conn = psycopg2.connect(**SQL_CONFIG) conn = psycopg2.connect(**SQL_CONFIG)
@ -17,14 +17,14 @@ cursor = conn.cursor()
cursor.execute("SELECT COUNT(*) FROM stock_price;") cursor.execute("SELECT COUNT(*) FROM stock_price;")
row_count = cursor.fetchone()[0] row_count = cursor.fetchone()[0]
if row_count <= 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) df = yf.download(ticker, start="2007-01-01", progress=False, threads=False)
if df is None or df.empty: if df is None or df.empty:
continue continue
price_col = 'Close' if 'Close' in df.columns else ('Adj Close' if 'Adj Close' in df.columns else None) price_col = 'Close' if 'Close' in df.columns else ('Adj Close' if 'Adj Close' in df.columns else None)
if price_col is None: if price_col is None:
continue 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:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as curs: with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as curs:
sql = "insert into stock_price (ticker, date, price) values %s" sql = "insert into stock_price (ticker, date, price) values %s"

@ -11,11 +11,34 @@ import time
from config import SQL_CONFIG from config import SQL_CONFIG
#SQL setting #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(): def update_data():
# Connect to the database # Connect to the database
conn = psycopg2.connect(**SQL_CONFIG) conn = psycopg2.connect(**SQL_CONFIG)
cursor = conn.cursor() cursor = conn.cursor()
print("Stock prices US updating") 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 # Get the list of tickers
cursor.execute("SELECT DISTINCT ticker FROM stock_price") cursor.execute("SELECT DISTINCT ticker FROM stock_price")
tickers = [row[0] for row in cursor.fetchall()] tickers = [row[0] for row in cursor.fetchall()]
@ -44,6 +67,13 @@ def update_data_tw():
conn = psycopg2.connect(**SQL_CONFIG) conn = psycopg2.connect(**SQL_CONFIG)
cursor = conn.cursor() cursor = conn.cursor()
print("Stock prices US updating") 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 # Get the list of tickers
cursor.execute("SELECT DISTINCT ticker FROM stock_price_tw") cursor.execute("SELECT DISTINCT ticker FROM stock_price_tw")
tickers = [row[0] for row in cursor.fetchall()] tickers = [row[0] for row in cursor.fetchall()]
@ -67,6 +97,9 @@ def update_data_tw():
sql = "insert into stock_price_tw (ticker, date, price) values %s" sql = "insert into stock_price_tw (ticker, date, price) values %s"
execute_values(curs, sql, value) execute_values(curs, sql, value)
print("Stock prices TW updated") print("Stock prices TW updated")
# Print once on startup
print_last_update()
# Schedule the task # Schedule the task
schedule.every().day.at("17:00").do(update_data) schedule.every().day.at("17:00").do(update_data)
schedule.every().day.at("18:00").do(update_data_tw) schedule.every().day.at("18:00").do(update_data_tw)

@ -12,6 +12,8 @@ services:
- db_data_new:/var/lib/postgresql/data - db_data_new:/var/lib/postgresql/data
networks: networks:
- common_network - common_network
ports:
- 5432:5432
redis: redis:
image: redis:7.0.11-alpine image: redis:7.0.11-alpine
@ -34,6 +36,18 @@ services:
container_name: flask container_name: flask
command: bash -c "cd ../flask_run ; python main.py runserver 0.0.0.0:8000" command: bash -c "cd ../flask_run ; python main.py runserver 0.0.0.0:8000"
image: tpm-flask image: tpm-flask
environment:
- LLM_PROVIDER=openrouter
- OPENROUTER_API_KEY=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
- OPENROUTER_MODEL=google/gemini-2.0-flash-exp:free
- OPENROUTER_REFERER=http://localhost:8007
- OPENROUTER_TITLE=TPM
- LLM_TIMEOUT=60
- LLM_MAX_TOKENS=1500
- LLM_TEMPERATURE=0.6
- LLM_MAX_RETRIES=3
- LLM_RETRY_DELAY=2
- MOCK_LLM=false
volumes: volumes:
- flask-data:/flask_run - flask-data:/flask_run
depends_on: depends_on:

@ -12,11 +12,24 @@ import os
import json import json
import time import time
import logging import logging
import hashlib
from typing import Dict, Any, Optional, Tuple from typing import Dict, Any, Optional, Tuple
import openai
from openai 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', '3000')),
'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) logging.basicConfig(level=logging.INFO)
@ -30,25 +43,18 @@ class PromptManager:
self.system_prompt = self._get_system_prompt() self.system_prompt = self._get_system_prompt()
def _get_system_prompt(self) -> str: def _get_system_prompt(self) -> str:
"""系統提示詞""" """系統提示詞 - 定義基金經理人專家角色"""
return """你是一位經驗豐富的專業投資顧問,擁有超過15年的投資經驗和深厚的金融知識。 return """你是一位頂尖的基金經理人與投資策略師,擁有超過20年橫跨牛熊市的實戰經驗。你的專長是將複雜的金融數據轉化為清晰、易於理解的語言,為大眾投資者提供專業見解。
請基於提供的投資組合數據提供專業客觀且實用的投資建議
你的溝通風格
請從以下幾個面向進行分析 - **權威且親切**你的語氣充滿自信與專業但同時讓非專業人士感到安心與信賴
1. 整體表現評估年化報酬風險指標 - **教育家精神**你會用生動的比喻來解釋關鍵指標例如將夏普比率比喻為投資的性價比或將最大回落形容為最顛簸的一段路
2. 風險收益特性分析夏普比率最大回落 - **客觀中立**你總是基於數據進行分析同時點出潛在的盲點與風險
3. 市場環境適配性評估
4. 具體的改進建議和操作建議 你的任務是根據接下來提供的策略回測數據撰寫一份專業的投資策略分析報告"""
5. 風險管理和再平衡建議
請確保你的回答
- 專業且易懂避免過度技術術語
- 基於數據事實客觀分析
- 提供可操作的具體建議
- 考慮台灣市場的特殊性如果適用"""
def build_strategy_context(self, strategy_data: Dict[str, Any]) -> str: def build_strategy_context(self, strategy_data: Dict[str, Any]) -> str:
"""將策略資料轉換為結構化context""" """將策略資料轉換為結構化context,並附帶指標提示"""
return f""" return f"""
策略基本資訊 策略基本資訊
- 策略編號{strategy_data.get('id', 'N/A')} - 策略編號{strategy_data.get('id', 'N/A')}
@ -57,38 +63,101 @@ class PromptManager:
- 建立時間{strategy_data.get('date', 'N/A')} - 建立時間{strategy_data.get('date', 'N/A')}
- 建立者{strategy_data.get('username', 'N/A')} - 建立者{strategy_data.get('username', 'N/A')}
績效指標 核心績效指標
- 年化報酬率{strategy_data.get('annual_ret', 0):.2%} - 年化報酬率{strategy_data.get('annual_ret', 0):.2%} (衡量平均每年賺取多少利潤)
- 年化波動率{strategy_data.get('vol', 0):.2%} - 年化波動率{strategy_data.get('vol', 0):.2%} (衡量資產價值的波動風險越高代表起伏越大)
- 年化夏普比率{strategy_data.get('annual_sr', 0):.2f} - 年化夏普比率{strategy_data.get('annual_sr', 0):.2f} (衡量每一單位風險能換來多少報酬可視為投資CP值)
- 最大回落MDD{strategy_data.get('mdd', 0):.2%} - 最大回落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%} - Alpha值{strategy_data.get('alpha', 0):.4f} (相對於市場基準的超額報酬能力)
- R-squared{strategy_data.get('r2', 0):.4f} - Beta值{strategy_data.get('beta', 0):.4f} (與市場波動的關聯性大於1代表比市場更敏感)
- Gamma值{strategy_data.get('gamma', 0):.4f} - VaR (10, 95%信心){strategy_data.get('var10', 0):.2%} (預估在未來10天內有95%的機率虧損不會超過此比例)
- R-squared{strategy_data.get('r2', 0):.4f} (策略表現有多大比例可由市場表現來解釋)
投資組合配置 投資組合配置
- 包含資產{', '.join(strategy_data.get('assets', []))} - 包含資產{', '.join(strategy_data.get('assets', []))}
- 市場類型{'台灣市場' if strategy_data.get('tw', True) else '美國市場'} - 市場類型{'台灣市場' if strategy_data.get('tw', True) else '美國市場'}
""" """
def get_investment_advice_prompt(self, strategy_data: Dict[str, Any]) -> str: def get_investment_advice_prompt(self, strategy_data: Dict[str, Any],
"""生成投資建議的完整Prompt""" use_cot: bool = False,
context = self.build_strategy_context(strategy_data) 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:
# 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(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:
"""內建模板作為回退方案"""
context = self.build_strategy_context(strategy_data)
return f"""{self.system_prompt} return f"""{self.system_prompt}
{context} {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: class LLMInvestmentAdvisor:
@ -96,56 +165,92 @@ class LLMInvestmentAdvisor:
def __init__(self, api_key: Optional[str] = None): def __init__(self, api_key: Optional[str] = None):
"""初始化LLM服務""" """初始化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': 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( self.client = OpenAI(
api_key=self.api_key, 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.prompt_manager = PromptManager()
self.model = OPENAI_CONFIG.get('model', 'gpt-4')
self.max_tokens = OPENAI_CONFIG.get('max_tokens', 2000) self.max_tokens = OPENAI_CONFIG.get('max_tokens', 2000)
self.temperature = OPENAI_CONFIG.get('temperature', 0.7) self.temperature = OPENAI_CONFIG.get('temperature', 0.7)
# 快取設定 # 快取設定
self.cache: Dict[str, Tuple[str, float]] = {} self.cache: Dict[str, Tuple[str, float]] = {}
self.cache_timeout = 3600 # 1小時快取 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: def _is_cache_valid(self, cache_time: float) -> bool:
"""檢查快取是否有效""" """檢查快取是否有效"""
return time.time() - cache_time < self.cache_timeout 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: Args:
strategy_id: 策略ID strategy_id: 策略ID
strategy_data: 策略資料字典 strategy_data: 策略資料字典
use_cot: 是否使用 Chain-of-Thought 模式預設 False
use_v2: 是否使用 V2 Context-Aware Prompt預設 True
risk_tolerance: 風險承受度 (conservative/moderate/aggressive)
Returns: Returns:
str: 投資建議文本 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}_v{2 if use_v2 else 1}_cot{use_cot}_risk{risk_tolerance}"
# 檢查快取 # 檢查快取
if cache_key in self.cache: if cache_key in self.cache:
advice, cache_time = self.cache[cache_key] advice, cache_time = self.cache[cache_key]
if self._is_cache_valid(cache_time): 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 return advice
try: try:
# 構建prompt # 構建prompt(根據參數選擇版本)
prompt = self.prompt_manager.get_investment_advice_prompt(strategy_data) prompt = self.prompt_manager.get_investment_advice_prompt(
strategy_data,
# 調用OpenAI API use_cot=use_cot,
response = self._call_openai_with_retry(prompt) use_v2=use_v2,
risk_tolerance=risk_tolerance
)
# 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()) 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 return response
except Exception as e: except Exception as e:
@ -184,58 +289,122 @@ class LLMInvestmentAdvisor:
return response.choices[0].message.content.strip() return response.choices[0].message.content.strip()
except openai.RateLimitError as e: except Exception as e:
if attempt < max_retries - 1: status_code = getattr(e, 'status_code', None)
# 指數退避:1s, 2s, 4s is_rate_limited = status_code == 429 or 'rate limit' in str(e).lower()
wait_time = retry_delay ** attempt if attempt < max_retries - 1 and (is_rate_limited or True):
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:
wait_time = retry_delay ** attempt 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) time.sleep(wait_time)
continue continue
else: logger.error(f"LLM API error after {max_retries} attempts: {str(e)}")
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)}")
raise e raise e
def _get_fallback_advice(self, strategy_data: Dict[str, Any]) -> str: def _get_fallback_advice(self, strategy_data: Dict[str, Any]) -> str:
"""獲取fallback投資建議""" """獲取fallback投資建議 - 使用原本的詳細 prompt 結構"""
try:
# 嘗試使用 prompts/investment_advice.py 的模板
from prompts.investment_advice import get_comprehensive_analysis_prompt
# 直接返回 prompt 作為 fallback(模擬 LLM 回覆的結構)
prompt = get_comprehensive_analysis_prompt(strategy_data)
# 提取 prompt 中的策略數據部分,生成基本回覆
return self._generate_basic_analysis(strategy_data)
except ImportError:
return self._generate_basic_analysis(strategy_data)
def _generate_basic_analysis(self, strategy_data: Dict[str, Any]) -> str:
"""生成基本分析(當無法調用 LLM 時)"""
annual_ret = strategy_data.get('annual_ret', 0) annual_ret = strategy_data.get('annual_ret', 0)
vol = strategy_data.get('vol', 0) vol = strategy_data.get('vol', 0)
sharpe = strategy_data.get('annual_sr', 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)
var = strategy_data.get('var10', 0)
r2 = strategy_data.get('r2', 0)
assets = strategy_data.get('assets', [])
# 評估表現等級
ret_level = "優秀" if annual_ret > 0.15 else "良好" if annual_ret > 0.08 else "一般" if annual_ret > 0 else "需改善"
risk_level = "低風險" if vol < 0.15 else "中風險" if vol < 0.25 else "高風險"
sharpe_level = "優良" if sharpe > 1.5 else "良好" if sharpe > 1.0 else "一般" if sharpe > 0.5 else "需改善"
return f""" return f"""
基於您的投資策略數據我提供以下初步分析 ## 📊 策略總評
這是一個{ret_level}的投資策略年化報酬率達到{annual_ret:.1%}屬於{risk_level}等級策略的風險調整後報酬表現{sharpe_level}夏普比率為{sharpe:.2f}顯示出{"良好" if sharpe > 1.0 else "一般"}的風險管理能力
## 📈 關鍵指標深度解析
### 💰 報酬表現
- **年化報酬率**{annual_ret:.2%} - 這個報酬率在當前市場環境下表現{'優異' if annual_ret > 0.12 else '良好' if annual_ret > 0.08 else '一般'}{'超越' if annual_ret > 0.1 else '接近' if annual_ret > 0.05 else '低於'}市場平均水準
- **風險調整報酬**{sharpe:.2f} - 夏普比率{'超過1.0' if sharpe > 1.0 else '接近1.0' if sharpe > 0.8 else '低於1.0'}表示每承擔一單位風險能獲得{'良好' if sharpe > 1.0 else '一般'}的超額報酬
### ⚠ 風險評估
- **波動率**{vol:.2%} - 年化波動率{'較低' if vol < 0.15 else '適中' if vol < 0.25 else '較高'}顯示策略的穩定性{'良好' if vol < 0.2 else '一般'}
- **最大回落**{mdd:.2%} - 最大虧損幅度{'控制在合理範圍' if abs(mdd) < 0.1 else '需要關注'}風險控制{'得當' if abs(mdd) < 0.15 else '有待改善'}
- **VaR (95%)**{var:.2%} - 在95%的信心水準下單日最大可能虧損為{abs(var):.2%}
### 📊 市場關聯性
- **Alpha值**{alpha:.4f} - {'正向' if alpha > 0 else '負向'}超額收益顯示策略{'優於' if alpha > 0 else '弱於'}市場基準表現
- **Beta值**{beta:.2f} - 策略與市場的關聯性{'較高' if beta > 1.1 else '適中' if beta > 0.9 else '較低'}市場波動1%策略預期波動{beta:.2f}%
- **R-squared**{r2:.2%} - 策略表現有{r2:.1%}可由市場變動解釋
## 🎯 策略優劣分析
### 👍 亮點優勢
- **風險調整報酬{'優異' if sharpe > 1.5 else '良好' if sharpe > 1.0 else '一般'}**夏普比率{sharpe:.2f}顯示策略在控制風險的同時能創造{'優質' if sharpe > 1.5 else '良好' if sharpe > 1.0 else '基本'}的報酬
- **資產配置{'合理' if len(assets) > 3 else '需強化'}**包含{len(assets)}個標的{'有效' if len(assets) > 5 else '適度' if len(assets) > 3 else '需增加'}分散投資風險
- **市場適應性**Alpha值{alpha:.4f}顯示策略{'具有' if alpha > 0 else '缺乏'}超額收益能力
### 🤔 潛在風險
- **市場敏感度**Beta值{beta:.2f}表示策略{'高度' if beta > 1.2 else '適度' if beta > 0.8 else '低度'}受市場波動影響
- **回檔風險**最大回落{mdd:.2%}顯示在極端市場情況下可能面臨{'較大' if abs(mdd) > 0.2 else '中等' if abs(mdd) > 0.1 else '有限'}的虧損
- **集中度風險**需要{'特別' if len(assets) < 5 else '持續'}關注資產配置是否過於集中在特定行業或地區
📊 **表現評估** ## 💡 具體投資建議
- 年化報酬率{annual_ret:.2%} - {'表現良好' if annual_ret > 0.1 else '有改進空間'}
- 年化波動率{vol:.2%} - {'風險適中' if vol < 0.2 else '風險較高'}
- 夏普比率{sharpe:.2f} - {'風險調整後報酬優良' if sharpe > 1 else '有改進空間'}
💡 **初步建議** ### 1. 核心觀點
1. 持續監控市場變化 **建議**{'✅ 繼續持有並定期調整' if annual_ret > 0.1 and sharpe > 1.0 else ' 考慮優化配置' if annual_ret > 0.05 else '❌ 需要重新評估策略'}
2. 定期檢視投資組合配置
3. 考慮分散投資降低風險
*注意此為預設建議如需詳細分析請稍後再試* **理由**
- 報酬表現{'符合' if annual_ret > 0.08 else '未達'}預期目標
- 風險控制{'良好' if sharpe > 1.0 else '需要改善'}
- 市場適應性{'優異' if alpha > 0.02 else '適中' if alpha > 0 else '不佳'}
### 2. 優化方向
- **資產配置調整**可考慮納入更多{'防禦性資產(如公債、公用事業)' if vol > 0.2 else '成長性資產(如科技股、新興市場)'}{'降低整體風險' if vol > 0.2 else '提升報酬潛力'}
- **再平衡頻率**建議將再平衡頻率調整為{'每月' if vol > 0.25 else '每季度' if vol > 0.15 else '每半年'}一次以適應市場變化
- **風險暴露管理**{'考慮降低' if beta > 1.2 else '可適度提高' if beta < 0.8 else '維持當前'}市場Beta暴露
### 3. 風險管理
- **止損設定**建議設定止損點在{abs(mdd)*1.3:.1%}當前最大回落的1.3避免過大虧損
- **倉位控制**在市場波動加劇時{'建議降低倉位至70-80%' if vol > 0.25 else '可維持滿倉' if vol < 0.15 else '維持80-90%倉位'}
- **監控指標**
- 持續關注VaR指標當VaR超過{abs(var)*1.5:.2%}時應考慮降低風險
- 密切追蹤市場波動性變化VIX指數等
- 定期檢視Alpha表現確保策略持續創造超額收益
## 🔮 未來展望
基於當前市場環境和策略表現預期未來3-6個月內
- **報酬預期**策略將維持{'穩定增長' if sharpe > 1.2 else '波動' if sharpe > 0.8 else '震盪'}表現年化報酬率預計在{annual_ret*0.8:.1%}{annual_ret*1.2:.1%}之間
- **風險展望**市場波動性{'可能上升' if vol > 0.2 else '預期維持穩定'}建議投資者{'提高警覺' if vol > 0.2 else '保持耐心'}
- **調整建議**定期檢視建議每{'' if vol > 0.25 else '季度'}一次並適時調整以應對市場變化
### 關鍵提醒
**免責聲明**此為基於歷史數據的模擬分析結果過去績效不代表未來表現實際投資請謹慎評估個人風險承受能力並建議諮詢專業投資顧問
📝 **行動建議**
1. 定期監控策略表現建議每週檢視一次
2. 設定明確的投資目標和停損點
3. 保持投資組合的靈活性適時調整
4. 持續學習市場動態提升投資判斷力
*本分析報告生成於{strategy_data.get('name', 'N/A')}策略僅供參考*
""" """
def clear_cache(self): # Duplicate clear_cache removed
"""清除快取"""
self.cache.clear()
self.generate_advice.cache_clear()
logger.info("LLM advice cache cleared")
# 創建全域實例 # 創建全域實例

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

@ -32,7 +32,7 @@ def get_basic_investment_prompt(strategy_data: Dict[str, Any]) -> str:
def get_comprehensive_analysis_prompt(strategy_data: Dict[str, Any]) -> str: def get_comprehensive_analysis_prompt(strategy_data: Dict[str, Any]) -> str:
"""全面分析Prompt""" """全面分析Prompt - 現代化結構"""
return f""" return f"""
作為專業投資顧問請對以下投資策略進行全面分析並提供建議 作為專業投資顧問請對以下投資策略進行全面分析並提供建議
@ -83,7 +83,8 @@ def get_comprehensive_analysis_prompt(strategy_data: Dict[str, Any]) -> str:
- 潛在風險預警 - 潛在風險預警
- 操作建議和時機點 - 操作建議和時機點
請用專業客觀且易懂的語言回答使用繁體中文 ---
請用繁體中文回答確保報告結構完整語氣專業且易於理解使用和格式化讓內容更生動
""" """

@ -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

@ -5,33 +5,173 @@
{% block style %} {% block style %}
<style> <style>
.llm-advice-content { .llm-advice-content {
font-size: 0.95rem; font-size: 1rem;
color: #333; color: #2d3748;
line-height: 1.7;
font-family: 'Segoe UI', 'Microsoft JhengHei', 'PingFang TC', sans-serif;
background: linear-gradient(135deg, #f8fafc 0%, #ffffff 100%);
padding: 24px;
border-radius: 12px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
border: 1px solid #e2e8f0;
} }
.llm-advice-content h5, .llm-advice-content h6 { .llm-advice-content h5, .llm-advice-content h6 {
color: #2c3e50; color: #1a365d;
border-left: 4px solid #3498db; background: linear-gradient(135deg, #3182ce 0%, #2c5282 100%);
padding-left: 10px; color: white;
margin-bottom: 15px; padding: 12px 16px;
margin: 20px 0 16px 0;
border-radius: 8px;
font-weight: 600;
font-size: 1.1rem;
box-shadow: 0 2px 4px rgba(49, 130, 206, 0.2);
position: relative;
}
.llm-advice-content h5::before, .llm-advice-content h6::before {
content: "📊";
margin-right: 8px;
font-size: 1.2rem;
} }
.llm-advice-content p { .llm-advice-content p {
margin-bottom: 12px; margin-bottom: 16px;
text-align: justify; text-align: left;
color: #4a5568;
font-weight: 400;
}
.llm-advice-content ul {
margin: 16px 0;
padding-left: 0;
} }
.llm-advice-content li { .llm-advice-content li {
margin-bottom: 5px; margin-bottom: 8px;
padding: 8px 12px;
background: #f7fafc;
border-left: 3px solid #3182ce;
border-radius: 4px;
list-style: none;
color: #2d3748;
transition: all 0.2s ease;
}
.llm-advice-content li:hover {
background: #edf2f7;
transform: translateX(2px);
} }
.llm-advice-content strong { .llm-advice-content strong {
color: #e74c3c; color: #e53e3e;
font-weight: 600;
background: linear-gradient(135deg, #fed7d7 0%, #feb2b2 100%);
padding: 2px 6px;
border-radius: 4px;
font-size: 0.95em;
}
.llm-advice-content em {
color: #805ad5;
font-style: italic;
font-weight: 500;
} }
#llm-advice-container .alert { #llm-advice-container .alert {
border-radius: 8px; border-radius: 12px;
margin-bottom: 0; margin-bottom: 0;
border: none;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
/* 響應式設計 */
@media (max-width: 768px) {
.llm-advice-content {
padding: 16px;
font-size: 0.95rem;
}
.llm-advice-content h5, .llm-advice-content h6 {
font-size: 1rem;
padding: 10px 12px;
}
}
/* 載入動畫 */
.llm-advice-content.loading {
opacity: 0.7;
transition: opacity 0.3s ease;
}
/* Popover 樣式 */
.popover {
max-width: 300px;
}
.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> </style>
{% endblock %} {% endblock %}
@ -162,7 +302,7 @@
</div> </div>
<div class="mb-4" id="bar" style="max-height:60vh"></div> <div class="mb-4" id="bar" style="max-height:60vh"></div>
<div class="row justify-content-center font-bold text-xl"> <div class="row justify-content-center font-bold text-xl">
🤖 LLM 投資建議 🤖 AI 投資建議
</div> </div>
<div class="card mb-4" style="border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.1);"> <div class="card mb-4" style="border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.1);">
<div class="card-body"> <div class="card-body">
@ -174,9 +314,33 @@
<p class="mt-2">正在生成AI投資建議...</p> <p class="mt-2">正在生成AI投資建議...</p>
</div> </div>
</div> </div>
<button id="refresh-advice" class="btn btn-outline-primary btn-sm mt-2" onclick="refreshLLMAdvice()">
🔄 重新生成建議 <!-- 重新生成按鈕組 -->
</button> <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>
<!-- 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>
<button class='btn btn-sm btn-outline-warning' onclick='generateAdviceWithMode(\"comprehensive\")'>🔍 深度分析</button>
<button class='btn btn-sm btn-outline-danger' onclick='generateAdviceWithMode(\"risk\")'> 風險分析</button>
</div>">
分析選項
</button> {% endcomment %}
</div>
</div> </div>
</div> </div>
</div> </div>
@ -204,6 +368,11 @@
{% block script %} {% block script %}
<script src="https://cdn.plot.ly/plotly-latest.min.js"></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"> <script type="text/javascript">
const wlayout = { const wlayout = {
'autosize': true, 'autosize': true,
@ -246,35 +415,53 @@
// 頁面載入時自動生成投資建議 // 頁面載入時自動生成投資建議
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
refreshLLMAdvice(); refreshLLMAdvice();
// 初始化 popover
var popoverTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="popover"]'));
var popoverList = popoverTriggerList.map(function (popoverTriggerEl) {
return new bootstrap.Popover(popoverTriggerEl);
});
}); });
async function refreshLLMAdvice() { async function refreshLLMAdvice() {
const container = document.getElementById('llm-advice-container'); const container = document.getElementById('llm-advice-container');
const button = document.getElementById('refresh-advice'); const button = document.getElementById('refresh-advice');
const cotToggle = document.getElementById('cot-toggle');
try { try {
// 禁用按鈕 // 禁用按鈕
button.disabled = true; button.disabled = true;
button.innerHTML = '<span class="spinner-border spinner-border-sm"></span> 正在生成...'; button.innerHTML = '<span class="spinner-border spinner-border-sm"></span> 正在生成...';
// 獲取CoT開關狀態
const useCot = cotToggle.checked;
const cotLabel = useCot ? '(使用思考鏈推理)' : '';
// 顯示loading狀態 // 顯示loading狀態
container.innerHTML = ` container.innerHTML = `
<div class="text-center text-muted"> <div class="text-center text-muted">
<div class="spinner-border spinner-border-sm" role="status"> <div class="spinner-border spinner-border-sm" role="status">
<span class="visually-hidden">Loading...</span> <span class="visually-hidden">Loading...</span>
</div> </div>
<p class="mt-2">正在生成AI投資建議...</p> <p class="mt-2">正在生成AI投資建議${cotLabel}...</p>
</div> </div>
`; `;
// 調用API // 調用API,加上cot參數
const response = await fetch(`/api/llm_advice/${strategyId}`); const url = `/api/llm_advice/${strategyId}${useCot ? '?cot=true' : ''}`;
const response = await fetch(url);
const result = await response.json(); const result = await response.json();
if (result.success) { if (result.success) {
// 格式化並顯示建議 // 格式化並顯示建議
const formattedAdvice = formatLLMAdvice(result.advice); 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 { } else {
container.innerHTML = ` container.innerHTML = `
<div class="alert alert-warning"> <div class="alert alert-warning">
@ -300,47 +487,167 @@
} }
function formatLLMAdvice(advice) { function formatLLMAdvice(advice) {
// 將LLM回應格式化為HTML // 使用 marked.js 解析 Markdown
const lines = advice.split('\n'); if (typeof marked !== 'undefined') {
let html = ''; // 配置 marked
marked.setOptions({
for (let line of lines) { breaks: true, // 支援換行
line = line.trim(); gfm: true, // GitHub Flavored Markdown
if (!line) { headerIds: false, // 不生成 header ID
html += '<br>'; mangle: false // 不編碼 email
continue; });
}
// 標題處理 try {
if (line.match(/^#{1,3}\s+/)) { const html = marked.parse(advice);
const level = line.match(/^#+/)[0].length;
const text = line.replace(/^#+\s+/, ''); // 包裝在容器中
html += `<h${level + 4} class="mt-3 mb-2">${text}</h${level + 4}>`; return `
} <div class="llm-advice-content markdown-body">
// 列表項目 ${html}
else if (line.startsWith('-') || line.startsWith('•')) { </div>
html += `<li>${line.substring(1).trim()}</li>`; `;
} } catch (e) {
// 數字列表 console.error('Markdown parsing error:', e);
else if (line.match(/^\d+\./)) { // Fallback to simple formatting
html += `<li>${line}</li>`; return `
<div class="llm-advice-content">
<pre style="white-space: pre-wrap; font-family: inherit;">${advice}</pre>
</div>
`;
} }
// 粗體文本 } else {
else if (line.includes('**') && line.split('**').length >= 3) { // Fallback: 簡單的 Markdown 解析 (如果 marked.js 沒載入)
const parts = line.split('**'); const lines = advice.split('\n');
html += `<p>${parts[0]}<strong>${parts[1]}</strong>${parts[2]}</p>`; 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>`;
}
} }
// 普通段落
else { return `
html += `<p>${line}</p>`; <div class="llm-advice-content">
${html}
</div>
`;
}
}
// HTML 轉義函數
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// 生成指定模式的建議
async function generateAdviceWithMode(mode) {
const container = document.getElementById('llm-advice-container');
const button = document.getElementById('refresh-advice');
try {
// 禁用按鈕
button.disabled = true;
button.innerHTML = '<span class="spinner-border spinner-border-sm"></span> 正在生成...';
// 顯示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">正在生成${getModeDescription(mode)}...</p>
</div>
`;
// 調用API
const response = await fetch(`/api/llm_advice/${strategyId}?mode=${mode}`);
const result = await response.json();
if (result.success) {
// 格式化並顯示建議
const formattedAdvice = formatLLMAdvice(result.advice);
container.innerHTML = formattedAdvice;
} else {
container.innerHTML = `
<div class="alert alert-warning">
<h6> 無法生成投資建議</h6>
<p>${result.error}</p>
${result.details ? `<small class="text-muted">${result.details}</small>` : ''}
</div>
`;
} }
} catch (error) {
container.innerHTML = `
<div class="alert alert-danger">
<h6>❌ 發生錯誤</h6>
<p>無法連接到投資建議服務,請稍後再試。</p>
<small class="text-muted">錯誤詳情:${error.message}</small>
</div>
`;
} finally {
// 恢復按鈕
button.disabled = false;
button.innerHTML = '🔄 重新生成建議';
} }
}
return ` // 獲取模式描述
<div class="llm-advice-content" style="line-height: 1.6;"> function getModeDescription(mode) {
${html} const descriptions = {
</div> 'simple': '快速分析',
`; 'comprehensive': '深度分析',
'risk': '風險分析'
};
return descriptions[mode] || '投資建議';
} }
</script> </script>
{% endblock script %} {% endblock script %}
Loading…
Cancel
Save