Web API v4.13
投資人 Python 使用指南
原始文件 PDF
諮詢專線
02-8712-5851
LINE 官方帳號
@yuantaforex
官方網站

槓桿全球贏家 Web API 使用指南

本頁將 PDF 規格文件(v4.12)翻譯成可直接執行的 Python 範例,涵蓋全部 14 個 API 功能。不懂程式也能對照使用。

REST + WebSocket 雙通道 Python 3.8+ 外匯 / CFD / 遠期

申請 API Key

使用 API 前,需先向元大期貨申請 API Key。申請完成後會以簡訊通知,簡訊內含 API KeyAPI 密碼,即為程式中填入的帳號資訊。

🔗 線上申請入口 元大期貨槓桿交易部 API 線上申請
前往申請 →

申請完成後的簡訊範例

📱 簡訊內容範例 王小明貴賓您的帳號:1041234 API申請已完成,API Key:ABCDEF0ECB394776ADDBA88CB3254321,API密碼:Ss12345#。若有疑問請洽業務員。

簡訊欄位對照表

簡訊欄位對應程式變數填入位置
API Key(藍色)
ABCDEF0ECB394776ADDBA88CB3254321
API_KEY ltm_client.py 第7行
API密碼(綠色)
Ss12345#
PASSWORD ltm_client.py 第8行
帳號
1041234
ACCOUNT ltm_client.py 第9行
⚠️ 安全提醒 API Key 與密碼請勿分享給他人、不要上傳到 GitHub 或任何公開平台。若不慎外洩,請立即聯繫業務員停用舊 Key 並重新申請。

架構說明

這個 API 使用兩個同時運作的通道,與一般只有 REST 的 API 不同,請務必理解:

你的程式
Client
REST 請求
下單 / 查詢
(送出指令)
元大伺服器
Server
WebSocket
推送結果 / 報價
(接收回應)
你的程式
Client
⚠️ 重要 你用 REST 下單,但伺服器的成交回報、報價更新都從 WebSocket 推送過來。兩個連線必須同時存在,缺一不可。

API 端點

用途位址
REST 請求(登入、下單、查詢)https://webltm.yuantafutures.com.tw/WEBTRADER/rest
WebSocket(接收報價與回報)wss://webltm.yuantafutures.com.tw/WEBTRADER/ws

正確連線流程

Step 1 — REST 登入送出帳號密碼,取得 Token(通行證)
Step 2 — 建立 WebSocket 連線用 Token 完成 WebSocket 登入,開始接收推播
Step 3 — 發送操作(REST)下單、查詢都用 REST 送出,並帶上 Token
Step 4 — 接收回應(WebSocket)成交回報、即時報價從 WebSocket 推送給你
💡 關於 ACK 每次從 WebSocket 收到任何訊息,程式都必須立刻回覆一個 ACK(確認),否則伺服器可能認為連線異常。範例程式已自動處理這件事。

安裝設定

⚠️ 此伺服器必須使用 CA 憑證登入(SignedReq) 元大槓桿全球贏家伺服器已啟用 CA 簽名驗證。直接用帳號密碼登入會回傳 error 120: Signature is required。 必須提供 .pfx 憑證檔案,透過 RSA-SHA256 簽名才能成功登入。詳見 1.1 REST 登入 說明。

安裝必要套件

bash — 在命令提示字元執行
# 純 Python 腳本用(ltm_client.py)
pip install requests websocket-client cryptography

# 本機代理伺服器用(server.py + 瀏覽器前台)
pip install flask flask-socketio requests websocket-client cryptography

下載完整範例檔

請到本頁底部 完整 Python 檔案 區域,複製整份程式碼存成 ltm_client.py

修改帳號設定(程式最上方)

python
# ================================================
# 【必填】請把以下資訊改成你自己的帳號
# ================================================
API_KEY  = "ABCDEF0ECB394776ADDBA88CB3254321"   # 從元大後台取得的 API Key
PASSWORD = "Ss12345#"                           # API Key 對應的密碼
ACCOUNT  = "1041234"                            # 交易帳戶號碼
PFX_PATH = r"C:\路徑\你的憑證.pfx"                # CA 憑證路徑(必填)
PFX_PASS = "憑證密碼"                            # .pfx 開啟密碼(沒有則留空)

本機代理伺服器設定(瀏覽器前台)

因為瀏覽器無法直接建立 WSS WebSocket 到元大伺服器(跨域限制),本專案提供一個 server.py 本機代理,讓瀏覽器操作 API。

Step 1 — 編輯 server.py 填入憑證設定打開 server.py,修改最頂端的 PFX_PATH 和 PFX_PASSWORD 兩行
Step 2 — 啟動本機伺服器python server.py,看到「Running on http://127.0.0.1:5000」表示成功
Step 3 — 開啟瀏覽器訪問 http://localhost:5000,輸入帳號密碼即可登入交易前台
python — server.py 設定區(只需改這兩行)
# ══════════════════════════════════════════════════════════
#  ★ 使用前必填:CA 憑證設定
#
#  PFX_PATH     : .pfx 檔案的完整路徑(Windows 路徑前加 r)
#  PFX_PASSWORD : 開啟 .pfx 的密碼(沒有密碼則留空字串 "")
# ══════════════════════════════════════════════════════════
PFX_PATH     = r"C:\Users\你的名字\Desktop\憑證\9813377.pfx"
PFX_PASSWORD = "你的憑證密碼"

快速開始(5分鐘跑起來)

python — quickstart.py
from ltm_client import LTMClient

# 建立客戶端
client = LTMClient()

# 1. 登入(REST + WebSocket 一起完成)
client.start()

# 2. 查詢帳戶餘額
acct = client.get_account_info()
bal = acct.get("AcctVal", {}).get("bal", 0)
print(f"帳戶餘額: {bal:,.2f}")

# 3. 查詢目前部位
pos = client.get_positions()
for p in pos.get("PositionAry", []):
    print(f"  商品: {p['sym']}  方向: {'買' if p['type']==1 else '賣'}  數量: {p['qty']}")

# 4. 訂閱 EUR/USD 即時報價(報價會自動印出)
client.subscribe_quote(["EUR/USD"])

import time; time.sleep(10)   # 等 10 秒看報價

# 5. 下一張 EUR/USD 市價買單(1手 = 100000)
result = client.place_market_order("EUR/USD", side="1", qty=100000)
print("下單回應:", result)

# 保持程式執行(持續接收 WebSocket 推播)
client.run_forever()

⚠️ 下單前必讀:10 個常見錯誤

以下是使用此 API 最容易踩到的坑,建議在實際下單前逐條確認。

① qty 是「合約單位」,不是「手數」

送出數量的欄位 qty 填的是合約數量,換算方式依商品類型而不同。最常見的錯誤是把外匯的手數直接填入,或是把黃金的手數搞錯。

商品類型手數應填 qty 值備註
外匯 FX
EUR/USD、GBP/USD
USD/JPY 等
1 手100000標準手(Standard Lot)
0.1 手10000迷你手(Mini Lot)
0.01 手1000微型手(Micro Lot)
黃金
XAU/USD
1 手100= 100 盎司
0.1 手10= 10 盎司
0.01 手1= 1 盎司(最小單位)
白銀
XAG/USD
1 手5000= 5000 盎司
0.1 手500= 500 盎司
0.01 手50= 50 盎司(最小單位)
石油(原油)
西德州 XTI/USD
布蘭特 XBR/USD
1 手1000= 1000 桶
0.1 手100= 100 桶
0.01 手10= 10 桶(最小單位)
CFD 指數
US500、US30、NAS100 等
1 口1以各商品 contractSz 為準,
建議先呼叫 2.2 查詢商品列表確認
💡 確認方式呼叫 2.2 商品列表(SymListReq),查看目標商品的 qtyDec(數量小數位)和 contractSz(合約規格),即可確認正確的換算比例。

② 停損停利單的方向必須與開倉「相反」

停損停利(type=U)本質上是「預掛的平倉單」,所以方向要反向填寫:

開倉方向停損停利單 side 要填
買進(side=1)2(賣出)
賣出(side=2)1(買進)

填錯方向伺服器不一定會報錯,但停損停利不會正確觸發,造成無保護的持倉。

③ 停損停利的 px 和 px2 各代表不同意義

Threshold 型(type=U)的訂單,pxpx2 的意思不直觀:

欄位代表意義範例(EUR/USD 買在 1.0850)
px 下限觸發價(即停損)
價格跌破此值時觸發
1.0800(停損在下方)
px2 上限觸發價(即停利)
價格漲過此值時觸發
1.0900(停利在上方)

賣出部位則相反:px 為上方停損,px2 為下方停利。

④ 市價單的 px 固定填 "0",不要填市場價

下市價單(type=1)時,px 欄位必須填字串 "0",不能填目前的市場報價,否則伺服器會拒絕。

python
# ✅ 正確:市價單 px 填 "0"
"px": "0",   "type": "1"

# ❌ 錯誤:不要填目前報價
"px": "1.0850",  "type": "1"    # 伺服器會拒絕

⑤ tktType 填錯會收到 Error 108

tktType 指定這張單是「開倉」還是「平倉」,填錯會被伺服器拒絕並回傳 error 108: Incorrect To Open Or To Close

tktType 值用途同時要填
"0"開倉(新建部位)tktNo="", refTktNo=""
"1"平倉 / 停損停利單tktNo=持倉票號, refTktNo=持倉票號

⑥ 修改或撤銷停損停利,需先查到 clOrdId

修改停損停利(OrdCxlRepReq)或撤單(OrdCxlReq)時,必須提供現有委託的 clOrdId(委託單 ID)。這個值無法自己決定,需先查詢:

呼叫 4.1「查詢掛單」(OpenOrdReq)取得帳戶目前所有掛單清單
找到對應票號(tktId)的停損停利單從清單中篩選 tktId 與持倉票號相同的那筆
取出 clOrdId這就是修改或撤單時要填入的 orgClOrdId

⑦ REST 登入密碼要 SHA-256 加密;WebSocket 登入用明文

登入方式密碼格式
REST LoginSHA-256 雜湊後的字串(程式已自動處理)
WebSocket Login明文密碼(直接填原始密碼)

使用本頁提供的 LTMClient 類別時,兩種格式都已自動處理,無需手動轉換。

⑧ txTime 使用 UTC 時間,台灣需減 8 小時

txTime 欄位使用 UTC(格林威治標準時間),格式為 YYYYMMDD-HH:MM:SS.mmm。台灣時間(UTC+8)需減去 8 小時。

