Merge PR #1675: implement read-only mStock transport with explicit valuation limits

This commit is contained in:
Haozhe Wu committed 2026-10-13 13:33:26 +08:00
commit 9497893488
23 files changed
+488 -28

No files matched your search

+4 -2
View File
@@ -472,6 +472,7 @@ use a broker sandbox or local simulation. See each profile's notes and the
| `longbridge-live-sdk-readonly` | longbridge | live | broker_sdk | read-only | `account.read`, `history.read`, `orders.read`, `positions.read`, `quotes.read` | broker_sdk | none declared | disabled (read-only) |
| `longbridge-paper-sdk` | longbridge | paper | broker_sdk | read-only | `account.read`, `history.read`, `orders.read`, `positions.read`, `quotes.read` | broker_sdk | none declared | disabled (read-only) |
| `longbridge-paper-trade` | longbridge | paper | broker_sdk | write-enabled | `account.read`, `history.read`, `orders.read`, `positions.read`, `quotes.read` | broker_sdk | `orders.place` | no mandate declared |
| `mstock-live-rest-readonly` | mstock | live | broker_sdk | read-only | `positions.read` | none declared | none declared | disabled (read-only) |
| `mt5-live-sdk-readonly` | mt5 | live | broker_sdk | read-only | `account.read`, `history.read`, `orders.read`, `positions.read`, `quotes.read` | broker_sdk | none declared | disabled (read-only) |
| `mt5-live-trade` | mt5 | live | broker_sdk | write-enabled | `account.read`, `history.read`, `orders.read`, `positions.read`, `quotes.read` | broker_sdk | `orders.place.requires_mandate` | mandate required |
| `mt5-paper-sdk` | mt5 | paper | broker_sdk | read-only | `account.read`, `history.read`, `orders.read`, `positions.read`, `quotes.read` | broker_sdk | none declared | disabled (read-only) |
@@ -716,15 +717,16 @@ run from a clone (`pip install -e .`).
</details>
<details>
<summary><b>Broker Connectors</b> <sub>18 brokers — read + paper, bounded-live where supported</sub></summary>
<summary><b>Broker Connectors</b> <sub>19 brokers — read + paper, bounded-live where supported</sub></summary>
Connector-first profiles. Most do read + paper-account order placement — IBKR is read-only, Robinhood is live-only (no paper account), Scalable Capital is read-only (no paper account exists at all), and Trading 212 and Toss Securities refuse order placement entirely, paper included; live order placement is bounded by a user-defined mandate (symbol allowlist, order-size / exposure caps, daily trade cap, instant kill switch) and never holds funds — the broker executes. Order-placing tools stay off MCP (agent + CLI only). Research / backtest paths are structurally barred from any live endpoint.
Connector-first profiles. Most do read + paper-account order placement — IBKR is read-only, Robinhood is live-only (no paper account), Scalable Capital is read-only (no paper account exists at all), mStock (India) is read-only live over its Type B REST API (no paper account), and Trading 212 and Toss Securities refuse order placement entirely, paper included; live order placement is bounded by a user-defined mandate (symbol allowlist, order-size / exposure caps, daily trade cap, instant kill switch) and never holds funds — the broker executes. Order-placing tools stay off MCP (agent + CLI only). Research / backtest paths are structurally barred from any live endpoint.
| Broker | Markets | Capabilities |
|--------|---------|--------------|
| **IBKR** | global | local TWS / Gateway, read-only |
| **Robinhood** | US | Agentic MCP (desktop OAuth) — equity read + bounded live; options order placement is not supported |
| **Scalable Capital** | DE / EU | Agentic MCP (desktop OAuth) — fully read-only; no paper account exists |
| **mStock** | India (NSE/BSE) | Separate holdings/net-position reads; no portfolio aggregation or orders |
| **Tiger** | US / HK / A | read + paper + bounded live |
| **Alpaca** | US | read + paper + bounded live (+ TAP credential-isolation mode) |
| **OKX** · **Binance** | crypto | read + paper + bounded live |
+3 -2
View File
@@ -618,15 +618,16 @@ LONGBRIDGE_ACCESS_TOKEN=...
</details>
<details>
<summary><b>موصّلات الوسطاء</b> <sub>18 وسيطاً — قراءة + حساب ورقي، وتداول حي محدود حيثما يُدعم</sub></summary>
<summary><b>موصّلات الوسطاء</b> <sub>19 وسيطاً — قراءة + حساب ورقي، وتداول حي محدود حيثما يُدعم</sub></summary>
ملفات تعريف قائمة على الموصّل. يوفّر معظم الموصّلات قراءةً وتنفيذ أوامر على حساب ورقي (paper) — أما IBKR فللقراءة فقط، وRobinhood حيّ فقط (بلا حساب ورقي)، وScalable Capital للقراءة فقط (لا يوجد حساب ورقي إطلاقاً)، وTrading 212 وToss Securities يرفضان تنفيذ الأوامر كلياً بما فيها الورقية؛ أما تنفيذ الأوامر الحية فمحدود بتفويض يحدّده المستخدم (قائمة رموز مسموح بها، وحدود لحجم الأمر / الانكشاف، وحد يومي للصفقات، ومفتاح إيقاف فوري) ولا يحتفظ الموصّل بأي أموال — الوسيط هو من ينفّذ. تبقى أدوات تنفيذ الأوامر خارج MCP (عبر agent + CLI فقط). ومسارات البحث / الاختبار الرجعي محظورة بنيوياً من أي نقطة نهاية حية.
ملفات تعريف قائمة على الموصّل. يوفّر معظم الموصّلات قراءةً وتنفيذ أوامر على حساب ورقي (paper) — أما IBKR فللقراءة فقط، وRobinhood حيّ فقط (بلا حساب ورقي)، وScalable Capital للقراءة فقط (لا يوجد حساب ورقي إطلاقاً)، وmStock (الهند) للقراءة فقط من الحساب الحي عبر REST Type B (بلا حساب ورقي)، وTrading 212 وToss Securities يرفضان تنفيذ الأوامر كلياً بما فيها الورقية؛ أما تنفيذ الأوامر الحية فمحدود بتفويض يحدّده المستخدم (قائمة رموز مسموح بها، وحدود لحجم الأمر / الانكشاف، وحد يومي للصفقات، ومفتاح إيقاف فوري) ولا يحتفظ الموصّل بأي أموال — الوسيط هو من ينفّذ. تبقى أدوات تنفيذ الأوامر خارج MCP (عبر agent + CLI فقط). ومسارات البحث / الاختبار الرجعي محظورة بنيوياً من أي نقطة نهاية حية.
| الوسيط | الأسواق | القدرات |
|--------|---------|---------|
| **IBKR** | عالمي | TWS / Gateway محلي، قراءة فقط |
| **Robinhood** | US | Agentic MCP (OAuth عبر سطح المكتب) — قراءة الأسهم + تداول أسهم حي محدود؛ تداول الخيارات غير مدعوم |
| **Scalable Capital** | DE / EU | Agentic MCP (OAuth عبر سطح المكتب) — قراءة فقط بالكامل؛ لا يوجد حساب ورقي |
| **mStock** | الهند (NSE/BSE) | قراءة الحيازات وصافي المراكز منفصلين؛ دون تجميع محفظة أو أوامر |
| **Tiger** | US / HK / A | قراءة + ورقي + تداول حي محدود |
| **Alpaca** | US | قراءة + ورقي + تداول حي محدود (+ وضع عزل الاعتماد TAP) |
| **OKX** · **Binance** | crypto | قراءة + ورقي + تداول حي محدود |
+3 -2
View File
@@ -622,15 +622,16 @@ clon (`pip install -e .`).
</details>
<details>
<summary><b>Conectores de Broker</b> <sub>18 brokers — lectura + paper, live acotado donde esté soportado</sub></summary>
<summary><b>Conectores de Broker</b> <sub>19 brokers — lectura + paper, live acotado donde esté soportado</sub></summary>
Perfiles centrados en el conector. La mayoría hace lectura + colocación de órdenes en cuenta paper — IBKR es de solo lectura, Robinhood es solo live (sin cuenta paper), Scalable Capital es de solo lectura (no existe cuenta paper), y Trading 212 y Toss Securities rechazan la colocación de órdenes por completo, incluido el paper; la colocación de órdenes live está acotada por un mandato definido por el usuario (lista blanca de símbolos, límites de tamaño de orden / exposición, límite diario de operaciones, interruptor de apagado instantáneo) y nunca retiene fondos — el broker ejecuta. Las herramientas de colocación de órdenes se mantienen fuera de MCP (solo agente + CLI). Las rutas de investigación / backtest están estructuralmente vetadas de cualquier endpoint live.
Perfiles centrados en el conector. La mayoría hace lectura + colocación de órdenes en cuenta paper — IBKR es de solo lectura, Robinhood es solo live (sin cuenta paper), Scalable Capital es de solo lectura (no existe cuenta paper), mStock (India) es live de solo lectura por su REST Type B (sin cuenta paper), y Trading 212 y Toss Securities rechazan la colocación de órdenes por completo, incluido el paper; la colocación de órdenes live está acotada por un mandato definido por el usuario (lista blanca de símbolos, límites de tamaño de orden / exposición, límite diario de operaciones, interruptor de apagado instantáneo) y nunca retiene fondos — el broker ejecuta. Las herramientas de colocación de órdenes se mantienen fuera de MCP (solo agente + CLI). Las rutas de investigación / backtest están estructuralmente vetadas de cualquier endpoint live.
| Broker | Mercados | Capacidades |
|--------|---------|--------------|
| **IBKR** | global | TWS / Gateway local, solo lectura |
| **Robinhood** | EE. UU. | MCP agéntico (OAuth de escritorio) — lectura de acciones + órdenes de acciones en vivo limitadas; las opciones no están admitidas |
| **Scalable Capital** | DE / UE | MCP agéntico (OAuth de escritorio) — totalmente de solo lectura; no existe cuenta paper |
| **mStock** | India (NSE/BSE) | Lectura separada de tenencias y posiciones netas; sin agregación de cartera ni órdenes |
| **Tiger** | EE. UU. / HK / A | lectura + paper + live acotado |
| **Alpaca** | EE. UU. | lectura + paper + live acotado (+ modo TAP de aislamiento de credenciales) |
| **OKX** · **Binance** | cripto | lectura + paper + live acotado |
+3 -2
View File
@@ -782,15 +782,16 @@ run from a clone (`pip install -e .`).
</details>
<details>
<summary><b>Broker Connectors</b> <sub>18 broker — read + paper, bounded-live bila didukung</sub></summary>
<summary><b>Broker Connectors</b> <sub>19 broker — read + paper, bounded-live bila didukung</sub></summary>
Profil berorientasi connector. Sebagian besar mendukung read + penempatan order akun paper — IBKR read-only, Robinhood live-only (tanpa akun paper), Scalable Capital read-only (tidak memiliki akun paper sama sekali), sedangkan Trading 212 dan Toss Securities menolak penempatan order sepenuhnya, termasuk paper; penempatan order live dibatasi oleh mandat yang ditentukan pengguna (allowlist simbol, batas ukuran order / eksposur, batas trade harian, dan penghentian darurat) serta tidak pernah menyimpan dana — broker yang mengeksekusi. Tool penempatan order tetap nonaktif di MCP (hanya agent + CLI). Jalur riset / backtest secara struktural tidak dapat mengakses endpoint live.
Profil berorientasi connector. Sebagian besar mendukung read + penempatan order akun paper — IBKR read-only, Robinhood live-only (tanpa akun paper), Scalable Capital read-only (tidak memiliki akun paper sama sekali), mStock (India) live read-only via REST Type B (tanpa akun paper), sedangkan Trading 212 dan Toss Securities menolak penempatan order sepenuhnya, termasuk paper; penempatan order live dibatasi oleh mandat yang ditentukan pengguna (allowlist simbol, batas ukuran order / eksposur, batas trade harian, dan penghentian darurat) serta tidak pernah menyimpan dana — broker yang mengeksekusi. Tool penempatan order tetap nonaktif di MCP (hanya agent + CLI). Jalur riset / backtest secara struktural tidak dapat mengakses endpoint live.
| Broker | Market | Kapabilitas |
|--------|---------|--------------|
| **IBKR** | global | local TWS / Gateway, read-only |
| **Robinhood** | US | Agentic MCP (desktop OAuth) — baca saham + order saham live terbatas; order opsi belum didukung |
| **Scalable Capital** | DE / EU | Agentic MCP (desktop OAuth) — fully read-only; no paper account exists |
| **mStock** | India (NSE/BSE) | Baca kepemilikan dan posisi bersih terpisah; tanpa agregasi portofolio atau order |
| **Tiger** | US / HK / A | read + paper + bounded live |
| **Alpaca** | US | read + paper + bounded live (+ TAP credential-isolation mode) |
| **OKX** · **Binance** | crypto | read + paper + bounded live |
+3 -2
View File
@@ -618,15 +618,16 @@ clone から実行してください(`pip install -e .`)。
</details>
<details>
<summary><b>ブローカー connectors</b> <sub>18 ブローカー — read + paper、対応先では bounded-live</sub></summary>
<summary><b>ブローカー connectors</b> <sub>19 ブローカー — read + paper、対応先では bounded-live</sub></summary>
connector-first のプロファイル。多くは read + ペーパー口座での発注に対応します —— IBKR は読み取り専用、Robinhood はライブのみ(ペーパー口座なし)、Scalable Capital は読み取り専用(ペーパー口座が存在しません)、Trading 212 と Toss Securities はペーパーを含め発注を一切拒否します。ライブ発注はユーザーが定義した mandate(銘柄許可リスト、発注サイズ / エクスポージャー上限、1 日の取引回数上限、即時 kill switch)で制限され、資金を預かることは一切ありません —— 執行するのはブローカーです。発注系ツールは MCP に公開されません(agent + CLI のみ)。リサーチ / バックテスト経路は構造的にあらゆるライブ endpoint から遮断されています。
connector-first のプロファイル。多くは read + ペーパー口座での発注に対応します —— IBKR は読み取り専用、Robinhood はライブのみ(ペーパー口座なし)、Scalable Capital は読み取り専用(ペーパー口座が存在しません)、mStock(インド)は Type B REST による読み取り専用ライブです(ペーパー口座なし)、Trading 212 と Toss Securities はペーパーを含め発注を一切拒否します。ライブ発注はユーザーが定義した mandate(銘柄許可リスト、発注サイズ / エクスポージャー上限、1 日の取引回数上限、即時 kill switch)で制限され、資金を預かることは一切ありません —— 執行するのはブローカーです。発注系ツールは MCP に公開されません(agent + CLI のみ)。リサーチ / バックテスト経路は構造的にあらゆるライブ endpoint から遮断されています。
| Broker | Markets | Capabilities |
|--------|---------|--------------|
| **IBKR** | global | local TWS / Gateway, read-only |
| **Robinhood** | US | Agentic MCP (desktop OAuth) — 株式の読み取り + 制限付き株式ライブ注文;オプション注文は未対応 |
| **Scalable Capital** | DE / EU | Agentic MCP (desktop OAuth) — 完全読み取り専用、ペーパー口座なし |
| **mStock** | インド(NSE/BSE) | 保有銘柄・ネットポジションを別々に読み取り。ポートフォリオ集計・発注不可 |
| **Tiger** | US / HK / A | read + paper + bounded live |
| **Alpaca** | US | read + paper + bounded live (+ TAP credential-isolation mode) |
| **OKX** · **Binance** | crypto | read + paper + bounded live |
+3 -2
View File
@@ -618,15 +618,16 @@ clone에서 실행하세요(`pip install -e .`).
</details>
<details>
<summary><b>브로커 커넥터</b> <sub>18개 브로커 — read + paper, 지원 시 bounded-live</sub></summary>
<summary><b>브로커 커넥터</b> <sub>19개 브로커 — read + paper, 지원 시 bounded-live</sub></summary>
connector-first 프로필. 대부분의 브로커가 read + 페이퍼 계정 주문 실행을 지원하지만 IBKR은 읽기 전용이고, Robinhood는 페이퍼 계정 없이 실거래 전용이며, Scalable Capital은 읽기 전용이고(페이퍼 계정이 아예 없습니다), Trading 212와 Toss Securities는 페이퍼를 포함해 주문 실행을 모두 거부하고, 실거래 주문 실행은 사용자 정의 mandate(심볼 허용목록, 주문 크기 / 익스포저 상한, 일일 거래 한도, 즉시 kill switch)로 제한되고 자금을 보관하지 않습니다 — 실행은 브로커가 합니다. 주문 실행 도구는 MCP에 노출되지 않습니다(agent + CLI 전용). 리서치 / 백테스트 경로는 구조적으로 모든 실거래 엔드포인트에서 차단됩니다.
connector-first 프로필. 대부분의 브로커가 read + 페이퍼 계정 주문 실행을 지원하지만 IBKR은 읽기 전용이고, Robinhood는 페이퍼 계정 없이 실거래 전용이며, Scalable Capital은 읽기 전용이고(페이퍼 계정이 아예 없습니다), mStock(인도)는 Type B REST 읽기 전용 실거래입니다(페이퍼 계정 없음), Trading 212와 Toss Securities는 페이퍼를 포함해 주문 실행을 모두 거부하고, 실거래 주문 실행은 사용자 정의 mandate(심볼 허용목록, 주문 크기 / 익스포저 상한, 일일 거래 한도, 즉시 kill switch)로 제한되고 자금을 보관하지 않습니다 — 실행은 브로커가 합니다. 주문 실행 도구는 MCP에 노출되지 않습니다(agent + CLI 전용). 리서치 / 백테스트 경로는 구조적으로 모든 실거래 엔드포인트에서 차단됩니다.
| Broker | Markets | Capabilities |
|--------|---------|--------------|
| **IBKR** | global | local TWS / Gateway, read-only |
| **Robinhood** | US | Agentic MCP (desktop OAuth) — 주식 조회 + 제한된 실거래 주문; 옵션 주문은 지원하지 않음 |
| **Scalable Capital** | DE / EU | Agentic MCP (desktop OAuth) — 완전 읽기 전용, 페이퍼 계정 없음 |
| **mStock** | 인도 (NSE/BSE) | 보유 자산·순포지션 별도 조회; 포트폴리오 집계 및 주문 불가 |
| **Tiger** | US / HK / A | read + paper + bounded live |
| **Alpaca** | US | read + paper + bounded live (+ TAP credential-isolation mode) |
| **OKX** · **Binance** | crypto | read + paper + bounded live |
+3 -2
View File
@@ -631,15 +631,16 @@ LONGBRIDGE_ACCESS_TOKEN=...
</details>
<details>
<summary><b>Broker Connectors</b> <sub>18 家券商——读取 + 模拟盘,支持的券商可受约束实盘</sub></summary>
<summary><b>Broker Connectors</b> <sub>19 家券商——读取 + 模拟盘,支持的券商可受约束实盘</sub></summary>
连接器优先(connector-first)的配置档。多数连接器支持读取 + 模拟盘下单 —— IBKR 只读,Robinhood 只有实盘(没有模拟盘),Scalable Capital 只读(完全没有模拟盘账户),Trading 212 与 Toss Securities 连模拟盘下单也一律拒绝;实盘下单受用户定义的 mandate 约束(标的白名单、下单规模 / 敞口上限、每日交易次数上限、即时 kill switch),且从不托管资金——由券商执行。下单类工具不经 MCP 暴露(仅 agent + CLI)。研究 / 回测路径在结构上被隔离,无法触达任何实盘端点。
连接器优先(connector-first)的配置档。多数连接器支持读取 + 模拟盘下单 —— IBKR 只读,Robinhood 只有实盘(没有模拟盘),Scalable Capital 只读(完全没有模拟盘账户),mStock(印度)经 Type B REST 只读实盘(没有模拟盘账户),Trading 212 与 Toss Securities 连模拟盘下单也一律拒绝;实盘下单受用户定义的 mandate 约束(标的白名单、下单规模 / 敞口上限、每日交易次数上限、即时 kill switch),且从不托管资金——由券商执行。下单类工具不经 MCP 暴露(仅 agent + CLI)。研究 / 回测路径在结构上被隔离,无法触达任何实盘端点。
| Broker | 市场 | 能力 |
|--------|------|------|
| **IBKR** | global | 本地 TWS / Gateway,只读 |
| **Robinhood** | US | Agentic MCP(桌面 OAuth)——读取股票 + 受约束股票实盘下单;不支持期权下单 |
| **Scalable Capital** | DE / EU | Agentic MCP(桌面 OAuth)——完全只读;没有模拟盘账户 |
| **mStock** | 印度(NSE/BSE) | 分别读取持仓和净头寸;不支持组合汇总或下单 |
| **Tiger** | US / HK / A | 读取 + 模拟盘 + 受约束实盘 |
| **Alpaca** | US | 读取 + 模拟盘 + 受约束实盘(+ TAP 密钥隔离模式) |
| **OKX** · **Binance** | crypto | 读取 + 模拟盘 + 受约束实盘 |
+23 -5
View File
@@ -4505,7 +4505,7 @@ def cmd_connector_setup(
from src.trading.connections import (
ConnectionStore,
credential_field_catalog,
is_portfolio_connection_profile,
is_readonly_connection_profile,
requires_account_selection,
)
from src.trading.profiles import profile_by_id
@@ -4513,8 +4513,8 @@ def cmd_connector_setup(
try:
profile = profile_by_id(profile_id)
if not is_portfolio_connection_profile(profile):
raise ValueError(f"{profile.id} is not a read-only portfolio profile")
if not is_readonly_connection_profile(profile):
raise ValueError(f"{profile.id} is not a read-only connection profile")
local_id = str(connection_id or f"{profile.connector}-{profile.environment}").strip().lower()
store = ConnectionStore()
connection = store.ensure(local_id, profile.id, label or profile.label)
@@ -4827,6 +4827,9 @@ def _enum_text(value: Any) -> str:
def _print_connector_account(result: dict[str, Any]) -> int:
if result.get("valuation_basis") == "margin_limits_not_net_liquidation":
console.print_json(data=result)
return EXIT_SUCCESS
accounts = ", ".join(result.get("accounts", [])) or "(none)"
rows = result.get("summary", [])
# broker_sdk connectors (Longbridge, …) return a ``balances`` list instead of
@@ -4898,12 +4901,16 @@ def cmd_connector_account(
port: int | None = None,
client_id: int | None = None,
account: str | None = None,
connection_id: str | None = None,
) -> int:
"""Print account summary from a connector profile."""
from src.trading.service import get_account
try:
result = get_account(_profile_id(profile_id), host=host, port=port, client_id=client_id, account=account)
options = {"host": host, "port": port, "client_id": client_id, "account": account}
if connection_id is not None:
options["connection_id"] = connection_id
result = get_account(_profile_id(profile_id), **options)
except Exception as exc: # noqa: BLE001
console.print(f"[red]Connector account failed:[/red] {exc}")
return EXIT_RUN_FAILED
@@ -4920,18 +4927,25 @@ def cmd_connector_positions(
port: int | None = None,
client_id: int | None = None,
account: str | None = None,
connection_id: str | None = None,
) -> int:
"""Print positions from a connector profile."""
from src.trading.service import get_positions
try:
result = get_positions(_profile_id(profile_id), host=host, port=port, client_id=client_id, account=account)
options = {"host": host, "port": port, "client_id": client_id, "account": account}
if connection_id is not None:
options["connection_id"] = connection_id
result = get_positions(_profile_id(profile_id), **options)
except Exception as exc: # noqa: BLE001
console.print(f"[red]Connector positions failed:[/red] {exc}")
return EXIT_RUN_FAILED
if result.get("status") == "error":
console.print(f"[red]{rich_escape(str(result.get('error')))}[/red]")
return EXIT_RUN_FAILED
if result.get("valuation_basis") == "broker_books_unaggregated":
console.print_json(data=result)
return EXIT_SUCCESS
rows = result.get("positions", [])
if not rows:
# Not the broker_sdk flat shape — try the remote-MCP nested shape
@@ -5313,6 +5327,7 @@ def _dispatch_connector(args: argparse.Namespace) -> int:
port=args.port,
client_id=args.client_id,
account=args.account,
**({"connection_id": args.connection_id} if getattr(args, "connection_id", None) else {}),
)
if sub == "positions":
return cmd_connector_positions(
@@ -5321,6 +5336,7 @@ def _dispatch_connector(args: argparse.Namespace) -> int:
port=args.port,
client_id=args.client_id,
account=args.account,
**({"connection_id": args.connection_id} if getattr(args, "connection_id", None) else {}),
)
if sub == "orders":
return cmd_connector_orders(
@@ -5647,10 +5663,12 @@ def _build_parser() -> argparse.ArgumentParser:
connector_account = connector_subparsers.add_parser("account", help="Read account summary")
_add_connector_profile_arg(connector_account)
_add_connector_local(connector_account)
connector_account.add_argument("--connection-id", default=None)
connector_positions = connector_subparsers.add_parser("positions", help="Read current positions")
_add_connector_profile_arg(connector_positions)
_add_connector_local(connector_positions)
connector_positions.add_argument("--connection-id", default=None)
connector_orders = connector_subparsers.add_parser("orders", help="Read open orders")
_add_connector_profile_arg(connector_orders)
+2
View File
@@ -37,6 +37,7 @@ from src.trading.connectors.scalable.classification import SCALABLE_TOOL_CLASS
from src.trading.connectors.shoonya.classification import SHOONYA_TOOL_CLASS
from src.trading.connectors.tiger.classification import TIGER_TOOL_CLASS
from src.trading.connectors.etoro.classification import ETORO_TOOL_CLASS
from src.trading.connectors.mstock.classification import MSTOCK_TOOL_CLASS
from src.trading.connectors.toss.classification import TOSS_TOOL_CLASS
from src.trading.connectors.trading212.classification import TRADING212_TOOL_CLASS
from src.trading.connectors.upbit.classification import UPBIT_TOOL_CLASS
@@ -66,6 +67,7 @@ _BROKER_CURATED_MAPS = {
"kis": KIS_TOOL_CLASS,
"upbit": UPBIT_TOOL_CLASS,
"toss": TOSS_TOOL_CLASS,
"mstock": MSTOCK_TOOL_CLASS,
"scalable": SCALABLE_TOOL_CLASS,
}
+3 -1
View File
@@ -77,7 +77,7 @@ def eligible_profiles() -> list[TradingProfile]:
Eligibility is decided by
:func:`src.trading.connections.is_portfolio_connection_profile` so the
settings gate and the connection registry can never drift apart: a profile
settings gate and source catalog share the same rule: a profile
qualifies only when it is read-only *and* declares both ``account.read``
and ``positions.read``. A profile that merely advertises remote tool
discovery (``mcp.read.discovery``) cannot serve either read, so selecting
@@ -113,6 +113,8 @@ def source_catalog(
for connection in store.public_list():
source = selected.get(connection["id"])
profile = profile_by_id(connection["profile_id"])
if not is_portfolio_connection_profile(profile):
continue
rows.append(
{
**connection,
+17 -4
View File
@@ -24,6 +24,17 @@ REQUIRED_READ_CAPABILITIES = frozenset({"account.read", "positions.read"})
_ACCOUNT_REF_MAX_LENGTH = 128
def is_readonly_connection_profile(profile: TradingProfile) -> bool:
"""Allow credential management for a verified read surface, even without valuation.
Portfolio eligibility is stricter and remains controlled separately by
is_portfolio_connection_profile. Discovery alone is not a usable read.
"""
from src.trading.types import READ_CAPABILITIES
return bool(profile.readonly) and bool(set(profile.capabilities).intersection(READ_CAPABILITIES))
def is_portfolio_connection_profile(profile: TradingProfile) -> bool:
"""Report whether a profile can back a read-only portfolio connection.
@@ -326,6 +337,7 @@ class ConnectionStore:
"environment": profile.environment,
"transport": profile.transport,
"readonly": profile.readonly,
"portfolio_eligible": is_portfolio_connection_profile(profile),
"capabilities": list(profile.capabilities),
"supports_reconnect": profile.transport == "remote_mcp",
"account_selection_required": requires_account_selection(profile),
@@ -360,8 +372,8 @@ class ConnectionStore:
raise ValueError(f"invalid local connection id: {connection_id or '?'}")
profile_id = str(raw.get("profile_id") or "").strip().lower()
profile = profile_by_id(profile_id)
if not is_portfolio_connection_profile(profile):
raise ValueError(f"connection profile is not eligible for read-only portfolios: {profile_id}")
if not is_readonly_connection_profile(profile):
raise ValueError(f"connection profile is not eligible for read-only connections: {profile_id}")
label = str(raw.get("label") or profile.label).strip()
if not label or len(label) > 80 or any(ord(character) < 32 for character in label):
raise ValueError("connection label must contain 1 to 80 printable characters")
@@ -453,7 +465,7 @@ def credential_fields(profile_id: str) -> tuple[str, ...]:
def readonly_profile_catalog() -> list[dict[str, Any]]:
"""Return the profiles that can back a read-only portfolio connection.
"""Return profiles with a usable read surface and their portfolio eligibility.
Returns:
One row per eligible profile, sorted by connector, environment and
@@ -464,7 +476,7 @@ def readonly_profile_catalog() -> list[dict[str, Any]]:
plugin_ids = {plugin.profile.id for plugin in plugins}
rows = []
for profile in list_profiles():
if not is_portfolio_connection_profile(profile):
if not is_readonly_connection_profile(profile):
continue
rows.append(
{
@@ -475,6 +487,7 @@ def readonly_profile_catalog() -> list[dict[str, Any]]:
"transport": profile.transport,
"capabilities": list(profile.capabilities),
"readonly": profile.readonly,
"portfolio_eligible": is_portfolio_connection_profile(profile),
"notes": profile.notes,
"local_plugin": profile.id in plugin_ids,
"credential_fields": credential_field_catalog(profile.id),
@@ -0,0 +1,33 @@
# mStock read-only boundary
This connector sends only GET requests to the documented Type B resources:
`portfolio/holdings`, `portfolio/positions`, and `user/fundsummary`. Credentials
come from the connection's OS credential vault. It never logs in, refreshes a
session, follows a redirect, or calls a logout/order endpoint. Both order entry
points refuse before any network call. The curated classification map also pins
those entry points as writes.
The official login example reports `state: "live"`, but this connector accepts an
existing session token and has not verified any structural paper/live boundary.
It exposes no paper simulation or live trading capability. A new order transport
would require its own verified discriminator and mandate gate.
Holdings and net positions remain separate books. A holding's `tradingsymbol`
can be a company name and its exchange can be null; these are not invented ticker
symbols. Available funds, collateral and utilized margin do not establish cash
or net liquidation. The profile therefore withholds `account.read` and is not
eligible for portfolio aggregation. Raw fund limits remain inspectable through
`trading_account` / `vibe-trading connector account mstock-live-rest-readonly --connection-id <id>`.
Set up `mstock-live-rest-readonly` in the connection center, or use
`vibe-trading connector setup --help` for the shared CLI flow. Supply the API key
and current Type B session access token; the broker documents midnight token
expiry. The connection test reads both position books without placing orders.
References (checked 2026-10-13):
- [Official authentication and fund-summary contract](https://tradingapi.mstock.com/docs/v1/typeB/User/)
- [Official holdings contract](https://tradingapi.mstock.com/docs/v1/typeB/Portfolio/)
- [Official net-position contract](https://tradingapi.mstock.com/docs/v1/typeB/Position/)
Tests replay those documented shapes through the real HTTP client. An actual
mStock account was unavailable; account-value normalization remains unverified.
@@ -0,0 +1 @@
"""mStock connector package."""
@@ -0,0 +1,10 @@
"""Curated mStock operations; unsupported or unknown calls never become reads."""
from src.live.classification import ToolClass
MSTOCK_TOOL_CLASS = {
"get_positions": ToolClass.READ,
"get_account_snapshot": ToolClass.READ,
"place_order": ToolClass.WRITE,
"cancel_order": ToolClass.WRITE,
}
@@ -0,0 +1,27 @@
"""Read-only mStock Type B books; portfolio valuation is not yet mapped."""
from __future__ import annotations
from src.trading.types import TradingProfile
# Holdings/net positions can be read, but no canonical cash/net-equity account
# summary is known. Withhold account.read so portfolio eligibility stays false.
MSTOCK_READ_CAPABILITIES = ("positions.read",)
MSTOCK_PROFILES: tuple[TradingProfile, ...] = (
TradingProfile(
id="mstock-live-rest-readonly",
connector="mstock",
label="mStock Live · REST Read-Only (India)",
environment="live",
transport="broker_sdk",
capabilities=MSTOCK_READ_CAPABILITIES,
readonly=True,
config={"auth_flavor": "typeb"},
notes=(
"Read-only Type B holdings and net-position books using the API key "
"and current session access token. Fund limits are not net assets; "
"portfolio aggregation is unavailable. All order operations are disabled."
),
),
)
+155
View File
@@ -0,0 +1,155 @@
"""Read-only mStock Type B REST transport with explicit unmapped account values.
Primary contracts: https://tradingapi.mstock.com/docs/v1/typeB/User/ and
https://tradingapi.mstock.com/docs/v1/typeB/Portfolio/ . Holdings and net positions
are separate broker books; their overlap is not inferred. Fund limits are not
cash or net liquidation. This connector cannot enter portfolio aggregation.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Mapping
import httpx
BASE_URL = "https://api.mstock.trade/openapi/typeb"
_OVERRIDE_KEYS = ("api_key", "access_token")
class MStockAPIError(RuntimeError):
"""A bounded, credential-free failure message from a read operation."""
@dataclass(frozen=True)
class MStockConfig:
"""Session credentials supplied by the connection's OS credential vault."""
api_key: str = field(default="", repr=False)
access_token: str = field(default="", repr=False)
environment: str = "live"
@classmethod
def from_mapping(cls, data: Mapping[str, Any] | None = None) -> MStockConfig:
"""Build the only supported environment; never simulate an account."""
values = dict(data or {})
if values.get("environment", "live") != "live":
raise ValueError("mStock supports only live read-only connections")
for key in _OVERRIDE_KEYS:
if not isinstance(values.get(key, ""), str):
raise ValueError(f"{key} must be a string")
return cls(api_key=values.get("api_key", "").strip(), access_token=values.get("access_token", "").strip())
def build_config(
profile_config: Mapping[str, Any] | None = None, overrides: Mapping[str, Any] | None = None
) -> MStockConfig:
"""Resolve profile settings and approved overrides, without a secret file."""
values = dict(profile_config or {})
values.update({key: value for key, value in dict(overrides or {}).items() if key in _OVERRIDE_KEYS})
return MStockConfig.from_mapping(values)
def _read_records(config: MStockConfig, resource: str) -> list[dict[str, Any]]:
if not config.api_key or not config.access_token:
raise MStockAPIError("Configure the mStock API key and current session access token in connection settings.")
# A fixed GET-only resource vocabulary excludes logout and all order APIs.
if resource not in {"portfolio/holdings", "portfolio/positions", "user/fundsummary"}:
raise MStockAPIError("Unsupported mStock read resource")
try:
with httpx.Client(timeout=15.0, follow_redirects=False) as client:
response = client.get(
f"{BASE_URL}/{resource}",
headers={
"X-Mirae-Version": "1",
"X-PrivateKey": config.api_key,
"Authorization": f"Bearer {config.access_token}",
"Accept": "application/json",
},
)
if response.status_code in {401, 403}:
raise MStockAPIError(
"mStock session rejected. Obtain a fresh session token and update the saved credentials."
)
if response.status_code != 200:
raise MStockAPIError(
f"mStock read failed (HTTP {response.status_code}); retry after checking broker availability."
)
payload = response.json()
except (httpx.HTTPError, ValueError) as exc:
# Exception strings may contain headers or a broker response with secrets.
raise MStockAPIError(
"mStock read failed: invalid response or network error. Check the session and retry."
) from exc
if not isinstance(payload, dict) or not (payload.get("status") is True or payload.get("status") == "true"):
raise MStockAPIError("mStock rejected the read. Check or renew the session token before retrying.")
if payload.get("errorcode") not in (None, ""):
raise MStockAPIError("mStock returned an error code for the read; check the session and retry.")
records = payload.get("data")
if not isinstance(records, list) or any(not isinstance(row, dict) for row in records):
raise MStockAPIError("mStock returned an unmapped data shape; no empty account is inferred.")
return records
def get_positions(config: MStockConfig | None = None) -> dict[str, Any]:
"""Read both books separately, without inventing ticker identity or netting.
Returns:
The broker's holdings and net-position rows. These are deliberately not
a canonical ``positions`` array: tradingsymbol can be a company name,
and overlapping delivery positions must not be counted twice.
"""
cfg = config or build_config()
holdings = _read_records(cfg, "portfolio/holdings")
net_positions = _read_records(cfg, "portfolio/positions")
return {
"status": "ok",
"holdings": holdings,
"net_positions": net_positions,
"portfolio_eligible": False,
"valuation_basis": "broker_books_unaggregated",
"note": "Holdings and net positions are separate books. Names are not resolved tickers; do not sum overlapping books.",
}
def get_account_snapshot(config: MStockConfig | None = None) -> dict[str, Any]:
"""Return raw fund limits with no invented cash/equity account summary."""
return {
"status": "ok",
"fund_limits": _read_records(config or build_config(), "user/fundsummary"),
"portfolio_eligible": False,
"valuation_basis": "margin_limits_not_net_liquidation",
"note": "Available balance, collateral and utilized margin are not mapped to cash or net assets.",
}
def check_status(config: MStockConfig | None = None) -> dict[str, Any]:
"""Verify the read-only connection without exposing credentials or holdings."""
try:
get_positions(config)
except MStockAPIError as exc:
return {"status": "error", "error": str(exc), "portfolio_eligible": False}
return {"status": "ok", "portfolio_eligible": False, "read_verified": True}
def _unsupported(*args, **kwargs) -> dict[str, Any]:
return {"status": "unsupported", "error": "This mStock read-only connector does not implement this operation."}
get_open_orders = _unsupported
get_quote = _unsupported
get_historical_bars = _unsupported
def place_order(*args, config: MStockConfig | None = None, **kwargs):
"""Refuse every order before any network operation."""
if config is None or config.environment != "paper":
raise MStockAPIError("mStock order placement is disabled for every environment")
raise MStockAPIError("mStock has no paper order implementation")
def cancel_order(*args, config: MStockConfig | None = None, **kwargs):
"""Refuse cancellation before any network operation."""
if config is None or config.environment != "paper":
raise MStockAPIError("mStock order cancellation is disabled for every environment")
raise MStockAPIError("mStock has no paper cancellation implementation")
+13 -2
View File
@@ -50,6 +50,18 @@ def _field(
_BUILTIN: dict[str, ConnectorOnboarding] = {
"mstock": ConnectorOnboarding(
auth_type="access_token",
credential_fields=(_field("api_key", "API Key"), _field("access_token", "Session Access Token")),
dependency="httpx",
install_command="pip install keyring",
test_operation="positions.read",
setup_hint=(
"Generate a Type B session token through mStock, then save it with the API key. "
"Tokens expire at midnight. Reads keep holdings and net positions separate; "
"portfolio aggregation and all orders are unavailable."
),
),
"alpaca": ConnectorOnboarding(
auth_type="api_key",
credential_fields=(
@@ -196,8 +208,7 @@ _BUILTIN: dict[str, ConnectorOnboarding] = {
dependency="requests",
install_command="pip install keyring",
setup_hint=(
"Apply via the Toss Securities app (더보기 > Open API), then fetch "
"account_seq from GET /api/v1/accounts."
"Apply via the Toss Securities app (더보기 > Open API), then fetch account_seq from GET /api/v1/accounts."
),
),
}
+2
View File
@@ -13,6 +13,7 @@ from src.trading.connectors.futu.profiles import FUTU_PROFILES
from src.trading.connectors.ibkr.profiles import IBKR_PROFILES
from src.trading.connectors.kis.profiles import KIS_PROFILES
from src.trading.connectors.longbridge.profiles import LONGBRIDGE_PROFILES
from src.trading.connectors.mstock.profiles import MSTOCK_PROFILES
from src.trading.connectors.mt5.profiles import MT5_PROFILES
from src.trading.connectors.okx.profiles import OKX_PROFILES
from src.trading.connectors.robinhood.profiles import ROBINHOOD_PROFILES
@@ -48,6 +49,7 @@ BUILTIN_PROFILES: tuple[TradingProfile, ...] = (
*UPBIT_PROFILES,
*TOSS_PROFILES,
*SCALABLE_PROFILES,
*MSTOCK_PROFILES,
)
+1
View File
@@ -16,6 +16,7 @@ RUNNER_CAPABILITY = "runner.manage.requires_mandate"
#: ``get_account_snapshot``, ``get_positions``, ``get_open_orders``, ``get_quote``,
#: ``get_historical_bars``).
_SDK_CONNECTOR_MODULES = {
"mstock": "src.trading.connectors.mstock.sdk",
"tiger": "src.trading.connectors.tiger.sdk",
"longbridge": "src.trading.connectors.longbridge.sdk",
"alpaca": "src.trading.connectors.alpaca.sdk",
+175
View File
@@ -0,0 +1,175 @@
"""Documented Type B transport replay, without assuming cash or ticker semantics."""
from __future__ import annotations
import httpx
import pytest
from src.live.classification import ToolClass
from src.trading.connectors.mstock.classification import MSTOCK_TOOL_CLASS
from src.trading import profiles, service
from src.trading.connections import is_portfolio_connection_profile
from src.trading.connectors.mstock import sdk
from src.trading.onboarding import onboarding_for_profile
pytestmark = pytest.mark.unit
PROFILE = "mstock-live-rest-readonly"
def test_readonly_profile_withholds_unmapped_portfolio_valuation():
profile = profiles.profile_by_id(PROFILE)
assert profile.readonly and profile.environment == "live"
assert profile.capabilities == ("positions.read",)
assert not is_portfolio_connection_profile(profile)
onboarding = onboarding_for_profile(profile)
assert [field.name for field in onboarding.credential_fields] == ["api_key", "access_token"]
assert onboarding.test_operation == "positions.read"
assert MSTOCK_TOOL_CLASS["get_positions"] is ToolClass.READ
assert MSTOCK_TOOL_CLASS["place_order"] is ToolClass.WRITE
@pytest.mark.parametrize("operation", ["place_order", "cancel_order"])
def test_orders_refused_before_network(monkeypatch, operation):
monkeypatch.setattr(sdk.httpx, "Client", lambda **kwargs: pytest.fail("order made a network call"))
result = getattr(service, operation)("RELIANCE", PROFILE, side="buy", quantity=1)
assert result["status"] == "error"
for config in (None, sdk.MStockConfig("key", "token"), sdk.MStockConfig("key", "token", environment="paper")):
with pytest.raises(sdk.MStockAPIError):
getattr(sdk, operation)(config=config)
def _transport(monkeypatch, handler):
original = httpx.Client
monkeypatch.setattr(
sdk.httpx, "Client", lambda **kwargs: original(transport=httpx.MockTransport(handler), **kwargs)
)
def test_actual_requests_keep_broker_books_and_fund_limits_separate(monkeypatch):
requests = []
holdings = [
{
"tradingsymbol": "BANK OF MAHARASHTRA",
"exchange": None,
"isin": "INE457A01014",
"quantity": 10,
"averageprice": 30,
"ltp": 84.7,
}
]
net = [
{
"symboltoken": "50374",
"exchange": "NSE",
"tradingsymbol": "",
"symbolname": "NIFTY-07Nov2024-24000-CE",
"instrumenttype": "OPTIDX",
"netqty": "-500",
}
]
funds = [{"AVAILABLE_BALANCE": "1234.56", "COLLATERALS": "500", "SEG": "A"}]
def handle(request):
requests.append(request)
assert request.method == "GET"
assert request.url.host == "api.mstock.trade"
assert request.headers["X-Mirae-Version"] == "1"
assert request.headers["X-PrivateKey"] == "key"
assert request.headers["Authorization"] == "Bearer token"
data = {"holdings": holdings, "positions": net, "fundsummary": funds}[request.url.path.rsplit("/", 1)[1]]
return httpx.Response(200, json={"status": "true", "errorcode": None, "data": data})
_transport(monkeypatch, handle)
result = service.get_positions(PROFILE, api_key="key", access_token="token")
assert result["holdings"] == holdings and result["net_positions"] == net
assert "positions" not in result
account = service.get_account(PROFILE, api_key="key", access_token="token")
assert account["fund_limits"] == funds
assert "account_summary" not in account and "cash" not in account
assert not account["portfolio_eligible"]
assert len(requests) == 3
@pytest.mark.parametrize(
"body",
[
None,
{},
{"status": "false", "data": []},
{"status": "true", "data": None},
{"status": "true", "data": "None"},
{"status": "true", "data": [None]},
{"status": "true", "errorcode": "MA500", "data": []},
],
)
def test_bad_replies_are_not_empty_accounts(monkeypatch, body):
_transport(monkeypatch, lambda request: httpx.Response(200, json=body))
with pytest.raises(sdk.MStockAPIError):
sdk.get_positions(sdk.MStockConfig("key", "token"))
@pytest.mark.parametrize("status", [301, 401, 403, 429, 500])
def test_transport_errors_never_echo_secret_response(monkeypatch, status):
_transport(
monkeypatch,
lambda request: httpx.Response(status, headers={"Location": "https://unexpected.test"}, text="key token"),
)
result = sdk.check_status(sdk.MStockConfig("key", "token"))
assert result["status"] == "error"
assert "key token" not in result["error"]
def test_missing_credentials_make_no_network_call(monkeypatch):
monkeypatch.setattr(sdk.httpx, "Client", lambda **kwargs: pytest.fail("missing credentials reached network"))
assert sdk.check_status()["status"] == "error"
assert "key" not in repr(sdk.MStockConfig("key", "token"))
with pytest.raises(ValueError, match="only live"):
sdk.MStockConfig.from_mapping({"environment": "paper"})
def test_cli_reads_use_named_vault_connection_and_render_raw_books(monkeypatch, capsys):
from types import SimpleNamespace
from cli import _legacy
from src.trading import connections
class Vault:
def load(self, connection_id, fields):
assert connection_id == "mstock-main"
assert set(fields) == {"api_key", "access_token"}
return {"api_key": "vault-key", "access_token": "vault-token"}
monkeypatch.setattr(
connections,
"ConnectionStore",
lambda: SimpleNamespace(
get=lambda _: SimpleNamespace(profile_id=PROFILE, id="mstock-main"), credentials=Vault()
),
)
def handle(request):
assert request.headers["Authorization"] == "Bearer vault-token"
data = [{"AVAILABLE_BALANCE": "123.45"}] if request.url.path.endswith("fundsummary") else []
return httpx.Response(200, json={"status": "true", "data": data})
_transport(monkeypatch, handle)
assert _legacy.cmd_connector_positions(PROFILE, connection_id="mstock-main") == 0
output = capsys.readouterr().out
assert "net_positions" in output and "broker_books_unaggregated" in output
assert _legacy.cmd_connector_account(PROFILE, connection_id="mstock-main") == 0
output = capsys.readouterr().out
assert "fund_limits" in output and "123.45" in output
assert "vault-token" not in output and "vault-key" not in output
def test_real_connection_store_accepts_reads_but_portfolio_rejects_them(tmp_path):
from src.trading.connections import ConnectionStore, readonly_profile_catalog
from src.portfolio.config import PortfolioSettings, parse_settings, source_catalog
store = ConnectionStore(tmp_path / "connections.json")
connection = store.create("mstock-main", PROFILE, "mStock account")
assert connection.profile_id == PROFILE
row = next(row for row in readonly_profile_catalog() if row.get("id") == PROFILE)
assert row["portfolio_eligible"] is False
assert source_catalog(PortfolioSettings(), connection_store=store) == []
with pytest.raises(ValueError, match="not eligible"):
parse_settings({"sources": [{"connection_id": "mstock-main"}]}, connection_store=store)
@@ -28,7 +28,7 @@ _CONNECTOR_ROOT = Path(__file__).resolve().parents[1] / "src" / "trading" / "con
# Brokers with no runtime paper/live discriminator. Capped at paper; every
# order entry point must refuse a non-paper config up front.
_PAPER_CAPPED = frozenset({"longbridge", "dhan", "shoonya", "trading212", "zerodha", "upbit", "toss"})
_PAPER_CAPPED = frozenset({"longbridge", "dhan", "shoonya", "trading212", "zerodha", "upbit", "toss", "mstock"})
# Brokers with a structural discriminator (host separation, demo flag, account
# id format, trade environment). Live placement is allowed here, but only
+1 -1
View File
@@ -72,7 +72,7 @@ BADGE_ORDER = ("skills", "brokers", "presets", "alphas", "engines", "quantlib")
# each other, which they did while all five were uniformly wrong — eToro
# shipped as the 13th connector and the pin stayed at 12. Same again for
# KIS, Upbit, Toss and Scalable Capital, which landed together as 15-18.
EXPECTED_BROKERS = 18
EXPECTED_BROKERS = 19
def _read(name: str) -> str:
+2
View File
@@ -293,6 +293,7 @@ export interface ConnectorOnboarding {
}
export interface LocalConnection {
portfolio_eligible?: boolean;
id: string;
profile_id: string;
label: string;
@@ -314,6 +315,7 @@ export interface LocalConnection {
}
export interface ReadonlyConnectionProfile {
portfolio_eligible?: boolean;
id: string;
connector: string;
label: string;