Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions agent/src/api/live_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ def clear(self) -> None:
"host_separated",
"read_only_no_runtime_discriminator",
"simulated_locally",
"path_separated_key_bound",
"trd_env_acc_list",
}
)
Expand Down
41 changes: 34 additions & 7 deletions agent/src/trading/connectors/etoro/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@

import json
import logging
import os
import time
import uuid
from dataclasses import asdict, dataclass
Expand All @@ -20,6 +19,7 @@

import requests

from src.config.accessor import get_env_config
from src.config.paths import get_runtime_root

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -111,10 +111,14 @@ def is_paper(self) -> bool:


_OVERRIDE_KEYS = ("api_key", "user_key", "profile")
_ENV_KEY_MAP = {
"api_key": "ETORO_API_KEY",
"user_key": "ETORO_USER_KEY",
}


def _environment_values() -> dict[str, str]:
data = get_env_config().data
return {
"api_key": str(data.etoro_api_key or "").strip(),
"user_key": str(data.etoro_user_key or "").strip(),
}


def build_config(
Expand Down Expand Up @@ -145,8 +149,7 @@ def load_config() -> EtoroConfig:
base = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError, json.JSONDecodeError) as exc:
raise EtoroConfigError(f"invalid eToro config at {path}: {exc}") from exc
for field, env_name in _ENV_KEY_MAP.items():
env_val = os.getenv(env_name, "").strip() # noqa: env-gate — connector credential helper
for field, env_val in _environment_values().items():
if env_val:
base[field] = env_val
if not base:
Expand Down Expand Up @@ -175,6 +178,30 @@ def _missing_fields(cfg: EtoroConfig) -> list[str]:
return missing


def credential_source() -> str | None:
"""Return where credentials were loaded from for runtime UI metadata."""
path = config_path()
file_present = False
if path.exists():
try:
data = json.loads(path.read_text(encoding="utf-8"))
if str(data.get("api_key") or "").strip() or str(data.get("user_key") or "").strip():
file_present = True
except (OSError, ValueError, json.JSONDecodeError):
file_present = False
if any(_environment_values().values()):
return "environment"
if file_present:
return "runtime_file"
return None


def _missing_credential_code(cfg: EtoroConfig) -> str:
if cfg.api_key or cfg.user_key:
return "credentials_partial"
return "credentials_missing"