python
from datetime import datetime, timezone

# ✅ 正確:使用 UTC 時間
now = datetime.now(timezone.utc).strftime("%Y%m%d-%H:%M:%S.%f")[:-3]

# ❌ 錯誤:用本地時間會快 8 小時
now = datetime.now().strftime("%Y%m%d-%H:%M:%S.%f")[:-3]

⑨ WebSocket 每條訊息都必須立即回 ACK

從 WebSocket 收到任何訊息,必須立刻回傳 ACK,包含報價、回報、帳戶更新等所有類型。如果沒有回 ACK,伺服器會認為連線異常並可能切斷連線。

python
def on_message(ws, message):
    data = json.loads(message)

    # 每條訊息收到後,第一件事就是回 ACK
    ws.send(json.dumps({"MT": "Ack"}))

    # 之後再處理訊息內容...

本頁提供的 LTMClient 已在 on_message 中自動處理 ACK,無需額外設定。

⑩ 商品代碼格式與 secType 必須對應正確

✅ 有斜線 CUR1/USD
EUR/USD GBP/USD USD/JPY
XAU/USD(黃金)
XAG/USD(白銀)
XTI/USD(西德州原油)
XBR/USD(布蘭特原油)
secType = "FOR"
✅ 無斜線(指數代碼)
US500(S&P 500)
US30(道瓊)
NAS100(那斯達克)
secType = "CFD"
商品類型secType 填商品代碼格式範例
外匯 "FOR" 有斜線 CUR1/CUR2 EUR/USDGBP/USDUSD/JPY
黃金/白銀 "FOR" 有斜線 CUR1/CUR2 XAU/USDXAG/USD
石油(原油) "FOR" 有斜線 CUR1/CUR2 XTI/USD(西德州)、XBR/USD(布蘭特)
CFD 指數 "CFD" 無斜線,直接代碼 US500US30NAS100
遠期外匯 "FORWARD" 有斜線 CUR1/CUR2 EUR/USD(搭配 tenor 參數)
❌ 常見錯誤 XAUUSD ✗ → 應為 XAU/USD
EURUSD ✗ → 應為 EUR/USD
XTIUSD ✗ → 應為 XTI/USD
斜線省略伺服器會找不到商品,回傳找不到 symbol 的錯誤。

1.1 REST 登入(CA SignedReq)

此伺服器啟用 CA 驗證,必須使用 SignedReq 模式登入。流程:用 .pfx 私鑰對登入訊息做 RSA-SHA256 簽名 → POST SignedReq → 取回加密 Token → RSA 解密得到真正 Token。

⚠️ signerId 填帳戶號碼,不是 API Key 也不是憑證 CN signerId 欄位必須填交易帳戶號碼(如 9814884),填錯會回傳 error 121 "Certificate is missing"。

SignedReq 請求 JSON

json — 送往 REST
{
  "MT": "SignedReq",
  "rawReq": "{\"MT\":\"Login\",\"UserInfo\":{\"login\":\"API_KEY\",\"password\":\"base64(SHA256(pwd))\"}}",
  "signerId": "9814884",
  "password": "明文密碼",
  "signature": "base64(RSA-SHA256簽名)"
}

欄位說明

欄位必填說明
MT必填固定填 "SignedReq"
rawReq必填原始 Login JSON 字串,密碼用 base64(SHA256(pwd))
signerId必填交易帳戶號碼(不是 API Key,不是憑證 CN)
password必填API 密碼(明文)
signature必填用 .pfx 私鑰對 rawReq 做 RSA-SHA256 簽名後 base64

回應:伺服器返回加密 Token,需用私鑰解密

💡 Token 解密 SignedReq 回傳的 Tok 是 base64(RSA加密後的token),約 344 字元。必須用 .pfx 私鑰 RSA 解密,得到真正的 session token(16~32 字元十六進位字串),再用此 token 登入 WebSocket。

Python 完整 CA 登入範例

python
import requests, hashlib, base64, json
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding as asym_padding
from cryptography.hazmat.primitives.serialization import pkcs12

REST_URL = "https://webltm.yuantafutures.com.tw/WEBTRADER/rest"
API_KEY  = "ABCDEF0ECB394776ADDBA88CB3254321"
PASSWORD = "Ss12345#"
ACCOUNT  = "1041234"          # 交易帳戶號碼(signerId 用這個)
PFX_PATH = r"C:\路徑\憑證.pfx"
PFX_PASS = "憑證密碼"

def ca_login():
    # 1. 載入 .pfx
    with open(PFX_PATH, "rb") as f:
        pfx_bytes = f.read()
    private_key, cert, _ = pkcs12.load_key_and_certificates(
        pfx_bytes, PFX_PASS.encode() if PFX_PASS else None
    )

    # 2. 建構 rawReq(密碼用 base64(SHA256))
    pw_b64 = base64.b64encode(hashlib.sha256(PASSWORD.encode()).digest()).decode()
    raw_obj = {"MT": "Login", "UserInfo": {"login": API_KEY, "password": pw_b64}}
    raw_req = json.dumps(raw_obj, separators=(",", ":"))

    # 3. RSA-SHA256 簽名
    sig_b64 = base64.b64encode(
        private_key.sign(raw_req.encode(), asym_padding.PKCS1v15(), hashes.SHA256())
    ).decode()

    # 4. POST SignedReq
    resp = requests.post(REST_URL, json={
        "MT": "SignedReq",
        "rawReq":    raw_req,
        "signerId":  ACCOUNT,   # ← 帳戶號碼,不是 API Key
        "password":  PASSWORD,
        "signature": sig_b64,
    }, timeout=10)
    data = resp.json()

    if data.get("MT") == "Logout" or "ErrInfo" in data:
        raise RuntimeError(f"登入失敗: {data}")

    # 5. RSA 解密 Token
    enc_tok = base64.b64decode(data["Tok"] + "==")
    token = private_key.decrypt(enc_tok, asym_padding.PKCS1v15()).decode()
    print(f"✅ CA 登入成功,token={token[:12]}...")
    return token

token = ca_login()

1.2 WebSocket 登入

REST 登入成功後,立即用取得的 Token 建立 WebSocket 連線並完成登入。之後所有回報都從這條連線推送。

請求 JSON(透過 WebSocket 送出)

json
{
  "MT": "Login",
  "UserInfo": {
    "login": "ABCDEF0ECB394776ADDBA88CB3254321",
    "password": "Ss12345#"
  },
  "Tok": "525197be1b9c44c98a2aef5d39f66ce9"
}

欄位說明

欄位必填說明
MT必填固定填 "Login"
UserInfo.login必填API Key
UserInfo.password必填密碼(WebSocket 登入可用明文)
Tok必填REST 登入拿到的 Token

Python 範例(REST + WebSocket 完整登入流程)

python
import websocket
import json, threading, time

WS_URL   = "wss://webltm.yuantafutures.com.tw/WEBTRADER/ws"
API_KEY  = "ABCDEF0ECB394776ADDBA88CB3254321"
PASSWORD = "Ss12345#"

ws_app = None   # 全域 WebSocket 連線

def start_websocket(token):
    global ws_app

    def on_open(ws):
        print("🔌 WebSocket 已連線,正在登入...")
        login_msg = {
            "MT": "Login",
            "UserInfo": {"login": API_KEY, "password": PASSWORD},
            "Tok": token
        }
        ws.send(json.dumps(login_msg))

    def on_message(ws, message):
        data = json.loads(message)

        # 每收到一條訊息,必須立即回傳 ACK
        ws.send(json.dumps({"MT": "Ack"}))

        mt = data.get("MT", "")

        if mt == "Login":
            print("✅ WebSocket 登入成功")
        elif mt == "ExecRp":
            # 下單後的執行回報
            print(f"📋 執行回報:\n{json.dumps(data, ensure_ascii=False, indent=2)}")
        elif mt == "Q":
            # 即時報價
            q = data.get("Q", {})
            print(f"💹 {q.get('s')}  買={q.get('b')}  賣={q.get('a')}")
        elif mt == "GetAcctInfo":
            bal = data.get("AcctVal", {}).get("bal")
            print(f"💰 帳戶餘額: {bal}")

    def on_error(ws, error):
        print(f"❌ WebSocket 錯誤: {error}")

    def on_close(ws, code, msg):
        print("🔌 WebSocket 已斷線")

    ws_app = websocket.WebSocketApp(
        WS_URL,
        on_open=on_open,
        on_message=on_message,
        on_error=on_error,
        on_close=on_close
    )

    # 在背景執行緒中運行,不阻塞主程式
    t = threading.Thread(target=ws_app.run_forever, kwargs={"ping_interval": 30})
    t.daemon = True
    t.start()

    time.sleep(2)   # 等待連線完成
    return ws_app

# 使用方式
token = rest_login()            # 先 REST 登入
ws    = start_websocket(token)  # 再建立 WebSocket

1.3 取得伺服器列表

查詢所有可用的接入伺服器,選擇延遲最低的那台,可提升交易速度。

Python 範例

python
def get_access_server_info(token):
    payload = {
        "MT": "GetAccessServerInfo",
        "Tok": token
    }
    resp = requests.post(REST_URL, json=payload, timeout=10)
    return resp.json()

# 伺服器列表會從 WebSocket 推送過來
result = get_access_server_info(token)
print(result)

1.4 Echo 測試

送一個訊息給伺服器,伺服器原樣回傳。可用來測試連線是否正常,或計算來回延遲時間。

python
from datetime import datetime

def echo_test(token):
    now = datetime.utcnow().strftime("%Y%m%d-%H:%M:%S.%f")[:-3]
    payload = {
        "MT": "Echo",
        "Tok": token,
        "SendTime": now    # 自訂欄位,伺服器會原樣回傳
    }
    resp = requests.post(REST_URL, json=payload, timeout=10)
    return resp.json()

echo_test(token)

2.1 帳戶資訊

查詢帳戶的餘額、保證金、盈虧等財務資訊。

回應欄位說明(重要欄位)

欄位路徑說明範例
AcctVal.bal目前帳戶淨值1000000.00
AcctVal.bodBal今日開盤餘額998000.00
AcctVal.clsPL今日已實現盈虧-197.51
AcctVal.rqdMrgn所需保證金75125.55
AcctVal.mntnMrgn維持保證金37500.00
AcctSetting.mrgnRt初始保證金比率0.04(即4%)
AcctVal.numTickets目前持倉單數3
python
def get_account_info(token, account):
    payload = {
        "MT": "GetAcctInfo",
        "Tok": token,
        "UserInfo": {"login": account}
    }
    resp = requests.post(REST_URL, json=payload, timeout=10)
    data = resp.json()

    # 解析重要數字
    acct_val = data.get("AcctVal", {})
    print(f"帳戶: {account}")
    print(f"  淨值:    {acct_val.get('bal', 0):,.2f}")
    print(f"  已實現盈虧: {acct_val.get('clsPL', 0):,.2f}")
    print(f"  所需保證金: {acct_val.get('rqdMrgn', 0):,.2f}")
    print(f"  持倉張數: {acct_val.get('numTickets', 0)}")
    return data

