feat(portfolio): add connector onboarding contracts

Signed-off-by: Yang <55687235+goatyyc@users.noreply.github.com>
This commit is contained in:
Yang committed 2026-08-29 11:09:10 +08:00
1 parent e69626ad7e
commit 6d193ddf54
33 files changed
+1651 -431

No files matched your search

+27 -1
View File
@@ -343,7 +343,7 @@ vibe-trading run -p "Analyze my trading behavior, extract my shadow strategy, an
## 💼 Local Multi-Broker Portfolio
The Web UI adds a read-only **Portfolio** page that aggregates holdings across the broker connections you pick. Sources are connection instances of read-only profiles declaring `account.read` and `positions.read` — set them up under **Broker Connectors** in [Detailed Capabilities](#-detailed-capabilities). The IBKR official-MCP profile is not eligible as a source yet.
The Web UI adds a read-only **Portfolio** page that aggregates holdings across the broker connections you pick. Sources are connection instances of read-only profiles declaring `account.read` and `positions.read` — set them up under **Broker Connectors** in [Detailed Capabilities](#-detailed-capabilities). Eligibility is only the safety gate; the connection center also shows how far each connector has been verified for portfolio valuation.
| Behavior | What you get |
|----------|--------------|
@@ -354,6 +354,32 @@ The Web UI adds a read-only **Portfolio** page that aggregates holdings across t
Broker-reported source currencies are preserved during valuation: HKD account totals and positions, including Futu `HK.*` holdings, are converted with the snapshot USD/HKD rate before USD and CNY values are displayed. Older snapshots remain stored, but value history compares only snapshots produced by the current valuation methodology to avoid false gains or losses after a valuation fix.
### Portfolio connector compatibility
| Badge | Meaning |
|-------|---------|
| **Native adapter** | Dedicated portfolio normalization and valuation behavior exists for the connector. |
| **Contract-tested** | Recorded connector-shaped fixtures pass the common account/position contract, but this is not a promise that every broker account variant has been live-tested. |
| **Experimental** | The profile is structurally read-only and can be selected, but its currencies, account totals, or instrument semantics still need broker-specific verification. |
Every position row must provide a symbol and quantity. Unsupported currencies fail the source explicitly instead of being silently treated as USD. The current valuation core supports USD, HKD and CNY; IBKR, Longbridge and Binance use native handling, while Alpaca and OKX have common-contract coverage. OKX account balance details are adapted into spot holdings alongside any open positions. A new built-in connector or local plugin defaults to **Experimental** until its portfolio contract fixtures are added.
### AI-friendly connector onboarding
Built-in SDK connectors publish one machine-readable onboarding contract: authentication type, required credential fields, optional dependency, install command, and the read-only operation used for verification. `trading_connections` exposes the same contract to MCP clients, while the Portfolio connection center renders it as a generic form. The contract contains field names only — never credential values.
For a terminal-first setup, let the CLI collect secrets locally instead of putting them in a prompt or shell arguments:
```bash
vibe-trading connector setup okx-live-sdk-readonly \
--connection-id main-okx \
--label "Main OKX"
```
The CLI prompts in the local terminal, stores values under that connection id in the OS keyring, and runs the connector's read-only check. The equivalent Web flow is **Portfolio → Manage accounts → Open connection center → choose a template → save to keyring → test connection**. MCP read tools may use `connection_id`; the process resolves its credentials from the vault, so keys never travel through MCP. Existing `~/.vibe-trading/<connector>.json` and environment configurations remain a compatibility fallback until a connection has a complete vault credential set.
Do not paste broker API keys into an AI chat. Let the AI choose the connector, install the declared dependency, and interpret diagnostics; enter secrets only in the local hidden terminal prompt or local connection form.
Read-only connectors you install yourself stay outside the checkout, in `~/.vibe-trading/connectors/<name>/`: a `connector.json` manifest plus an `adapter.py` implementing `check_status` / `get_account_snapshot` / `get_positions`. A manifest declaring any write capability is rejected.
```bash
+16
View File
@@ -352,6 +352,22 @@ Web UI 新增只读的 **持仓** 页面,把你选中的券商连接的持仓
| **不可变快照** | 每次刷新都写入 `~/.vibe-trading/portfolio/portfolio.sqlite3`;不含凭证的设置保存在 `~/.vibe-trading/portfolio.json` 与 `connections.json`。 |
| **导出与分析** | 支持 CSV 导出,并提供脱敏的 `portfolio_summary` agent 工具,其 `risk_xray_args` 可直接传给 `portfolio_risk_xray`。终端里 `vibe-trading portfolio show` 打印同一份快照(另有 `refresh` / `sources`)。 |
### 面向 AI 的连接器接入
内置 SDK 连接器统一公布机器可读的 onboarding 合同:认证类型、所需凭证字段、可选依赖、安装命令以及只读验证操作。MCP 的 `trading_connections` 会返回同一份合同,持仓页的连接中心则据此自动生成通用表单。合同只包含字段名称,永远不包含凭证值。
终端优先的接入方式如下。密钥由本机 CLI 隐藏输入,不要写入 prompt 或命令行参数:
```bash
vibe-trading connector setup okx-live-sdk-readonly \
--connection-id main-okx \
--label "Main OKX"
```
CLI 会把凭证按连接 ID 保存到系统钥匙串,并执行连接器的只读测试。等价的网页流程是:**持仓 → 管理账户 → 打开连接中心 → 选择模板 → 保存到钥匙串 → 测试连接**。MCP 读取工具可以传入 `connection_id`,程序会在本机解析对应凭证,因此密钥不会经过 MCP。现有 `~/.vibe-trading/<connector>.json` 与环境变量配置继续作为兼容回退;一旦某个连接拥有完整的钥匙串凭证,就不会与其他账户的旧配置混用。
不要把券商 API Key 粘贴进 AI 对话。AI 负责选择连接器、安装声明的依赖和解释诊断;密钥只在本机终端隐藏输入或本地连接表单中填写。
你自己安装的只读连接器留在仓库之外的 `~/.vibe-trading/connectors/<name>/`:一个 `connector.json` manifest,加一个实现 `check_status` / `get_account_snapshot` / `get_positions` 的 `adapter.py`。声明了任何写能力的 manifest 会被拒绝。
```bash
+107 -7
View File
@@ -4365,6 +4365,7 @@ def cmd_connector_configure(
def cmd_connector_check(
profile_id: Optional[str] = None,
*,
connection_id: str | None = None,
host: str | None = None,
port: int | None = None,
client_id: int | None = None,
@@ -4375,7 +4376,15 @@ def cmd_connector_check(
try:
profile = _selected_profile_or(profile_id)
report = check_connection(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
report = check_connection(profile.id, **options)
except Exception as exc: # noqa: BLE001
console.print(f"[red]Connector check failed:[/red] {exc}")
return EXIT_RUN_FAILED
@@ -4440,6 +4449,78 @@ def cmd_connector_check(
return EXIT_SUCCESS
def cmd_connector_setup(
profile_id: str,
*,
connection_id: str | None = None,
label: str | None = None,
skip_check: bool = False,
) -> int:
"""Create a local read-only connection and collect secrets outside AI prompts."""
from src.trading.connections import (
ConnectionStore,
credential_field_catalog,
is_portfolio_connection_profile,
)
from src.trading.profiles import profile_by_id
from src.trading.service import check_connection
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")
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)
fields = credential_field_catalog(profile.id)
names = [str(field["name"]) for field in fields]
status = store.credentials.status(connection.id, names) if names else {}
values: dict[str, str] = {}
for field in fields:
name = str(field["name"])
required = bool(field.get("required", True))
while True:
saved = bool(status.get(name))
suffix = " [already saved; Enter keeps it]" if saved else ""
value = Prompt.ask(
f"{field.get('label') or name}{suffix}",
password=bool(field.get("secret", True)),
default="",
show_default=False,
)
if value:
values[name] = value
break
if saved or not required:
break
console.print(f"[yellow]{field.get('label') or name} is required.[/yellow]")
if values:
store.credentials.save(connection.id, values)
except (RuntimeError, ValueError) as exc:
console.print(f"[red]Connector setup failed:[/red] {rich_escape(str(exc))}")
return EXIT_USAGE_ERROR
console.print(
f"[green]Local read-only connection ready[/green] "
f"{connection.id} [dim]({connection.profile_id})[/dim]"
)
if skip_check:
return EXIT_SUCCESS
try:
report = check_connection(profile.id, connection_id=connection.id)
except Exception as exc: # noqa: BLE001 - return an actionable diagnostic
console.print(f"[red]Connection test failed:[/red] {rich_escape(str(exc))}")
return EXIT_RUN_FAILED
if report.get("status") != "ok":
console.print(
f"[red]Connection test failed:[/red] "
f"{rich_escape(str(report.get('error') or report.get('status')))}"
)
return EXIT_RUN_FAILED
console.print("[green]Connection test passed.[/green]")
return EXIT_SUCCESS
def _first_present(row: dict[str, Any], *keys: str) -> Any:
"""Return the first key whose value is not None (0/'' are kept), else None.
@@ -5033,14 +5114,23 @@ def _dispatch_connector(args: argparse.Namespace) -> int:
account=args.account,
yes=args.yes,
)
if sub == "check":
return cmd_connector_check(
if sub == "setup":
return cmd_connector_setup(
args.profile,
host=args.host,
port=args.port,
client_id=args.client_id,
account=args.account,
connection_id=args.connection_id,
label=args.label,
skip_check=args.skip_check,
)
if sub == "check":
options = {
"host": args.host,
"port": args.port,
"client_id": args.client_id,
"account": args.account,
}
if args.connection_id is not None:
options["connection_id"] = args.connection_id
return cmd_connector_check(args.profile, **options)
if sub == "account":
return cmd_connector_account(
args.profile,
@@ -5340,9 +5430,19 @@ def _build_parser() -> argparse.ArgumentParser:
connector_configure.add_argument("--account", default=None)
connector_configure.add_argument("-y", "--yes", action="store_true", help="Overwrite without prompting")
connector_setup = connector_subparsers.add_parser(
"setup",
help="Create a local read-only connection and securely collect its credentials",
)
_add_connector_profile_arg(connector_setup, required=True)
connector_setup.add_argument("--connection-id", default=None)
connector_setup.add_argument("--label", default=None)
connector_setup.add_argument("--skip-check", action="store_true")
connector_check = connector_subparsers.add_parser("check", help="Check selected connector readiness")
_add_connector_profile_arg(connector_check)
_add_connector_local(connector_check)
connector_check.add_argument("--connection-id", default=None)
connector_status = connector_subparsers.add_parser("status", help="Show selected connector status")
_add_connector_profile_arg(connector_status)
+55 -32
View File
@@ -254,9 +254,7 @@ class _HostGuardMiddleware:
if not any(_host_matches(normalized, pattern) for pattern in self.allowed_hosts):
from starlette.responses import PlainTextResponse
await PlainTextResponse("Invalid host header", status_code=400)(
scope, receive, send
)
await PlainTextResponse("Invalid host header", status_code=400)(scope, receive, send)
return
await self.app(scope, receive, send)
@@ -283,9 +281,7 @@ class _OriginGuardMiddleware:
if not _origin_allowed(origin, self.allowed_hosts):
from starlette.responses import PlainTextResponse
await PlainTextResponse("Origin not allowed", status_code=403)(
scope, receive, send
)
await PlainTextResponse("Origin not allowed", status_code=403)(scope, receive, send)
return
await self.app(scope, receive, send)
@@ -1293,9 +1289,7 @@ def get_strategy_evidence(strategy_id: str, regime: str | None = None) -> str:
@mcp.tool
def refresh_strategy_evidence(
manifest_path: str | None = None, runs: list | None = None
) -> str:
def refresh_strategy_evidence(manifest_path: str | None = None, runs: list | None = None) -> str:
"""Rebuild the strategy-discovery evidence cache from backtest run artifacts.
WRITE tool with disposable-cache-only scope: it replaces the facade-owned
@@ -1332,6 +1326,7 @@ def refresh_strategy_evidence(
def _trading_common_args(
*,
connection: str | None = None,
connection_id: str | None = None,
host: str | None = None,
port: int | None = None,
client_id: int | None = None,
@@ -1341,6 +1336,8 @@ def _trading_common_args(
payload: dict[str, Any] = {}
if connection:
payload["connection"] = connection
if connection_id:
payload["connection_id"] = connection_id
if host:
payload["host"] = host
if port is not None:
@@ -1377,6 +1374,7 @@ def trading_select_connection(connection: str) -> str:
@mcp.tool
def trading_check(
connection: str | None = None,
connection_id: str | None = None,
host: str | None = None,
port: int | None = None,
client_id: int | None = None,
@@ -1390,6 +1388,7 @@ def trading_check(
Args:
connection: Optional profile id. Defaults to the selected profile.
connection_id: Optional local connection id whose OS-vault credentials are used.
host: Optional local host override.
port: Optional local socket port override.
client_id: Optional local client id override.
@@ -1398,13 +1397,21 @@ def trading_check(
registry = _get_registry()
return registry.execute(
"trading_check",
_trading_common_args(connection=connection, host=host, port=port, client_id=client_id, account=account),
_trading_common_args(
connection=connection,
connection_id=connection_id,
host=host,
port=port,
client_id=client_id,
account=account,
),
)
@mcp.tool
def trading_account(
connection: str | None = None,
connection_id: str | None = None,
host: str | None = None,
port: int | None = None,
client_id: int | None = None,
@@ -1414,6 +1421,7 @@ def trading_account(
Args:
connection: Optional profile id. Defaults to the selected profile.
connection_id: Optional local connection id whose OS-vault credentials are used.
host: Optional local host override.
port: Optional local socket port override.
client_id: Optional local client id override.
@@ -1422,13 +1430,21 @@ def trading_account(
registry = _get_registry()
return registry.execute(
"trading_account",
_trading_common_args(connection=connection, host=host, port=port, client_id=client_id, account=account),
_trading_common_args(
connection=connection,
connection_id=connection_id,
host=host,
port=port,
client_id=client_id,
account=account,
),
)
@mcp.tool
def trading_positions(
connection: str | None = None,
connection_id: str | None = None,
host: str | None = None,
port: int | None = None,
client_id: int | None = None,
@@ -1438,6 +1454,7 @@ def trading_positions(
Args:
connection: Optional profile id. Defaults to the selected profile.
connection_id: Optional local connection id whose OS-vault credentials are used.
host: Optional local host override.
port: Optional local socket port override.
client_id: Optional local client id override.
@@ -1446,13 +1463,21 @@ def trading_positions(
registry = _get_registry()
return registry.execute(
"trading_positions",
_trading_common_args(connection=connection, host=host, port=port, client_id=client_id, account=account),
_trading_common_args(
connection=connection,
connection_id=connection_id,
host=host,
port=port,
client_id=client_id,
account=account,
),
)
@mcp.tool
def trading_orders(
connection: str | None = None,
connection_id: str | None = None,
host: str | None = None,
port: int | None = None,
client_id: int | None = None,
@@ -1471,7 +1496,9 @@ def trading_orders(
account: Optional account code filter.
include_executions: Include recent executions when available.
"""
params = _trading_common_args(connection=connection, host=host, port=port, client_id=client_id, account=account)
params = _trading_common_args(
connection=connection, connection_id=connection_id, host=host, port=port, client_id=client_id, account=account
)
params["include_executions"] = include_executions
registry = _get_registry()
return registry.execute("trading_orders", params)
@@ -1481,6 +1508,7 @@ def trading_orders(
def trading_quote(
symbol: str,
connection: str | None = None,
connection_id: str | None = None,
host: str | None = None,
port: int | None = None,
client_id: int | None = None,
@@ -1502,7 +1530,9 @@ def trading_quote(
currency: Contract currency, default USD.
sec_type: Security type, default STK.
"""
params = _trading_common_args(connection=connection, host=host, port=port, client_id=client_id, account=account)
params = _trading_common_args(
connection=connection, connection_id=connection_id, host=host, port=port, client_id=client_id, account=account
)
params.update({"symbol": symbol, "exchange": exchange, "currency": currency, "sec_type": sec_type})
registry = _get_registry()
return registry.execute("trading_quote", params)
@@ -1512,6 +1542,7 @@ def trading_quote(
def trading_history(
symbol: str,
connection: str | None = None,
connection_id: str | None = None,
host: str | None = None,
port: int | None = None,
client_id: int | None = None,
@@ -1545,7 +1576,9 @@ def trading_history(
period: Bar interval for SDK connectors (broker_sdk): 1m/5m/1h/1d/1w.
limit: Number of bars for SDK connectors.
"""
params = _trading_common_args(connection=connection, host=host, port=port, client_id=client_id, account=account)
params = _trading_common_args(
connection=connection, connection_id=connection_id, host=host, port=port, client_id=client_id, account=account
)
params.update(
{
"symbol": symbol,
@@ -1629,9 +1662,7 @@ async def run_swarm(
runtime = SwarmRuntime(store=store, agent_config=agent_config)
try:
run = runtime.start_run(
preset_name, variables, include_shell_tools=_include_shell_tools
)
run = runtime.start_run(preset_name, variables, include_shell_tools=_include_shell_tools)
except FileNotFoundError as exc:
return json.dumps({"status": "error", "error": str(exc)}, ensure_ascii=False)
except ValueError as exc:
@@ -1699,6 +1730,7 @@ async def run_swarm(
# Market data tool
# ---------------------------------------------------------------------------
def _detect_source(code: str) -> str:
return detect_source(code)
@@ -2405,9 +2437,7 @@ def _mirrored_tool_classes() -> list[Any]:
try:
classes.append(getattr(import_module(module_path), class_name))
except Exception: # noqa: BLE001 - one unavailable module, not four
logger.exception(
"Tool module %s is unavailable; its MCP tool will be absent", module_path
)
logger.exception("Tool module %s is unavailable; its MCP tool will be absent", module_path)
return classes
@@ -2495,8 +2525,7 @@ def _register_mirrored_tool(tool_cls: Any) -> bool:
name = getattr(tool_cls, "name", "")
if getattr(tool_cls, "is_readonly", False) is not True:
logger.error(
"Refusing to expose non-read-only tool %r via MCP; only read-only "
"tools are ever surfaced.",
"Refusing to expose non-read-only tool %r via MCP; only read-only tools are ever surfaced.",
name or tool_cls,
)
return False
@@ -2984,9 +3013,7 @@ def main():
default="127.0.0.1",
help="Network bind host for --transport sse / http (default: 127.0.0.1)",
)
parser.add_argument(
"--port", type=int, default=8900, help="SSE/HTTP port (default: 8900)"
)
parser.add_argument("--port", type=int, default=8900, help="SSE/HTTP port (default: 8900)")
parser.add_argument(
"--enable-shell-tools",
action="store_true",
@@ -3003,9 +3030,7 @@ def main():
_migrate.migrate_legacy_state()
except Exception: # pragma: no cover — best-effort
logging.getLogger(__name__).warning(
"Legacy state migration failed", exc_info=True
)
logging.getLogger(__name__).warning("Legacy state migration failed", exc_info=True)
_include_shell_tools = _resolve_include_shell_tools(args.enable_shell_tools)
_registry = None
@@ -3022,9 +3047,7 @@ def main():
from src.config.accessor import get_env_config
allowed_hosts = _parse_allowed_hosts(
get_env_config().api.vibe_trading_mcp_allowed_hosts
)
allowed_hosts = _parse_allowed_hosts(get_env_config().api.vibe_trading_mcp_allowed_hosts)
transport = "streamable-http" if args.transport == "http" else "sse"
app = _build_network_app(transport, allowed_hosts)
uvicorn.run(app, host=args.host, port=args.port)
+26 -15
View File
@@ -6,6 +6,7 @@ from fastapi import Depends, FastAPI, HTTPException
from pydantic import BaseModel, Field
from src.api.security import require_auth, require_settings_write_auth
from src.portfolio.compatibility import profile_compatibility
from src.portfolio.config import PortfolioSettingsStore
from src.trading.connections import (
ConnectionStore,
@@ -14,6 +15,7 @@ from src.trading.connections import (
readonly_profile_catalog,
)
from src.trading.local_plugins import plugin_root
from src.trading.profiles import profile_by_id
from src.trading.service import check_connection
@@ -48,10 +50,28 @@ def register_connection_routes(app: FastAPI) -> None:
@app.get("/api/connections", dependencies=[Depends(require_auth)])
def list_connections():
"""List connection instances, eligible profiles, and the plugin root."""
connections = [
{
**row,
"portfolio_compatibility": profile_compatibility(profile_by_id(row["profile_id"])),
}
for row in store().public_list()
]
profiles = []
for row in readonly_profile_catalog():
if row.get("invalid_plugin"):
profiles.append(row)
else:
profiles.append(
{
**row,
"portfolio_compatibility": profile_compatibility(profile_by_id(row["id"])),
}
)
return {
"status": "ok",
"connections": store().public_list(),
"profiles": readonly_profile_catalog(),
"connections": connections,
"profiles": profiles,
"plugin_directory": str(plugin_root()),
}
@@ -74,9 +94,7 @@ def register_connection_routes(app: FastAPI) -> None:
def update_connection(connection_id: str, payload: ConnectionRequest):
"""Rename a connection instance; its id and profile are immutable."""
if payload.id.strip().lower() != connection_id.strip().lower():
raise HTTPException(
status_code=400, detail="connection id cannot be changed"
)
raise HTTPException(status_code=400, detail="connection id cannot be changed")
try:
instance = store()
current = instance.get(connection_id)
@@ -106,9 +124,7 @@ def register_connection_routes(app: FastAPI) -> None:
connection = instance.get(connection_id)
allowed = set(credential_fields(connection.profile_id))
if not set(payload.values).issubset(allowed):
raise ValueError(
"credential payload contains fields not declared by the connector"
)
raise ValueError("credential payload contains fields not declared by the connector")
instance.credentials.save(connection.id, payload.values)
return {
"status": "ok",
@@ -147,14 +163,9 @@ def register_connection_routes(app: FastAPI) -> None:
def delete_connection(connection_id: str):
"""Delete a connection and its secrets once no portfolio source uses it."""
try:
selected = {
source.connection_id
for source in PortfolioSettingsStore().load().sources
}
selected = {source.connection_id for source in PortfolioSettingsStore().load().sources}
if connection_id.strip().lower() in selected:
raise ValueError(
"remove this connection from the portfolio before deleting it"
)
raise ValueError("remove this connection from the portfolio before deleting it")
store().delete(connection_id)
return {"status": "ok", "deleted": connection_id}
except ValueError as exc:
+227
View File
@@ -0,0 +1,227 @@
"""Portfolio compatibility metadata and connector payload contracts.
The trading layer exposes a deliberately broad connector interface. The
portfolio page has a narrower contract: account and position reads must be
read-only, position rows must identify a symbol and quantity, and every value
must be convertible without guessing. This module keeps that distinction
explicit so a newly registered connector is shown as experimental until its
portfolio payload has dedicated or fixture-backed coverage.
"""
from __future__ import annotations
from dataclasses import asdict, dataclass
from decimal import Decimal, InvalidOperation
from typing import Any, Literal
from src.trading.types import TradingProfile
CompatibilityLevel = Literal["native", "contract_tested", "experimental"]
SUPPORTED_VALUE_CURRENCIES = frozenset({"USD", "HKD", "CNY"})
_SYMBOL_FIELDS = ("symbol", "code", "ticker")
_QUANTITY_FIELDS = (
"quantity",
"qty",
"position",
"position_qty",
"volume",
"units",
)
@dataclass(frozen=True)
class PortfolioCompatibility:
"""Public, credential-free compatibility metadata for one connector."""
level: CompatibilityLevel
contract_version: int
asset_scope: str
note: str
def to_dict(self) -> dict[str, Any]:
"""Return the metadata in its API wire shape."""
return asdict(self)
_CONNECTOR_COMPATIBILITY: dict[str, PortfolioCompatibility] = {
"ibkr": PortfolioCompatibility("native", 1, "stocks_etfs", "Dedicated IBKR account and position mapping."),
"longbridge": PortfolioCompatibility(
"native",
1,
"stocks_etfs",
"Dedicated Longbridge mapping and quote fallback.",
),
"binance": PortfolioCompatibility("native", 1, "spot", "Dedicated spot and Simple Earn balance handling."),
"alpaca": PortfolioCompatibility(
"contract_tested",
1,
"stocks_etfs",
"Canonical USD account and position payloads are covered by contract tests.",
),
"okx": PortfolioCompatibility(
"contract_tested",
1,
"spot_and_positions",
"Spot balances are adapted alongside open OKX positions.",
),
"dhan": PortfolioCompatibility("experimental", 1, "positions", "INR valuation is not supported yet."),
"shoonya": PortfolioCompatibility("experimental", 1, "positions", "INR valuation is not supported yet."),
"futu": PortfolioCompatibility(
"experimental",
1,
"stocks_etfs",
"Multi-market currency metadata still requires live verification.",
),
"tiger": PortfolioCompatibility(
"experimental",
1,
"stocks_etfs",
"Multi-market account totals still require live verification.",
),
"trading212": PortfolioCompatibility(
"experimental",
1,
"stocks_etfs",
"Position aliases are supported; cash totals still require verification.",
),
"mt5": PortfolioCompatibility(
"experimental",
1,
"open_positions",
"Contract-size and non-USD account valuation require verification.",
),
"etoro": PortfolioCompatibility(
"experimental",
1,
"open_positions",
"Account totals and instrument quote resolution require verification.",
),
}
_EXPERIMENTAL_DEFAULT = PortfolioCompatibility(
"experimental",
1,
"declared_by_connector",
"The connector declares portfolio reads but has no built-in contract fixture.",
)
class PortfolioContractError(RuntimeError):
"""Raised when a connector payload cannot be aggregated without guessing."""
def profile_compatibility(profile: TradingProfile) -> dict[str, Any]:
"""Return compatibility metadata for a built-in or local profile."""
return _CONNECTOR_COMPATIBILITY.get(profile.connector, _EXPERIMENTAL_DEFAULT).to_dict()
def adapt_and_validate_payloads(
connector: str,
account_payload: dict[str, Any],
positions_payload: dict[str, Any],
) -> tuple[dict[str, Any], dict[str, Any]]:
"""Adapt connector-specific reads and validate the portfolio contract.
The returned payloads are shallow copies. Connector responses are never
mutated because the trading service may reuse them for another consumer.
"""
if not isinstance(account_payload, dict):
raise PortfolioContractError("account read must return an object")
if not isinstance(positions_payload, dict):
raise PortfolioContractError("positions read must return an object")
account = dict(account_payload)
positions = dict(positions_payload)
raw_rows = positions.get("positions", [])
if not isinstance(raw_rows, list):
raise PortfolioContractError("positions payload must contain a list")
rows = [dict(row) if isinstance(row, dict) else row for row in raw_rows]
if connector == "okx":
rows.extend(_okx_spot_rows(account, rows))
default_currency = _account_currency(account)
validated: list[dict[str, Any]] = []
for index, row in enumerate(rows):
if not isinstance(row, dict):
raise PortfolioContractError(f"position {index} must be an object")
if not any(str(row.get(field) or "").strip() for field in _SYMBOL_FIELDS):
raise PortfolioContractError(f"position {index} has no symbol")
if not any(field in row and row.get(field) is not None for field in _QUANTITY_FIELDS):
raise PortfolioContractError(f"position {index} has no quantity")
if default_currency and not row.get("currency"):
row["currency"] = default_currency
validated.append(row)
positions["positions"] = validated
return account, positions
def ensure_supported_currencies(rows: list[dict[str, Any]], account_payload: dict[str, Any] | None = None) -> None:
"""Fail closed when the current portfolio FX model cannot value a source.
Account currency is checked as well as position currency so a cash-only
account cannot accidentally be reported as USD.
"""
currencies = {str(row.get("price_currency") or row.get("currency") or "USD").upper() for row in rows}
account_currency = _account_currency(account_payload or {})
if account_currency:
currencies.add(account_currency)
unsupported = sorted(currencies - SUPPORTED_VALUE_CURRENCIES)
if unsupported:
raise PortfolioContractError("portfolio FX conversion is not available for: " + ", ".join(unsupported))
def _account_currency(payload: dict[str, Any]) -> str | None:
account = payload.get("account")
if isinstance(account, dict):
currency = account.get("currency")
if currency:
return str(currency).upper()
aggregate = account.get("aggregated_portfolio")
if isinstance(aggregate, dict) and aggregate.get("accountCurrency"):
return str(aggregate["accountCurrency"]).upper()
cash = payload.get("cash")
if isinstance(cash, dict):
currency = cash.get("currencyCode") or cash.get("currency")
if currency:
return str(currency).upper()
return None
def _okx_spot_rows(account_payload: dict[str, Any], existing_rows: list[object]) -> list[dict[str, Any]]:
account = account_payload.get("account")
details = account.get("details", []) if isinstance(account, dict) else []
if not isinstance(details, list):
return []
existing_symbols = {str(row.get("symbol") or "").upper() for row in existing_rows if isinstance(row, dict)}
result = []
stablecoins = {"USDT", "USDC", "FDUSD", "TUSD", "BUSD"}
for detail in details:
if not isinstance(detail, dict):
continue
symbol = str(detail.get("currency") or "").strip().upper()
quantity = _decimal(detail.get("equity"))
if not symbol or quantity <= 0 or symbol in existing_symbols:
continue
result.append(
{
"symbol": symbol,
"quantity": str(quantity),
"currency": "USD",
"quote_symbol": symbol if symbol in stablecoins else f"{symbol}-USDT",
"asset_type": "stablecoin" if symbol in stablecoins else "crypto",
"free": detail.get("available"),
"used": detail.get("frozen"),
"source": "spot",
}
)
return result
def _decimal(value: Any) -> Decimal:
try:
result = Decimal(str(value or "0"))
return result if result.is_finite() else Decimal("0")
except (InvalidOperation, TypeError, ValueError):
return Decimal("0")
+10 -25
View File
@@ -11,6 +11,7 @@ from pathlib import Path
from typing import Any
from src.config.paths import get_runtime_root
from src.portfolio.compatibility import profile_compatibility
from src.trading.connections import (
ConnectionStore,
is_portfolio_connection_profile,
@@ -84,11 +85,7 @@ def eligible_profiles() -> list[TradingProfile]:
Eligible profiles sorted by connector, environment and label.
"""
return sorted(
(
profile
for profile in list_profiles()
if is_portfolio_connection_profile(profile)
),
(profile for profile in list_profiles() if is_portfolio_connection_profile(profile)),
key=lambda profile: (profile.connector, profile.environment, profile.label),
)
@@ -113,12 +110,14 @@ def source_catalog(
rows = []
for connection in store.public_list():
source = selected.get(connection["id"])
profile = profile_by_id(connection["profile_id"])
rows.append(
{
**connection,
"connection_id": connection["id"],
"selected": source is not None,
"source_id": source.id if source is not None else None,
"portfolio_compatibility": profile_compatibility(profile),
}
)
return rows
@@ -158,9 +157,7 @@ def parse_settings(
for index, raw in enumerate(raw_sources):
if not isinstance(raw, dict):
raise ValueError("each portfolio source must be an object")
connection_id = (
str(raw.get("connection_id") or raw.get("id") or "").strip().lower()
)
connection_id = str(raw.get("connection_id") or raw.get("id") or "").strip().lower()
if not _SOURCE_ID_RE.fullmatch(connection_id):
raise ValueError(f"invalid portfolio connection id: {connection_id or '?'}")
legacy_profile_id = str(raw.get("profile_id") or "").strip().lower()
@@ -175,20 +172,12 @@ def parse_settings(
connection = store.get(connection_id)
profile = profile_by_id(connection.profile_id)
if profile not in eligible_profiles():
raise ValueError(
f"connection is not eligible for read-only portfolios: {connection_id}"
)
raise ValueError(f"connection is not eligible for read-only portfolios: {connection_id}")
if connection_id in seen_ids:
raise ValueError("portfolio connection ids must be unique")
label = str(raw.get("label") or connection.label).strip()
if (
not label
or len(label) > 80
or any(ord(character) < 32 for character in label)
):
raise ValueError(
"portfolio source labels must contain 1 to 80 printable characters"
)
if not label or len(label) > 80 or any(ord(character) < 32 for character in label):
raise ValueError("portfolio source labels must contain 1 to 80 printable characters")
seen_ids.add(connection_id)
sources.append(
PortfolioSource(
@@ -224,9 +213,7 @@ class PortfolioSettingsStore:
user's real registry.
"""
self.path = path or (get_runtime_root() / CONFIG_FILENAME)
connection_path = (
self.path.with_name("connections.json") if path is not None else None
)
connection_path = self.path.with_name("connections.json") if path is not None else None
self.connection_store = connection_store or ConnectionStore(connection_path)
def load(self) -> PortfolioSettings:
@@ -273,9 +260,7 @@ class PortfolioSettingsStore:
else parse_settings(settings.to_dict(), self.connection_store)
)
self.path.parent.mkdir(parents=True, exist_ok=True)
descriptor, temporary = tempfile.mkstemp(
prefix=".portfolio-", suffix=".json", dir=self.path.parent
)
descriptor, temporary = tempfile.mkstemp(prefix=".portfolio-", suffix=".json", dir=self.path.parent)
try:
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
json.dump(validated.to_dict(), handle, ensure_ascii=False, indent=2)
+10 -9
View File
@@ -10,6 +10,7 @@ from __future__ import annotations
import json
import os
import sys
MARKER = "VIBE_PORTFOLIO_JSON="
@@ -22,13 +23,15 @@ def main() -> None:
credentials or account identifiers into the parent process's logs.
"""
try:
from src.trading.connectors.longbridge.sdk import (
build_config,
get_account_and_positions,
get_quotes,
)
from src.trading.connectors.longbridge.sdk import get_account_and_positions, get_quotes
from src.trading.profiles import profile_by_id
from src.trading.service import _sdk_config, _sdk_module
config = build_config({"profile": "live-readonly", "region": "global"})
profile = profile_by_id("longbridge-live-sdk-readonly")
module = _sdk_module(profile.connector)
connection_id = str(sys.argv[1] if len(sys.argv) > 1 else "").strip()
overrides = {"connection_id": connection_id} if connection_id else {}
config = _sdk_config(profile, module, overrides)
payload = get_account_and_positions(config)
symbols = [
str(item.get("symbol") or "")
@@ -37,9 +40,7 @@ def main() -> None:
]
try:
payload["quotes"] = get_quotes(symbols, config=config)
except (
Exception
) as exc: # account totals remain usable if quote permission/network fails
except Exception as exc: # account totals remain usable if quote permission/network fails
payload["quotes"] = {
"status": "error",
"error": type(exc).__name__,
+54 -52
View File
@@ -77,9 +77,7 @@ def normalize_position(broker: str, row: dict[str, Any]) -> dict[str, Any]:
"exchange": row.get("exchange"),
"currency": str(row.get("currency") or "USD").upper(),
"quantity": _number(_decimal(row.get("position", row.get("quantity")))),
"cost_price": _number(
_decimal(row.get("avg_cost", row.get("average_cost")))
),
"cost_price": _number(_decimal(row.get("avg_cost", row.get("average_cost")))),
"market_price": _number(market_price) if market_price > 0 else None,
"source_market_value": row.get("market_value"),
"source_unrealized_pnl": row.get("unrealized_pnl"),
@@ -102,27 +100,55 @@ def normalize_position(broker: str, row: dict[str, Any]) -> dict[str, Any]:
"updated_at": _now(),
}
symbol = str(row.get("symbol") or row.get("code") or "").upper()
symbol = str(row.get("symbol") or row.get("code") or row.get("ticker") or "").upper()
source = str(row.get("source") or ("spot" if broker == "binance" else "account"))
market = str(row.get("market") or row.get("exchange") or broker).upper()
currency = str(row.get("currency") or "").upper()
if not currency:
currency = (
"HKD" if symbol.startswith("HK.") or symbol.endswith(".HK") else "USD"
"HKD"
if symbol.endswith(".HK") or symbol.startswith("HK.")
else "CNY"
if symbol.startswith(("SH.", "SZ.", "BJ."))
else "USD"
)
quantity = _decimal(
row.get(
"quantity", row.get("qty", row.get("position", row.get("position_qty")))
"quantity",
row.get(
"qty",
row.get(
"position",
row.get("position_qty", row.get("volume", row.get("units"))),
),
),
)
)
cost = _decimal(
row.get(
"cost_price",
row.get("average_cost", row.get("avg_cost", row.get("avg_entry_price"))),
row.get(
"average_cost",
row.get(
"avg_cost",
row.get(
"avg_entry_price",
row.get(
"average_price",
row.get("price_open", row.get("open_rate")),
),
),
),
),
)
)
market_price = _decimal(row.get("market_price", row.get("current_price")))
source_market_value = row.get("market_value", row.get("market_val"))
market_price = _decimal(
row.get(
"market_price",
row.get("current_price", row.get("ltp", row.get("price_current"))),
)
)
source_market_value = row.get("market_value", row.get("market_val", row.get("value")))
if market_price <= 0 and quantity != 0 and _decimal(source_market_value) > 0:
market_price = abs(_decimal(source_market_value) / quantity)
sec_type = str(row.get("sec_type") or "").upper()
@@ -132,25 +158,16 @@ def normalize_position(broker: str, row: dict[str, Any]) -> dict[str, Any]:
"stablecoin",
}
asset_type = declared_asset_type or (
"stablecoin"
if symbol in STABLECOINS
else "crypto" if crypto else "etf" if sec_type == "ETF" else "stock"
"stablecoin" if symbol in STABLECOINS else "crypto" if crypto else "etf" if sec_type == "ETF" else "stock"
)
return {
"broker": broker,
"symbol": symbol,
"quote_symbol": str(
row.get("quote_symbol")
or (f"{symbol}/USDT" if broker == "binance" else symbol)
),
"quote_symbol": str(row.get("quote_symbol") or (f"{symbol}/USDT" if broker == "binance" else symbol)),
"name": str(
row.get("name")
or row.get("symbol_name")
or (
f"{symbol} (Simple Earn)"
if source == "simple_earn_flexible"
else symbol
)
or (f"{symbol} (Simple Earn)" if source == "simple_earn_flexible" else symbol)
),
"asset_type": asset_type,
"market": market,
@@ -160,7 +177,10 @@ def normalize_position(broker: str, row: dict[str, Any]) -> dict[str, Any]:
"market_price": _number(market_price) if market_price > 0 else None,
"price_currency": str(row.get("price_currency") or currency).upper(),
"source_market_value": source_market_value,
"source_unrealized_pnl": row.get("unrealized_pnl", row.get("unrealized_pl")),
"source_unrealized_pnl": row.get(
"unrealized_pnl",
row.get("unrealized_pl", row.get("pnl", row.get("profit"))),
),
"free": row.get("free"),
"used": row.get("used"),
"source": source,
@@ -168,9 +188,7 @@ def normalize_position(broker: str, row: dict[str, Any]) -> dict[str, Any]:
}
def value_position(
row: dict[str, Any], *, usd_hkd: Decimal, usd_cny: Decimal
) -> dict[str, Any]:
def value_position(row: dict[str, Any], *, usd_hkd: Decimal, usd_cny: Decimal) -> dict[str, Any]:
"""Calculate USD/CNY market value and unrealized P/L.
Args:
@@ -198,7 +216,9 @@ def value_position(
pnl_usd = (
_decimal(source_pnl) * fx_to_usd
if priced and source_pnl is not None
else (price - cost) * quantity * fx_to_usd if priced and cost > 0 else None
else (price - cost) * quantity * fx_to_usd
if priced and cost > 0
else None
)
row.update(
priced=priced,
@@ -217,9 +237,7 @@ def value_position(
return row
def _to_usd(
value: Decimal, currency: str, usd_hkd: Decimal, usd_cny: Decimal
) -> Decimal:
def _to_usd(value: Decimal, currency: str, usd_hkd: Decimal, usd_cny: Decimal) -> Decimal:
if currency == "HKD":
return value / usd_hkd
if currency == "CNY":
@@ -262,16 +280,11 @@ def account_total_usd(
for row in account.get("summary", []):
if str(row.get("tag") or "").lower() == "netliquidation":
currency = str(row.get("currency") or "USD").upper()
candidates[currency] = max(
candidates.get(currency, Decimal("0")), _decimal(row.get("value"))
)
candidates[currency] = max(candidates.get(currency, Decimal("0")), _decimal(row.get("value")))
if "USD" in candidates:
return candidates["USD"]
return sum(
(
_to_usd(value, currency, usd_hkd, usd_cny)
for currency, value in candidates.items()
),
(_to_usd(value, currency, usd_hkd, usd_cny) for currency, value in candidates.items()),
Decimal("0"),
)
nested = account.get("account") if isinstance(account.get("account"), dict) else {}
@@ -282,18 +295,12 @@ def account_total_usd(
return _to_usd(value, currency, usd_hkd, usd_cny)
total = Decimal("0")
for row in account.get("assets", []):
value = _decimal(
row.get("net_liquidation", row.get("total_assets", row.get("equity")))
)
total += _to_usd(
value, str(row.get("currency") or currency).upper(), usd_hkd, usd_cny
)
value = _decimal(row.get("net_liquidation", row.get("total_assets", row.get("equity"))))
total += _to_usd(value, str(row.get("currency") or currency).upper(), usd_hkd, usd_cny)
return total if total > 0 else fallback
def account_cash_usd(
broker: str, account: dict[str, Any], usd_hkd: Decimal, usd_cny: Decimal
) -> Decimal:
def account_cash_usd(broker: str, account: dict[str, Any], usd_hkd: Decimal, usd_cny: Decimal) -> Decimal:
"""Return broker-reported cash without guessing from missing quotes.
Args:
@@ -328,18 +335,13 @@ def account_cash_usd(
for row in account.get("summary", []):
if str(row.get("tag") or "").lower() in {"totalcashvalue", "cashbalance"}:
currency = str(row.get("currency") or "USD").upper()
candidates[currency] = max(
candidates.get(currency, Decimal("0")), _decimal(row.get("value"))
)
candidates[currency] = max(candidates.get(currency, Decimal("0")), _decimal(row.get("value")))
if "USD" in candidates:
return max(Decimal("0"), candidates["USD"])
return max(
Decimal("0"),
sum(
(
_to_usd(value, currency, usd_hkd, usd_cny)
for currency, value in candidates.items()
),
(_to_usd(value, currency, usd_hkd, usd_cny) for currency, value in candidates.items()),
Decimal("0"),
),
)
+92 -126
View File
@@ -19,6 +19,11 @@ from src.portfolio.config import (
PortfolioSource,
source_catalog,
)
from src.portfolio.compatibility import (
adapt_and_validate_payloads,
ensure_supported_currencies,
profile_compatibility,
)
from src.portfolio.normalization import (
STABLECOINS,
account_cash_usd,
@@ -36,9 +41,7 @@ from src.trading.types import TradingProfile
# not (see ``src.market_data._SOURCE_PATTERNS``).
_RISK_XRAY_MAX_SYMBOLS = 50
PORTFOLIO_VALUATION_VERSION = 2
_LOADER_MARKET_SUFFIXES = frozenset(
{"US", "HK", "SZ", "SH", "BJ", "KS", "KQ", "NS", "BO", "TO", "V"}
)
_LOADER_MARKET_SUFFIXES = frozenset({"US", "HK", "SZ", "SH", "BJ", "KS", "KQ", "NS", "BO", "TO", "V"})
_NON_EQUITY_ASSET_TYPES = frozenset({"crypto", "stablecoin", "cash"})
@@ -76,11 +79,7 @@ def _contains_system_exit(error: BaseException) -> bool:
if isinstance(error, SystemExit):
return True
nested = getattr(error, "exceptions", ())
return any(
_contains_system_exit(item)
for item in nested
if isinstance(item, BaseException)
)
return any(_contains_system_exit(item) for item in nested if isinstance(item, BaseException))
def _quote_price(payload: dict[str, Any]) -> Decimal | None:
@@ -133,7 +132,9 @@ class PortfolioService:
progress_callback: Called as ``(source_id, status, error)`` while a
refresh walks its sources.
"""
self._use_longbridge_worker = get_account is None and get_positions is None
self._use_connection_scoped_reads = get_account is None and get_positions is None
self._use_connection_scoped_quotes = get_quote is None
self._use_longbridge_worker = self._use_connection_scoped_reads
if get_account is None or get_positions is None or get_quote is None:
from src.trading import service as trading
@@ -214,10 +215,7 @@ class PortfolioService:
settings = self.settings_store.load()
sources = [source for source in settings.sources if source.enabled]
if not sources:
raise RuntimeError(
"Add and enable at least one read-only account on the Portfolio "
"page before refreshing"
)
raise RuntimeError("Add and enable at least one read-only account on the Portfolio page before refreshing")
usd_cny, usd_hkd, fx_at, fx_stale = self._rates()
refreshed_at = _now()
results: dict[str, dict[str, Any]] = {}
@@ -248,14 +246,9 @@ class PortfolioService:
connection, profile = self._connection_profile(source)
broker = profile.connector
if result["status"] != "ok":
accounts.append(
self._failed_account(source, connection.profile_id, profile, result)
)
accounts.append(self._failed_account(source, connection.profile_id, profile, result))
continue
broker_positions = [
value_position(row, usd_hkd=usd_hkd, usd_cny=usd_cny)
for row in result["positions"]
]
broker_positions = [value_position(row, usd_hkd=usd_hkd, usd_cny=usd_cny) for row in result["positions"]]
priced_total = sum(
(_decimal(row.get("market_value_usd")) for row in broker_positions),
Decimal("0"),
@@ -276,9 +269,7 @@ class PortfolioService:
if not source.include_cash:
account_total = max(Decimal("0"), account_total - cash_total)
cash_total = Decimal("0")
unpriced_or_other = max(
Decimal("0"), account_total - priced_total - cash_total
)
unpriced_or_other = max(Decimal("0"), account_total - priced_total - cash_total)
priced_count = sum(1 for row in broker_positions if row.get("priced"))
accounts.append(
{
@@ -297,22 +288,17 @@ class PortfolioService:
"priced_position_count": priced_count,
"unpriced_position_count": len(broker_positions) - priced_count,
"auth": auth_metadata(profile),
"portfolio_compatibility": profile_compatibility(profile),
}
)
positions.extend(broker_positions)
total_usd = sum(
(_decimal(row.get("total_usd")) for row in accounts), Decimal("0")
)
total_usd = sum((_decimal(row.get("total_usd")) for row in accounts), Decimal("0"))
# Every enabled source produces exactly one account row, so a snapshot
# is complete only when none of them failed.
complete = all(row["status"] == "ok" for row in accounts)
priced_usd = sum(
(_decimal(row.get("priced_value_usd")) for row in accounts), Decimal("0")
)
cash_usd = sum(
(_decimal(row.get("cash_usd")) for row in accounts), Decimal("0")
)
priced_usd = sum((_decimal(row.get("priced_value_usd")) for row in accounts), Decimal("0"))
cash_usd = sum((_decimal(row.get("cash_usd")) for row in accounts), Decimal("0"))
unpriced_usd = sum(
(_decimal(row.get("unpriced_or_other_usd")) for row in accounts),
Decimal("0"),
@@ -329,9 +315,7 @@ class PortfolioService:
"priced_usd": _number(priced_usd),
"cash_usd": _number(cash_usd),
"unpriced_or_other_usd": _number(unpriced_usd),
"identified_coverage": (
_number(identified_usd / total_usd) if total_usd > 0 else 0.0
),
"identified_coverage": (_number(identified_usd / total_usd) if total_usd > 0 else 0.0),
},
"fx": {
"usd_cny": _number(usd_cny),
@@ -388,6 +372,7 @@ class PortfolioService:
"total_cny": None,
"position_count": 0,
"auth": auth_metadata(profile),
"portfolio_compatibility": profile_compatibility(profile),
}
def latest(self) -> dict[str, Any] | None:
@@ -402,14 +387,29 @@ class PortfolioService:
return None
if snapshot.get("valuation_version") != PORTFOLIO_VALUATION_VERSION:
return None
enabled = {
source.id for source in self.settings_store.load().sources if source.enabled
}
observed = {
str(account.get("source_id") or account.get("broker"))
settings = self.settings_store.load()
enabled_sources = [source for source in settings.sources if source.enabled]
enabled = {source.id for source in enabled_sources}
observed = {str(account.get("source_id") or account.get("broker")) for account in snapshot.get("accounts", [])}
if not enabled or observed != enabled:
return None
compatibility_by_source = {}
for source in enabled_sources:
try:
_, profile = self._connection_profile(source)
except ValueError:
continue
compatibility_by_source[source.id] = profile_compatibility(profile)
snapshot["accounts"] = [
{
**account,
"portfolio_compatibility": account.get("portfolio_compatibility")
or compatibility_by_source.get(str(account.get("source_id") or account.get("broker"))),
}
for account in snapshot.get("accounts", [])
}
return snapshot if enabled and observed == enabled else None
]
return snapshot
def reconnect_source(self, source_id: str) -> dict[str, Any]:
"""Run a source's interactive OAuth flow on explicit user request.
@@ -431,9 +431,7 @@ class PortfolioService:
from src.tools.mcp import MCPServerAdapter
source, profile, server = self.reconnect_target(source_id)
authorize_timeout = float(
get_env_config().agent_tuning.vibe_live_authorize_timeout_s or 300
)
authorize_timeout = float(get_env_config().agent_tuning.vibe_live_authorize_timeout_s or 300)
if hasattr(server, "model_copy"):
updates = {}
if float(server.init_timeout or 0) < authorize_timeout:
@@ -455,9 +453,7 @@ class PortfolioService:
except BaseException as exc:
if not _contains_system_exit(exc):
raise
raise RuntimeError(
"OAuth callback startup failed; authorization stopped safely"
) from exc
raise RuntimeError("OAuth callback startup failed; authorization stopped safely") from exc
return {
"status": "ok",
"authorized": True,
@@ -470,11 +466,7 @@ class PortfolioService:
from src.config.loader import load_agent_config
source = next(
(
item
for item in self.settings_store.load().sources
if item.id == source_id
),
(item for item in self.settings_store.load().sources if item.id == source_id),
None,
)
if source is None:
@@ -486,9 +478,7 @@ class PortfolioService:
if server is None:
raise RuntimeError(f"the {profile.connector} MCP connection is not configured")
if server.auth is None:
raise RuntimeError(
f"the {profile.connector} MCP connection does not configure OAuth"
)
raise RuntimeError(f"the {profile.connector} MCP connection does not configure OAuth")
return source, profile, server
def history(self, limit: int = 180) -> list[dict[str, Any]]:
@@ -615,9 +605,7 @@ class PortfolioService:
head, _, suffix = symbol.rpartition(".")
if head and suffix in _LOADER_MARKET_SUFFIXES:
return symbol
currency = str(
position.get("price_currency") or position.get("currency") or ""
).upper()
currency = str(position.get("price_currency") or position.get("currency") or "").upper()
market = str(position.get("market") or "").upper()
if currency == "HKD" or market == "HK":
return f"{symbol}.HK" if symbol.isdigit() else None
@@ -690,11 +678,7 @@ class PortfolioService:
region = (
"HK"
if currency == "HKD" or market == "HK" or symbol.endswith(".HK")
else (
"CRYPTO"
if row.get("asset_type") in {"crypto", "stablecoin"}
else "US"
)
else ("CRYPTO" if row.get("asset_type") in {"crypto", "stablecoin"} else "US")
)
key = f"{region}:{canonical}"
item = grouped.setdefault(
@@ -730,9 +714,7 @@ class PortfolioService:
for key in ("market_value_usd", "market_value_cny", "unrealized_pnl_usd"):
item[key] = _number(item[key])
result.append(item)
return sorted(
result, key=lambda row: _decimal(row["market_value_usd"]), reverse=True
)
return sorted(result, key=lambda row: _decimal(row["market_value_usd"]), reverse=True)
def _collect_source(self, source: PortfolioSource) -> dict[str, Any]:
"""Read one source's account and positions, pricing what the connector omits.
@@ -750,18 +732,12 @@ class PortfolioService:
"""
connection, profile = self._connection_profile(source)
if not profile.readonly:
raise RuntimeError(
"portfolio sources must use a read-only connector profile"
)
raise RuntimeError("portfolio sources must use a read-only connector profile")
broker = profile.connector
profile_id = profile.id
longbridge_batch: dict[str, Any] | None = None
if (
broker == "longbridge"
and profile_id == "longbridge-live-sdk-readonly"
and self._use_longbridge_worker
):
isolated = self._read_longbridge_isolated()
if broker == "longbridge" and profile_id == "longbridge-live-sdk-readonly" and self._use_longbridge_worker:
isolated = self._read_longbridge_isolated(connection.id)
account = isolated["account"]
positions_payload = isolated["positions"]
longbridge_batch = isolated.get("quotes")
@@ -771,7 +747,9 @@ class PortfolioService:
# A dashboard refresh must never open a browser. reconnect_source()
# is the one explicit interactive path.
read_options["interactive_oauth"] = False
elif profile.transport == "local_plugin":
elif profile.transport in {"broker_sdk", "local_plugin"} and (
profile.transport == "local_plugin" or self._use_connection_scoped_reads
):
read_options["connection_id"] = connection.id
account = self._get_account(profile_id, **read_options)
positions_payload = self._get_positions(profile_id, **read_options)
@@ -780,10 +758,8 @@ class PortfolioService:
raise RuntimeError(
str(payload.get("error") or f"{broker} {label} read failed")
)
normalized_rows = [
normalize_position(broker, raw)
for raw in positions_payload.get("positions", [])
]
account, positions_payload = adapt_and_validate_payloads(broker, account, positions_payload)
normalized_rows = [normalize_position(broker, raw) for raw in positions_payload.get("positions", [])]
for row in normalized_rows:
row.update(
source_id=source.id,
@@ -802,14 +778,10 @@ class PortfolioService:
"ok",
"success",
}:
longbridge_price_error = str(
longbridge_batch.get("error") or "Longbridge quote read failed"
)[:160]
longbridge_price_error = str(longbridge_batch.get("error") or "Longbridge quote read failed")[:160]
elif broker == "longbridge" and self._get_longbridge_quotes is not None:
try:
batch = self._get_longbridge_quotes(
[str(row["quote_symbol"]) for row in normalized_rows]
)
batch = self._get_longbridge_quotes([str(row["quote_symbol"]) for row in normalized_rows])
longbridge_prices = {
str(item.get("symbol") or "").upper(): _decimal(item.get("last"))
for item in batch.get("quotes", [])
@@ -825,7 +797,7 @@ class PortfolioService:
if quantity == 0:
continue
try:
if broker == "binance" and normalized["symbol"] in STABLECOINS:
if broker in {"binance", "okx"} and normalized["symbol"] in STABLECOINS:
normalized["market_price"] = 1.0
normalized["price_currency"] = "USD"
normalized["pricing_basis"] = "USDT/USD proxy"
@@ -833,15 +805,11 @@ class PortfolioService:
normalized["price_currency"] = normalized["currency"]
normalized["pricing_basis"] = f"{broker} position snapshot"
elif broker == "longbridge" and longbridge_prices is not None:
price = longbridge_prices.get(
str(normalized["quote_symbol"]).upper()
)
price = longbridge_prices.get(str(normalized["quote_symbol"]).upper())
normalized["market_price"] = _number(price) if price else None
normalized["price_currency"] = normalized["currency"]
normalized["pricing_basis"] = (
"Longbridge official quote"
if longbridge_batch is not None
else "Fallback market quote"
"Longbridge official quote" if longbridge_batch is not None else "Fallback market quote"
)
if not price and longbridge_price_error:
normalized["price_error"] = longbridge_price_error
@@ -856,6 +824,7 @@ class PortfolioService:
**(
{"connection_id": connection.id}
if profile.transport == "local_plugin"
or (profile.transport == "broker_sdk" and self._use_connection_scoped_quotes)
else {}
),
)
@@ -865,6 +834,7 @@ class PortfolioService:
except Exception as exc:
normalized["price_error"] = str(exc)[:160]
rows.append(normalized)
ensure_supported_currencies(rows, account)
return {
"source_id": source.id,
"profile_id": connection.profile_id,
@@ -876,7 +846,7 @@ class PortfolioService:
}
@staticmethod
def _read_longbridge_isolated() -> dict[str, Any]:
def _read_longbridge_isolated(connection_id: str) -> dict[str, Any]:
"""Read Longbridge in a throwaway subprocess and parse its payload.
Returns:
@@ -888,7 +858,12 @@ class PortfolioService:
"""
marker = "VIBE_PORTFOLIO_JSON="
process = subprocess.run(
[sys.executable, "-m", "src.portfolio.longbridge_worker"],
[
sys.executable,
"-m",
"src.portfolio.longbridge_worker",
connection_id,
],
check=False,
capture_output=True,
text=True,
@@ -906,9 +881,7 @@ class PortfolioService:
if payload.get("error"):
raise RuntimeError(f"Longbridge isolated reader failed: {payload['error']}")
if "account" not in payload or "positions" not in payload:
raise RuntimeError(
"Longbridge isolated reader returned an incomplete payload"
)
raise RuntimeError("Longbridge isolated reader returned an incomplete payload")
return payload
def _rates(self) -> tuple[Decimal, Decimal, str, bool]:
@@ -930,9 +903,7 @@ class PortfolioService:
cny = self.store.load_fx("USD", "CNY")
hkd = self.store.load_fx("USD", "HKD")
if cny is None or hkd is None:
raise RuntimeError(
"FX service unavailable and no prior USD/CNY + USD/HKD cache exists"
)
raise RuntimeError("FX service unavailable and no prior USD/CNY + USD/HKD cache exists")
return _decimal(cny[0]), _decimal(hkd[0]), min(cny[1], hkd[1]), True
@staticmethod
@@ -943,12 +914,8 @@ class PortfolioService:
``(usd_cny, usd_hkd, fetched_at)``.
"""
url = "https://api.frankfurter.app/latest?from=USD&to=CNY,HKD"
request = urllib.request.Request(
url, headers={"User-Agent": "Vibe-Trading/portfolio"}
)
with urllib.request.urlopen(
request, timeout=8
) as response: # noqa: S310 - fixed HTTPS host
request = urllib.request.Request(url, headers={"User-Agent": "Vibe-Trading/portfolio"})
with urllib.request.urlopen(request, timeout=8) as response: # noqa: S310 - fixed HTTPS host
payload = json.load(response)
return (
_decimal(payload["rates"]["CNY"]),
@@ -957,9 +924,7 @@ class PortfolioService:
)
@staticmethod
def _warnings(
accounts: list[dict[str, Any]], positions: list[dict[str, Any]], fx_stale: bool
) -> list[str]:
def _warnings(accounts: list[dict[str, Any]], positions: list[dict[str, Any]], fx_stale: bool) -> list[str]:
"""Describe every reason this snapshot must be read with caution.
Args:
@@ -976,28 +941,29 @@ class PortfolioService:
"Exchange-rate service unavailable; valued with the last "
"successfully fetched USD/CNY and USD/HKD rates."
)
failed = [
str(row.get("label") or row["broker"])
for row in accounts
if row["status"] != "ok"
]
failed = [str(row.get("label") or row["broker"]) for row in accounts if row["status"] != "ok"]
if failed:
warnings.append(
"These sources failed to refresh and their value is excluded "
"from the totals, positions and combined holdings, so the "
"portfolio shown here is incomplete: " + ", ".join(failed) + "."
)
unpriced = [
f"{row['broker']}:{row['symbol']}"
for row in positions
if not row.get("priced")
]
unpriced = [f"{row['broker']}:{row['symbol']}" for row in positions if not row.get("priced")]
if unpriced:
warnings.append("No price available for these positions: " + ", ".join(unpriced[:20]))
experimental = [
str(row.get("label") or row.get("broker"))
for row in accounts
if row.get("portfolio_compatibility", {}).get("level") == "experimental"
]
if experimental:
warnings.append(
"No price available for these positions: " + ", ".join(unpriced[:20])
)
if any(row.get("broker") == "binance" for row in positions):
warnings.append(
"Binance spot balances are valued with USDT treated as 1 USD."
"Experimental portfolio connectors require broker-specific "
"verification: " + ", ".join(experimental) + "."
)
crypto_proxies = sorted(
{str(row.get("broker")) for row in positions if row.get("broker") in {"binance", "okx"}}
)
if crypto_proxies:
warnings.append(", ".join(crypto_proxies) + " spot balances are valued with USDT treated as 1 USD.")
return warnings
+17 -6
View File
@@ -17,6 +17,7 @@ from src.trading.profiles import (
profile_by_id,
save_selected_profile_id,
)
from src.trading.onboarding import onboarding_dict
from src.trading.service import (
cancel_close_order,
cancel_order,
@@ -148,6 +149,10 @@ TRADING_COMMON_PARAMETERS = {
"type": "string",
"description": "Trading connector profile id, e.g. ibkr-paper-local or robinhood-live-mcp. Defaults to the selected profile.",
},
"connection_id": {
"type": "string",
"description": "Optional local read-only connection instance id. Credentials are loaded from the OS vault and never passed through MCP.",
},
"host": {
"type": "string",
"description": "Optional local TWS/Gateway host override for local profiles.",
@@ -163,10 +168,7 @@ TRADING_COMMON_PARAMETERS = {
"market_type": {
"type": "string",
"enum": ["spot", "usdm"],
"description": (
"Optional Binance read market. USD-M is accepted only by the "
"live read-only Binance profile."
),
"description": ("Optional Binance read market. USD-M is accepted only by the live read-only Binance profile."),
},
"observation_absolute_tolerance": {
"type": "number",
@@ -209,6 +211,7 @@ def _overrides(kwargs: dict[str, Any]) -> dict[str, Any]:
terminal than the caller asked for.
"""
return {
"connection_id": _connection(kwargs.get("connection_id")),
"host": _connection(kwargs.get("host")),
"port": _int_or_none(kwargs.get("port"), "port"),
"client_id": _int_or_none(kwargs.get("client_id"), "client_id"),
@@ -249,7 +252,13 @@ class TradingConnectionsTool(BaseTool):
{
"status": "ok",
"selected_profile": selected,
"profiles": [profile.to_dict(selected=profile.id == selected) for profile in list_profiles()],
"profiles": [
{
**profile.to_dict(selected=profile.id == selected),
"onboarding": onboarding_dict(profile),
}
for profile in list_profiles()
],
}
)
except Exception as exc: # noqa: BLE001
@@ -533,7 +542,9 @@ class TradingCapitalDistributionTool(BaseTool):
"""
name = "trading_capital_distribution"
description = "Read today's capital in-flow vs out-flow snapshot (super/big/mid/small buckets) for a symbol. Read-only."
description = (
"Read today's capital in-flow vs out-flow snapshot (super/big/mid/small buckets) for a symbol. Read-only."
)
parameters = {
"type": "object",
"properties": {
+30 -53
View File
@@ -13,7 +13,8 @@ from typing import Any
from src.config.paths import get_runtime_root
from src.trading.credentials import CredentialStore
from src.trading.local_plugins import discover_plugins, plugin_by_profile_id
from src.trading.local_plugins import discover_plugins
from src.trading.onboarding import onboarding_dict, onboarding_for_profile
from src.trading.profiles import list_profiles, profile_by_id
from src.trading.types import TradingProfile
@@ -36,9 +37,7 @@ def is_portfolio_connection_profile(profile: TradingProfile) -> bool:
Returns:
True when the profile can serve account and position reads.
"""
return bool(profile.readonly) and REQUIRED_READ_CAPABILITIES.issubset(
profile.capabilities
)
return bool(profile.readonly) and REQUIRED_READ_CAPABILITIES.issubset(profile.capabilities)
def _now() -> str:
@@ -53,15 +52,15 @@ def _credential_reference(profile_id: str, connection_id: str) -> str:
connection_id: Identifier of the connection instance.
Returns:
An opaque reference naming where the credentials live. Only local
plugins keep secrets in the OS vault; the other transports reuse the
connector's own OAuth cache, local session, or config file.
An opaque reference naming where the credentials live. SDK profiles
and local plugins use the OS vault; OAuth and local-session transports
retain their own stores.
Raises:
ValueError: If the profile id is unknown.
"""
profile = profile_by_id(profile_id)
if profile.transport == "local_plugin":
if profile.transport in {"broker_sdk", "local_plugin"}:
return CredentialStore.reference(connection_id)
if profile.transport == "remote_mcp":
return f"oauth-cache://{profile.connector}"
@@ -135,9 +134,7 @@ class ConnectionStore:
raise ValueError(f"invalid local connection registry: {exc}") from exc
rows = payload.get("connections", []) if isinstance(payload, dict) else []
if not isinstance(rows, list):
raise ValueError(
"invalid local connection registry: connections must be a list"
)
raise ValueError("invalid local connection registry: connections must be a list")
return [self._parse(row) for row in rows]
def get(self, connection_id: str) -> TradingConnection:
@@ -178,9 +175,7 @@ class ConnectionStore:
self._write(sorted(rows, key=lambda row: (row.created_at, row.id)))
return validated
def create(
self, connection_id: str, profile_id: str, label: str
) -> TradingConnection:
def create(self, connection_id: str, profile_id: str, label: str) -> TradingConnection:
"""Create a new connection instance.
Args:
@@ -208,9 +203,7 @@ class ConnectionStore:
)
)
def ensure(
self, connection_id: str, profile_id: str, label: str
) -> TradingConnection:
def ensure(self, connection_id: str, profile_id: str, label: str) -> TradingConnection:
"""Return an existing connection, creating it when absent.
Args:
@@ -230,9 +223,7 @@ class ConnectionStore:
except ValueError:
return self.create(connection_id, profile_id, label)
if existing.profile_id != str(profile_id or "").strip().lower():
raise ValueError(
f"local connection {existing.id} already uses profile {existing.profile_id}"
)
raise ValueError(f"local connection {existing.id} already uses profile {existing.profile_id}")
return existing
def delete(self, connection_id: str) -> None:
@@ -264,9 +255,10 @@ class ConnectionStore:
for connection in self.list():
profile = profile_by_id(connection.profile_id)
fields = credential_fields(profile.id)
field_status = (
self.credentials.status(connection.id, fields) if fields else {}
required_fields = tuple(
str(field["name"]) for field in credential_field_catalog(profile.id) if field.get("required", True)
)
field_status = self.credentials.status(connection.id, fields) if fields else {}
result.append(
{
**connection.to_dict(),
@@ -278,8 +270,9 @@ class ConnectionStore:
"supports_reconnect": profile.transport == "remote_mcp",
"credential_fields": credential_field_catalog(profile.id),
"credential_status": field_status,
"credentials_configured": bool(field_status)
and all(field_status.values()),
"credentials_configured": bool(required_fields)
and all(field_status.get(field, False) for field in required_fields),
"onboarding": onboarding_dict(profile),
}
)
return result
@@ -307,29 +300,17 @@ class ConnectionStore:
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}"
)
raise ValueError(f"connection profile is not eligible for read-only portfolios: {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"
)
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")
expected_ref = _credential_reference(profile.id, connection_id)
credential_ref = str(raw.get("credential_ref") or expected_ref)
if (
credential_ref == CredentialStore.reference(connection_id)
and profile.transport != "local_plugin"
):
legacy_sdk_ref = f"connector-config://{profile.connector}"
if profile.transport == "broker_sdk" and credential_ref == legacy_sdk_ref:
credential_ref = expected_ref
if credential_ref != expected_ref:
raise ValueError(
"connection credential_ref does not match its local transport"
)
raise ValueError("connection credential_ref does not match its local transport")
return TradingConnection(
id=connection_id,
profile_id=profile.id,
@@ -378,20 +359,15 @@ def credential_field_catalog(profile_id: str) -> list[dict[str, Any]]:
profile_id: Profile to inspect.
Returns:
Field metadata for local plugin profiles, otherwise an empty list
because the other transports own their own credential storage.
Field metadata declared by a local plugin or built-in SDK onboarding
contract. OAuth and local-session transports return an empty list.
Raises:
ValueError: If the profile id is unknown, or names a local plugin that
is not installed.
"""
profile = profile_by_id(profile_id)
if profile.transport == "local_plugin":
return [
field.to_dict()
for field in plugin_by_profile_id(profile.id).credential_fields
]
return []
return [field.to_dict() for field in onboarding_for_profile(profile).credential_fields]
def credential_fields(profile_id: str) -> tuple[str, ...]:
@@ -438,8 +414,9 @@ def readonly_profile_catalog() -> list[dict[str, Any]]:
"local_plugin": profile.id in plugin_ids,
"credential_fields": credential_field_catalog(profile.id),
"supports_reconnect": profile.transport == "remote_mcp",
"onboarding": onboarding_dict(profile),
}
)
return sorted(
rows, key=lambda row: (row["connector"], row["environment"], row["label"])
) + [{"invalid_plugin": True, **error} for error in errors]
return sorted(rows, key=lambda row: (row["connector"], row["environment"], row["label"])) + [
{"invalid_plugin": True, **error} for error in errors
]
@@ -14,7 +14,7 @@ from src.config.paths import get_runtime_root
_CREDENTIAL_FIELDS = ("app_key", "app_secret", "access_token")
_RUNTIME_FILENAME = "longbridge.json"
CredentialSource = Literal["environment", "runtime_file"]
CredentialSource = Literal["environment", "runtime_file", "keyring"]
_RuntimeFileState = Literal["absent", "valid", "invalid"]
@@ -119,14 +119,8 @@ def require_longbridge_credentials(
if resolution.credentials is not None:
return resolution.credentials
if resolution.conflict_fields:
raise LongbridgeCredentialError(
"credentials_conflict", resolution.conflict_fields
)
code = (
"credentials_missing"
if resolution.source is None
else "credentials_partial"
)
raise LongbridgeCredentialError("credentials_conflict", resolution.conflict_fields)
code = "credentials_missing" if resolution.source is None else "credentials_partial"
raise LongbridgeCredentialError(code, resolution.missing_fields)
+6 -2
View File
@@ -133,9 +133,13 @@ class CredentialStore:
Raises:
RuntimeError: If the ``keyring`` extra is not installed.
"""
backend = self.backend
field_names = tuple(fields)
try:
backend = self.backend
except RuntimeError:
return {field: False for field in field_names}
result = {}
for field in fields:
for field in field_names:
try:
result[field] = bool(
backend.get_password(
+197
View File
@@ -0,0 +1,197 @@
"""Machine-readable onboarding contracts for built-in trading connectors.
The contracts deliberately contain no credential values. They describe the
local dependency, authentication shape, and safe verification operation so the
CLI, MCP tools, and Web UI can all drive the same setup flow.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from src.trading.local_plugins import CredentialField, plugin_by_profile_id
from src.trading.types import TradingProfile
@dataclass(frozen=True)
class ConnectorOnboarding:
"""Public setup metadata for one connector implementation."""
auth_type: str
credential_fields: tuple[CredentialField, ...] = ()
dependency: str | None = None
install_command: str | None = None
test_operation: str = "account.read"
setup_hint: str = ""
def to_dict(self) -> dict[str, Any]:
"""Return a JSON-safe contract that never contains credential values."""
return {
"schema_version": 1,
"auth_type": self.auth_type,
"credential_fields": [field.to_dict() for field in self.credential_fields],
"dependency": self.dependency,
"install_command": self.install_command,
"test_operation": self.test_operation,
"setup_hint": self.setup_hint,
"secret_storage": "os_keyring" if self.credential_fields else None,
}
def _field(
name: str,
label: str,
*,
secret: bool = True,
required: bool = True,
) -> CredentialField:
return CredentialField(name=name, label=label, secret=secret, required=required)
_BUILTIN: dict[str, ConnectorOnboarding] = {
"alpaca": ConnectorOnboarding(
auth_type="api_key",
credential_fields=(
_field("api_key", "API Key ID"),
_field("secret_key", "Secret Key"),
),
dependency="alpaca-py",
install_command="pip install alpaca-py keyring",
setup_hint="Use a paper key for paper profiles and a live key for live profiles.",
),
"binance": ConnectorOnboarding(
auth_type="api_key",
credential_fields=(
_field("api_key", "API Key"),
_field("api_secret", "API Secret"),
),
dependency="ccxt",
install_command="pip install ccxt keyring",
setup_hint="Create a read-only Spot API key; do not enable withdrawals.",
),
"dhan": ConnectorOnboarding(
auth_type="access_token",
credential_fields=(
_field("client_id", "Client ID", secret=False),
_field("access_token", "Access Token"),
),
dependency="dhanhq",
install_command="pip install dhanhq keyring",
),
"etoro": ConnectorOnboarding(
auth_type="api_key",
credential_fields=(
_field("api_key", "API Key"),
_field("user_key", "User Key"),
),
dependency="requests",
install_command="pip install keyring",
),
"futu": ConnectorOnboarding(
auth_type="local_gateway",
dependency="futu-api",
install_command="pip install futu-api",
setup_hint="Start and sign in to Futu OpenD on this computer before testing.",
),
"longbridge": ConnectorOnboarding(
auth_type="api_key",
credential_fields=(
_field("app_key", "App Key"),
_field("app_secret", "App Secret"),
_field("access_token", "Access Token"),
),
dependency="longbridge",
install_command="pip install longbridge keyring",
setup_hint="The three LongPort values are stored and resolved as one atomic set.",
),
"mt5": ConnectorOnboarding(
auth_type="local_terminal",
credential_fields=(
_field("login", "Account Login", secret=False),
_field("password", "Account Password"),
_field("server", "Broker Server", secret=False),
_field("terminal_path", "Terminal Path", secret=False, required=False),
_field("symbol_suffix", "Symbol Suffix", secret=False, required=False),
),
dependency="MetaTrader5",
install_command='pip install "vibe-trading-ai[mt5]" keyring',
setup_hint="MetaTrader5 is Windows-only and requires a local terminal session.",
),
"okx": ConnectorOnboarding(
auth_type="api_key",
credential_fields=(
_field("api_key", "API Key"),
_field("api_secret", "API Secret"),
_field("passphrase", "Passphrase"),
),
dependency="python-okx",
install_command="pip install python-okx keyring",
setup_hint="Create a read-only key; do not grant Trade or Withdraw permissions.",
),
"shoonya": ConnectorOnboarding(
auth_type="totp",
credential_fields=(
_field("user_id", "User ID", secret=False),
_field("password", "Password"),
_field("vendor_code", "Vendor Code", secret=False),
_field("api_secret", "API Secret"),
_field("totp_secret", "TOTP Secret"),
),
dependency="NorenRestApiPy",
install_command="pip install NorenRestApiPy pyotp keyring",
),
"tiger": ConnectorOnboarding(
auth_type="private_key_file",
credential_fields=(
_field("tiger_id", "Tiger ID", secret=False),
_field("private_key_path", "Private Key Path", secret=False),
_field("account", "Account", secret=False),
),
dependency="tigeropen",
install_command="pip install tigeropen keyring",
setup_hint="Only the private-key path is stored; the PEM file remains local.",
),
"trading212": ConnectorOnboarding(
auth_type="api_key",
credential_fields=(
_field("api_key", "API Key"),
_field("api_secret", "API Secret", required=False),
),
dependency="requests",
install_command="pip install keyring",
),
}
def onboarding_for_profile(profile: TradingProfile) -> ConnectorOnboarding:
"""Resolve the setup contract for a built-in or local plugin profile."""
if profile.transport == "local_plugin":
plugin = plugin_by_profile_id(profile.id)
return ConnectorOnboarding(
auth_type=plugin.auth_type,
credential_fields=plugin.credential_fields,
setup_hint="Operator-installed read-only connector.",
)
if profile.transport == "remote_mcp":
return ConnectorOnboarding(
auth_type="oauth",
test_operation="account.read",
setup_hint="Authorize through the connector OAuth flow.",
)
if profile.transport == "local_tws":
return ConnectorOnboarding(
auth_type="local_session",
dependency="ib_async",
install_command='pip install "vibe-trading-ai[ibkr]"',
setup_hint="Start TWS or IB Gateway and enable its local API socket.",
)
return _BUILTIN.get(
profile.connector,
ConnectorOnboarding(auth_type="connector_managed"),
)
def onboarding_dict(profile: TradingProfile) -> dict[str, Any]:
"""Return the public onboarding contract for ``profile``."""
return onboarding_for_profile(profile).to_dict()
+80 -28
View File
@@ -39,6 +39,64 @@ def _sdk_module(connector: str):
return importlib.import_module(path)
def _sdk_config(
profile: TradingProfile,
module: Any,
overrides: dict[str, Any],
) -> Any:
"""Build an SDK config, preferring one connection's OS-vault credentials.
A connection-scoped call never receives raw credentials from MCP or Web
request arguments. When the vault has a credential set it must contain
every required field and it replaces, rather than mixes with, the legacy
connector JSON. With no vault values the old config/env resolver remains
the compatibility fallback.
"""
from src.trading.connections import (
ConnectionStore,
credential_field_catalog,
credential_fields,
)
options = dict(overrides or {})
connection_id = str(options.pop("connection_id", "") or "").strip().lower()
if not connection_id:
return module.build_config(profile.config, options)
store = ConnectionStore()
connection = store.get(connection_id)
if connection.profile_id != profile.id:
raise ValueError("local connection profile does not match the requested SDK profile")
fields = credential_fields(profile.id)
direct_secret_fields = set(fields).intersection(options)
if direct_secret_fields:
raise ValueError("connection-scoped credentials must come from the OS credential vault")
try:
credentials = store.credentials.load(connection.id, fields) if fields else {}
except RuntimeError:
# A base install without keyring must keep legacy connector JSON/env
# configurations working. Saving a new secret still reports the normal
# actionable keyring installation error at the credential endpoint.
credentials = {}
if not credentials:
return module.build_config(profile.config, options)
required = {str(field["name"]) for field in credential_field_catalog(profile.id) if field.get("required", True)}
missing = sorted(required.difference(credentials))
if missing:
raise ValueError("OS credential vault entry is incomplete; missing " + ", ".join(missing))
# Resolve only the config class through the connector's existing builder,
# then construct a fresh instance so credentials cannot be mixed with a
# different account's legacy JSON file.
config_type = type(module.build_config(profile.config, options))
payload = {**dict(profile.config), **credentials, **options}
if profile.connector == "longbridge":
payload["_credential_source"] = "keyring"
return config_type.from_mapping(payload)
def _local_plugin_call(
profile: TradingProfile,
operation: str,
@@ -87,7 +145,7 @@ def check_connection(profile_id: str | None = None, **overrides: Any) -> dict[st
if profile.transport == "broker_sdk":
module = _sdk_module(profile.connector)
report = module.check_status(module.build_config(profile.config, overrides))
report = module.check_status(_sdk_config(profile, module, overrides))
report["profile_id"] = profile.id
report["connector"] = profile.connector
report["environment"] = profile.environment
@@ -110,7 +168,7 @@ def get_account(profile_id: str | None = None, **overrides: Any) -> dict[str, An
return _with_profile(profile, get_account_snapshot(_ibkr_config(profile, overrides)))
if profile.transport == "broker_sdk":
module = _sdk_module(profile.connector)
return _with_profile(profile, module.get_account_snapshot(module.build_config(profile.config, overrides)))
return _with_profile(profile, module.get_account_snapshot(_sdk_config(profile, module, overrides)))
if profile.transport == "local_plugin":
return _with_profile(
profile,
@@ -133,7 +191,7 @@ def get_positions(profile_id: str | None = None, **overrides: Any) -> dict[str,
return _with_profile(profile, _get_positions(_ibkr_config(profile, overrides)))
if profile.transport == "broker_sdk":
module = _sdk_module(profile.connector)
return _with_profile(profile, module.get_positions(module.build_config(profile.config, overrides)))
return _with_profile(profile, module.get_positions(_sdk_config(profile, module, overrides)))
if profile.transport == "local_plugin":
return _with_profile(
profile,
@@ -166,9 +224,7 @@ def get_open_orders(
module = _sdk_module(profile.connector)
return _with_profile(
profile,
module.get_open_orders(
module.build_config(profile.config, overrides), include_executions=include_executions
),
module.get_open_orders(_sdk_config(profile, module, overrides), include_executions=include_executions),
)
if profile.transport == "local_plugin":
return _with_profile(
@@ -209,7 +265,7 @@ def get_quote(
)
if profile.transport == "broker_sdk":
module = _sdk_module(profile.connector)
return _with_profile(profile, module.get_quote(symbol, config=module.build_config(profile.config, overrides)))
return _with_profile(profile, module.get_quote(symbol, config=_sdk_config(profile, module, overrides)))
if profile.transport == "local_plugin":
return _with_profile(
profile,
@@ -237,7 +293,7 @@ def search_instruments(
profile,
module.search_instruments(
query,
module.build_config(profile.config, overrides),
_sdk_config(profile, module, overrides),
limit=limit,
mode=mode,
instrument_type_id=instrument_type_id,
@@ -292,7 +348,7 @@ def get_history(
profile,
module.get_historical_bars(
symbol,
config=module.build_config(profile.config, overrides),
config=_sdk_config(profile, module, overrides),
period=period,
limit=limit,
),
@@ -332,7 +388,7 @@ def get_rehab(symbol: str, profile_id: str | None = None, **overrides: Any) -> d
return _unsupported(profile, "rehab.read")
return _with_profile(
profile,
fn(symbol, config=module.build_config(profile.config, overrides)),
fn(symbol, config=_sdk_config(profile, module, overrides)),
)
@@ -355,15 +411,13 @@ def get_capital_flow(
profile,
fn(
symbol,
config=module.build_config(profile.config, overrides),
config=_sdk_config(profile, module, overrides),
period_type=period_type,
),
)
def get_capital_distribution(
symbol: str, profile_id: str | None = None, **overrides: Any
) -> dict[str, Any]:
def get_capital_distribution(symbol: str, profile_id: str | None = None, **overrides: Any) -> dict[str, Any]:
"""Latest capital in-flow vs out-flow snapshot for ``symbol``."""
profile = profile_by_id(profile_id)
if profile.transport != "broker_sdk":
@@ -374,7 +428,7 @@ def get_capital_distribution(
return _unsupported(profile, "capital_distribution.read")
return _with_profile(
profile,
fn(symbol, config=module.build_config(profile.config, overrides)),
fn(symbol, config=_sdk_config(profile, module, overrides)),
)
@@ -399,7 +453,7 @@ def get_history_deals(
fn(
start,
end,
config=module.build_config(profile.config, overrides),
config=_sdk_config(profile, module, overrides),
code=code,
),
)
@@ -422,7 +476,7 @@ def get_acc_cash_flow(
profile,
fn(
clearing_date,
config=module.build_config(profile.config, overrides),
config=_sdk_config(profile, module, overrides),
),
)
@@ -447,7 +501,7 @@ def get_financials(
profile,
fn(
symbol,
config=module.build_config(profile.config, overrides),
config=_sdk_config(profile, module, overrides),
statement_type=statement_type,
num=num,
),
@@ -473,7 +527,7 @@ def get_earnings_calendar(
return _with_profile(
profile,
fn(
config=module.build_config(profile.config, overrides),
config=_sdk_config(profile, module, overrides),
market=market,
begin_date=begin_date,
end_date=end_date,
@@ -565,7 +619,7 @@ def place_order(
return _unsupported(profile, "orders.place")
module = _sdk_module(profile.connector)
config = module.build_config(profile.config, overrides)
config = _sdk_config(profile, module, overrides)
place_kwargs = {
"symbol": symbol,
"side": side,
@@ -624,7 +678,7 @@ def cancel_order(
if profile.readonly:
return _unsupported(profile, "orders.cancel")
module = _sdk_module(profile.connector)
config = module.build_config(profile.config, overrides)
config = _sdk_config(profile, module, overrides)
result = module.cancel_order(config, order_id, symbol=symbol)
if profile.environment == "live":
_audit_live_cancel(profile, order_id, symbol, result, session_id)
@@ -652,7 +706,7 @@ def _route_sdk_write(
if profile.readonly:
return _unsupported(profile, unsupported_capability)
module = _sdk_module(profile.connector)
config = module.build_config(profile.config, overrides)
config = _sdk_config(profile, module, overrides)
if profile.environment == "paper":
return _with_profile(profile, execute(config))
from src.live.sdk_order_gate import execute_live_action
@@ -931,7 +985,7 @@ def etoro_copy_precheck(
if blocked is not None:
return blocked
module = _sdk_module(profile.connector)
config = module.build_config(profile.config, overrides)
config = _sdk_config(profile, module, overrides)
return _with_profile(
profile,
module.copy_precheck(
@@ -977,7 +1031,7 @@ def etoro_copy_start(
account_currency: str | None = None
structural_reason: str | None = None
if profile.environment == "live" and amount_value > 0:
config = module.build_config(profile.config, overrides)
config = _sdk_config(profile, module, overrides)
try:
account_currency = _etoro_account_currency(module.get_account_snapshot(config))
except Exception as exc: # noqa: BLE001
@@ -1041,7 +1095,7 @@ def etoro_copy_poll(
if blocked is not None:
return blocked
module = _sdk_module(profile.connector)
config = module.build_config(profile.config, overrides)
config = _sdk_config(profile, module, overrides)
return _with_profile(profile, module.copy_poll(config, reference_id=reference_id, request_id=request_id))
@@ -1344,9 +1398,7 @@ def _remote_arguments(connector: str, operation: str, arguments: dict[str, Any])
return {}
def _normalize_remote_result(
connector: str, operation: str, result: dict[str, Any]
) -> dict[str, Any]:
def _normalize_remote_result(connector: str, operation: str, result: dict[str, Any]) -> dict[str, Any]:
"""Map connector-specific MCP envelopes into shared read payloads."""
if connector == "ibkr":
from src.trading.connectors.ibkr.mcp import normalize_result
+70
View File
@@ -0,0 +1,70 @@
from __future__ import annotations
from cli import _legacy
from src.trading.connections import ConnectionStore
from src.trading.credentials import CredentialStore
class _MemoryCredentials:
def __init__(self):
self.values: dict[tuple[str, str], str] = {}
def get_password(self, service_name: str, username: str):
return self.values.get((service_name, username))
def set_password(self, service_name: str, username: str, password: str):
self.values[(service_name, username)] = password
def delete_password(self, service_name: str, username: str):
self.values.pop((service_name, username), None)
def test_connector_setup_parser_exposes_a_safe_local_onboarding_command():
args = _legacy._build_parser().parse_args(
[
"connector",
"setup",
"okx-live-sdk-readonly",
"--connection-id",
"main-okx",
"--label",
"Main OKX",
"--skip-check",
]
)
assert args.connector_command == "setup"
assert args.connection_id == "main-okx"
assert args.label == "Main OKX"
assert args.skip_check is True
def test_connector_setup_prompts_locally_and_never_writes_secrets_to_registry(
tmp_path,
monkeypatch,
):
from src.trading import connections, service
credentials = CredentialStore(_MemoryCredentials())
store = ConnectionStore(tmp_path / "connections.json", credential_store=credentials)
monkeypatch.setattr(connections, "ConnectionStore", lambda: store)
monkeypatch.setattr(service, "check_connection", lambda *args, **kwargs: {"status": "ok"})
answers = iter(("key-value", "secret-value", "passphrase-value"))
monkeypatch.setattr(_legacy.Prompt, "ask", lambda *args, **kwargs: next(answers))
result = _legacy.cmd_connector_setup(
"okx-live-sdk-readonly",
connection_id="main-okx",
label="Main OKX",
)
assert result == _legacy.EXIT_SUCCESS
assert credentials.load("main-okx", ("api_key", "api_secret", "passphrase")) == {
"api_key": "key-value",
"api_secret": "secret-value",
"passphrase": "passphrase-value",
}
registry = (tmp_path / "connections.json").read_text(encoding="utf-8")
assert "key-value" not in registry
assert "secret-value" not in registry
assert "passphrase-value" not in registry
+20 -5
View File
@@ -53,16 +53,31 @@ def test_connection_routes_create_list_and_check_without_returning_secrets(
},
)
assert created.status_code == 200
saved = client.post(
"/api/connections/main-binance/credentials",
json={
"values": {
"api_key": "must-not-leak-key",
"api_secret": "must-not-leak-secret",
}
},
)
assert saved.status_code == 200
listed = client.get("/api/connections").json()
assert listed["connections"][0]["id"] == "main-binance"
assert "secret" not in str(listed).lower()
assert listed["connections"][0]["portfolio_compatibility"]["level"] == "native"
binance_profiles = [row for row in listed["profiles"] if row.get("connector") == "binance"]
assert {row["portfolio_compatibility"]["level"] for row in binance_profiles} == {"native"}
assert "must-not-leak" not in str(listed).lower()
assert [field["name"] for field in listed["connections"][0]["credential_fields"]] == [
"api_key",
"api_secret",
]
checked = client.post("/api/connections/main-binance/check")
assert checked.json()["report"]["connection_id"] == "main-binance"
def test_connection_routes_reject_credentials_not_declared_by_profile(
tmp_path, monkeypatch
):
def test_connection_routes_reject_credentials_not_declared_by_profile(tmp_path, monkeypatch):
store = ConnectionStore(
tmp_path / "connections.json",
credential_store=CredentialStore(_MemoryCredentials()),
@@ -75,6 +90,6 @@ def test_connection_routes_reject_credentials_not_declared_by_profile(
response = client.post(
"/api/connections/main-binance/credentials",
json={"values": {"api_secret": "must-not-be-accepted"}},
json={"values": {"withdrawal_token": "must-not-be-accepted"}},
)
assert response.status_code == 400
+85 -11
View File
@@ -36,7 +36,7 @@ def test_connection_registry_never_serializes_credentials(tmp_path):
payload = (tmp_path / "connections.json").read_text(encoding="utf-8")
assert "secret-value" not in payload
assert connection.credential_ref == "connector-config://binance"
assert connection.credential_ref == "keyring://vibe-trading/main-binance"
assert (tmp_path / "connections.json").stat().st_mode & 0o777 == 0o600
@@ -140,15 +140,89 @@ def get_positions(*, credentials, config):
registry = ConnectionStore()
registry.create("my-sample", "sample-live-readonly", "My Sample")
assert (
get_account("sample-live-readonly", connection_id="my-sample")["account"][
"portfolio_value"
]
== "42"
assert get_account("sample-live-readonly", connection_id="my-sample")["account"]["portfolio_value"] == "42"
assert get_positions("sample-live-readonly", connection_id="my-sample")["positions"][0]["symbol"] == "DEMO"
def test_builtin_sdk_connections_use_isolated_keyring_credentials(
tmp_path,
monkeypatch,
):
from src.trading import connections
from src.trading.profiles import profile_by_id
from src.trading.service import _sdk_config, _sdk_module
credentials = CredentialStore(_MemoryCredentials())
class CredentialFactory:
reference = staticmethod(CredentialStore.reference)
def __new__(cls):
return credentials
monkeypatch.setattr(connections, "get_runtime_root", lambda: tmp_path)
monkeypatch.setattr(connections, "CredentialStore", CredentialFactory)
store = ConnectionStore()
store.create("binance-one", "binance-live-sdk-readonly", "Binance one")
store.create("binance-two", "binance-live-sdk-readonly", "Binance two")
credentials.save(
"binance-one",
{"api_key": "first-key", "api_secret": "first-secret"},
)
assert (
get_positions("sample-live-readonly", connection_id="my-sample")["positions"][
0
]["symbol"]
== "DEMO"
credentials.save(
"binance-two",
{"api_key": "second-key", "api_secret": "second-secret"},
)
profile = profile_by_id("binance-live-sdk-readonly")
module = _sdk_module("binance")
first = _sdk_config(profile, module, {"connection_id": "binance-one"})
second = _sdk_config(profile, module, {"connection_id": "binance-two"})
assert (first.api_key, first.api_secret) == ("first-key", "first-secret")
assert (second.api_key, second.api_secret) == ("second-key", "second-secret")
def test_builtin_sdk_connection_rejects_partial_vault_set(tmp_path, monkeypatch):
from src.trading import connections
from src.trading.profiles import profile_by_id
from src.trading.service import _sdk_config, _sdk_module
credentials = CredentialStore(_MemoryCredentials())
class CredentialFactory:
reference = staticmethod(CredentialStore.reference)
def __new__(cls):
return credentials
monkeypatch.setattr(connections, "get_runtime_root", lambda: tmp_path)
monkeypatch.setattr(connections, "CredentialStore", CredentialFactory)
ConnectionStore().create(
"partial-okx",
"okx-live-sdk-readonly",
"Partial OKX",
)
credentials.save("partial-okx", {"api_key": "only-one-field"})
with pytest.raises(ValueError, match="api_secret, passphrase"):
_sdk_config(
profile_by_id("okx-live-sdk-readonly"),
_sdk_module("okx"),
{"connection_id": "partial-okx"},
)
def test_mcp_connector_discovery_exposes_onboarding_contract_without_values():
from src.tools.trading_connector_tool import TradingConnectionsTool
payload = json.loads(TradingConnectionsTool().execute())
okx = next(profile for profile in payload["profiles"] if profile["id"] == "okx-live-sdk-readonly")
assert okx["onboarding"]["dependency"] == "python-okx"
assert [field["name"] for field in okx["onboarding"]["credential_fields"]] == [
"api_key",
"api_secret",
"passphrase",
]
assert "credential_values" not in okx["onboarding"]
+174
View File
@@ -0,0 +1,174 @@
from __future__ import annotations
import pytest
from src.portfolio.compatibility import (
PortfolioContractError,
adapt_and_validate_payloads,
ensure_supported_currencies,
profile_compatibility,
)
from src.portfolio.config import eligible_profiles
from src.portfolio.normalization import normalize_position
from src.trading.types import TradingProfile
def test_every_builtin_portfolio_connector_has_an_explicit_compatibility_tier():
expected = {
"alpaca": "contract_tested",
"binance": "native",
"dhan": "experimental",
"etoro": "experimental",
"futu": "experimental",
"ibkr": "native",
"longbridge": "native",
"mt5": "experimental",
"okx": "contract_tested",
"shoonya": "experimental",
"tiger": "experimental",
"trading212": "experimental",
}
observed = {profile.connector: profile_compatibility(profile)["level"] for profile in eligible_profiles()}
assert observed == expected
def test_unknown_local_connector_is_experimental_by_default():
profile = TradingProfile(
id="sample-live-readonly",
connector="sample",
label="Sample",
environment="live",
transport="local_plugin",
capabilities=("account.read", "positions.read"),
readonly=True,
)
compatibility = profile_compatibility(profile)
assert compatibility["level"] == "experimental"
assert compatibility["contract_version"] == 1
def test_contract_rejects_position_rows_without_symbol_or_quantity():
with pytest.raises(PortfolioContractError, match="no symbol"):
adapt_and_validate_payloads("sample", {"account": {}}, {"positions": [{"quantity": 1}]})
with pytest.raises(PortfolioContractError, match="no quantity"):
adapt_and_validate_payloads("sample", {"account": {}}, {"positions": [{"symbol": "DEMO"}]})
def test_okx_account_details_are_adapted_to_spot_positions():
account, positions = adapt_and_validate_payloads(
"okx",
{
"account": {
"total_equity": "65250",
"details": [
{
"currency": "BTC",
"equity": "1.5",
"available": "1.0",
"frozen": "0.5",
},
{
"currency": "USDT",
"equity": "250",
"available": "250",
"frozen": "0",
},
{"currency": "ETH", "equity": "0"},
],
}
},
{"positions": []},
)
assert account["account"]["total_equity"] == "65250"
assert positions["positions"] == [
{
"symbol": "BTC",
"quantity": "1.5",
"currency": "USD",
"quote_symbol": "BTC-USDT",
"asset_type": "crypto",
"free": "1.0",
"used": "0.5",
"source": "spot",
},
{
"symbol": "USDT",
"quantity": "250",
"currency": "USD",
"quote_symbol": "USDT",
"asset_type": "stablecoin",
"free": "250",
"used": "0",
"source": "spot",
},
]
def test_contract_propagates_account_currency_and_rejects_unsupported_fx():
account, positions = adapt_and_validate_payloads(
"dhan",
{"account": {"currency": "INR"}},
{"positions": [{"symbol": "RELIANCE", "quantity": 1}]},
)
assert positions["positions"][0]["currency"] == "INR"
with pytest.raises(PortfolioContractError, match="INR"):
ensure_supported_currencies(positions["positions"], account)
def test_cash_only_unsupported_currency_fails_closed() -> None:
with pytest.raises(PortfolioContractError, match="INR"):
ensure_supported_currencies([], {"account": {"currency": "INR"}})
@pytest.mark.parametrize(
("connector", "raw", "expected"),
[
(
"trading212",
{
"ticker": "AAPL_US_EQ",
"quantity": 2,
"average_price": 150,
"current_price": 151.25,
"currency": "USD",
},
("AAPL_US_EQ", 2.0, 150.0, 151.25),
),
(
"shoonya",
{"symbol": "INFY", "quantity": 3, "average_cost": 10, "ltp": 12},
("INFY", 3.0, 10.0, 12.0),
),
(
"mt5",
{
"symbol": "EURUSD",
"volume": 0.5,
"price_open": 1.1,
"price_current": 1.2,
},
("EURUSD", 0.5, 1.1, 1.2),
),
(
"etoro",
{"symbol": "Apple", "units": 4, "open_rate": 180},
("APPLE", 4.0, 180.0, None),
),
],
)
def test_generic_normalizer_accepts_builtin_connector_aliases(connector, raw, expected):
row = normalize_position(connector, raw)
assert (
row["symbol"],
row["quantity"],
row["cost_price"],
row["market_price"],
) == expected
+92 -32
View File
@@ -17,9 +17,7 @@ from src.trading.types import TradingProfile
def _settings_store(tmp_path):
store = PortfolioSettingsStore(tmp_path / "portfolio.json")
store.connection_store.ensure("ibkr", "ibkr-live-local-readonly", "IBKR")
store.connection_store.ensure(
"longbridge", "longbridge-live-sdk-readonly", "Longbridge"
)
store.connection_store.ensure("longbridge", "longbridge-live-sdk-readonly", "Longbridge")
store.connection_store.ensure("binance", "binance-live-sdk-readonly", "Binance")
store.save(
{
@@ -36,12 +34,8 @@ def _settings_store(tmp_path):
def test_refresh_aggregates_three_readonly_connectors(tmp_path):
accounts = {
"ibkr-live-local-readonly": {
"summary": [{"tag": "NetLiquidation", "value": "1000", "currency": "USD"}]
},
"longbridge-live-sdk-readonly": {
"balances": [{"net_assets": "7800", "currency": "HKD"}]
},
"ibkr-live-local-readonly": {"summary": [{"tag": "NetLiquidation", "value": "1000", "currency": "USD"}]},
"longbridge-live-sdk-readonly": {"balances": [{"net_assets": "7800", "currency": "HKD"}]},
"binance-live-sdk-readonly": {"balances": []},
}
positions = {
@@ -114,6 +108,30 @@ def test_refresh_aggregates_three_readonly_connectors(tmp_path):
assert "quantity" not in context["holdings"][0]
def test_latest_enriches_legacy_snapshot_with_current_compatibility(tmp_path):
store = PortfolioStore(tmp_path / "portfolio.sqlite3")
settings = _settings_store(tmp_path)
store.save_snapshot(
{
"snapshot_id": "legacy-snapshot",
"valuation_version": portfolio_service.PORTFOLIO_VALUATION_VERSION,
"created_at": "2026-08-09T00:00:00+00:00",
"complete": True,
"totals": {"usd": 0, "cny": 0},
"accounts": [
{"source_id": source, "broker": source, "status": "ok"} for source in ("ibkr", "longbridge", "binance")
],
"positions": [],
"warnings": [],
}
)
latest = PortfolioService(store, settings_store=settings).latest()
assert latest is not None
assert {row["portfolio_compatibility"]["level"] for row in latest["accounts"]} == {"native"}
def test_partial_refresh_is_saved_and_marked_incomplete(tmp_path):
def get_account(profile_id):
if profile_id.startswith("longbridge"):
@@ -146,11 +164,7 @@ def test_failed_source_is_excluded_from_totals_and_reports_its_last_success(tmp_
if offline and profile_id.startswith("ibkr"):
raise ConnectionError("offline")
if profile_id.startswith("ibkr"):
return {
"summary": [
{"tag": "NetLiquidation", "value": "1000", "currency": "USD"}
]
}
return {"summary": [{"tag": "NetLiquidation", "value": "1000", "currency": "USD"}]}
return {"summary": []}
service = PortfolioService(
@@ -199,9 +213,7 @@ def test_failed_source_is_excluded_from_totals_and_reports_its_last_success(tmp_
assert ibkr["total_cny"] is None
assert ibkr["position_count"] == 0
assert [item["broker"] for item in partial["positions"] if item["broker"] == "ibkr"] == []
assert [
item for item in partial["combined_holdings"] if "ibkr" in item["brokers"]
] == []
assert [item for item in partial["combined_holdings"] if "ibkr" in item["brokers"]] == []
assert partial["valuation"]["priced_usd"] == 0.0
# ...but the dashboard can still say when that source was last healthy.
@@ -212,9 +224,7 @@ def test_failed_source_is_excluded_from_totals_and_reports_its_last_success(tmp_
assert all("data_state" not in item for item in partial["accounts"])
assert all("stale" not in item for item in partial["positions"])
excluded = [
warning for warning in partial["warnings"] if "excluded" in warning.lower()
]
excluded = [warning for warning in partial["warnings"] if "excluded" in warning.lower()]
assert len(excluded) == 1
assert "IBKR" in excluded[0]
@@ -249,9 +259,7 @@ def test_a_source_that_never_succeeded_reports_no_last_success_time(tmp_path):
assert ibkr["last_success_at"] is None
def test_remote_mcp_sources_are_read_without_an_interactive_oauth_prompt(
tmp_path, monkeypatch
):
def test_remote_mcp_sources_are_read_without_an_interactive_oauth_prompt(tmp_path, monkeypatch):
"""A dashboard refresh must never be able to pop a browser, for any connector."""
settings = PortfolioSettingsStore(tmp_path / "portfolio.json")
settings.connection_store.ensure("remote", "alpaca-live-sdk-readonly", "Remote")
@@ -309,12 +317,8 @@ def test_remote_mcp_sources_are_read_without_an_interactive_oauth_prompt(
def test_analysis_context_supplies_risk_xray_arguments(tmp_path):
"""The portfolio feeds the existing risk x-ray; it does not reimplement it."""
accounts = {
"ibkr-live-local-readonly": {
"summary": [{"tag": "NetLiquidation", "value": "300", "currency": "USD"}]
},
"longbridge-live-sdk-readonly": {
"balances": [{"net_assets": "3900", "currency": "HKD"}]
},
"ibkr-live-local-readonly": {"summary": [{"tag": "NetLiquidation", "value": "300", "currency": "USD"}]},
"longbridge-live-sdk-readonly": {"balances": [{"net_assets": "3900", "currency": "HKD"}]},
"binance-live-sdk-readonly": {"balances": []},
}
positions = {
@@ -427,9 +431,7 @@ def test_generic_readonly_profile_uses_common_account_and_position_fields(tmp_pa
service = PortfolioService(
PortfolioStore(tmp_path / "portfolio.sqlite3"),
settings_store=settings,
get_account=lambda profile_id: {
"account": {"portfolio_value": "1000", "cash": "200", "currency": "USD"}
},
get_account=lambda profile_id: {"account": {"portfolio_value": "1000", "cash": "200", "currency": "USD"}},
get_positions=lambda profile_id: {
"positions": [
{
@@ -460,6 +462,64 @@ def test_generic_readonly_profile_uses_common_account_and_position_fields(tmp_pa
assert position["market_value_usd"] == 800.0
def test_okx_spot_balances_flow_through_the_generic_portfolio_contract(tmp_path):
settings = PortfolioSettingsStore(tmp_path / "portfolio.json")
settings.connection_store.ensure(
"okx-spot",
"okx-live-sdk-readonly",
"OKX Spot",
)
settings.save(
{
"display_currency": "USD",
"sources": [
{
"connection_id": "okx-spot",
"label": "OKX Spot",
"enabled": True,
"order": 0,
"include_cash": True,
}
],
}
)
quoted = []
def get_quote(symbol, profile_id, **kwargs):
quoted.append((symbol, profile_id))
return {"quote": {"last": "40000"}}
service = PortfolioService(
PortfolioStore(tmp_path / "portfolio.sqlite3"),
settings_store=settings,
get_account=lambda profile_id: {
"account": {
"total_equity": "60250",
"details": [
{"currency": "BTC", "equity": "1.5", "available": "1.5"},
{"currency": "USDT", "equity": "250", "available": "250"},
],
}
},
get_positions=lambda profile_id: {"positions": []},
get_quote=get_quote,
fx_fetcher=lambda: (
Decimal("7.2"),
Decimal("7.8"),
"2026-08-25T00:00:00+00:00",
),
)
snapshot = service.refresh()
assert snapshot["complete"] is True
assert snapshot["totals"]["usd"] == 60250.0
assert [row["symbol"] for row in snapshot["positions"]] == ["BTC", "USDT"]
assert quoted == [("BTC-USDT", "okx-live-sdk-readonly")]
assert snapshot["accounts"][0]["portfolio_compatibility"]["level"] == ("contract_tested")
assert any("okx" in warning.lower() for warning in snapshot["warnings"])
def test_auth_metadata_describes_the_profile_without_claiming_key_permissions():
profile = TradingProfile(
id="example-live-readonly",
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { CheckCircle2, FolderCode, Loader2, PlugZap, Plus, ShieldCheck, Trash2, X } from "lucide-react";
import { PortfolioCompatibilityBadge } from "@/components/portfolio/PortfolioCompatibilityBadge";
import { api, type ConnectionsResponse, type LocalConnection } from "@/lib/api";
interface Props {
@@ -10,6 +11,7 @@ interface Props {
}
const fieldClass = "w-full rounded-md border bg-background px-3 py-2 text-sm outline-none focus:border-primary focus:ring-2 focus:ring-primary/20";
type TestResult = { ok: boolean; message: string };
/**
* Drawer for the connections that exist only on this computer: create one from
@@ -24,6 +26,7 @@ export function ConnectionCenter({ open, onClose, onChanged }: Props) {
const [label, setLabel] = useState("");
const [busy, setBusy] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [testResults, setTestResults] = useState<Record<string, TestResult>>({});
async function load() {
setData(await api.getConnections());
@@ -36,6 +39,12 @@ export function ConnectionCenter({ open, onClose, onChanged }: Props) {
}, [open]);
const profiles = useMemo(() => data?.profiles.filter((profile) => !profile.invalid_plugin) ?? [], [data]);
const selectedProfile = profiles.find((profile) => profile.id === profileId);
const compatibilityLabel = (level: "native" | "contract_tested" | "experimental") => level === "native"
? t("portfolio.compatibility.native")
: level === "contract_tested"
? t("portfolio.compatibility.contractTested")
: t("portfolio.compatibility.experimental");
function chooseProfile(value: string) {
setProfileId(value);
@@ -70,13 +79,35 @@ export function ConnectionCenter({ open, onClose, onChanged }: Props) {
async function test(connection: LocalConnection) {
setBusy(`test:${connection.id}`);
setMessage(null);
setTestResults((current) => {
const next = { ...current };
delete next[connection.id];
return next;
});
try {
const result = await api.checkConnection(connection.id);
const ok = result.report.status === "ok" || result.report.configured === true;
setMessage(ok ? t("portfolio.connections.testOk", { label: connection.label }) : JSON.stringify(result.report));
const error = typeof result.report.error === "string"
? result.report.error
: t("portfolio.connections.unknownTestError");
setTestResults((current) => ({
...current,
[connection.id]: {
ok,
message: ok
? t("portfolio.connections.testOk", { label: connection.label })
: t("portfolio.connections.testFailed", { label: connection.label, error }),
},
}));
} catch (error) {
setMessage(error instanceof Error ? error.message : String(error));
const detail = error instanceof Error ? error.message : String(error);
setTestResults((current) => ({
...current,
[connection.id]: {
ok: false,
message: t("portfolio.connections.testFailed", { label: connection.label, error: detail }),
},
}));
} finally {
setBusy(null);
}
@@ -112,16 +143,17 @@ export function ConnectionCenter({ open, onClose, onChanged }: Props) {
<div className="flex items-center gap-2"><Plus className="h-4 w-4 text-primary" /><h3 className="font-medium">{t("portfolio.connections.createTitle")}</h3></div>
<p className="mt-1 text-xs text-muted-foreground">{t("portfolio.connections.createHint")}</p>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<label className="text-xs text-muted-foreground sm:col-span-2">{t("portfolio.connections.profile")}<select value={profileId} onChange={(event) => chooseProfile(event.target.value)} className={`mt-1 ${fieldClass}`}><option value="">{t("portfolio.connections.chooseProfile")}</option>{profiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.label}</option>)}</select></label>
<label className="text-xs text-muted-foreground sm:col-span-2">{t("portfolio.connections.profile")}<select value={profileId} onChange={(event) => chooseProfile(event.target.value)} className={`mt-1 ${fieldClass}`}><option value="">{t("portfolio.connections.chooseProfile")}</option>{profiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.label} · {compatibilityLabel(profile.portfolio_compatibility?.level ?? "experimental")}</option>)}</select></label>
<label className="text-xs text-muted-foreground">{t("portfolio.connections.localId")}<input value={connectionId} onChange={(event) => setConnectionId(event.target.value.toLowerCase())} className={`mt-1 ${fieldClass}`} placeholder="my-broker-live" /></label>
<label className="text-xs text-muted-foreground">{t("portfolio.connections.displayName")}<input value={label} onChange={(event) => setLabel(event.target.value)} className={`mt-1 ${fieldClass}`} placeholder={t("portfolio.connections.displayNamePlaceholder")} /></label>
</div>
{selectedProfile?.onboarding ? <SetupContract onboarding={selectedProfile.onboarding} /> : null}
<button type="button" onClick={() => void create()} disabled={!profileId || busy === "create"} className="mt-3 inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground disabled:opacity-40">{busy === "create" ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}{t("portfolio.connections.create")}</button>
</section>
<section>
<div className="flex items-end justify-between"><div><h3 className="font-medium">{t("portfolio.connections.listTitle")}</h3><p className="mt-1 text-xs text-muted-foreground">{t("portfolio.connections.listCount", { count: data?.connections.length ?? 0 })}</p></div><ShieldCheck className="h-5 w-5 text-positive" /></div>
<div className="mt-3 space-y-3">{data?.connections.map((connection) => <ConnectionCard key={connection.id} connection={connection} busy={busy} onTest={test} onDelete={remove} onSaved={async () => { await load(); await onChanged(); }} setBusy={setBusy} setMessage={setMessage} />)}{data && !data.connections.length ? <div className="rounded-lg border border-dashed p-8 text-center text-sm text-muted-foreground">{t("portfolio.connections.empty")}</div> : null}</div>
<div className="mt-3 space-y-3">{data?.connections.map((connection) => <ConnectionCard key={connection.id} connection={connection} busy={busy} testResult={testResults[connection.id]} onTest={test} onDelete={remove} onSaved={async () => { await load(); await onChanged(); }} setBusy={setBusy} setMessage={setMessage} />)}{data && !data.connections.length ? <div className="rounded-lg border border-dashed p-8 text-center text-sm text-muted-foreground">{t("portfolio.connections.empty")}</div> : null}</div>
</section>
<section className="rounded-xl border border-dashed p-4">
@@ -134,7 +166,7 @@ export function ConnectionCenter({ open, onClose, onChanged }: Props) {
</div>;
}
function ConnectionCard({ connection, busy, onTest, onDelete, onSaved, setBusy, setMessage }: { connection: LocalConnection; busy: string | null; onTest: (connection: LocalConnection) => Promise<void>; onDelete: (connection: LocalConnection) => Promise<void>; onSaved: () => Promise<void>; setBusy: (value: string | null) => void; setMessage: (value: string | null) => void }) {
function ConnectionCard({ connection, busy, testResult, onTest, onDelete, onSaved, setBusy, setMessage }: { connection: LocalConnection; busy: string | null; testResult?: TestResult; onTest: (connection: LocalConnection) => Promise<void>; onDelete: (connection: LocalConnection) => Promise<void>; onSaved: () => Promise<void>; setBusy: (value: string | null) => void; setMessage: (value: string | null) => void }) {
const { t } = useTranslation();
const [values, setValues] = useState<Record<string, string>>({});
async function saveCredentials() {
@@ -152,9 +184,20 @@ function ConnectionCard({ connection, busy, onTest, onDelete, onSaved, setBusy,
}
}
return <article className="rounded-xl border bg-card p-4">
<div className="flex items-start justify-between gap-3"><div><div className="font-medium">{connection.label}</div><div className="mt-1 flex flex-wrap gap-2 text-xs text-muted-foreground"><span>{connection.connector.toUpperCase()}</span><span>·</span><span>{connection.environment === "live" ? t("portfolio.env.live") : t("portfolio.env.paper")}</span><span>·</span><span>{connection.transport}</span></div></div><span className="inline-flex items-center gap-1 rounded-full bg-positive/10 px-2 py-1 text-xs text-positive"><ShieldCheck className="h-3 w-3" />{t("portfolio.connections.readOnly")}</span></div>
{connection.credential_fields.length ? <div className="mt-4 grid gap-2 sm:grid-cols-2">{connection.credential_fields.map((field) => <label key={field.name} className="text-xs text-muted-foreground">{field.label}{connection.credential_status[field.name] ? <span className="ml-1 text-positive">{t("portfolio.connections.credentialSaved")}</span> : null}<input type={field.secret ? "password" : "text"} value={values[field.name] ?? ""} onChange={(event) => setValues((current) => ({ ...current, [field.name]: event.target.value }))} className={`mt-1 ${fieldClass}`} placeholder={connection.credential_status[field.name] ? "••••••••" : ""} autoComplete="off" /></label>)}</div> : <p className="mt-3 text-xs text-muted-foreground">{connection.transport === "remote_mcp" ? t("portfolio.connections.oauthNote") : t("portfolio.connections.localNote")}</p>}
<div className="flex items-start justify-between gap-3"><div><div className="font-medium">{connection.label}</div><div className="mt-1 flex flex-wrap gap-2 text-xs text-muted-foreground"><span>{connection.connector.toUpperCase()}</span><span>·</span><span>{connection.environment === "live" ? t("portfolio.env.live") : t("portfolio.env.paper")}</span><span>·</span><span>{connection.transport}</span></div></div><div className="flex flex-col items-end gap-2"><span className="inline-flex items-center gap-1 rounded-full bg-positive/10 px-2 py-1 text-xs text-positive"><ShieldCheck className="h-3 w-3" />{t("portfolio.connections.readOnly")}</span><PortfolioCompatibilityBadge compatibility={connection.portfolio_compatibility} /></div></div>
{connection.onboarding ? <SetupContract onboarding={connection.onboarding} /> : null}
{connection.credential_fields.length ? <div className="mt-4 grid gap-2 sm:grid-cols-2">{connection.credential_fields.map((field) => <label key={field.name} className="text-xs text-muted-foreground">{field.label}{field.required ? <span className="ml-1 text-danger">*</span> : <span className="ml-1">{t("portfolio.connections.optional")}</span>}{connection.credential_status[field.name] ? <span className="ml-1 text-positive">{t("portfolio.connections.credentialSaved")}</span> : null}<input type={field.secret ? "password" : "text"} value={values[field.name] ?? ""} onChange={(event) => setValues((current) => ({ ...current, [field.name]: event.target.value }))} className={`mt-1 ${fieldClass}`} placeholder={connection.credential_status[field.name] ? "••••••••" : ""} autoComplete="off" /></label>)}</div> : <p className="mt-3 text-xs text-muted-foreground">{connection.transport === "remote_mcp" ? t("portfolio.connections.oauthNote") : t("portfolio.connections.localNote")}</p>}
<div className="mt-4 flex flex-wrap gap-2">{connection.credential_fields.length ? <button type="button" onClick={() => void saveCredentials()} disabled={busy === `credentials:${connection.id}` || !Object.values(values).some(Boolean)} className="inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs disabled:opacity-40"><ShieldCheck className="h-3.5 w-3.5" />{t("portfolio.connections.saveVault")}</button> : null}<button type="button" onClick={() => void onTest(connection)} disabled={busy === `test:${connection.id}`} className="inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs">{busy === `test:${connection.id}` ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <PlugZap className="h-3.5 w-3.5" />}{t("portfolio.connections.test")}</button><button type="button" onClick={() => void onDelete(connection)} disabled={busy === `delete:${connection.id}`} className="ml-auto inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs text-danger hover:bg-danger/10"><Trash2 className="h-3.5 w-3.5" />{t("portfolio.connections.delete")}</button></div>
{testResult ? <div role="status" className={`mt-3 rounded-md border px-3 py-2 text-xs ${testResult.ok ? "border-positive/30 bg-positive/5 text-positive" : "border-danger/30 bg-danger/5 text-danger"}`}>{testResult.message}</div> : null}
{connection.credentials_configured ? <div className="mt-3 flex items-center gap-1.5 text-xs text-positive"><CheckCircle2 className="h-3.5 w-3.5" />{t("portfolio.connections.configured")}</div> : null}
</article>;
}
function SetupContract({ onboarding }: { onboarding: NonNullable<LocalConnection["onboarding"]> }) {
const { t } = useTranslation();
return <div className="mt-3 rounded-md border bg-muted/20 p-3 text-xs text-muted-foreground">
<div className="flex flex-wrap gap-x-3 gap-y-1"><span>{t("portfolio.connections.authType")}: <strong className="font-medium text-foreground">{onboarding.auth_type}</strong></span>{onboarding.dependency ? <span>{t("portfolio.connections.dependency")}: <strong className="font-medium text-foreground">{onboarding.dependency}</strong></span> : null}</div>
{onboarding.setup_hint ? <p className="mt-2 leading-5">{onboarding.setup_hint}</p> : null}
{onboarding.install_command ? <code className="mt-2 block overflow-x-auto rounded bg-muted px-2 py-1.5 text-foreground">{onboarding.install_command}</code> : null}
</div>;
}
@@ -0,0 +1,29 @@
import { useTranslation } from "react-i18next";
import type { PortfolioConnectorCompatibility } from "@/lib/api";
const STYLE = {
native: "bg-positive/10 text-positive",
contract_tested: "bg-primary/10 text-primary",
experimental: "bg-warning/10 text-warning",
} as const;
/** Explain how far a connector has been verified for portfolio aggregation. */
export function PortfolioCompatibilityBadge({
compatibility,
}: {
compatibility?: PortfolioConnectorCompatibility;
}) {
const { t } = useTranslation();
if (!compatibility) return null;
const label = compatibility.level === "native"
? t("portfolio.compatibility.native")
: compatibility.level === "contract_tested"
? t("portfolio.compatibility.contractTested")
: t("portfolio.compatibility.experimental");
return <span
className={`inline-flex rounded-full px-2 py-1 text-xs ${STYLE[compatibility.level]}`}
title={compatibility.note}
>
{label}
</span>;
}
+10
View File
@@ -1550,6 +1550,11 @@
"live": "حي",
"paper": "ورقي"
},
"compatibility": {
"native": "موصل أصلي",
"contractTested": "مختبر بالعقد",
"experimental": "تجريبي"
},
"editor": {
"title": "إدارة حسابات المحفظة",
"subtitle": "يُحفظ هنا اختيار الحسابات وتفضيلات العرض فقط، دون أي بيانات اعتماد.",
@@ -1595,12 +1600,17 @@
"codexBody": "اطلب من Codex تشغيل connector init، ثم كتابة موصل القراءة اعتمادًا على وثائق الوسيط الرسمية، واجتياز validate قبل install. تبقى الإضافة خارج مستودع المشروع.",
"readOnly": "للقراءة فقط",
"credentialSaved": "محفوظ",
"optional": "(اختياري)",
"authType": "المصادقة",
"dependency": "اعتماد محلي",
"oauthNote": "يستخدم OAuth؛ امنح التفويض عبر إعادة الاتصال عند الحاجة.",
"localNote": "تدير بيانات الاعتماد إعدادات الموصل المحلي الحالية.",
"saveVault": "حفظ في الخزنة",
"savedVault": "حُفظت بيانات الاعتماد في خزنة النظام.",
"test": "اختبار",
"testOk": "{{label}} جاهز.",
"testFailed": "فشل اختبار {{label}}: {{error}}",
"unknownTestError": "أرجع الموصل خطأ غير معروف.",
"delete": "حذف",
"configured": "بيانات الاعتماد المطلوبة مضبوطة"
}
+10
View File
@@ -1550,6 +1550,11 @@
"live": "Live",
"paper": "Paper"
},
"compatibility": {
"native": "Nativer Adapter",
"contractTested": "Vertragstest",
"experimental": "Experimentell"
},
"editor": {
"title": "Portfolio-Konten verwalten",
"subtitle": "Hier werden nur die Kontoauswahl und Anzeigeeinstellungen gespeichert.",
@@ -1595,12 +1600,17 @@
"codexBody": "Lassen Sie Codex connector init ausführen, den Leseadapter anhand der offiziellen Dokumentation implementieren, ihn validieren und dann installieren. Das Plugin bleibt außerhalb des Repositorys.",
"readOnly": "Schreibgeschützt",
"credentialSaved": "gespeichert",
"optional": "(optional)",
"authType": "Authentifizierung",
"dependency": "Lokale Abhängigkeit",
"oauthNote": "Nutzt OAuth; bei Bedarf über das erneute Verbinden autorisieren.",
"localNote": "Die Zugangsdaten verwaltet die vorhandene lokale Connector-Konfiguration.",
"saveVault": "Im Speicher ablegen",
"savedVault": "Zugangsdaten im Systemspeicher abgelegt.",
"test": "Testen",
"testOk": "{{label}} ist bereit.",
"testFailed": "Test für {{label}} fehlgeschlagen: {{error}}",
"unknownTestError": "Der Connector hat einen unbekannten Fehler zurückgegeben.",
"delete": "Löschen",
"configured": "Erforderliche Zugangsdaten konfiguriert"
}
+11 -1
View File
@@ -1550,6 +1550,11 @@
"live": "Live",
"paper": "Paper"
},
"compatibility": {
"native": "Native adapter",
"contractTested": "Contract-tested",
"experimental": "Experimental"
},
"editor": {
"title": "Manage portfolio accounts",
"subtitle": "Only account selection and display preferences are stored here.",
@@ -1595,12 +1600,17 @@
"codexBody": "Ask Codex to run connector init, implement the read adapter from official docs, validate it, then install it. The plugin stays outside the repository.",
"readOnly": "Read-only",
"credentialSaved": "saved",
"optional": "(optional)",
"authType": "Authentication",
"dependency": "Local dependency",
"oauthNote": "Uses OAuth; authorize through reconnect when required.",
"localNote": "Credentials are managed by the existing local connector configuration.",
"localNote": "This connector uses a local app or gateway session; start it as described above, then test.",
"saveVault": "Save to vault",
"savedVault": "Credentials saved to the OS vault.",
"test": "Test",
"testOk": "{{label}} is ready.",
"testFailed": "{{label}} check failed: {{error}}",
"unknownTestError": "The connector returned an unknown error.",
"delete": "Delete",
"configured": "Required credentials configured"
}
+10
View File
@@ -1550,6 +1550,11 @@
"live": "En vivo",
"paper": "Demo"
},
"compatibility": {
"native": "Adaptador nativo",
"contractTested": "Probado por contrato",
"experimental": "Experimental"
},
"editor": {
"title": "Gestionar las cuentas de la cartera",
"subtitle": "Aquí solo se guardan la selección de cuentas y las preferencias de visualización.",
@@ -1595,12 +1600,17 @@
"codexBody": "Pide a Codex que ejecute connector init, implemente el adaptador de lectura a partir de la documentación oficial, lo valide y luego lo instale. El plugin queda fuera del repositorio.",
"readOnly": "Solo lectura",
"credentialSaved": "guardada",
"optional": "(opcional)",
"authType": "Autenticación",
"dependency": "Dependencia local",
"oauthNote": "Usa OAuth; autoriza mediante la reconexión cuando haga falta.",
"localNote": "Las credenciales las gestiona la configuración local del conector existente.",
"saveVault": "Guardar en el almacén",
"savedVault": "Credenciales guardadas en el almacén del sistema.",
"test": "Probar",
"testOk": "{{label}} está lista.",
"testFailed": "Falló la prueba de {{label}}: {{error}}",
"unknownTestError": "El conector devolvió un error desconocido.",
"delete": "Eliminar",
"configured": "Credenciales necesarias configuradas"
}
+10
View File
@@ -1550,6 +1550,11 @@
"live": "ライブ",
"paper": "ペーパー"
},
"compatibility": {
"native": "専用アダプター",
"contractTested": "契約テスト済み",
"experimental": "実験的"
},
"editor": {
"title": "ポートフォリオの口座を管理",
"subtitle": "ここに保存されるのは口座の選択と表示設定だけで、認証情報は保存されません。",
@@ -1595,12 +1600,17 @@
"codexBody": "Codex に connector init を実行させ、公式ドキュメントから読み取り用アダプターを実装し、validate を通してから install します。プラグインはリポジトリの外に置かれます。",
"readOnly": "読み取り専用",
"credentialSaved": "保存済み",
"optional": "(任意)",
"authType": "認証方式",
"dependency": "ローカル依存関係",
"oauthNote": "OAuth を使用します。必要に応じて再接続で認可してください。",
"localNote": "認証情報は既存のローカルコネクタ設定が管理します。",
"saveVault": "資格情報ストアに保存",
"savedVault": "認証情報を OS の資格情報ストアに保存しました。",
"test": "接続テスト",
"testOk": "{{label}} は正常です。",
"testFailed": "{{label}} のテストに失敗しました: {{error}}",
"unknownTestError": "コネクターが不明なエラーを返しました。",
"delete": "削除",
"configured": "必要な認証情報は設定済みです"
}
+10
View File
@@ -1550,6 +1550,11 @@
"live": "실거래",
"paper": "페이퍼"
},
"compatibility": {
"native": "전용 어댑터",
"contractTested": "계약 테스트 완료",
"experimental": "실험적"
},
"editor": {
"title": "포트폴리오 계좌 관리",
"subtitle": "여기에는 계좌 선택과 표시 설정만 저장되며 자격 증명은 저장되지 않습니다.",
@@ -1595,12 +1600,17 @@
"codexBody": "Codex에 connector init을 실행시키고, 공식 문서를 보고 읽기 전용 어댑터를 구현한 뒤 validate를 통과시키고 install 하세요. 플러그인은 저장소 밖에 설치됩니다.",
"readOnly": "읽기 전용",
"credentialSaved": "저장됨",
"optional": "(선택 사항)",
"authType": "인증 방식",
"dependency": "로컬 종속성",
"oauthNote": "OAuth를 사용합니다. 필요하면 재연결로 승인하세요.",
"localNote": "자격 증명은 기존 로컬 커넥터 설정이 관리합니다.",
"saveVault": "자격 증명 저장소에 저장",
"savedVault": "자격 증명을 OS 저장소에 저장했습니다.",
"test": "연결 테스트",
"testOk": "{{label}} 연결이 정상입니다.",
"testFailed": "{{label}} 테스트 실패: {{error}}",
"unknownTestError": "커넥터가 알 수 없는 오류를 반환했습니다.",
"delete": "삭제",
"configured": "필요한 자격 증명이 설정되었습니다"
}
+11 -1
View File
@@ -1550,6 +1550,11 @@
"live": "实盘",
"paper": "模拟"
},
"compatibility": {
"native": "专用适配",
"contractTested": "契约测试通过",
"experimental": "实验性"
},
"editor": {
"title": "管理持仓账户",
"subtitle": "这里只保存账户选择和显示偏好,不保存密钥。",
@@ -1595,12 +1600,17 @@
"codexBody": "让 Codex 运行 connector init,按券商官方文档补全只读 adapter,通过 validate 后再 install。插件安装在本机目录,不进入项目仓库。",
"readOnly": "结构只读",
"credentialSaved": "已保存",
"optional": "(可选)",
"authType": "认证方式",
"dependency": "本机依赖",
"oauthNote": "使用 OAuth;需要时通过重新连接完成授权。",
"localNote": "凭证由现有的本地连接器配置管理。",
"localNote": "该连接使用本机应用或网关会话;请按上方提示启动后测试。",
"saveVault": "保存到钥匙串",
"savedVault": "凭证已保存到系统钥匙串。",
"test": "测试连接",
"testOk": "{{label}} 连接正常。",
"testFailed": "{{label}} 测试失败:{{error}}",
"unknownTestError": "连接器返回了未知错误。",
"delete": "删除",
"configured": "所需凭证已配置"
}
+24
View File
@@ -84,6 +84,13 @@ export interface PortfolioPosition {
price_error?: string;
}
export interface PortfolioConnectorCompatibility {
level: "native" | "contract_tested" | "experimental";
contract_version: number;
asset_scope: string;
note: string;
}
/**
* One portfolio source as of the last refresh.
*
@@ -115,6 +122,7 @@ export interface PortfolioAccount {
readonly: boolean;
detail: string;
};
portfolio_compatibility?: PortfolioConnectorCompatibility;
}
export interface PortfolioSnapshot {
@@ -198,6 +206,7 @@ export interface PortfolioSourceCatalogItem {
credential_fields: CredentialField[];
credential_status: Record<string, boolean>;
credentials_configured: boolean;
portfolio_compatibility: PortfolioConnectorCompatibility;
}
export interface CredentialField {
@@ -207,6 +216,17 @@ export interface CredentialField {
required: boolean;
}
export interface ConnectorOnboarding {
schema_version: number;
auth_type: string;
credential_fields: CredentialField[];
dependency: string | null;
install_command: string | null;
test_operation: string;
setup_hint: string;
secret_storage: "os_keyring" | null;
}
export interface LocalConnection {
id: string;
profile_id: string;
@@ -220,6 +240,8 @@ export interface LocalConnection {
credential_fields: CredentialField[];
credential_status: Record<string, boolean>;
credentials_configured: boolean;
onboarding?: ConnectorOnboarding;
portfolio_compatibility: PortfolioConnectorCompatibility;
}
export interface ReadonlyConnectionProfile {
@@ -233,7 +255,9 @@ export interface ReadonlyConnectionProfile {
notes: string;
local_plugin: boolean;
credential_fields: CredentialField[];
onboarding?: ConnectorOnboarding;
supports_reconnect: boolean;
portfolio_compatibility: PortfolioConnectorCompatibility;
invalid_plugin?: boolean;
directory?: string;
error?: string;
+3 -2
View File
@@ -16,6 +16,7 @@ import {
WifiOff,
} from "lucide-react";
import { PortfolioSourceEditor } from "@/components/portfolio/PortfolioSourceEditor";
import { PortfolioCompatibilityBadge } from "@/components/portfolio/PortfolioCompatibilityBadge";
import {
api,
type PortfolioAccount,
@@ -503,8 +504,8 @@ function AccountCard({ account, active, displayCurrency, onClick, onReconnect, r
const { t } = useTranslation();
const failed = account.status === "error";
return <div role="button" tabIndex={0} onClick={onClick} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") onClick(); }} className={`cursor-pointer rounded-xl border bg-card p-5 transition ${active ? "border-primary ring-1 ring-primary/20" : "hover:border-primary/40"}`}>
<div className="flex items-center justify-between">
<div><div className="font-medium">{account.label ?? account.broker.toUpperCase()}</div><div className="mt-1 text-xs"><BrokerBadge broker={account.broker} /></div></div>
<div className="flex items-center justify-between gap-3">
<div><div className="font-medium">{account.label ?? account.broker.toUpperCase()}</div><div className="mt-1 flex flex-wrap items-center gap-2 text-xs"><BrokerBadge broker={account.broker} /><PortfolioCompatibilityBadge compatibility={account.portfolio_compatibility} /></div></div>
{failed ? <WifiOff className="h-4 w-4 text-danger" /> : <CheckCircle2 className="h-4 w-4 text-positive" />}
</div>
{failed ? (
@@ -53,9 +53,9 @@ const snapshot = {
valuation: { priced_usd: 800, cash_usd: 200, unpriced_or_other_usd: 0, identified_coverage: 1 },
fx: { usd_cny: 7.2, usd_hkd: 7.8, fetched_at: "2026-08-09T00:00:00Z", stale: false },
accounts: [
{ broker: "ibkr", status: "ok" as const, total_usd: 600, total_cny: 4320, position_count: 1, last_success_at: "2026-08-09T00:00:00Z" },
{ broker: "longbridge", status: "ok" as const, total_usd: 300, total_cny: 2160, position_count: 1, last_success_at: "2026-08-09T00:00:00Z" },
{ broker: "binance", status: "ok" as const, total_usd: 100, total_cny: 720, position_count: 1, last_success_at: "2026-08-09T00:00:00Z" },
{ broker: "ibkr", status: "ok" as const, total_usd: 600, total_cny: 4320, position_count: 1, last_success_at: "2026-08-09T00:00:00Z", portfolio_compatibility: { level: "native" as const, contract_version: 1, asset_scope: "stocks_etfs", note: "Dedicated mapping." } },
{ broker: "longbridge", status: "ok" as const, total_usd: 300, total_cny: 2160, position_count: 1, last_success_at: "2026-08-09T00:00:00Z", portfolio_compatibility: { level: "native" as const, contract_version: 1, asset_scope: "stocks_etfs", note: "Dedicated mapping." } },
{ broker: "binance", status: "ok" as const, total_usd: 100, total_cny: 720, position_count: 1, last_success_at: "2026-08-09T00:00:00Z", portfolio_compatibility: { level: "native" as const, contract_version: 1, asset_scope: "spot", note: "Dedicated mapping." } },
],
positions: [{
broker: "ibkr", symbol: "AAPL", name: "Apple", asset_type: "stock", market: "US",
@@ -122,9 +122,9 @@ const portfolioConfiguration = {
],
},
catalog: [
{ id: "ibkr", connection_id: "ibkr", profile_id: "ibkr-live-official-mcp-readonly", connector: "ibkr", label: "IBKR", environment: "live" as const, transport: "remote_mcp" as const, capabilities: ["mcp.read.discovery"], readonly: true, notes: "", selected: true, source_id: "ibkr", supports_reconnect: true, credential_fields: [], credential_status: {}, credentials_configured: false },
{ id: "longbridge", connection_id: "longbridge", profile_id: "longbridge-live-sdk-readonly", connector: "longbridge", label: "Longbridge", environment: "live" as const, transport: "broker_sdk" as const, capabilities: ["account.read", "positions.read"], readonly: true, notes: "", selected: true, source_id: "longbridge", supports_reconnect: false, credential_fields: [], credential_status: {}, credentials_configured: false },
{ id: "binance", connection_id: "binance", profile_id: "binance-live-sdk-readonly", connector: "binance", label: "Binance", environment: "live" as const, transport: "broker_sdk" as const, capabilities: ["account.read", "positions.read"], readonly: true, notes: "", selected: true, source_id: "binance", supports_reconnect: false, credential_fields: [], credential_status: {}, credentials_configured: false },
{ id: "ibkr", connection_id: "ibkr", profile_id: "ibkr-live-official-mcp-readonly", connector: "ibkr", label: "IBKR", environment: "live" as const, transport: "remote_mcp" as const, capabilities: ["mcp.read.discovery"], readonly: true, notes: "", selected: true, source_id: "ibkr", supports_reconnect: true, credential_fields: [], credential_status: {}, credentials_configured: false, portfolio_compatibility: { level: "native" as const, contract_version: 1, asset_scope: "stocks_etfs", note: "Dedicated mapping." } },
{ id: "longbridge", connection_id: "longbridge", profile_id: "longbridge-live-sdk-readonly", connector: "longbridge", label: "Longbridge", environment: "live" as const, transport: "broker_sdk" as const, capabilities: ["account.read", "positions.read"], readonly: true, notes: "", selected: true, source_id: "longbridge", supports_reconnect: false, credential_fields: [], credential_status: {}, credentials_configured: false, portfolio_compatibility: { level: "native" as const, contract_version: 1, asset_scope: "stocks_etfs", note: "Dedicated mapping." } },
{ id: "binance", connection_id: "binance", profile_id: "binance-live-sdk-readonly", connector: "binance", label: "Binance", environment: "live" as const, transport: "broker_sdk" as const, capabilities: ["account.read", "positions.read"], readonly: true, notes: "", selected: true, source_id: "binance", supports_reconnect: false, credential_fields: [], credential_status: {}, credentials_configured: false, portfolio_compatibility: { level: "native" as const, contract_version: 1, asset_scope: "spot", note: "Dedicated mapping." } },
],
};
@@ -171,16 +171,19 @@ describe("Portfolio page", () => {
local_plugin: true,
credential_fields: [],
supports_reconnect: false,
portfolio_compatibility: { level: "experimental", contract_version: 1, asset_scope: "declared_by_connector", note: "No built-in fixture." },
}],
plugin_directory: "/tmp/vibe/connectors",
});
mocked.createConnection.mockResolvedValue({ status: "ok", connection: {} });
mocked.checkConnection.mockResolvedValue({ status: "ok", connection_id: "ibkr", report: { status: "ok" } });
});
it("renders the combined portfolio and refreshes all connectors", async () => {
render(<Portfolio />);
expect(await screen.findByText("AAPL")).toBeInTheDocument();
expect(screen.getByText("$1,000.00")).toBeInTheDocument();
expect(screen.getAllByText(i18n.t("portfolio.compatibility.native")).length).toBeGreaterThan(0);
fireEvent.click(screen.getByRole("button", { name: i18n.t("portfolio.page.refreshAll") }));
await waitFor(() => expect(mocked.refreshPortfolio).toHaveBeenCalledTimes(1));
@@ -256,6 +259,71 @@ describe("Portfolio page", () => {
}));
});
it("shows a failed connection check on the matching connection card", async () => {
mocked.checkConnection.mockResolvedValue({
status: "ok",
connection_id: "ibkr",
report: { status: "error", error: "missing api_key" },
});
render(<Portfolio />);
await screen.findByText("AAPL");
fireEvent.click(screen.getByRole("button", { name: i18n.t("portfolio.page.manageAccounts") }));
fireEvent.click(await screen.findByRole("button", { name: i18n.t("portfolio.editor.openConnections") }));
fireEvent.click((await screen.findAllByRole("button", { name: i18n.t("portfolio.connections.test") }))[0]);
expect(await screen.findByRole("status")).toHaveTextContent(
i18n.t("portfolio.connections.testFailed", {
label: "IBKR",
error: "missing api_key",
}),
);
});
it("renders built-in onboarding fields and saves values through the vault API", async () => {
mocked.getConnections.mockResolvedValue({
status: "ok",
connections: [{
...portfolioConfiguration.catalog[2],
credential_fields: [
{ name: "api_key", label: "API Key", secret: true, required: true },
{ name: "api_secret", label: "API Secret", secret: true, required: true },
],
credential_status: { api_key: false, api_secret: false },
onboarding: {
schema_version: 1,
auth_type: "api_key",
credential_fields: [],
dependency: "ccxt",
install_command: "pip install ccxt keyring",
test_operation: "account.read",
setup_hint: "Create a read-only key.",
secret_storage: "os_keyring",
},
}],
profiles: [],
plugin_directory: "/tmp/vibe/connectors",
});
mocked.saveConnectionCredentials.mockResolvedValue({
status: "ok",
credential_status: { api_key: true, api_secret: true },
});
render(<Portfolio />);
await screen.findByText("AAPL");
fireEvent.click(screen.getByRole("button", { name: i18n.t("portfolio.page.manageAccounts") }));
fireEvent.click(await screen.findByRole("button", { name: i18n.t("portfolio.editor.openConnections") }));
fireEvent.change(await screen.findByLabelText(/API Key/), { target: { value: "local-key" } });
fireEvent.change(screen.getByLabelText(/API Secret/), { target: { value: "local-secret" } });
fireEvent.click(screen.getByRole("button", { name: i18n.t("portfolio.connections.saveVault") }));
await waitFor(() => expect(mocked.saveConnectionCredentials).toHaveBeenCalledWith(
"binance",
{ api_key: "local-key", api_secret: "local-secret" },
));
expect(screen.getByText("ccxt")).toBeInTheDocument();
});
it("reports an unreadable account as an error and keeps it out of the total", async () => {
mocked.getPortfolio.mockResolvedValue({ status: "ok", snapshot: snapshotWithFailedSource });
render(<Portfolio />);