def public_config(cfg: EtoroConfig) -> dict[str, Any]:
return {
"profile": cfg.profile,
Expand Down
75 changes: 62 additions & 13 deletions agent/src/trading/connectors/etoro/sdk.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

from datetime import datetime, timezone
from typing import Any

from src.trading.connectors.etoro.client import (
Expand All @@ -12,12 +13,15 @@
PAPER_GUARD,
aggregate_portfolio_path,
build_config,
credential_source,
info_root,
load_config,
make_client,
public_config,
save_config,
trade_history_root,
_missing_credential_code,
_missing_fields,
)
from src.trading.connectors.etoro.copy_trading import (
copy_close,
Expand Down Expand Up @@ -79,42 +83,87 @@ def _base_payload(cfg: EtoroConfig) -> dict[str, Any]:


def check_status(config: EtoroConfig | None = None) -> dict[str, Any]:
from src.trading.connectors.etoro.client import _missing_fields

"""Check read-only readiness with stable, redaction-safe diagnostics."""
cfg = config or load_config()
configured = not _missing_fields(cfg)
report: dict[str, Any] = {
"status": "ok",
"configured": configured,
"credential_source": credential_source(),
"connection_state": "connected",
"error_code": None,
"error": None,
"config": public_config(cfg),
"sdk": {"package": "requests", "installed": True},
"paper_guard": PAPER_GUARD,
"base_url": BASE_URL,
}
missing_fields = _missing_fields(cfg)
if missing_fields:
report["status"] = "error"
report["error"] = f"eToro connector not configured: missing {', '.join(missing_fields)}."
return report
if not configured:
code = _missing_credential_code(cfg)
fields = ", ".join(_missing_fields(cfg))
message = (
f"eToro credentials are partial; missing fields: {fields}."
if code == "credentials_partial"
else f"eToro connector not configured: missing {fields}."
)
return _status_error(report, code, message)
try:
me = get_user_profile(cfg)
portfolio = get_account_snapshot(cfg)
except (EtoroConfigError, EtoroAPIError) as exc:
report["status"] = "error"
report["error"] = str(exc)
return report
code = _connection_error_code(exc)
return _status_error(report, code, _connection_error_message(code, str(exc)))
except Exception as exc: # noqa: BLE001
report["status"] = "error"
report["error"] = f"eToro connector check failed: {exc}"
return report
return _status_error(report, "broker_error", f"eToro connector check failed: {exc}")
report["account"] = {
"profile": cfg.profile,
"portfolio_status": portfolio.get("status"),
"scopes": me.get("scopes"),
"real_cid": me.get("realCid") or me.get("realCID"),
"demo_cid": me.get("demoCid") or me.get("demoCID"),
}
account = portfolio.get("account") if isinstance(portfolio.get("account"), dict) else {}
pnl = account.get("pnl") if isinstance(account.get("pnl"), dict) else {}
if pnl.get("account_current_pnl") is not None:
report["account"]["account_current_pnl"] = pnl.get("account_current_pnl")
report["last_checked_at"] = datetime.now(timezone.utc).isoformat()
return report


def _status_error(report: dict[str, Any], code: str, message: str) -> dict[str, Any]:
if code in ("credentials_missing", "credentials_partial"):
report["configured"] = False
report.update(
status="error",
connection_state=(
"not_configured" if code in ("credentials_missing", "credentials_partial") else "error"
),
error_code=code,
error=message,
)
return report


def _connection_error_code(exc: Exception) -> str:
if isinstance(exc, (ConnectionError, TimeoutError, OSError)):
return "network_unreachable"
if isinstance(exc, EtoroAPIError):
text = str(exc).lower()
if any(token in text for token in ("401", "403", "auth", "unauthorized", "forbidden")):
return "authentication_failed"
if "network error" in text:
return "network_unreachable"
return "broker_error"


def _connection_error_message(code: str, detail: str) -> str:
if code == "authentication_failed":
return "eToro authentication failed."
if code == "network_unreachable":
return "eToro Public API is unreachable."
return detail or "eToro broker request failed."


def get_user_profile(config: EtoroConfig | None = None) -> dict[str, Any]:
"""Return authenticated user profile and granted API scopes (`GET /api/v1/me`)."""
cfg = config or load_config()
Expand Down
52 changes: 52 additions & 0 deletions agent/tests/test_etoro_connector.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,58 @@ def test_credentials_redacted_in_public_config() -> None:
assert payload["api_key_configured"] is True


def test_check_status_not_configured() -> None:
cfg = EtoroConfig(profile="live-readonly", api_key="", user_key="")
result = etoro_sdk.check_status(cfg)
assert result["status"] == "error"
assert result["configured"] is False
assert result["connection_state"] == "not_configured"
assert result["error_code"] == "credentials_missing"


def test_check_status_partial_credentials() -> None:
cfg = EtoroConfig(profile="live-readonly", api_key="k", user_key="")
result = etoro_sdk.check_status(cfg)
assert result["error_code"] == "credentials_partial"
assert result["connection_state"] == "not_configured"


def test_check_status_connected(monkeypatch) -> None:
cfg = EtoroConfig(profile="live-readonly", api_key="k", user_key="u")

def _transport(method: str, url: str, **kwargs: Any) -> _FakeResponse:
if url.endswith("/api/v1/me"):
return _FakeResponse(200, {"scopes": ["etoro-public:trade.real:read"], "realCid": 1})
if "/aggregate-portfolio" in url:
return _FakeResponse(200, {"accountTotals": {"accountCurrentPnl": 1.0}})
if url.endswith("/portfolio"):
return _FakeResponse(200, {"clientPortfolio": {"credit": 1.0}})
raise AssertionError(f"unexpected {method} {url}")

set_client_factory(lambda c: EtoroClient(cfg, transport=_transport))
result = etoro_sdk.check_status(cfg)
assert result["status"] == "ok"
assert result["configured"] is True
assert result["connection_state"] == "connected"
assert result["error_code"] is None
assert result["last_checked_at"]
assert "etoro-public:trade.real:read" in result["account"]["scopes"]


def test_check_status_auth_failure_keeps_configured(monkeypatch) -> None:
cfg = EtoroConfig(profile="live-readonly", api_key="k", user_key="u")

def _transport(method: str, url: str, **kwargs: Any) -> _FakeResponse:
return _FakeResponse(401, {"error": "unauthorized"})

set_client_factory(lambda c: EtoroClient(cfg, transport=_transport))
result = etoro_sdk.check_status(cfg)
assert result["status"] == "error"
assert result["configured"] is True
assert result["connection_state"] == "error"
assert result["error_code"] == "authentication_failed"


def test_get_account_snapshot_uses_aggregate_portfolio_for_pnl(monkeypatch) -> None:
cfg = EtoroConfig(profile="live", api_key="k", user_key="u")
captured_urls: list[str] = []
Expand Down
Loading