account_data = get_account_info(token, ACCOUNT)

2.2 商品列表

查詢所有可交易商品,包含外匯(FX)、CFD 指數、金屬等,以及其規格(小數位、點值等)。

商品類型(type 欄位)

type 值商品類型範例
3外匯(FX)EUR/USD, GBP/JPY
5金屬XAU/USD(黃金)
7CFD 指數/股票US500, US30
python
def get_symbols(token):
    payload = {"MT": "SymListReq", "Tok": token}
    resp = requests.post(REST_URL, json=payload, timeout=10)
    data = resp.json()

    symbols = data.get("CfgSymAry", [])
    print(f"共 {len(symbols)} 個可交易商品:")
    for s in symbols[:10]:     # 只印前10個
        type_name = {3: "外匯", 5: "金屬", 7: "CFD"}.get(s.get("type"), "?")
        print(f"  {s['sym']:<15} {type_name}  買={s.get('bodBid')}  賣={s.get('bodAsk')}")
    return symbols

symbols = get_symbols(token)

2.3 部位查詢

查詢目前持倉中的所有部位(未平倉的交易)。

回應欄位說明

欄位說明值域
sym商品代碼EUR/USD
qty數量正數=買方向,負數=賣方向
openAvgPx平均開倉價1.08523
type方向1=買開, 2=賣開, 3=買平, 4=賣平
clsPL已平倉盈虧-197.51
python
def get_positions(token, account):
    payload = {
        "MT": "NetPosReq",
        "Tok": token,
        "UserInfo": {"login": account}
    }
    resp = requests.post(REST_URL, json=payload, timeout=10)
    data = resp.json()

    positions = data.get("PositionAry", [])
    if not positions:
        print("目前無持倉")
        return data

    print(f"目前持倉(共 {len(positions)} 筆):")
    for p in positions:
        direction = {1: "買開", 2: "賣開", 3: "買平", 4: "賣平"}.get(p.get("type"), "?")
        print(f"  {p.get('sym'):<15} {direction}  數量={p.get('qty')}  均價={p.get('openAvgPx')}")
    return data

get_positions(token, ACCOUNT)

3.1 即時報價訂閱

透過 WebSocket 訂閱商品的即時買賣價(L1)或市場深度(L2)。訂閱後伺服器會持續推送報價更新。

quote_type 說明

說明
1L1:基本買賣價(Bid/Ask),一般使用這個
2L2:市場深度(多筆掛單價位)
3L1 + L2 全部
⚠️ QReq 走 REST,不是 WebSocket 根據雙通道架構:所有請求(包含 QReq)都用 REST 發送,報價推播才是從 WebSocket 接收。把 QReq 送進 WebSocket 會收到 error 5: Unsupported message type
python
def subscribe_quote(token, symbols, unsubscribe=False):
    """
    訂閱或取消訂閱即時報價
    symbols: 商品代碼列表,例如 ["EUR/USD", "GBP/USD", "XAU/USD"]
    unsubscribe: True = 取消訂閱

    注意:QReq 走 REST(不是 WebSocket),報價更新從 WebSocket 接收
    """
    payload = {
        "MT": "QReq",
        "Tok": token,
        "QReq": {
            "sym": symbols,
            "sub_type": 2 if unsubscribe else 1,  # 1=訂閱, 2=取消
            "quote_type": 1                         # 必填:1=L1基本報價
        }
    }
    resp = requests.post(REST_URL, json=payload, timeout=10)
    result = resp.json()
    action = "取消訂閱" if unsubscribe else "訂閱"
    print(f"✅ 已{action}: {', '.join(symbols)}  回應: {result.get('MT')}")
    return result

# 使用範例(REST 送出,WebSocket 接收報價推播)
subscribe_quote(token, ["EUR/USD", "GBP/USD", "XAU/USD"])

# 報價會從 on_message 的 WebSocket 接收,格式如下:
# {"MT": "Q", "Q": {"s": "EUR/USD", "b": 1.08525, "a": 1.08528, "t": "20241118-10:30:00.123"}}
# s=商品  b=買價(Bid)  a=賣價(Ask)  t=時間

3.2 歷史 K 線

查詢指定商品的歷史 K 線資料(日線或分鐘線)。

欄位說明

欄位必填說明
sym必填商品代碼(如 EUR/USD)
type必填0=日線,1=分鐘線
days必填往前幾天的資料
endTime必填結束時間(格式: YYYYMMDD-HH:MM:SS,UTC 時間)
groupSize選填分鐘線合併(如 5 = 5分鐘K線),僅 type=1 有效
reqId必填自訂請求編號,回應中會帶回
python
from datetime import datetime, timezone

def get_chart_history(token, symbol, chart_type=1, days=2, group_size=5):
    """
    取得歷史K線
    chart_type: 0=日線  1=分鐘線
    days: 往前幾天
    group_size: 幾根合一(分鐘線用,5=5分鐘K)
    """
    end_time = datetime.now(timezone.utc).strftime("%Y%m%d-%H:%M:%S")

    payload = {
        "MT": "ChartHisReq",
        "Tok": token,
        "ChartHisReq": {
            "sym": symbol,
            "type": chart_type,
            "days": days,
            "reqId": 1,
            "endTime": end_time,
            "groupSize": group_size    # 選填,只在分鐘線有效
        }
    }
    resp = requests.post(REST_URL, json=payload, timeout=15)
    data = resp.json()

    candles = data.get("ChartDataAry", [])
    print(f"{symbol} {group_size}分鐘K(最近{days}天,共{len(candles)}根)")
    for c in candles[-5:]:    # 印最後5根
        print(f"  {c['t']}  開={c['o']}  高={c['h']}  低={c['l']}  收={c['c']}")
    return data

# 取得 EUR/USD 的 5 分鐘 K 線
get_chart_history(token, "EUR/USD", chart_type=1, days=2, group_size=5)

# 取得 EUR/USD 的日線
get_chart_history(token, "EUR/USD", chart_type=0, days=30, group_size=1)

4.1 查詢掛單(未成交委託)

查詢目前還未成交的限價單或停損單。

回應重要欄位

欄位說明
sym商品代碼
ordtype委託類型:1=市價, 2=限價, 3=停損, U=停損停利
side方向:1=買, 2=賣
px委託價格
ordQty委託數量
ordstatus狀態:0=掛單中, 1=部分成交, 2=全部成交
clOrdId委託單ID(撤單時需要這個值)
tktId對應的持倉票號
python
def get_open_orders(token, account):
    payload = {
        "MT": "OpenOrdReq",
        "Tok": token,
        "UserInfo": {"login": account}
    }
    resp = requests.post(REST_URL, json=payload, timeout=10)
    data = resp.json()

    orders = data.get("OpenOrdAry", [])
    if not orders:
        print("目前無掛單")
        return data

    type_map = {"1": "市價", "2": "限價", "3": "停損", "U": "停損停利"}
    side_map = {"1": "買", "2": "賣"}

    print(f"目前掛單(共 {len(orders)} 筆):")
    for o in orders:
        print(f"  委託ID={o.get('clOrdId')}  {o.get('sym')}  "
              f"{side_map.get(o.get('side',''),'?')}  "
              f"{type_map.get(o.get('ordtype',''),'?')}  "
              f"價={o.get('px')}  量={o.get('ordQty')}")
    return data

get_open_orders(token, ACCOUNT)

4.2 查詢成交紀錄

查詢已成交(含已平倉)的歷史交易紀錄。

💡 日期格式clsFrom / clsTo 格式為 "YYYY/MM/DD HH:mm:ss",時間為當地時間。
python
def get_tickets(token, account,
                include_open=True,
                include_closed=True,
                from_date="2025/01/01 00:00:00",
                to_date="2025/12/31 23:59:59"):
    """
    查詢成交紀錄
    include_open:   是否包含未平倉的持倉
    include_closed: 是否包含已平倉的紀錄
    """
    payload = {
        "MT": "GetTickets",
        "Tok": token,
        "UserInfo": {"login": account},
        "GetTickets": {
            "inclOp":  include_open,
            "inclCls": include_closed,
            "clsFrom": from_date,
            "clsTo":   to_date
        }
    }
    resp = requests.post(REST_URL, json=payload, timeout=10)
    data = resp.json()

    tickets = data.get("TicketAry", [])
    print(f"成交紀錄(共 {len(tickets)} 筆):")
    for t in tickets:
        status = {0: "持倉中", 1: "部分平倉", 2: "已平倉"}.get(t.get("status"), "?")
        print(f"  TktID={t.get('tktId')}  {t.get('sym')}  "
              f"{'買' if t.get('side')=='B' else '賣'}  "
              f"開={t.get('opPx')}  量={t.get('opQty') or t.get('clsQty')}  "
              f"狀態={status}")
    return data

# 查詢本月所有紀錄
get_tickets(token, ACCOUNT,
            from_date="2025/06/01 00:00:00",
            to_date="2025/06/30 23:59:59")

4.3 下市價單

以當前市場價格立即成交。買賣方向:1 = 買進,2 = 賣出。FX 數量:100000 = 1 手。

⚠️ 注意市價單下出去通常立即成交,請確認商品名稱、方向、數量無誤再執行。

關鍵參數

欄位市價單設定說明
type"1"1=市價單
px"0"市價單固定填 0
tktType"0"0=開倉
side"1""2"1=買, 2=賣
secType"FOR"FOR=外匯, CFD=指數, FORWARD=遠期
qty"100000"FX:100000=1手, CFD視商品而定
python
from datetime import datetime, timezone

