stocksim-sdk 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- stocksim_sdk-0.1.0/.gitignore +10 -0
- stocksim_sdk-0.1.0/.python-version +1 -0
- stocksim_sdk-0.1.0/PKG-INFO +112 -0
- stocksim_sdk-0.1.0/README.md +99 -0
- stocksim_sdk-0.1.0/pyproject.toml +26 -0
- stocksim_sdk-0.1.0/src/stocksim_sdk/__init__.py +2 -0
- stocksim_sdk-0.1.0/src/stocksim_sdk/client.py +67 -0
- stocksim_sdk-0.1.0/src/stocksim_sdk/exceptions.py +75 -0
- stocksim_sdk-0.1.0/src/stocksim_sdk/models.py +66 -0
- stocksim_sdk-0.1.0/src/stocksim_sdk/py.typed +0 -0
- stocksim_sdk-0.1.0/src/tests/__init__.py +0 -0
- stocksim_sdk-0.1.0/src/tests/test_client.py +141 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.14
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: stocksim-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official async Python SDK for StockSim Pro API
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Requires-Dist: httpx>=0.27
|
|
8
|
+
Provides-Extra: dev
|
|
9
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
10
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
11
|
+
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# stocksim-sdk
|
|
15
|
+
|
|
16
|
+
Official async Python SDK for [StockSim Pro](https://stocksim.pro).
|
|
17
|
+
|
|
18
|
+
- Python 3.13+
|
|
19
|
+
- Async-first (`asyncio` / `httpx`)
|
|
20
|
+
- Typed responses via `dataclasses`
|
|
21
|
+
- Zero heavy dependencies
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install stocksim-sdk
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Quick Start
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
import asyncio
|
|
33
|
+
from stocksim import StockSimClient
|
|
34
|
+
|
|
35
|
+
async def main():
|
|
36
|
+
async with StockSimClient(api_key="your-api-key") as client:
|
|
37
|
+
price = await client.get_price("AAPL")
|
|
38
|
+
print(price.ticker, price.price)
|
|
39
|
+
|
|
40
|
+
asyncio.run(main())
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Usage
|
|
44
|
+
|
|
45
|
+
### Get price
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
async with StockSimClient(api_key="your-api-key") as client:
|
|
49
|
+
price = await client.get_price("TSLA")
|
|
50
|
+
# PriceResponse(ticker='TSLA', price=182.5, timestamp='2026-06-16T10:00:00Z')
|
|
51
|
+
print(f"{price.ticker}: ${price.price}")
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Get portfolio
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
async with StockSimClient(api_key="your-api-key") as client:
|
|
58
|
+
portfolio = await client.get_portfolio()
|
|
59
|
+
print(f"Balance: ${portfolio.balance:.2f}")
|
|
60
|
+
for pos in portfolio.positions:
|
|
61
|
+
print(f" {pos.ticker} qty={pos.quantity} avg=${pos.avg_price}")
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Place an order
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
async with StockSimClient(api_key="your-api-key") as client:
|
|
68
|
+
order = await client.place_order(ticker="AAPL", action="buy", quantity=5)
|
|
69
|
+
print(f"Order {order.order_id} — {order.status}")
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Error Handling
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
from stocksim import (
|
|
76
|
+
StockSimClient,
|
|
77
|
+
StockSimAuthError,
|
|
78
|
+
StockSimRateLimitError,
|
|
79
|
+
StockSimValidationError,
|
|
80
|
+
StockSimError,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
async with StockSimClient(api_key="your-api-key") as client:
|
|
84
|
+
try:
|
|
85
|
+
price = await client.get_price("AAPL")
|
|
86
|
+
except StockSimAuthError:
|
|
87
|
+
print("Invalid or missing API key")
|
|
88
|
+
except StockSimRateLimitError as e:
|
|
89
|
+
retry = e.retry_after
|
|
90
|
+
print(f"Rate limit hit — retry after {retry}s")
|
|
91
|
+
except StockSimValidationError as e:
|
|
92
|
+
for err in e.errors:
|
|
93
|
+
print(f"Validation: {err['msg']} at {err['loc']}")
|
|
94
|
+
except StockSimError as e:
|
|
95
|
+
print(f"API error {e.status_code}: {e}")
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Manual session management
|
|
99
|
+
|
|
100
|
+
If you prefer not to use `async with`, call `close()` explicitly:
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
client = StockSimClient(api_key="your-api-key")
|
|
104
|
+
try:
|
|
105
|
+
price = await client.get_price("AAPL")
|
|
106
|
+
finally:
|
|
107
|
+
await client.close()
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## License
|
|
111
|
+
|
|
112
|
+
MIT
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# stocksim-sdk
|
|
2
|
+
|
|
3
|
+
Official async Python SDK for [StockSim Pro](https://stocksim.pro).
|
|
4
|
+
|
|
5
|
+
- Python 3.13+
|
|
6
|
+
- Async-first (`asyncio` / `httpx`)
|
|
7
|
+
- Typed responses via `dataclasses`
|
|
8
|
+
- Zero heavy dependencies
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install stocksim-sdk
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Quick Start
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
import asyncio
|
|
20
|
+
from stocksim import StockSimClient
|
|
21
|
+
|
|
22
|
+
async def main():
|
|
23
|
+
async with StockSimClient(api_key="your-api-key") as client:
|
|
24
|
+
price = await client.get_price("AAPL")
|
|
25
|
+
print(price.ticker, price.price)
|
|
26
|
+
|
|
27
|
+
asyncio.run(main())
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Usage
|
|
31
|
+
|
|
32
|
+
### Get price
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
async with StockSimClient(api_key="your-api-key") as client:
|
|
36
|
+
price = await client.get_price("TSLA")
|
|
37
|
+
# PriceResponse(ticker='TSLA', price=182.5, timestamp='2026-06-16T10:00:00Z')
|
|
38
|
+
print(f"{price.ticker}: ${price.price}")
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Get portfolio
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
async with StockSimClient(api_key="your-api-key") as client:
|
|
45
|
+
portfolio = await client.get_portfolio()
|
|
46
|
+
print(f"Balance: ${portfolio.balance:.2f}")
|
|
47
|
+
for pos in portfolio.positions:
|
|
48
|
+
print(f" {pos.ticker} qty={pos.quantity} avg=${pos.avg_price}")
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Place an order
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
async with StockSimClient(api_key="your-api-key") as client:
|
|
55
|
+
order = await client.place_order(ticker="AAPL", action="buy", quantity=5)
|
|
56
|
+
print(f"Order {order.order_id} — {order.status}")
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Error Handling
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from stocksim import (
|
|
63
|
+
StockSimClient,
|
|
64
|
+
StockSimAuthError,
|
|
65
|
+
StockSimRateLimitError,
|
|
66
|
+
StockSimValidationError,
|
|
67
|
+
StockSimError,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
async with StockSimClient(api_key="your-api-key") as client:
|
|
71
|
+
try:
|
|
72
|
+
price = await client.get_price("AAPL")
|
|
73
|
+
except StockSimAuthError:
|
|
74
|
+
print("Invalid or missing API key")
|
|
75
|
+
except StockSimRateLimitError as e:
|
|
76
|
+
retry = e.retry_after
|
|
77
|
+
print(f"Rate limit hit — retry after {retry}s")
|
|
78
|
+
except StockSimValidationError as e:
|
|
79
|
+
for err in e.errors:
|
|
80
|
+
print(f"Validation: {err['msg']} at {err['loc']}")
|
|
81
|
+
except StockSimError as e:
|
|
82
|
+
print(f"API error {e.status_code}: {e}")
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Manual session management
|
|
86
|
+
|
|
87
|
+
If you prefer not to use `async with`, call `close()` explicitly:
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
client = StockSimClient(api_key="your-api-key")
|
|
91
|
+
try:
|
|
92
|
+
price = await client.get_price("AAPL")
|
|
93
|
+
finally:
|
|
94
|
+
await client.close()
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## License
|
|
98
|
+
|
|
99
|
+
MIT
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "stocksim-sdk"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Official async Python SDK for StockSim Pro API"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = { text = "MIT" }
|
|
7
|
+
requires-python = ">=3.10"
|
|
8
|
+
dependencies = [
|
|
9
|
+
"httpx>=0.27",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
[project.optional-dependencies]
|
|
13
|
+
dev = [
|
|
14
|
+
"pytest>=8.0",
|
|
15
|
+
"pytest-asyncio>=0.24",
|
|
16
|
+
"respx>=0.21",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[tool.uv.workspace]
|
|
20
|
+
members = [
|
|
21
|
+
"stocksim-sdk",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[build-system]
|
|
25
|
+
requires = ["hatchling"]
|
|
26
|
+
build-backend = "hatchling.build"
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from types import TracebackType
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from stocksim.exceptions import _raise_for_status
|
|
9
|
+
from stocksim.models import OrderResponse, PortfolioResponse, PriceResponse
|
|
10
|
+
|
|
11
|
+
_DEFAULT_BASE_URL = "https://api.stocksim.pro"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class StockSimClient:
|
|
15
|
+
"""Async HTTP client for the StockSim Pro API."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, api_key: str, base_url: str = _DEFAULT_BASE_URL) -> None:
|
|
18
|
+
self._http = httpx.AsyncClient(
|
|
19
|
+
base_url=base_url,
|
|
20
|
+
headers={"X-API-Key": api_key},
|
|
21
|
+
timeout=30.0,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
async def __aenter__(self) -> StockSimClient:
|
|
25
|
+
return self
|
|
26
|
+
|
|
27
|
+
async def __aexit__(
|
|
28
|
+
self,
|
|
29
|
+
exc_type: type[BaseException] | None,
|
|
30
|
+
exc_val: BaseException | None,
|
|
31
|
+
exc_tb: TracebackType | None,
|
|
32
|
+
) -> None:
|
|
33
|
+
await self.close()
|
|
34
|
+
|
|
35
|
+
async def close(self) -> None:
|
|
36
|
+
"""Close the underlying HTTP session."""
|
|
37
|
+
await self._http.aclose()
|
|
38
|
+
|
|
39
|
+
async def _request(self, method: str, endpoint: str, **kwargs: Any) -> Any:
|
|
40
|
+
response = await self._http.request(method, endpoint, **kwargs)
|
|
41
|
+
_raise_for_status(response)
|
|
42
|
+
return response.json()
|
|
43
|
+
|
|
44
|
+
async def get_price(self, ticker: str) -> PriceResponse:
|
|
45
|
+
"""Fetch the current price for *ticker*."""
|
|
46
|
+
data = await self._request("GET", f"/prices/{ticker}")
|
|
47
|
+
return PriceResponse.from_dict(data)
|
|
48
|
+
|
|
49
|
+
async def get_portfolio(self) -> PortfolioResponse:
|
|
50
|
+
"""Fetch the authenticated user's portfolio."""
|
|
51
|
+
data = await self._request("GET", "/portfolio")
|
|
52
|
+
return PortfolioResponse.from_dict(data)
|
|
53
|
+
|
|
54
|
+
async def place_order(self, ticker: str, action: str, quantity: int) -> OrderResponse:
|
|
55
|
+
"""Place a buy/sell order.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
ticker: Stock symbol, e.g. ``"AAPL"``.
|
|
59
|
+
action: ``"buy"`` or ``"sell"``.
|
|
60
|
+
quantity: Number of shares.
|
|
61
|
+
"""
|
|
62
|
+
data = await self._request(
|
|
63
|
+
"POST",
|
|
64
|
+
"/orders",
|
|
65
|
+
json={"ticker": ticker, "action": action, "quantity": quantity},
|
|
66
|
+
)
|
|
67
|
+
return OrderResponse.from_dict(data)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class StockSimError(Exception):
|
|
9
|
+
"""Base exception for all StockSim SDK errors."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, message: str, *, status_code: int | None = None, detail: Any = None) -> None:
|
|
12
|
+
super().__init__(message)
|
|
13
|
+
self.status_code = status_code
|
|
14
|
+
self.detail = detail
|
|
15
|
+
|
|
16
|
+
def __repr__(self) -> str:
|
|
17
|
+
return f"{type(self).__name__}({self.args[0]!r}, status_code={self.status_code})"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class StockSimAuthError(StockSimError):
|
|
21
|
+
"""Raised on 401 Unauthorized or 403 Forbidden responses."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class StockSimRateLimitError(StockSimError):
|
|
25
|
+
"""Raised on 429 Too Many Requests responses."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, message: str, *, status_code: int = 429, detail: Any = None, retry_after: int | None = None) -> None:
|
|
28
|
+
super().__init__(message, status_code=status_code, detail=detail)
|
|
29
|
+
self.retry_after = retry_after
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class StockSimValidationError(StockSimError):
|
|
33
|
+
"""Raised on 422 Unprocessable Entity responses (FastAPI validation errors)."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, message: str, *, status_code: int = 422, detail: Any = None) -> None:
|
|
36
|
+
super().__init__(message, status_code=status_code, detail=detail)
|
|
37
|
+
self.errors: list[dict[str, Any]] = detail if isinstance(detail, list) else []
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _raise_for_status(response: httpx.Response) -> None:
|
|
41
|
+
"""Parse an error response from the StockSim API and raise the appropriate exception."""
|
|
42
|
+
if response.is_success:
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
status_code = response.status_code
|
|
46
|
+
detail: Any = None
|
|
47
|
+
message = f"HTTP {status_code}"
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
body = response.json()
|
|
51
|
+
detail = body.get("detail", body)
|
|
52
|
+
if isinstance(detail, str):
|
|
53
|
+
message = detail
|
|
54
|
+
elif isinstance(detail, list) and detail:
|
|
55
|
+
first = detail[0]
|
|
56
|
+
if isinstance(first, dict):
|
|
57
|
+
message = first.get("msg", message)
|
|
58
|
+
except Exception:
|
|
59
|
+
message = response.text or message
|
|
60
|
+
|
|
61
|
+
if status_code in (401, 403):
|
|
62
|
+
raise StockSimAuthError(message, status_code=status_code, detail=detail)
|
|
63
|
+
if status_code == 422:
|
|
64
|
+
raise StockSimValidationError(message, status_code=status_code, detail=detail)
|
|
65
|
+
if status_code == 429:
|
|
66
|
+
retry_after: int | None = None
|
|
67
|
+
raw = response.headers.get("Retry-After")
|
|
68
|
+
if raw is not None:
|
|
69
|
+
try:
|
|
70
|
+
retry_after = int(raw)
|
|
71
|
+
except ValueError:
|
|
72
|
+
pass
|
|
73
|
+
raise StockSimRateLimitError(message, status_code=status_code, detail=detail, retry_after=retry_after)
|
|
74
|
+
|
|
75
|
+
raise StockSimError(message, status_code=status_code, detail=detail)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Self
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class PriceResponse:
|
|
9
|
+
ticker: str
|
|
10
|
+
price: float
|
|
11
|
+
timestamp: str
|
|
12
|
+
|
|
13
|
+
@classmethod
|
|
14
|
+
def from_dict(cls, data: dict[str, Any]) -> Self:
|
|
15
|
+
return cls(
|
|
16
|
+
ticker=data["ticker"],
|
|
17
|
+
price=float(data["price"]),
|
|
18
|
+
timestamp=data["timestamp"],
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class PortfolioPosition:
|
|
24
|
+
ticker: str
|
|
25
|
+
quantity: int
|
|
26
|
+
avg_price: float
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def from_dict(cls, data: dict[str, Any]) -> Self:
|
|
30
|
+
return cls(
|
|
31
|
+
ticker=data["ticker"],
|
|
32
|
+
quantity=int(data["quantity"]),
|
|
33
|
+
avg_price=float(data["avg_price"]),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class PortfolioResponse:
|
|
39
|
+
balance: float
|
|
40
|
+
positions: list[PortfolioPosition]
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def from_dict(cls, data: dict[str, Any]) -> Self:
|
|
44
|
+
return cls(
|
|
45
|
+
balance=float(data["balance"]),
|
|
46
|
+
positions=[PortfolioPosition.from_dict(p) for p in data.get("positions", [])],
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class OrderResponse:
|
|
52
|
+
order_id: str
|
|
53
|
+
ticker: str
|
|
54
|
+
action: str
|
|
55
|
+
quantity: int
|
|
56
|
+
status: str
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def from_dict(cls, data: dict[str, Any]) -> Self:
|
|
60
|
+
return cls(
|
|
61
|
+
order_id=data["order_id"],
|
|
62
|
+
ticker=data["ticker"],
|
|
63
|
+
action=data["action"],
|
|
64
|
+
quantity=int(data["quantity"]),
|
|
65
|
+
status=data["status"],
|
|
66
|
+
)
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
import respx
|
|
5
|
+
from httpx import Response
|
|
6
|
+
|
|
7
|
+
from stocksim import (
|
|
8
|
+
StockSimAuthError,
|
|
9
|
+
StockSimClient,
|
|
10
|
+
StockSimRateLimitError,
|
|
11
|
+
StockSimValidationError,
|
|
12
|
+
)
|
|
13
|
+
from stocksim.models import OrderResponse, PortfolioResponse, PriceResponse
|
|
14
|
+
|
|
15
|
+
BASE = "https://api.stocksim.pro"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@pytest.fixture
|
|
19
|
+
def client() -> StockSimClient:
|
|
20
|
+
return StockSimClient(api_key="test-key", base_url=BASE)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@respx.mock
|
|
24
|
+
@pytest.mark.asyncio
|
|
25
|
+
async def test_get_price_success(client: StockSimClient) -> None:
|
|
26
|
+
respx.get(f"{BASE}/prices/AAPL").mock(
|
|
27
|
+
return_value=Response(200, json={"ticker": "AAPL", "price": 182.5, "timestamp": "2026-06-16T10:00:00Z"})
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
result = await client.get_price("AAPL")
|
|
31
|
+
|
|
32
|
+
assert isinstance(result, PriceResponse)
|
|
33
|
+
assert result.ticker == "AAPL"
|
|
34
|
+
assert result.price == 182.5
|
|
35
|
+
assert result.timestamp == "2026-06-16T10:00:00Z"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@respx.mock
|
|
39
|
+
@pytest.mark.asyncio
|
|
40
|
+
async def test_get_portfolio_success(client: StockSimClient) -> None:
|
|
41
|
+
respx.get(f"{BASE}/portfolio").mock(
|
|
42
|
+
return_value=Response(
|
|
43
|
+
200,
|
|
44
|
+
json={
|
|
45
|
+
"balance": 10000.0,
|
|
46
|
+
"positions": [{"ticker": "AAPL", "quantity": 5, "avg_price": 175.0}],
|
|
47
|
+
},
|
|
48
|
+
)
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
result = await client.get_portfolio()
|
|
52
|
+
|
|
53
|
+
assert isinstance(result, PortfolioResponse)
|
|
54
|
+
assert result.balance == 10000.0
|
|
55
|
+
assert len(result.positions) == 1
|
|
56
|
+
assert result.positions[0].ticker == "AAPL"
|
|
57
|
+
assert result.positions[0].quantity == 5
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@respx.mock
|
|
61
|
+
@pytest.mark.asyncio
|
|
62
|
+
async def test_place_order_success(client: StockSimClient) -> None:
|
|
63
|
+
respx.post(f"{BASE}/orders").mock(
|
|
64
|
+
return_value=Response(
|
|
65
|
+
201,
|
|
66
|
+
json={"order_id": "ord-123", "ticker": "TSLA", "action": "buy", "quantity": 10, "status": "filled"},
|
|
67
|
+
)
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
result = await client.place_order("TSLA", "buy", 10)
|
|
71
|
+
|
|
72
|
+
assert isinstance(result, OrderResponse)
|
|
73
|
+
assert result.order_id == "ord-123"
|
|
74
|
+
assert result.action == "buy"
|
|
75
|
+
assert result.status == "filled"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@respx.mock
|
|
79
|
+
@pytest.mark.asyncio
|
|
80
|
+
async def test_auth_error_raises_StockSimAuthError(client: StockSimClient) -> None:
|
|
81
|
+
respx.get(f"{BASE}/portfolio").mock(
|
|
82
|
+
return_value=Response(401, json={"detail": "Invalid API key"})
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
with pytest.raises(StockSimAuthError) as exc_info:
|
|
86
|
+
await client.get_portfolio()
|
|
87
|
+
|
|
88
|
+
assert exc_info.value.status_code == 401
|
|
89
|
+
assert "Invalid API key" in str(exc_info.value)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@respx.mock
|
|
93
|
+
@pytest.mark.asyncio
|
|
94
|
+
async def test_forbidden_raises_StockSimAuthError(client: StockSimClient) -> None:
|
|
95
|
+
respx.get(f"{BASE}/portfolio").mock(
|
|
96
|
+
return_value=Response(403, json={"detail": "Forbidden"})
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
with pytest.raises(StockSimAuthError) as exc_info:
|
|
100
|
+
await client.get_portfolio()
|
|
101
|
+
|
|
102
|
+
assert exc_info.value.status_code == 403
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@respx.mock
|
|
106
|
+
@pytest.mark.asyncio
|
|
107
|
+
async def test_rate_limit_raises_StockSimRateLimitError(client: StockSimClient) -> None:
|
|
108
|
+
respx.get(f"{BASE}/prices/AAPL").mock(
|
|
109
|
+
return_value=Response(
|
|
110
|
+
429,
|
|
111
|
+
json={"detail": "Rate limit exceeded"},
|
|
112
|
+
headers={"Retry-After": "30"},
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
with pytest.raises(StockSimRateLimitError) as exc_info:
|
|
117
|
+
await client.get_price("AAPL")
|
|
118
|
+
|
|
119
|
+
assert exc_info.value.status_code == 429
|
|
120
|
+
assert exc_info.value.retry_after == 30
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@respx.mock
|
|
124
|
+
@pytest.mark.asyncio
|
|
125
|
+
async def test_validation_error_raises_StockSimValidationError(client: StockSimClient) -> None:
|
|
126
|
+
respx.post(f"{BASE}/orders").mock(
|
|
127
|
+
return_value=Response(
|
|
128
|
+
422,
|
|
129
|
+
json={
|
|
130
|
+
"detail": [
|
|
131
|
+
{"loc": ["body", "quantity"], "msg": "value is not a valid integer", "type": "type_error.integer"}
|
|
132
|
+
]
|
|
133
|
+
},
|
|
134
|
+
)
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
with pytest.raises(StockSimValidationError) as exc_info:
|
|
138
|
+
await client.place_order("AAPL", "buy", -1)
|
|
139
|
+
|
|
140
|
+
assert exc_info.value.status_code == 422
|
|
141
|
+
assert len(exc_info.value.errors) == 1
|