forked from lab/TPM
Compare commits
No commits in common. 'cbd580bb120c6ddb2dbada52adc6c1882c5ffa7d' and 'c35d0cd8aa3f9293933324db07249173d8067d97' have entirely different histories.
cbd580bb12
...
c35d0cd8aa
17 changed files with 163 additions and 1776 deletions
@ -1,74 +0,0 @@ |
||||
### 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`。 |
||||
|
||||
|
||||
|
||||
|
||||
@ -1,60 +0,0 @@ |
||||
### 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 中說明動機與回滾方案。 |
||||
|
||||
|
||||
|
||||
|
||||
@ -1,259 +0,0 @@ |
||||
""" |
||||
市場基準資料模組 |
||||
|
||||
從資料庫取得實際的市場基準資料(台股加權指數、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) |
||||
@ -1,46 +0,0 @@ |
||||
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 |
||||
Loading…
Reference in new issue