def place_market_order(token, account, symbol, side, qty,
                       sec_type="FOR", comment=""):
    """
    下市價單
    symbol:   商品代碼,例如 "EUR/USD"
    side:     "1"=買進  "2"=賣出
    qty:      數量(外匯:100000=1手)
    sec_type: "FOR"=外匯  "CFD"=指數  "FORWARD"=遠期
    """
    now = datetime.now(timezone.utc).strftime("%Y%m%d-%H:%M:%S.%f")[:-3]

    payload = {
        "MT": "OrdReq",
        "Tok": token,
        "OrdReq": {
            "acct":     account,
            "sym":      symbol,
            "secType":  sec_type,
            "side":     str(side),   # "1"=買  "2"=賣
            "qty":      str(qty),
            "px":       "0",         # 市價單固定為 0
            "type":     "1",         # 1=市價單
            "tif":      "1",         # 1=GTC(直到取消)
            "sl":       "0",         # 停損(0=不設)
            "tp":       "0",         # 停利(0=不設)
            "execDst":  "INTX",
            "minQty":   "0",
            "stopPx":   "0",
            "qtyRsrv":  "0",
            "maxShow":  "0",
            "execBrk":  "",
            "execInst": "u",
            "px2":      "0",
            "txTime":   now,
            "handlInst":"1",
            "prnAgc":   "false",
            "slpg":     "0",
            "tktType":  "0",         # 0=開倉
            "tktNo":    "",
            "refTktNo": "",
            "comment":  comment
        }
    }

    resp = requests.post(REST_URL, json=payload, timeout=10)
    result = resp.json()
    print(f"下單送出: {symbol} {'買' if str(side)=='1' else '賣'} {qty}")
    print(f"  回應: {result}")
    # 實際成交結果會從 WebSocket ExecRp 推送過來
    return result

# 買進 1 手 EUR/USD
place_market_order(token, ACCOUNT, "EUR/USD", side="1", qty=100000)

# 賣出 1 手 GBP/USD
place_market_order(token, ACCOUNT, "GBP/USD", side="2", qty=100000)

4.4 下限價單

指定價格,當市場達到該價格時才成交。買進通常設低於現價,賣出通常設高於現價。

python
def place_limit_order(token, account, symbol, side, qty, price,
                      sec_type="FOR"):
    """
    下限價單
    price: 希望成交的目標價格
    """
    now = datetime.now(timezone.utc).strftime("%Y%m%d-%H:%M:%S.%f")[:-3]

    payload = {
        "MT": "OrdReq",
        "Tok": token,
        "OrdReq": {
            "acct":     account,
            "sym":      symbol,
            "secType":  sec_type,
            "side":     str(side),
            "qty":      str(qty),
            "px":       str(price),  # 限價單:填目標價格
            "type":     "2",         # 2=限價單
            "tif":      "1",
            "sl":       "0",
            "tp":       "0",
            "execDst":  "INTX",
            "minQty":   "0",
            "stopPx":   "0",
            "qtyRsrv":  "0",
            "maxShow":  "0",
            "execBrk":  "",
            "execInst": "u",
            "px2":      "0",
            "txTime":   now,
            "handlInst":"1",
            "prnAgc":   "false",
            "slpg":     "0",
            "tktType":  "0",
            "tktNo":    "",
            "refTktNo": ""
        }
    }

    resp = requests.post(REST_URL, json=payload, timeout=10)
    result = resp.json()
    print(f"限價單送出: {symbol} {'買' if str(side)=='1' else '賣'} @ {price}")
    return result

# 以 1.0800 限價買入 EUR/USD 1手
place_limit_order(token, ACCOUNT, "EUR/USD", side="1", qty=100000, price=1.0800)

4.5 市價單含停損停利

下市價單的同時一併設定停損(SL)和停利(TP)價格。

python
def place_market_order_with_sltp(token, account, symbol, side, qty,
                                  sl_price, tp_price, sec_type="FOR"):
    """
    含停損停利的市價單
    sl_price: 停損價格(0=不設)
    tp_price: 停利價格(0=不設)
    """
    now = datetime.now(timezone.utc).strftime("%Y%m%d-%H:%M:%S.%f")[:-3]

    payload = {
        "MT": "OrdReq",
        "Tok": token,
        "OrdReq": {
            "acct":     account,
            "sym":      symbol,
            "secType":  sec_type,
            "side":     str(side),
            "qty":      str(qty),
            "px":       "0",          # 市價
            "type":     "1",          # 市價單
            "tif":      "1",
            "sl":       str(sl_price), # 停損價
            "tp":       str(tp_price), # 停利價
            "execDst":  "INTX",
            "minQty":   "0",
            "stopPx":   "0",
            "qtyRsrv":  "0",
            "maxShow":  "0",
            "execBrk":  "",
            "execInst": "u",
            "px2":      "0",
            "txTime":   now,
            "handlInst":"1",
            "prnAgc":   "false",
            "slpg":     "-1",         # 含SL/TP時設 -1
            "tktType":  "0",
            "tktNo":    "",
            "refTktNo": ""
        }
    }

    resp = requests.post(REST_URL, json=payload, timeout=10)
    return resp.json()

# 買進 GBP/USD,停損 1.20,停利 1.30
place_market_order_with_sltp(
    token, ACCOUNT,
    symbol="GBP/USD",
    side="1",         # 買
    qty=100000,
    sl_price=1.20,    # 跌到 1.20 停損
    tp_price=1.30     # 漲到 1.30 停利
)

4.6 為現有部位新增停損停利

已開倉但未設停損停利的單子,用這個方法補設。需要提供持倉票號(tktId)。

⚠️ 方向要反向停損停利本質是「預掛的平倉單」,方向必須與原始開倉相反。例如原本是買進,停損停利單要填賣出(side=2)。
python
def add_sltp(token, account, ticket_id, symbol, open_side, qty,
             sl_price, tp_price, sec_type="FOR"):
    """
    為持倉新增停損停利(type=U,Threshold 模式)
    ticket_id: 持倉的票號(從 get_tickets 取得 tktId)
    open_side: 原始開倉方向 ("1"=買  "2"=賣)
    sl_price:  停損價格(U型單的 px)
    tp_price:  停利價格(U型單的 px2)
    """
    # 停損停利單方向與開倉相反
    close_side = "2" if str(open_side) == "1" else "1"
    now = datetime.now(timezone.utc).strftime("%Y%m%d-%H:%M:%S.%f")[:-3]

    payload = {
        "MT": "OrdReq",
        "Tok": token,
        "OrdReq": {
            "acct":     account,
            "sym":      symbol,
            "secType":  sec_type,
            "side":     close_side,      # 與開倉相反
            "qty":      str(qty),
            "px":       str(sl_price),   # U型:px = 停損價
            "type":     "U",             # U = Threshold(停損停利)
            "tif":      "1",
            "sl":       "0",
            "tp":       "0",
            "execDst":  "INTX",
            "minQty":   "0",
            "stopPx":   "0",
            "qtyRsrv":  "0",
            "maxShow":  "0",
            "execBrk":  "",
            "execInst": "u",
            "px2":      str(tp_price),   # U型:px2 = 停利價
            "txTime":   now,
            "handlInst":"1",
            "prnAgc":   "true",
            "slpg":     "0",
            "tktType":  "1",             # 1=平倉單
            "tktNo":    str(ticket_id),
            "refTktNo": str(ticket_id)
        }
    }

    resp = requests.post(REST_URL, json=payload, timeout=10)
    result = resp.json()
    print(f"新增停損停利: 票號={ticket_id}  停損={sl_price}  停利={tp_price}")
    return result

# 票號 22130 是買進的倉,新增停損=1.05,停利=1.06
add_sltp(token, ACCOUNT,
         ticket_id=22130,
         symbol="EUR/USD",
         open_side="1",    # 原本是買
         qty=100000,
         sl_price=1.05,
         tp_price=1.06)

4.7 修改停損停利

修改已設定的停損停利價格。需要先查到現有停損停利單的委託ID(clOrdId)。

💡 如何取得 orgClOrdId 先呼叫 4.1「查詢掛單」,找到對應票號(tktId)的停損停利單,其 clOrdId 就是這裡的 org_order_id
python
def modify_sltp(token, account, org_order_id, ticket_id,
                symbol, open_side, qty, new_sl, new_tp,
                sec_type="FOR"):
    """
    修改停損停利(Cancel + Replace)
    org_order_id: 原始停損停利單的委託ID(從查詢掛單取得的 clOrdId)
    ticket_id:    對應的持倉票號
    new_sl:       新的停損價
    new_tp:       新的停利價
    """
    close_side = "2" if str(open_side) == "1" else "1"
    now = datetime.now(timezone.utc).strftime("%Y%m%d-%H:%M:%S.%f")[:-3]

    payload = {
        "MT": "OrdCxlRepReq",
        "Tok": token,
        "OrdCxlRepReq": {
            "acct":       account,
            "orgClOrdId": str(org_order_id),  # 要被替換的舊委託ID
            "sym":        symbol,
            "secType":    sec_type,
            "side":       close_side,
            "qty":        str(qty),
            "px":         str(new_sl),         # 新停損價
            "type":       "U",
            "tif":        "1",
            "sl":         "0",
            "tp":         "0",
            "execDst":    "INTX",
            "minQty":     "0",
            "stopPx":     "0",
            "qtyRsrv":    "0",
            "maxShow":    "0",
            "execBrk":    "",
            "execInst":   "u",
            "px2":        str(new_tp),         # 新停利價
            "txTime":     now,
            "handlInst":  "1",
            "prnAgc":     "false",
            "slpg":       "0",
            "tktType":    "1",
            "tktNo":      str(ticket_id),
            "refTktNo":   str(ticket_id)
        }
    }

    resp = requests.post(REST_URL, json=payload, timeout=10)
    result = resp.json()
    print(f"修改停損停利: 委託={org_order_id}  新停損={new_sl}  新停利={new_tp}")
    return result

# 範例:把票號22130的停損從1.05改到1.04,停利從1.06改到1.07
modify_sltp(token, ACCOUNT,
            org_order_id=14564,   # 從查詢掛單取得的 clOrdId
            ticket_id=22130,
            symbol="EUR/USD",
            open_side="1",
            qty=100000,
            new_sl=1.04,
            new_tp=1.07)

4.8 撤銷掛單

撤銷尚未成交的限價單或停損單。需要知道委託單的 ID(clOrdId)。

python
def cancel_order(token, account, order_id, symbol, side, sec_type="FOR"):
    """
    撤銷掛單
    order_id: 要撤銷的委託ID(從查詢掛單取得的 clOrdId)
    """
    now = datetime.now(timezone.utc).strftime("%Y%m%d-%H:%M:%S.%f")[:-3]

    payload = {
        "MT": "OrdCxlReq",
        "Tok": token,
        "OrdCxlReq": {
            "acct":    account,
            "clOrdId": str(order_id),  # 要撤銷的委託ID
            "sym":     symbol,
            "secType": sec_type,
            "side":    str(side),
            "txTime":  now
        }
    }

    resp = requests.post(REST_URL, json=payload, timeout=10)
    result = resp.json()
    print(f"撤單送出: 委託ID={order_id}  {symbol}")
    return result

# 撤銷委託ID=883 的掛單
cancel_order(token, ACCOUNT, order_id=883, symbol="EUR/USD", side="1")

4.9 平倉(市價關閉持倉)

對有設停損停利的倉位,先取消原本的停損停利單,同時送出市價平倉單。需提供停損停利委託的 ID 和持倉票號。

💡 沒有停損停利的倉位 如果持倉沒有設停損停利,直接送一張反向市價單即可(方向相反,tktType="1"tktNo 填票號)。
python
def close_position(token, account, org_sltp_order_id,
                   ticket_id, symbol, open_side, qty,
                   sec_type="FOR"):
    """
    市價平倉(同時取消停損停利)
    org_sltp_order_id: 現有停損停利單的委託ID(從查詢掛單取得)
    ticket_id:         持倉票號
    open_side:         原始開倉方向("1"=買  "2"=賣)
    """
    close_side = "2" if str(open_side) == "1" else "1"
    now = datetime.now(timezone.utc).strftime("%Y%m%d-%H:%M:%S.%f")[:-3]

    payload = {
        "MT": "OrdCxlRepReq",
        "Tok": token,
        "OrdCxlRepReq": {
            "acct":       account,
            "orgClOrdId": str(org_sltp_order_id),  # 要取消的停損停利單ID
            "sym":        symbol,
            "secType":    sec_type,
            "side":       close_side,               # 平倉方向(反向)
            "qty":        str(qty),
            "px":         "0",                      # 市價平倉
            "type":       "1",                      # 改回市價單
            "tif":        "3",                      # 3=IOC(立即成交否則取消)
            "sl":         "0",
            "tp":         "0",
            "execDst":    "INTX",
            "minQty":     "0",
            "stopPx":     "0",
            "qtyRsrv":    "0",
            "maxShow":    "0",
            "execBrk":    "",
            "execInst":   "v",
            "px2":        "0",
            "txTime":     now,
            "handlInst":  "1",
            "prnAgc":     "false",
            "slpg":       "0",
            "tktType":    "1",                      # 1=平倉
            "tktNo":      str(ticket_id),
            "refTktNo":   str(ticket_id)
        }
    }

    resp = requests.post(REST_URL, json=payload, timeout=10)
    result = resp.json()
    print(f"平倉送出: 票號={ticket_id}  {symbol}  {'買' if close_side=='1' else '賣'}")
    return result

# 平倉票號 22130 的持倉(原本是買進,停損停利委託ID=14565)
close_position(token, ACCOUNT,
               org_sltp_order_id=14565,
               ticket_id=22130,
               symbol="EUR/USD",
               open_side="1",
               qty=100000)

5.1 傳送憑證(TransferCert)

僅在伺服器啟用 CA 登入功能時使用。可將憑證從一台裝置轉移到另一台裝置,需搭配 5.2 匯入憑證使用。預設憑證保存 10 分鐘。

python
import base64

def transfer_cert(token, account, transfer_password, cert_file_path):
    """
    傳送憑證到伺服器(供另一台裝置取用)
    transfer_password: 自訂的取用密碼(接收端需用相同密碼)
    cert_file_path:   .pfx 憑證檔案路徑
    """
    with open(cert_file_path, "rb") as f:
        cert_b64 = base64.b64encode(f.read()).decode("utf-8")

    payload = {
        "MT": "TransferCert",
        "Tok": token,
        "login": account,
        "transfer_pwd": transfer_password,
        "cert_b64": cert_b64
    }
    resp = requests.post(REST_URL, json=payload, timeout=10)
    print("傳送憑證結果:", resp.json())
    return resp.json()

transfer_cert(token, ACCOUNT, "自訂密碼123", "my_cert.pfx")

5.2 匯入憑證(ImportCert)

從伺服器取回已傳送的憑證。

python
def import_cert(account, transfer_password):
    """
    匯入憑證(不需要 Token)
    transfer_password: 與 transfer_cert 相同的密碼
    """
    payload = {
        "MT": "ImportCert",
        "login": account,
        "transfer_pwd": transfer_password
    }
    resp = requests.post(REST_URL, json=payload, timeout=10)
    print("匯入憑證結果:", resp.json())
    return resp.json()

import_cert(ACCOUNT, "自訂密碼123")

兩種使用模式

本 API 提供兩種完全獨立的整合方式,請依你的需求選擇其中一種:

模式一
Python 腳本
ltm_client.py
純 Python 程式,直接連接元大 API。
適合寫自動交易策略、排程腳本、數據分析。
執行方式python ltm_client.py
有沒有 UI無(終端機輸出)
安裝套件requests websocket-client cryptography
適合對象量化交易、Python 開發者
查看 ltm_client.py →
模式二
瀏覽器前台
server.py + dashboard.html
本機代理伺服器 + 網頁介面。
適合想要可視化操作介面、點擊下單、看即時報價的使用者。
執行方式python server.py → 開瀏覽器
有沒有 UI有(交易面板)
安裝套件flask flask-socketio + 上面全部
適合對象一般交易者、需要 UI 的使用者
查看 server.py →
💡 兩者互相獨立,擇一使用即可 ltm_client.pyserver.py 都能完成相同的 API 操作,只是介面不同。不需要同時使用,也不需要先跑一個再跑另一個。

模式一:完整 Python 腳本(ltm_client.py)

⚠️
範例程式碼(Sample Code)— 請勿直接照抄上線
以下程式碼為串接示範,供開發者理解 API 流程使用。實際部署前請依自身環境調整帳號設定、錯誤處理與安全性措施。帳號資訊(API_KEYPASSWORDACCOUNT)務必改為您自己的資料。

純 Python 客戶端,直接從終端機連接元大 API。下載後修改最上方設定區的 API_KEYPASSWORDACCOUNT,以及 CA 憑證路徑 PFX_PATHPFX_PASSWORD,再執行即可。

下載 ltm_client.py
無需瀏覽器 純 Python 適合量化策略
python — ltm_client.py(完整版)
"""
槓桿全球贏家 Web API Python 客戶端
=====================================
Version:  4.13
安裝套件: pip install requests websocket-client cryptography

使用方式:
    from ltm_client import LTMClient
    client = LTMClient()
    client.start()
    client.place_market_order("EUR/USD", "1", 100000)
    client.run_forever()
"""

import requests
import websocket
import json
import hashlib
import threading
import time
import base64
import os
from datetime import datetime, timezone

# CA 憑證登入(需安裝:pip install cryptography)
try:
    from cryptography.hazmat.primitives import hashes
    from cryptography.hazmat.primitives.asymmetric import padding as asym_padding
    from cryptography.hazmat.primitives.serialization import pkcs12
    HAS_CRYPTO = True
except ImportError:
    HAS_CRYPTO = False


# ============================================================
# 【設定區】請修改這裡
# ============================================================
API_KEY  = "ABCDEF0ECB394776ADDBA88CB3254321"   # 從元大後台取得的 API Key
PASSWORD = "Ss12345#"                           # API Key 對應的密碼
ACCOUNT  = "1041234"                            # 交易帳戶號碼

# CA 憑證設定(正式帳號必填,留空則走 Plain 登入)
PFX_PATH     = r""   # .pfx 憑證完整路徑,例:r"C:\Users\你的名字\Downloads\1041234.pfx"
PFX_PASSWORD = ""    # 憑證密碼(沒有則留空字串)

REST_URL = "https://webltm.yuantafutures.com.tw/WEBTRADER/rest"
WS_URL   = "wss://webltm.yuantafutures.com.tw/WEBTRADER/ws"
# ============================================================


class LTMClient:
    """槓桿全球贏家 Web API 客戶端"""

    def __init__(self):
        self.token   = None
        self.ws_app  = None
        self._thread = None
        self._connected = False

        # ── 即時資料快取 ──────────────────────────────────────────
        # quotes: 最新報價,key = 商品代碼,每次收到 Q 訊息自動更新
        #   用法:client.quotes["EUR/USD"]["bid"]   → 最新買價
        #         client.quotes["EUR/USD"]["ask"]   → 最新賣價
        #         client.quotes["EUR/USD"]["time"]  → 報價時間
        self.quotes = {}

        # orders: 委託狀態,key = clOrdId(下單時自訂的委託 ID)
        #   每次收到 ExecRp 訊息(新委託確認/部分成交/全數成交/取消)都會更新
        #   用法:client.orders["my-order-id"]["status"]  → 委託狀態碼
        #         client.orders["my-order-id"]["cumQty"]  → 已成交量
        self.orders = {}
        # ─────────────────────────────────────────────────────────

        # 自訂訊息處理器(可由外部設定)
        self.on_quote    = None   # 報價更新:fn(symbol, bid, ask, time)
        self.on_execrp   = None   # 執行回報:fn(data)
        self.on_ws_msg   = None   # 所有 WS 訊息:fn(data)

    # ----------------------------------------------------------
    # 登入與連線
    # ----------------------------------------------------------

    def start(self):
        """一鍵完成登入 + WebSocket 連線(自動選擇 CA 或 Plain 登入)"""
        if PFX_PATH and os.path.exists(PFX_PATH):
            print("🔐 使用 CA 憑證登入...")
            success = self._signed_login()
        else:
            print("🔑 使用 Plain 登入(未設定 PFX_PATH)...")
            success = self._rest_login()
        if not success:
            raise RuntimeError("登入失敗")
        self._start_websocket()
        print("✅ 已完成登入並建立 WebSocket 連線")

    def _signed_login(self):
        """CA 憑證登入 (SignedReq):用 .pfx 私鑰簽名後登入,正式帳號使用"""
        if not HAS_CRYPTO:
            print("❌ 未安裝 cryptography,請執行:pip install cryptography")
            return False

        with open(PFX_PATH, "rb") as f:
            pfx_bytes = f.read()

        pfx_pass = PFX_PASSWORD.encode() if PFX_PASSWORD else None
        try:
            private_key, _, _ = pkcs12.load_key_and_certificates(pfx_bytes, pfx_pass)
        except Exception as e:
            print(f"❌ 無法讀取 .pfx 憑證:{e}")
            return False

        # 密碼用 base64(SHA256) 格式
        pw_b64  = base64.b64encode(hashlib.sha256(PASSWORD.encode()).digest()).decode()
        raw_obj = {"MT": "Login", "UserInfo": {"login": API_KEY, "password": pw_b64}}
        raw_req = json.dumps(raw_obj, separators=(",", ":"))

        # RSA-SHA256 簽名
        try:
            sig     = private_key.sign(raw_req.encode(), asym_padding.PKCS1v15(), hashes.SHA256())
            sig_b64 = base64.b64encode(sig).decode()
        except Exception as e:
            print(f"❌ 簽名失敗:{e}")
            return False

        # POST SignedReq
        payload = {
            "MT":        "SignedReq",
            "rawReq":    raw_req,
            "signerId":  ACCOUNT,
            "password":  PASSWORD,
            "signature": sig_b64,
        }
        try:
            resp = requests.post(REST_URL, json=payload, timeout=10)
            data = resp.json()
        except Exception as e:
            print(f"❌ SignedReq 連線失敗:{e}")
            return False

        if data.get("ErrCode", 0) != 0:
            print(f"❌ CA 登入失敗: {data}")
            return False

        raw_tok = data.get("Tok", "")
        if not raw_tok:
            print(f"❌ CA 登入無回傳 Token: {data}")
            return False

        # RSA 解密 Token
        try:
            enc_tok    = base64.b64decode(raw_tok + "==")
            self.token = private_key.decrypt(enc_tok, asym_padding.PKCS1v15()).decode()
        except Exception:
            self.token = raw_tok  # 解密失敗則直接用原始 token

        print(f"🔑 CA 登入成功 Token={self.token[:10]}...")
        return True

    def _rest_login(self):
        """Plain 登入:SHA256 密碼(未設定 PFX_PATH 時使用)"""
        hashed_pw = hashlib.sha256(PASSWORD.encode("utf-8")).hexdigest()
        payload = {
            "MT": "Login",
            "UserInfo": {"login": API_KEY, "password": hashed_pw}
        }
        try:
            resp = requests.post(REST_URL, json=payload, timeout=10)
            data = resp.json()
            if "Tok" in data:
                self.token = data["Tok"]
                print(f"🔑 REST 登入成功 Token={self.token[:10]}...")
                return True
            else:
                print(f"❌ REST 登入失敗: {data}")
                return False
        except Exception as e:
            print(f"❌ 連線錯誤: {e}")
            return False

    def _start_websocket(self):
        """Step 2: 建立 WebSocket 連線並登入"""

        def on_open(ws):
            print("🔌 WebSocket 已連線,正在登入...")
            ws.send(json.dumps({
                "MT": "Login",
                "UserInfo": {"login": API_KEY, "password": PASSWORD},
                "Tok": self.token
            }))

        def on_message(ws, message):
            try:
                data = json.loads(message)
            except Exception:
                return

            # 每條訊息都要立即回 ACK
            try:
                ws.send(json.dumps({"MT": "Ack"}))
            except Exception:
                pass

            mt = data.get("MT", "")

            # 通知外部處理器
            if self.on_ws_msg:
                self.on_ws_msg(data)

            if mt == "Login":
                self._connected = True
                print("✅ WebSocket 登入成功")

            elif mt == "Q":
                q   = data.get("Q", {})
                sym = q.get("s")
                # 無論有無自訂 handler,都先把最新報價存入快取
                # 策略程式可在任何時間點直接讀 self.quotes["EUR/USD"]["bid"]
                # 不需要等待下一次 WebSocket 推播
                if sym:
                    self.quotes[sym] = {
                        "bid":  q.get("b"),   # 買價(Bid)
                        "ask":  q.get("a"),   # 賣價(Ask)
                        "time": q.get("t"),   # 報價時間
                    }
                if self.on_quote:
                    self.on_quote(sym, q.get("b"), q.get("a"), q.get("t"))
                else:
                    print(f"💹 {sym:<12} 買={q.get('b')}  賣={q.get('a')}")

            elif mt == "ExecRp":
                rp       = data.get("ExecRp", {})
                order_id = rp.get("clOrdId")
                # 以 clOrdId 為 key,持續覆蓋成最新狀態
                # 同一筆委託從「新委託」→「部分成交」→「全數成交」都記在同一個 key 下
                # 查詢:self.orders["my-order-id"]["status"] / ["cumQty"] 等
                if order_id:
                    self.orders[order_id] = rp
                if self.on_execrp:
                    self.on_execrp(data)
                else:
                    print(f"📋 執行回報: {json.dumps(data, ensure_ascii=False, indent=2)}")

            elif mt == "GetAcctInfo":
                bal = data.get("AcctVal", {}).get("bal", "?")
                print(f"💰 帳戶餘額: {bal}")
            elif mt == "NetPosReq":
                positions = data.get("PositionAry", [])
                print(f"📊 部位回應: {len(positions)} 筆")

        def on_error(ws, error):
            print(f"❌ WebSocket 錯誤: {error}")

        def on_close(ws, code, msg):
            self._connected = False
            print("🔌 WebSocket 已斷線")

        self.ws_app = websocket.WebSocketApp(
            WS_URL,
            on_open=on_open,
            on_message=on_message,
            on_error=on_error,
            on_close=on_close
        )
        self._thread = threading.Thread(
            target=self.ws_app.run_forever,
            kwargs={"ping_interval": 30}
        )
        self._thread.daemon = True
        self._thread.start()
        time.sleep(2)

    def run_forever(self):
        """保持程式執行,持續接收 WebSocket 推播"""
        print("▶ 持續接收 WebSocket 推播(Ctrl+C 結束)")
        try:
            while True:
                time.sleep(1)
        except KeyboardInterrupt:
            print("\n⏹ 程式結束")
            if self.ws_app:
                self.ws_app.close()

    # ----------------------------------------------------------
    # REST 工具
    # ----------------------------------------------------------

    def _rest(self, payload):
        """發送 REST 請求(自動帶入 Token)"""
        payload["Tok"] = self.token
        resp = requests.post(REST_URL, json=payload, timeout=10)
        return resp.json()

    def _now(self):
        return datetime.now(timezone.utc).strftime("%Y%m%d-%H:%M:%S.%f")[:-3]

    # ----------------------------------------------------------
    # 帳戶查詢
    # ----------------------------------------------------------

    def get_account_info(self, account=None):
        """查詢帳戶資訊(餘額、保證金、盈虧)"""
        return self._rest({
            "MT": "GetAcctInfo",
            "UserInfo": {"login": account or ACCOUNT}
        })

    def get_symbols(self):
        """查詢所有可交易商品"""
        return self._rest({"MT": "SymListReq"})

    def get_positions(self, account=None):
        """查詢目前持倉部位"""
        return self._rest({
            "MT": "NetPosReq",
            "UserInfo": {"login": account or ACCOUNT}
        })

    def get_open_orders(self, account=None):
        """查詢掛單(未成交委託)"""
        return self._rest({
            "MT": "OpenOrdReq",
            "UserInfo": {"login": account or ACCOUNT}
        })

    def get_tickets(self, include_open=True, include_closed=True,
                    from_date="2025/01/01 00:00:00",
                    to_date="2025/12/31 23:59:59",
                    account=None):
        """查詢成交紀錄"""
        return self._rest({
            "MT": "GetTickets",
            "UserInfo": {"login": account or ACCOUNT},
            "GetTickets": {
                "inclOp":  include_open,
                "inclCls": include_closed,
                "clsFrom": from_date,
                "clsTo":   to_date
            }
        })

    def get_access_server_info(self):
        """取得所有可用伺服器列表"""
        return self._rest({"MT": "GetAccessServerInfo"})

    def echo(self, **kwargs):
        """Echo 測試(量測延遲)"""
        payload = {"MT": "Echo", **kwargs}
        return self._rest(payload)

    # ----------------------------------------------------------
    # 報價訂閱(QReq 走 REST,報價推播從 WebSocket 接收)
    # ----------------------------------------------------------

    def subscribe_quote(self, symbols, quote_type=1):
        """訂閱即時報價
        symbols:    商品代碼列表 ["EUR/USD", "GBP/USD"]
        quote_type: 1=L1基本, 2=L2深度, 3=全部

        重要:QReq 用 REST 送出(不是 WebSocket)
        報價更新(MT="Q")從 WebSocket on_message 接收
        """
        return self._rest({
            "MT": "QReq",
            "QReq": {"sym": symbols, "sub_type": 1, "quote_type": quote_type}
        })

    def unsubscribe_quote(self, symbols):
        """取消訂閱報價"""
        return self._rest({
            "MT": "QReq",
            "QReq": {"sym": symbols, "sub_type": 2, "quote_type": 1}
        })

    def get_chart_history(self, symbol, chart_type=1, days=2, group_size=5):
        """取得歷史K線
        chart_type: 0=日線  1=分鐘線
        group_size: 分鐘線合併(5=5分K)
        """
        return self._rest({
            "MT": "ChartHisReq",
            "ChartHisReq": {
                "sym":       symbol,
                "type":      chart_type,
                "days":      days,
                "reqId":     1,
                "endTime":   datetime.now(timezone.utc).strftime("%Y%m%d-%H:%M:%S"),
                "groupSize": group_size
            }
        })

    # ----------------------------------------------------------
    # 交易操作
    # ----------------------------------------------------------

    def _build_ord(self, account, symbol, side, qty, px, ord_type,
                   tif="1", sl=0, tp=0, px2=0, tkt_type="0",
                   tkt_no="", ref_tkt_no="", exec_inst="u",
                   prn_agc="false", slpg="0", sec_type="FOR"):
        """建構 OrdReq 內容"""
        return {
            "acct":     account,
            "sym":      symbol,
            "secType":  sec_type,
            "side":     str(side),
            "qty":      str(qty),
            "px":       str(px),
            "type":     str(ord_type),
            "tif":      str(tif),
            "sl":       str(sl),
            "tp":       str(tp),
            "execDst":  "INTX",
            "minQty":   "0",
            "stopPx":   "0",
            "qtyRsrv":  "0",
            "maxShow":  "0",
            "execBrk":  "",
            "execInst": exec_inst,
            "px2":      str(px2),
            "txTime":   self._now(),
            "handlInst":"1",
            "prnAgc":   prn_agc,
            "slpg":     slpg,
            "tktType":  str(tkt_type),
            "tktNo":    str(tkt_no),
            "refTktNo": str(ref_tkt_no)
        }

    def place_market_order(self, symbol, side, qty,
                           sl=0, tp=0, sec_type="FOR", comment=""):
        """下市價單(side: '1'=買  '2'=賣)"""
        slpg = "-1" if (sl != 0 or tp != 0) else "0"
        ord_req = self._build_ord(
            ACCOUNT, symbol, side, qty,
            px=0, ord_type=1, sl=sl, tp=tp, slpg=slpg
        )
        ord_req["comment"] = comment
        return self._rest({"MT": "OrdReq", "OrdReq": ord_req})

    def place_limit_order(self, symbol, side, qty, price,
                          sl=0, tp=0, sec_type="FOR"):
        """下限價單"""
        ord_req = self._build_ord(
            ACCOUNT, symbol, side, qty,
            px=price, ord_type=2, sl=sl, tp=tp
        )
        return self._rest({"MT": "OrdReq", "OrdReq": ord_req})

    def add_sltp(self, ticket_id, symbol, open_side, qty,
                 sl_price, tp_price, sec_type="FOR"):
        """為持倉新增停損停利"""
        close_side = "2" if str(open_side) == "1" else "1"
        ord_req = self._build_ord(
            ACCOUNT, symbol, close_side, qty,
            px=sl_price, ord_type="U", px2=tp_price,
            tkt_type=1, tkt_no=ticket_id, ref_tkt_no=ticket_id,
            prn_agc="true"
        )
        return self._rest({"MT": "OrdReq", "OrdReq": ord_req})

    def modify_sltp(self, org_order_id, ticket_id, symbol,
                    open_side, qty, new_sl, new_tp, sec_type="FOR"):
        """修改停損停利"""
        close_side = "2" if str(open_side) == "1" else "1"
        req = self._build_ord(
            ACCOUNT, symbol, close_side, qty,
            px=new_sl, ord_type="U", px2=new_tp,
            tkt_type=1, tkt_no=ticket_id, ref_tkt_no=ticket_id
        )
        req["orgClOrdId"] = str(org_order_id)
        return self._rest({"MT": "OrdCxlRepReq", "OrdCxlRepReq": req})

    def cancel_order(self, order_id, symbol, side, sec_type="FOR"):
        """撤銷掛單"""
        payload = {
            "MT": "OrdCxlReq",
            "OrdCxlReq": {
                "acct":    ACCOUNT,
                "clOrdId": str(order_id),
                "sym":     symbol,
                "secType": sec_type,
                "side":    str(side),
                "txTime":  self._now()
            }
        }
        return self._rest(payload)

    def close_position(self, org_sltp_order_id, ticket_id,
                       symbol, open_side, qty, sec_type="FOR"):
        """市價平倉"""
        close_side = "2" if str(open_side) == "1" else "1"
        req = self._build_ord(
            ACCOUNT, symbol, close_side, qty,
            px=0, ord_type=1, tif="3",
            tkt_type=1, tkt_no=ticket_id, ref_tkt_no=ticket_id,
            exec_inst="v"
        )
        req["orgClOrdId"] = str(org_sltp_order_id)
        return self._rest({"MT": "OrdCxlRepReq", "OrdCxlRepReq": req})

    # ----------------------------------------------------------
    # 憑證管理
    # ----------------------------------------------------------

    def transfer_cert(self, transfer_password, cert_file_path):
        """傳送憑證到伺服器"""
        with open(cert_file_path, "rb") as f:
            cert_b64 = base64.b64encode(f.read()).decode("utf-8")
        return self._rest({
            "MT": "TransferCert",
            "login": ACCOUNT,
            "transfer_pwd": transfer_password,
            "cert_b64": cert_b64
        })

    def import_cert(self, transfer_password):
        """從伺服器匯入憑證"""
        payload = {
            "MT": "ImportCert",
            "login": ACCOUNT,
            "transfer_pwd": transfer_password
        }
        resp = requests.post(REST_URL, json=payload, timeout=10)
        return resp.json()


# ============================================================
# 使用範例(直接執行此檔時執行)
# ============================================================
if __name__ == "__main__":
    client = LTMClient()

    # 自訂報價處理
    def my_quote_handler(symbol, bid, ask, t):
        print(f"  [{t}] {symbol:<12} 買={bid}  賣={ask}")

    client.on_quote = my_quote_handler

    # 登入
    client.start()

    # 查帳戶
    acct = client.get_account_info()
    v = acct.get("AcctVal", {})
    print(f"\n帳戶淨值: {v.get('bal', '?'):>15}")
    print(f"已實現P&L: {v.get('clsPL', '?'):>14}")
    print(f"所需保證金: {v.get('rqdMrgn', '?'):>13}\n")

    # 查持倉
    pos = client.get_positions()
    plist = pos.get("PositionAry", [])
    print(f"持倉數: {len(plist)}")

    # 訂閱報價(背景自動接收)
    client.subscribe_quote(["EUR/USD", "GBP/USD"])

    # 持續接收
    client.run_forever()
✅ 模式一使用流程 1. 安裝套件:pip install requests websocket-client cryptography
2. 複製上方程式碼,儲存為 ltm_client.py
3. 修改最上方的 API_KEYPASSWORDACCOUNTPFX_PATHPFX_PASSWORD
4. 在命令提示字元執行:python ltm_client.py

模式二:瀏覽器前台(server.py + dashboard.html)

⚠️
範例程式碼(Sample Code)— 請勿直接照抄上線
以下程式碼為串接示範,供開發者理解架構與 API 流程。PFX_PATHPFX_PASSWORD 需改為您自己的憑證路徑與密碼,切勿將憑證資訊硬寫入程式碼後公開發布。正式環境請加入適當的身份驗證與存取控制。

本機代理伺服器,讓瀏覽器能透過視覺化介面操作元大 API。需同時搭配 dashboard.html 使用。

下載 server.py
有視覺介面 Flask + Socket.IO 需搭配 dashboard.html
⚠️ 使用前必填:修改 server.py 最上方的憑證設定 找到 PFX_PATHPFX_PASSWORD 兩行,填入你自己的 .pfx 路徑與密碼,才能完成 CA 登入。

所需檔案(兩個都需要)

檔案用途取得方式
server.py本機 API 代理伺服器 下載
dashboard.html瀏覽器交易介面 下載
你的帳號.pfxCA 憑證(元大核發)向元大期貨申請
python — server.py(完整版)
"""
槓桿全球贏家 Web API — 本機代理伺服器
=========================================
安裝套件: pip install flask flask-socketio requests websocket-client cryptography
執行方式: python server.py
開啟瀏覽器: http://localhost:5000
"""

from flask import Flask, jsonify, request, send_file
from flask_socketio import SocketIO
import requests as req
import websocket
import json
import hashlib
import base64
import threading
import time
import ssl
from datetime import datetime, timezone

try:
    from cryptography.hazmat.primitives import hashes, serialization
    from cryptography.hazmat.primitives.asymmetric import padding as asym_padding
    from cryptography.hazmat.primitives.serialization import pkcs12
    HAS_CRYPTO = True
except ImportError:
    HAS_CRYPTO = False

app = Flask(__name__)
socketio = SocketIO(app, cors_allowed_origins="*", async_mode="threading")

REST_URL = "https://webltm.yuantafutures.com.tw/WEBTRADER/rest"
WS_URL   = "wss://webltm.yuantafutures.com.tw/WEBTRADER/ws"

# ══════════════════════════════════════════════════════════
#  ★ 使用前必填:CA 憑證設定
#
#  PFX_PATH     : .pfx 檔案的完整路徑(Windows 路徑前加 r)
#  PFX_PASSWORD : 開啟 .pfx 的密碼(沒有密碼則留空字串 "")
#
#  範例:
#    PFX_PATH     = r"C:\Users\YourName\Desktop\1041234.pfx"
#    PFX_PASSWORD = "your_password"
# ══════════════════════════════════════════════════════════
PFX_PATH     = r"C:\Users\YourName\Desktop\1041234.pfx"
PFX_PASSWORD = "your_password"

# 全域連線狀態
state = {
    "token":     None,
    "api_key":   None,
    "password":  None,
    "account":   None,
    "ws":        None,
    "connected": False,
}


# ============================================================
# 元大 WebSocket 管理
# ============================================================

def start_ltm_websocket():
    def on_open(ws):
        ws.send(json.dumps({
            "MT": "Login",
            "UserInfo": {"login": state["api_key"], "password": state["password"]},
            "Tok": state["token"]
        }))

    def on_message(ws, raw):
        try:
            data = json.loads(raw)
        except Exception:
            return
        try:
            ws.send(json.dumps({"MT": "Ack"}))
        except Exception:
            pass
        mt = data.get("MT", "")
        if mt == "MsgAry":
            for msg in data.get("MsgAry", []):
                _dispatch(msg)
            return
        _dispatch(data)

    def on_error(ws, error):
        socketio.emit("ws_error", {"msg": str(error)})

    def on_close(ws, code, msg):
        state["connected"] = False
        socketio.emit("ws_status", {"connected": False})

    state["ws"] = websocket.WebSocketApp(
        WS_URL,
        on_open=on_open, on_message=on_message,
        on_error=on_error, on_close=on_close
    )

    def _run():
        state["ws"].run_forever(
            ping_interval=30,
            sslopt={"cert_reqs": ssl.CERT_NONE}
        )
    threading.Thread(target=_run, daemon=True).start()


def _dispatch(data):
    mt = data.get("MT", "")
    if mt == "Login":
        state["connected"] = True
        socketio.emit("ws_status", {"connected": True})
        def _auto_sub():
            time.sleep(1)
            if state["connected"]:
                try:
                    r = req.post(REST_URL, json={
                        "MT": "QReq", "Tok": state["token"],
                        "QReq": {"sym": ["EUR/USD", "GBP/USD", "XAU/USD"],
                                 "sub_type": 1, "quote_type": 1}
                    }, timeout=10)
                except Exception:
                    pass
        threading.Thread(target=_auto_sub, daemon=True).start()
    elif mt == "Q":      socketio.emit("quote",        data.get("Q", {}))
    elif mt == "MD":     socketio.emit("market_depth", data.get("MD", {}))
    elif mt == "ExecRp": socketio.emit("exec_report",  data)
    elif mt == "GetAcctInfo": socketio.emit("acct_info",   data)
    elif mt == "NetPosReq":   socketio.emit("positions",   data)
    elif mt == "OpenOrdReq":  socketio.emit("open_orders", data)
    elif mt == "GetTickets":  socketio.emit("tickets",     data)
    elif mt == "ChartHisReq": socketio.emit("chart_data",  data)
    else: socketio.emit("ws_msg", data)


# ============================================================
# 工具函式
# ============================================================

def ltm_rest(payload):
    if state["token"]:
        payload["Tok"] = state["token"]
    try:
        r = req.post(REST_URL, json=payload, timeout=10)
        return r.json(), 200
    except Exception as e:
        return {"error": str(e)}, 500

def now_utc():
    return datetime.now(timezone.utc).strftime("%Y%m%d-%H:%M:%S.%f")[:-3]

def require_login():
    if not state["token"]:
        return jsonify({"error": "尚未登入"}), 401
    return None


# ============================================================
# HTTP 路由
# ============================================================

@app.route("/")
def index():
    return send_file("dashboard.html")

@app.route("/docs")
def docs():
    return send_file("index.html")


# --- 登入(CA SignedReq)---

def _do_signed_login(api_key, password, account, pfx_bytes, pfx_pwd):
    if not HAS_CRYPTO:
        return None, "請安裝 cryptography:pip install cryptography"
    pfx_pass = pfx_pwd.encode() if pfx_pwd else None
    try:
        private_key, certificate, _ = pkcs12.load_key_and_certificates(pfx_bytes, pfx_pass)
    except Exception as e:
        return None, f"無法讀取 .pfx:{e}"

    signer_id = account  # signerId = 帳戶號碼(不是 API Key)

    pw_b64  = base64.b64encode(hashlib.sha256(password.encode()).digest()).decode()
    raw_req = json.dumps({"MT": "Login", "UserInfo": {"login": api_key, "password": pw_b64}},
                         separators=(",", ":"))

    try:
        sig_b64 = base64.b64encode(
            private_key.sign(raw_req.encode(), asym_padding.PKCS1v15(), hashes.SHA256())
        ).decode()
    except Exception as e:
        return None, f"簽名失敗:{e}"

    try:
        r    = req.post(REST_URL, json={
            "MT": "SignedReq", "rawReq": raw_req,
            "signerId": signer_id, "password": password, "signature": sig_b64,
        }, timeout=10)
        data = r.json()
    except Exception as e:
        return None, str(e)

    if data.get("MT") == "Logout" or "ErrInfo" in data:
        return None, str(data)
    if "Tok" not in data:
        return None, str(data)

    try:
        enc_tok = base64.b64decode(data["Tok"] + "==")
        token   = private_key.decrypt(enc_tok, asym_padding.PKCS1v15()).decode()
    except Exception as e:
        token = data["Tok"]  # 解密失敗時直接使用原始值

    return token, None


@app.route("/api/login", methods=["POST"])
def login():
    body     = request.json or {}
    api_key  = body.get("api_key", "")
    password = body.get("password", "")
    account  = body.get("account", "")

    import os
    if PFX_PATH and os.path.exists(PFX_PATH):
        with open(PFX_PATH, "rb") as f:
            pfx_bytes = f.read()
        token, err = _do_signed_login(api_key, password, account, pfx_bytes, PFX_PASSWORD)
    else:
        return jsonify({"ok": False, "error": "找不到 .pfx 憑證,請設定 PFX_PATH"}), 401

    if err:
        return jsonify({"ok": False, "error": err}), 401

    state.update({"token": token, "api_key": api_key, "password": password, "account": account})
    threading.Thread(target=start_ltm_websocket, daemon=True).start()
    return jsonify({"ok": True})


@app.route("/api/logout", methods=["POST"])
def logout():
    if state["ws"]:
        try: state["ws"].close()
        except Exception: pass
    state.update({"token": None, "api_key": None, "password": None,
                  "account": None, "ws": None, "connected": False})
    return jsonify({"ok": True})

@app.route("/api/status")
def status():
    return jsonify({"logged_in": bool(state["token"]),
                    "ws_connected": state["connected"],
                    "account": state["account"]})


# --- 帳戶查詢 ---

@app.route("/api/account")
def get_account():
    err = require_login()
    if err: return err
    data, code = ltm_rest({"MT": "GetAcctInfo", "UserInfo": {"login": state["account"]}})
    return jsonify(data), code

@app.route("/api/positions")
def get_positions():
    err = require_login()
    if err: return err
    data, code = ltm_rest({"MT": "NetPosReq", "UserInfo": {"login": state["account"]}})
    return jsonify(data), code

@app.route("/api/orders")
def get_orders():
    err = require_login()
    if err: return err
    data, code = ltm_rest({"MT": "OpenOrdReq", "UserInfo": {"login": state["account"]}})
    return jsonify(data), code

@app.route("/api/symbols")
def get_symbols():
    err = require_login()
    if err: return err
    data, code = ltm_rest({"MT": "SymListReq"})
    return jsonify(data), code

@app.route("/api/tickets")
def get_tickets():
    err = require_login()
    if err: return err
    data, code = ltm_rest({
        "MT": "GetTickets",
        "UserInfo": {"login": state["account"]},
        "GetTickets": {
            "inclOp":  True, "inclCls": True,
            "clsFrom": request.args.get("from", "2025/01/01 00:00:00"),
            "clsTo":   request.args.get("to",   "2025/12/31 23:59:59"),
        }
    })
    return jsonify(data), code

@app.route("/api/chart")
def get_chart():
    err = require_login()
    if err: return err
    data, code = ltm_rest({
        "MT": "ChartHisReq",
        "ChartHisReq": {
            "sym":       request.args.get("sym", "EUR/USD"),
            "type":      int(request.args.get("type", 1)),
            "days":      int(request.args.get("days", 2)),
            "reqId":     1,
            "endTime":   datetime.now(timezone.utc).strftime("%Y%m%d-%H:%M:%S"),
            "groupSize": int(request.args.get("group", 5)),
        }
    })
    return jsonify(data), code


# --- 報價訂閱(QReq 走 REST,報價推播走 WS)---

@app.route("/api/quote/subscribe", methods=["POST"])
def subscribe_quote():
    err = require_login()
    if err: return err
    body    = request.json or {}
    symbols = body.get("symbols", [])
    data, code = ltm_rest({
        "MT":   "QReq",
        "QReq": {"sym": symbols, "sub_type": body.get("sub_type", 1), "quote_type": 1}
    })
    return jsonify(data), code


# --- 交易 ---

def _build_ord(b):
    return {
        "acct": state["account"], "sym": b["sym"],
        "secType": b.get("secType", "FOR"),
        "side": str(b["side"]), "qty": str(b["qty"]),
        "px": str(b.get("px", 0)), "type": str(b["type"]),
        "tif": str(b.get("tif", 1)),
        "sl": str(b.get("sl", 0)), "tp": str(b.get("tp", 0)),
        "execDst": "INTX", "minQty": "0", "stopPx": "0",
        "qtyRsrv": "0", "maxShow": "0", "execBrk": "",
        "execInst": b.get("execInst", "u"),
        "px2": str(b.get("px2", 0)), "txTime": now_utc(),
        "handlInst": "1", "prnAgc": str(b.get("prnAgc", "false")).lower(),
        "slpg": str(b.get("slpg", 0)), "tktType": str(b.get("tktType", 0)),
        "tktNo": str(b.get("tktNo", "")), "refTktNo": str(b.get("refTktNo", "")),
        "comment": b.get("comment", ""),
    }

@app.route("/api/order", methods=["POST"])
def place_order():
    err = require_login()
    if err: return err
    data, code = ltm_rest({"MT": "OrdReq", "OrdReq": _build_ord(request.json or {})})
    return jsonify(data), code

@app.route("/api/cancel", methods=["POST"])
def cancel_order():
    err = require_login()
    if err: return err
    b = request.json or {}
    data, code = ltm_rest({
        "MT": "OrdCxlReq",
        "OrdCxlReq": {
            "acct": state["account"], "clOrdId": str(b["orderId"]),
            "sym": b["sym"], "secType": b.get("secType", "FOR"),
            "side": str(b.get("side", 1)), "txTime": now_utc(),
        }
    })
    return jsonify(data), code

@app.route("/api/close_position", methods=["POST"])
def close_position():
    err = require_login()
    if err: return err
    b = request.json or {}
    close_side = "2" if str(b.get("openSide", 1)) == "1" else "1"
    data, code = ltm_rest({
        "MT": "OrdCxlRepReq",
        "OrdCxlRepReq": {
            "acct": state["account"], "orgClOrdId": str(b["orgClOrdId"]),
            "sym": b["sym"], "secType": b.get("secType", "FOR"),
            "side": close_side, "qty": str(b["qty"]),
            "px": "0", "type": "1", "tif": "3",
            "sl": "0", "tp": "0",
            "execDst": "INTX", "minQty": "0", "stopPx": "0",
            "qtyRsrv": "0", "maxShow": "0", "execBrk": "",
            "execInst": "v", "px2": "0", "txTime": now_utc(),
            "handlInst": "1", "prnAgc": "false", "slpg": "0",
            "tktType": "1", "tktNo": str(b["tktNo"]), "refTktNo": str(b["tktNo"]),
        }
    })
    return jsonify(data), code

@app.route("/api/modify_sltp", methods=["POST"])
def modify_sltp():
    err = require_login()
    if err: return err
    b = request.json or {}
    close_side = "2" if str(b.get("openSide", 1)) == "1" else "1"
    data, code = ltm_rest({
        "MT": "OrdCxlRepReq",
        "OrdCxlRepReq": {
            "acct": state["account"], "orgClOrdId": str(b["orgClOrdId"]),
            "sym": b["sym"], "secType": b.get("secType", "FOR"),
            "side": close_side, "qty": str(b["qty"]),
            "px": str(b.get("sl", 0)), "type": "U", "tif": "1",
            "sl": "0", "tp": "0",
            "execDst": "INTX", "minQty": "0", "stopPx": "0",
            "qtyRsrv": "0", "maxShow": "0", "execBrk": "",
            "execInst": "u", "px2": str(b.get("tp", 0)), "txTime": now_utc(),
            "handlInst": "1", "prnAgc": "false", "slpg": "0",
            "tktType": "1", "tktNo": str(b["tktNo"]), "refTktNo": str(b["tktNo"]),
        }
    })
    return jsonify(data), code


# ============================================================
if __name__ == "__main__":
    print("=" * 52)
    print("  槓桿全球贏家 API 交易前台")
    print("  請開啟瀏覽器前往: http://localhost:5000")
    print("=" * 52)
    socketio.run(app, host="0.0.0.0", port=5000, debug=False)
✅ 模式二使用流程 1. 安裝套件:pip install flask flask-socketio requests websocket-client cryptography
2. 複製上方程式碼存成 server.py,與 dashboard.html 放在同一資料夾
3. 修改 PFX_PATHPFX_PASSWORD(程式最上方標有 ★ 的區塊)
4. 執行:python server.py
5. 瀏覽器開啟 http://localhost:5000,輸入 API Key、密碼、帳戶號碼即可登入