Pārlūkot izejas kodu

websocket, get_account_info

Lukas Goldschmidt 1 mēnesi atpakaļ
vecāks
revīzija
2599530764

+ 20 - 0
README.md

@@ -55,3 +55,23 @@ This folder is the starting scaffold. The architecture and intent are captured i
 ## Notes
 
 This project is intended to stay lightweight at the FastMCP boundary and push exchange-specific details into adapter modules.
+
+## Bitstamp reference docs
+
+A local copy of the Bitstamp WebSocket API docs lives at `bitstamp_websocket_api.md` in the project root. Use that as the primary reference for live market data, private streams, and reconnect behavior.
+
+The older REST reference file is still present too, but the websocket doc is the one to follow for the next integration step.
+
+## Bitstamp metadata cache
+
+The app now persists Bitstamp metadata tables and refreshes them on startup, then once a day in the background:
+- `GET /api/v2/currencies/`
+- `GET /api/v2/markets/`
+
+These are stored in SQLite for reuse across restarts.
+
+## Bitstamp websocket prices
+
+The app also starts a Bitstamp websocket loop at startup and reconnects on failure.
+It subscribes to public `live_trades_[market]` channels for markets inferred from held assets in enabled Bitstamp accounts.
+Latest prices are persisted in SQLite for later valuation work.

+ 1047 - 0
bitstamp_api_docs.md

@@ -0,0 +1,1047 @@
+# Bitstamp HTTP API v2 — Python Reference
+
+> **Base URL:** `https://www.bitstamp.net`  
+> **Official docs:** https://www.bitstamp.net/api/
+
+---
+
+## Table of Contents
+
+1. [Overview](#overview)
+2. [Rate Limits](#rate-limits)
+3. [Authentication](#authentication)
+4. [HTTP Status Codes & Error Codes](#http-status-codes--error-codes)
+5. [Public Endpoints](#public-endpoints)
+   - [Currencies](#currencies)
+   - [Markets](#markets)
+   - [Ticker](#ticker)
+   - [Order Book](#order-book)
+   - [Transactions](#transactions)
+   - [OHLC (Candlestick Data)](#ohlc-candlestick-data)
+   - [EUR/USD Conversion Rate](#eurusd-conversion-rate)
+   - [Travel Rule VASPs](#travel-rule-vasps)
+6. [Private Endpoints](#private-endpoints)
+   - [Account Balances](#account-balances)
+   - [Fees](#fees)
+   - [Orders](#orders)
+   - [User Transactions](#user-transactions)
+   - [Crypto Deposits](#crypto-deposits)
+   - [Crypto Withdrawals](#crypto-withdrawals)
+   - [Bank Withdrawals](#bank-withdrawals)
+   - [Withdrawal Requests](#withdrawal-requests)
+   - [API Key Management](#api-key-management)
+7. [Python Examples](#python-examples)
+
+---
+
+## Overview
+
+The Bitstamp API allows clients to access and control their accounts using custom software. All responses are JSON. Private endpoints require authentication via HMAC-SHA256 signed headers.
+
+---
+
+## Rate Limits
+
+| Limit | Value |
+|---|---|
+| Requests per second | 400 |
+| Default threshold | 10,000 requests per 10 minutes |
+
+Rate limits can be increased by contacting Bitstamp. For real-time data, use the WebSocket API instead of polling REST endpoints.
+
+---
+
+## Authentication
+
+All private API calls require the following HTTP headers:
+
+| Header | Value |
+|---|---|
+| `X-Auth` | `"BITSTAMP " + api_key` |
+| `X-Auth-Signature` | HMAC-SHA256 signature (hex string) |
+| `X-Auth-Nonce` | UUID4 string (lowercase, 36 chars, unique per request, valid for 150 seconds) |
+| `X-Auth-Timestamp` | Request departure time — UTC milliseconds |
+| `X-Auth-Version` | `"v2"` |
+| `Content-Type` | `"application/x-www-form-urlencoded"` (omit if request body is empty) |
+
+### Signature Construction
+
+The string to sign is constructed by concatenating (with no separators):
+
+```
+"BITSTAMP " + api_key
++ HTTP_METHOD          (e.g. "POST" or "GET")
++ "www.bitstamp.net"
++ URL_PATH             (e.g. "/api/v2/balance/")
++ QUERY_STRING         (empty string if none)
++ CONTENT_TYPE         (empty string if no body)
++ NONCE
++ TIMESTAMP
++ "v2"
++ PAYLOAD_STRING       (URL-encoded body, empty string if none)
+```
+
+> **Important:** If the request body is empty, omit `Content-Type` from both the headers and the signature string.
+
+### Python Authentication Example
+
+```python
+import hashlib
+import hmac
+import time
+import uuid
+import requests
+from urllib.parse import urlencode
+
+API_KEY = 'your_api_key'
+API_SECRET = b'your_api_secret'
+
+def build_headers(method, path, payload=None, query=''):
+    timestamp = str(int(round(time.time() * 1000)))
+    nonce = str(uuid.uuid4())
+
+    if payload:
+        payload_string = urlencode(payload)
+        content_type = 'application/x-www-form-urlencoded'
+    else:
+        payload_string = ''
+        content_type = ''
+
+    message = (
+        'BITSTAMP ' + API_KEY
+        + method
+        + 'www.bitstamp.net'
+        + path
+        + query
+        + content_type
+        + nonce
+        + timestamp
+        + 'v2'
+        + payload_string
+    ).encode('utf-8')
+
+    signature = hmac.new(API_SECRET, msg=message, digestmod=hashlib.sha256).hexdigest()
+
+    headers = {
+        'X-Auth': 'BITSTAMP ' + API_KEY,
+        'X-Auth-Signature': signature,
+        'X-Auth-Nonce': nonce,
+        'X-Auth-Timestamp': timestamp,
+        'X-Auth-Version': 'v2',
+    }
+    if content_type:
+        headers['Content-Type'] = content_type
+
+    return headers, payload_string
+
+
+def private_post(path, payload=None):
+    headers, body = build_headers('POST', path, payload)
+    url = 'https://www.bitstamp.net' + path
+    response = requests.post(url, headers=headers, data=body)
+    return response.json()
+
+
+def private_get(path, query=''):
+    headers, _ = build_headers('GET', path, query=query)
+    url = 'https://www.bitstamp.net' + path
+    if query:
+        url += '?' + query
+    response = requests.get(url, headers=headers)
+    return response.json()
+```
+
+---
+
+## HTTP Status Codes & Error Codes
+
+### HTTP Status Codes
+
+| Code | Meaning |
+|---|---|
+| `200` | OK |
+| `400` | Bad Request — invalid parameters |
+| `401` | Unauthorized — authentication failed |
+| `403` | Forbidden — insufficient permissions |
+| `404` | Not Found |
+| `429` | Too Many Requests — rate limit exceeded |
+| `500` | Internal Server Error |
+
+### API Response Codes (selected)
+
+Errors may include a `response_code` field (e.g. `"400.001"`) and optional `response_explanation`:
+
+| Code | Description |
+|---|---|
+| `400.001` | Unknown validation error |
+| `400.002` | Rate limit exceeded |
+| `API0020` | Content-Type header should not be present (on empty-body requests) |
+| `API5012` | Returned on certain earn endpoint errors |
+
+---
+
+## Public Endpoints
+
+Public endpoints require no authentication.
+
+---
+
+### Currencies
+
+#### `GET /api/v2/currencies/`
+
+Returns a list of all listed currencies with basic info, including supported blockchain networks, minimum withdrawal amounts, deposit/withdrawal status, and decimal precision per network.
+
+```python
+import requests
+
+response = requests.get('https://www.bitstamp.net/api/v2/currencies/')
+currencies = response.json()
+```
+
+---
+
+### Markets
+
+#### `GET /api/v2/markets/`
+
+Returns info about all available trading markets/pairs. Replaces the deprecated `/api/v2/trading-pairs-info/`.
+
+```python
+response = requests.get('https://www.bitstamp.net/api/v2/markets/')
+markets = response.json()
+```
+
+---
+
+### Ticker
+
+#### `GET /api/v2/ticker/`
+
+Returns ticker data for **all** markets. Do not pass any GET parameters.
+
+#### `GET /api/v2/ticker/{market_symbol}/`
+
+Returns ticker data for a specific currency pair (e.g. `btcusd`, `ethusd`, `ethbtc`).
+
+**Response fields:**
+
+| Field | Description |
+|---|---|
+| `last` | Last traded price |
+| `high` | 24-hour high |
+| `low` | 24-hour low |
+| `bid` | Highest buy order |
+| `ask` | Lowest sell order |
+| `volume` | 24-hour volume |
+| `vwap` | 24-hour volume weighted average price |
+| `open` | Opening price |
+| `timestamp` | Unix timestamp |
+
+```python
+response = requests.get('https://www.bitstamp.net/api/v2/ticker/btcusd/')
+ticker = response.json()
+print(ticker['last'], ticker['bid'], ticker['ask'])
+```
+
+#### `GET /api/v2/ticker_hour/{market_symbol}/`
+
+Returns hourly ticker data (values computed over the past hour) for a currency pair.
+
+```python
+response = requests.get('https://www.bitstamp.net/api/v2/ticker_hour/btcusd/')
+```
+
+---
+
+### Order Book
+
+#### `GET /api/v2/order_book/{market_symbol}/`
+
+Returns the current order book for a market.
+
+**Query parameters:**
+
+| Parameter | Type | Description |
+|---|---|---|
+| `group` | int | `0` = ungrouped, `1` = group by price (default), `2` = group by price, include order count |
+
+**Response fields:**
+
+| Field | Description |
+|---|---|
+| `timestamp` | Unix timestamp |
+| `microtimestamp` | Microsecond timestamp |
+| `bids` | List of `[price, amount]` arrays (descending by price) |
+| `asks` | List of `[price, amount]` arrays (ascending by price) |
+
+```python
+response = requests.get('https://www.bitstamp.net/api/v2/order_book/btcusd/')
+order_book = response.json()
+best_bid = order_book['bids'][0]
+best_ask = order_book['asks'][0]
+```
+
+---
+
+### Transactions
+
+#### `GET /api/v2/transactions/{market_symbol}/`
+
+Returns recent public trades for a market.
+
+**Query parameters:**
+
+| Parameter | Type | Default | Description |
+|---|---|---|---|
+| `time` | string | `hour` | Time window: `minute`, `hour`, or `day` |
+
+**Response fields (per trade):**
+
+| Field | Description |
+|---|---|
+| `date` | Unix timestamp |
+| `tid` | Trade ID |
+| `price` | Trade price |
+| `amount` | Trade amount |
+| `type` | `0` = buy, `1` = sell |
+
+```python
+response = requests.get(
+    'https://www.bitstamp.net/api/v2/transactions/btcusd/',
+    params={'time': 'hour'}
+)
+trades = response.json()
+```
+
+---
+
+### OHLC (Candlestick Data)
+
+#### `GET /api/v2/ohlc/{market_symbol}/`
+
+Returns OHLCV candlestick data.
+
+**Query parameters:**
+
+| Parameter | Type | Required | Description |
+|---|---|---|---|
+| `step` | int | Yes | Candle duration in seconds. Valid values: `60, 180, 300, 900, 1800, 3600, 7200, 14400, 21600, 43200, 86400, 259200` |
+| `limit` | int | Yes | Number of candles to return (1–1000) |
+| `start` | int | No | Start Unix timestamp |
+| `end` | int | No | End Unix timestamp |
+| `exclude_current_candle` | bool | No | Exclude the still-open current candle |
+
+**Response structure:**
+
+```json
+{
+  "data": {
+    "pair": "BTC/USD",
+    "ohlc": [
+      {
+        "timestamp": "1609459200",
+        "open": "28999.63",
+        "high": "29022.01",
+        "low": "28999.14",
+        "close": "29006.31",
+        "volume": "0.86157958"
+      }
+    ]
+  }
+}
+```
+
+```python
+import requests
+import pandas as pd
+
+currency_pair = 'btcusd'
+url = f'https://www.bitstamp.net/api/v2/ohlc/{currency_pair}/'
+
+params = {
+    'step': 3600,    # 1-hour candles
+    'limit': 100,
+}
+
+response = requests.get(url, params=params)
+ohlc_data = response.json()['data']['ohlc']
+
+df = pd.DataFrame(ohlc_data)
+df['timestamp'] = pd.to_datetime(df['timestamp'], unit='s')
+df[['open', 'high', 'low', 'close', 'volume']] = df[['open', 'high', 'low', 'close', 'volume']].astype(float)
+print(df.tail())
+```
+
+**Fetching historical OHLC in date ranges:**
+
+```python
+import requests
+import pandas as pd
+
+currency_pair = 'btcusd'
+url = f'https://www.bitstamp.net/api/v2/ohlc/{currency_pair}/'
+
+start = '2021-01-01'
+end   = '2021-01-02'
+
+dates = pd.date_range(start, end, freq='6H')
+dates = [int(x.value / 10**9) for x in list(dates)]
+
+master_data = []
+for first, last in zip(dates, dates[1:]):
+    params = {'step': 60, 'limit': 1000, 'start': first, 'end': last}
+    data = requests.get(url, params=params).json()['data']['ohlc']
+    master_data += data
+
+df = pd.DataFrame(master_data)
+```
+
+---
+
+### EUR/USD Conversion Rate
+
+#### `GET /api/v2/eur_usd/`
+
+Returns the current EUR/USD conversion rate.
+
+**Response fields:**
+
+| Field | Description |
+|---|---|
+| `buy` | Conversion rate for buying |
+| `sell` | Conversion rate for selling |
+
+```python
+response = requests.get('https://www.bitstamp.net/api/v2/eur_usd/')
+rate = response.json()
+```
+
+---
+
+### Travel Rule VASPs
+
+#### `GET /api/v2/travel-rule/vasps/`
+
+Returns a list of Virtual Asset Service Providers (VASPs) required for Travel Rule compliance when transferring crypto to/from other platforms.
+
+```python
+response = requests.get('https://www.bitstamp.net/api/v2/travel-rule/vasps/')
+vasps = response.json()
+```
+
+---
+
+## Private Endpoints
+
+All private endpoints require authentication headers (see [Authentication](#authentication)).
+
+---
+
+### Account Balances
+
+#### `POST /api/v2/account_balances/`
+
+Returns all account balances across all currencies.
+
+**Response fields (per currency, e.g. `btc_`):**
+
+| Field | Description |
+|---|---|
+| `{currency}_balance` | Total balance |
+| `{currency}_available` | Available (not reserved) |
+| `{currency}_reserved` | Reserved in open orders |
+
+```python
+balances = private_post('/api/v2/account_balances/')
+btc_available = balances.get('btc_available')
+usd_available = balances.get('usd_available')
+```
+
+#### `POST /api/v2/account_balances/{currency}/`
+
+Returns balances for a specific currency.
+
+```python
+btc_balance = private_post('/api/v2/account_balances/btc/')
+```
+
+---
+
+### Fees
+
+#### `POST /api/v2/fees/trading/`
+
+Returns all trading fees.
+
+#### `POST /api/v2/fees/trading/{market_symbol}/`
+
+Returns trading fees for a specific market.
+
+#### `POST /api/v2/fees/withdrawal/`
+
+Returns all withdrawal fees.
+
+#### `POST /api/v2/fees/withdrawal/{currency}/`
+
+Returns withdrawal fee for a specific currency.
+
+```python
+fees = private_post('/api/v2/fees/trading/')
+btcusd_fee = private_post('/api/v2/fees/trading/btcusd/')
+withdrawal_fees = private_post('/api/v2/fees/withdrawal/')
+```
+
+---
+
+### Orders
+
+#### `POST /api/v2/order_status/`
+
+Returns the status of an order. Works for the account (sub or main) the API key is bound to. For closed orders, only data from the past 30 days is available.
+
+**Parameters:**
+
+| Parameter | Type | Description |
+|---|---|---|
+| `id` | string | Order ID (use either this or `client_order_id`) |
+| `client_order_id` | string | Client-assigned order ID |
+
+```python
+status = private_post('/api/v2/order_status/', {'id': '1234567890'})
+```
+
+#### `POST /api/v2/open_orders/all/`
+
+Returns all open orders across all markets. Cached for 10 seconds.
+
+```python
+open_orders = private_post('/api/v2/open_orders/all/')
+```
+
+#### `POST /api/v2/open_orders/{market_symbol}/`
+
+Returns open orders for a specific market.
+
+```python
+open_orders = private_post('/api/v2/open_orders/btcusd/')
+```
+
+**Open order response fields:**
+
+| Field | Description |
+|---|---|
+| `id` | Order ID |
+| `client_order_id` | Client-specified ID (if provided) |
+| `datetime` | Order creation datetime |
+| `type` | `0` = buy, `1` = sell |
+| `price` | Order price |
+| `amount` | Remaining amount |
+| `market` | Market symbol |
+
+#### `POST /api/v2/cancel_order/`
+
+Cancels an order.
+
+**Parameters:**
+
+| Parameter | Type | Description |
+|---|---|---|
+| `id` | string | Order ID to cancel |
+
+**Response:** May include `"status": "Canceled"` or `"status": "Cancel pending"` (if a duplicate cancel is in progress; HTTP 200 is still returned).
+
+```python
+result = private_post('/api/v2/cancel_order/', {'id': '1234567890'})
+```
+
+#### `POST /api/v2/cancel_all_orders/`
+
+Cancels all open orders.
+
+```python
+result = private_post('/api/v2/cancel_all_orders/')
+```
+
+#### `POST /api/v2/cancel_all_orders/{market_symbol}/`
+
+Cancels all open orders for a specific market.
+
+```python
+result = private_post('/api/v2/cancel_all_orders/btcusd/')
+```
+
+#### `POST /api/v2/buy/{market_symbol}/`
+
+Places a **limit buy order**.
+
+**Parameters:**
+
+| Parameter | Type | Required | Description |
+|---|---|---|---|
+| `amount` | decimal string | Yes | Amount to buy |
+| `price` | decimal string | Yes | Limit price |
+| `limit_price` | decimal string | No | If set, order becomes a stop-limit buy |
+| `daily_order` | bool | No | Order expires at end of day (UTC midnight) if true |
+| `ioc_order` | bool | No | Immediate-or-cancel order |
+| `client_order_id` | string | No | Custom order identifier |
+
+```python
+order = private_post('/api/v2/buy/btcusd/', {
+    'amount': '0.001',
+    'price': '30000.00',
+})
+```
+
+#### `POST /api/v2/buy/market/{market_symbol}/`
+
+Places a **market buy order**.
+
+**Parameters:**
+
+| Parameter | Type | Required | Description |
+|---|---|---|---|
+| `amount` | decimal string | Yes | Amount of quote currency to spend |
+| `client_order_id` | string | No | Custom order identifier |
+
+```python
+order = private_post('/api/v2/buy/market/btcusd/', {'amount': '100.00'})
+```
+
+#### `POST /api/v2/buy/instant/{market_symbol}/`
+
+Places an **instant buy order** (fills at current market price against the order book).
+
+**Parameters:** Same as market order.
+
+#### `POST /api/v2/sell/{market_symbol}/`
+
+Places a **limit sell order**.
+
+**Parameters:** Same as limit buy.
+
+```python
+order = private_post('/api/v2/sell/btcusd/', {
+    'amount': '0.001',
+    'price': '50000.00',
+})
+```
+
+#### `POST /api/v2/sell/market/{market_symbol}/`
+
+Places a **market sell order**.
+
+**Parameters:**
+
+| Parameter | Type | Required | Description |
+|---|---|---|---|
+| `amount` | decimal string | Yes | Amount of base currency to sell |
+| `client_order_id` | string | No | Custom order identifier |
+
+```python
+order = private_post('/api/v2/sell/market/btcusd/', {'amount': '0.001'})
+```
+
+#### `POST /api/v2/sell/instant/{market_symbol}/`
+
+Places an **instant sell order**.
+
+#### `POST /api/v2/replace_order/`
+
+Replaces an existing order (cancel + new order atomically).
+
+**Parameters:** `id` of the order to replace, plus the standard order creation parameters.
+
+#### `POST /api/v2/order_data/`
+
+Retrieves historical **public** order events for a market (for WebSocket gap recovery).
+
+#### `POST /api/v2/account_order_data/`
+
+Retrieves historical order events for the authenticated user's account (for WebSocket gap recovery).
+
+**Order response fields:**
+
+| Field | Description |
+|---|---|
+| `id` | Order ID |
+| `client_order_id` | Client-assigned ID |
+| `datetime` | Datetime string |
+| `type` | `0` = buy, `1` = sell |
+| `price` | Order price |
+| `amount` | Order amount |
+| `market` | Market symbol |
+| `status` | Order status |
+
+---
+
+### User Transactions
+
+#### `POST /api/v2/user_transactions/`
+
+Returns transaction history for the account.
+
+**Parameters:**
+
+| Parameter | Type | Default | Description |
+|---|---|---|---|
+| `offset` | int | `0` | Number of transactions to skip |
+| `limit` | int | `100` | Number of transactions to return |
+| `sort` | string | `desc` | Sort order: `asc` or `desc` |
+| `since_timestamp` | int | — | Return only transactions after this Unix timestamp |
+| `since_id` | int | — | Return only transactions after this transaction ID |
+
+**Transaction types:**
+
+| Value | Description |
+|---|---|
+| `0` | Deposit |
+| `1` | Withdrawal |
+| `2` | Market trade |
+| `14` | Sub account transfer |
+| `25` | Credited with staked assets |
+| `26` | Sent assets to staking |
+| `27` | Staking reward |
+| `32` | Referral reward |
+| `35` | Inter-account transfer |
+
+```python
+transactions = private_post('/api/v2/user_transactions/', {
+    'offset': '0',
+    'limit': '100',
+    'sort': 'desc',
+})
+```
+
+#### `POST /api/v2/user_transactions/{market_symbol}/`
+
+Returns transactions filtered to a specific market.
+
+```python
+transactions = private_post('/api/v2/user_transactions/btcusd/', {
+    'limit': '50',
+    'sort': 'desc',
+})
+```
+
+---
+
+### Crypto Deposits
+
+#### `POST /api/v2/crypto-transactions/deposits/`
+
+Returns crypto deposit transaction history.
+
+#### `POST /api/v2/crypto-transactions/deposits/{deposit_id}/reject/`
+
+Rejects a specific crypto deposit.
+
+#### `POST /api/v2/{currency}_address/`
+
+Returns (or generates) the deposit address for a given cryptocurrency.
+
+Available address endpoints include:
+
+- `POST /api/v2/btc_address/`
+- `POST /api/v2/eth_address/`
+- `POST /api/v2/ltc_address/`
+- `POST /api/v2/xrp_address/`
+- `POST /api/v2/bch_address/`
+- `POST /api/v2/xlm_address/`
+- `POST /api/v2/pax_address/`
+- `POST /api/v2/link_address/`
+- `POST /api/v2/omg_address/`
+- `POST /api/v2/usdc_address/`
+
+```python
+btc_address = private_post('/api/v2/btc_address/')
+eth_address = private_post('/api/v2/eth_address/')
+```
+
+---
+
+### Crypto Withdrawals
+
+#### `POST /api/v2/crypto-transactions/withdrawals/`
+
+Returns crypto withdrawal transaction history.
+
+#### `POST /api/v2/btc_withdrawal/`
+
+Withdraw Bitcoin.
+
+**Parameters:**
+
+| Parameter | Type | Required | Description |
+|---|---|---|---|
+| `amount` | decimal string | Yes | Amount in BTC |
+| `address` | string | Yes | Bitcoin address |
+| `instant` | int | No | `1` for instant, `0` for standard |
+
+```python
+result = private_post('/api/v2/btc_withdrawal/', {
+    'amount': '0.001',
+    'address': 'bc1q...',
+})
+```
+
+#### `POST /api/v2/eth_withdrawal/`
+
+Withdraw Ethereum.
+
+**Parameters:** `amount` and `address`.
+
+```python
+result = private_post('/api/v2/eth_withdrawal/', {
+    'amount': '0.01',
+    'address': '0x...',
+})
+```
+
+Similar endpoints exist for other currencies (e.g. `ltc_withdrawal`, `xrp_withdrawal`, `bch_withdrawal`, `xlm_withdrawal`, `usdc_withdrawal`, etc.).
+
+> **Note:** Travel Rule data fields are optional now but will become mandatory in a future breaking change.
+
+---
+
+### Bank Withdrawals
+
+#### `POST /api/v2/withdrawal/open/`
+
+Opens a bank (fiat) withdrawal.
+
+**Common parameters** (see official docs for full field list by withdrawal type):
+
+| Parameter | Description |
+|---|---|
+| `amount` | Withdrawal amount |
+| `account_currency` | Currency (e.g. `USD`, `EUR`) |
+| `name` | Account holder name |
+| `bank_name` | Receiving bank name |
+| `iban` | IBAN (for SEPA transfers) |
+| `bic` | BIC/SWIFT code |
+| `address` | Account holder address |
+| `city` | City |
+| `country` | Country code |
+
+```python
+result = private_post('/api/v2/withdrawal/open/', {
+    'amount': '100.00',
+    'account_currency': 'EUR',
+    'name': 'John Doe',
+    'iban': 'DE89...',
+    'bic': 'COBADEFFXXX',
+    'address': '123 Main St',
+    'city': 'Berlin',
+    'country': 'DE',
+})
+```
+
+#### `POST /api/v2/withdrawal/status/`
+
+Returns the status of a withdrawal.
+
+**Parameters:** `id` — withdrawal ID.
+
+#### `POST /api/v2/withdrawal/cancel/`
+
+Cancels a pending bank withdrawal.
+
+**Parameters:** `id` — withdrawal ID.
+
+---
+
+### Withdrawal Requests
+
+#### `POST /api/v2/withdrawal-requests/`
+
+Returns a list of all withdrawal requests.
+
+**Parameters:**
+
+| Parameter | Type | Default | Description |
+|---|---|---|---|
+| `timedelta` | int | `86400` | Return requests within this many seconds of now |
+
+**Response fields (per request):**
+
+| Field | Description |
+|---|---|
+| `id` | Withdrawal ID |
+| `datetime` | Datetime string |
+| `type` | Withdrawal type (0=SEPA, 1=Bitcoin, 2=Wire, etc.) |
+| `status` | 0=Open, 1=In process, 2=Finished, 3=Cancelled, 4=Failed |
+| `amount` | Amount |
+| `data` | Extra type-specific data |
+
+```python
+requests_list = private_post('/api/v2/withdrawal-requests/', {'timedelta': '86400'})
+```
+
+---
+
+### API Key Management
+
+#### `POST /api/v2/api-key/revoke/all/`
+
+Revokes **all** API keys for the account (kill switch). Use with extreme caution.
+
+```python
+result = private_post('/api/v2/api-key/revoke/all/')
+```
+
+---
+
+## Python Examples
+
+### Public: Fetch Ticker
+
+```python
+import requests
+
+def get_ticker(pair='btcusd'):
+    url = f'https://www.bitstamp.net/api/v2/ticker/{pair}/'
+    response = requests.get(url)
+    response.raise_for_status()
+    return response.json()
+
+ticker = get_ticker('ethusd')
+print(f"ETH/USD last: {ticker['last']}, bid: {ticker['bid']}, ask: {ticker['ask']}")
+```
+
+### Public: Fetch Order Book
+
+```python
+import requests
+
+def get_order_book(pair='btcusd', group=1):
+    url = f'https://www.bitstamp.net/api/v2/order_book/{pair}/'
+    response = requests.get(url, params={'group': group})
+    response.raise_for_status()
+    return response.json()
+
+book = get_order_book('btcusd')
+print("Best bid:", book['bids'][0])
+print("Best ask:", book['asks'][0])
+```
+
+### Public: Fetch OHLC Data into DataFrame
+
+```python
+import requests
+import pandas as pd
+
+def get_ohlc(pair='btcusd', step=3600, limit=100):
+    url = f'https://www.bitstamp.net/api/v2/ohlc/{pair}/'
+    response = requests.get(url, params={'step': step, 'limit': limit})
+    response.raise_for_status()
+    data = response.json()['data']['ohlc']
+    df = pd.DataFrame(data)
+    df['timestamp'] = pd.to_datetime(df['timestamp'], unit='s')
+    numeric_cols = ['open', 'high', 'low', 'close', 'volume']
+    df[numeric_cols] = df[numeric_cols].astype(float)
+    return df.set_index('timestamp')
+
+df = get_ohlc('btcusd', step=86400, limit=30)  # 30 daily candles
+print(df.tail())
+```
+
+### Private: Full Authentication & Balance Check
+
+```python
+import hashlib
+import hmac
+import time
+import uuid
+import requests
+from urllib.parse import urlencode
+
+API_KEY = 'your_api_key'
+API_SECRET = b'your_api_secret'
+
+def signed_post(path, payload=None):
+    timestamp = str(int(round(time.time() * 1000)))
+    nonce = str(uuid.uuid4())
+
+    if payload:
+        payload_string = urlencode(payload)
+        content_type = 'application/x-www-form-urlencoded'
+    else:
+        payload_string = ''
+        content_type = ''
+
+    message = (
+        'BITSTAMP ' + API_KEY
+        + 'POST'
+        + 'www.bitstamp.net'
+        + path
+        + ''           # query string (empty for POST)
+        + content_type
+        + nonce
+        + timestamp
+        + 'v2'
+        + payload_string
+    ).encode('utf-8')
+
+    signature = hmac.new(API_SECRET, msg=message, digestmod=hashlib.sha256).hexdigest()
+
+    headers = {
+        'X-Auth': 'BITSTAMP ' + API_KEY,
+        'X-Auth-Signature': signature,
+        'X-Auth-Nonce': nonce,
+        'X-Auth-Timestamp': timestamp,
+        'X-Auth-Version': 'v2',
+    }
+    if content_type:
+        headers['Content-Type'] = content_type
+
+    url = 'https://www.bitstamp.net' + path
+    response = requests.post(url, headers=headers, data=payload_string)
+    response.raise_for_status()
+    return response.json()
+
+# Get all balances
+balances = signed_post('/api/v2/account_balances/')
+print(f"BTC available: {balances.get('btc_available')}")
+print(f"USD available: {balances.get('usd_available')}")
+```
+
+### Private: Place a Limit Buy Order
+
+```python
+order = signed_post('/api/v2/buy/btcusd/', {
+    'amount': '0.0001',
+    'price': '25000.00',
+})
+print("Order placed:", order)
+```
+
+### Private: Get Open Orders & Cancel All
+
+```python
+# List open orders
+open_orders = signed_post('/api/v2/open_orders/all/')
+print(f"Open orders: {len(open_orders)}")
+
+# Cancel all open orders
+result = signed_post('/api/v2/cancel_all_orders/')
+print("Cancel result:", result)
+```
+
+### Private: User Transaction History
+
+```python
+transactions = signed_post('/api/v2/user_transactions/', {
+    'offset': '0',
+    'limit': '50',
+    'sort': 'desc',
+})
+
+for tx in transactions:
+    tx_type = {0: 'Deposit', 1: 'Withdrawal', 2: 'Trade'}.get(int(tx['type']), str(tx['type']))
+    print(f"{tx['datetime']} | {tx_type} | {tx.get('btc', '')} BTC | {tx.get('usd', '')} USD")
+```
+
+---
+
+*This document is compiled from the official Bitstamp API documentation at https://www.bitstamp.net/api/ and is Python-focused. Always refer to the official docs for the most up-to-date endpoint parameters and response schemas.*

+ 165 - 0
bitstamp_websocket_api.md

@@ -0,0 +1,165 @@
+# WebSocket API v2
+
+## What Is WebSocket?
+
+WebSocket is a protocol providing full-duplex communication channels over a single TCP connection.  
+Standardized by the IETF as **RFC 6455 (2011)** and by the W3C (WebSocket API).
+
+---
+
+## Connection
+
+Bitstamp WebSocket endpoint:
+
+
+wss://ws.bitstamp.net
+
+
+Documentation:  
+https://websockets.readthedocs.io/en/stable/
+
+After establishing a connection (HTTP upgrade → WebSocket), you can subscribe to channels and receive live event streams.
+
+---
+
+## Subscriptions
+
+### Public Channels
+
+```json
+{
+  "event": "bts:subscribe",
+  "data": {
+    "channel": "[channel_name]"
+  }
+}
+Private Channels
+{
+  "event": "bts:subscribe",
+  "data": {
+    "channel": "[channel_name]-[user-id]",
+    "auth": "[token]"
+  }
+}
+token and user-id are generated via HTTP API.
+Unsubscriptions
+{
+  "event": "bts:unsubscribe",
+  "data": {
+    "channel": "[channel_name]"
+  }
+}
+
+⚠️ Only valid message formats are processed — invalid messages return errors.
+
+Channels
+Public Channels
+Name	Event	Channel
+Live ticker	trade	live_trades_[market]
+Live orders	order_created / changed / deleted	live_orders_[market]
+Order book	data	order_book_[market]
+Detail order book	data	detail_order_book_[market]
+Full order book	data	diff_order_book_[market]
+Funding rate	funding_rate_saved	funding_rate_[market]
+Private Channels
+Name	Event	Channel
+My Orders	order events + stop events	private-my_orders_[market]-[userId]
+My Trades	trade	private-my_trades_[market]-[userId]
+My Settlements	settlement	private-my_settlements-[userId]
+Live Trades	self_trade	private-live_trades_[market]-[userId]
+Liquidations	liquidation + margin alerts	private-my_liquidations-[userId]
+Channel Data Structures
+Live Trades
+Field	Description
+id	Trade ID
+amount	Trade amount
+price	Trade price
+type	0 = buy, 1 = sell
+timestamp	Trade timestamp
+Order Book
+Field	Description
+bids	Top 100 bids
+asks	Top 100 asks
+timestamp	Timestamp
+Detail Order Book
+Field	Description
+bids	[price, amount, order_id]
+asks	[price, amount, order_id]
+Full Order Book (Diff)
+Field	Description
+bids	Changed bids
+asks	Changed asks
+Live Orders
+
+Includes:
+
+id, amount, price
+order_type (0 = buy, 1 = sell)
+order_subtype (limit, market, stop, etc.)
+datetime, microtimestamp
+event_id, pre_event_id
+Funding Rate
+Field	Description
+funding_rate	Rate
+mark_price	Mark price
+index_price	Index price
+next_funding_time	Next funding
+Private My Orders
+
+Includes:
+
+id, amount, price
+client_order_id
+order_type, order_subtype
+stop_price, activation_price
+reduce_only
+event_id
+Private My Trades
+Field	Description
+id	Trade ID
+order_id	Linked order
+amount	Trade amount
+price	Trade price
+fee	Fee
+side	buy / sell
+Private My Settlements
+Field	Description
+id	Position ID
+price	Settlement price
+ccy	Currency
+Private Live Trades
+
+Self-trade events only.
+
+Field	Description
+buy_order_id	Buy order
+sell_order_id	Sell order
+amount	Amount
+price	Price
+Private Liquidations
+Field	Description
+position_id	Position ID
+alert_type	Liquidation stage
+margin_mode	ISOLATED / CROSS
+initial_margin_ratio	Initial margin
+maintenance_margin_ratio	Maintenance margin
+Technical Notes
+1. Heartbeat
+{
+  "event": "bts:heartbeat"
+}
+
+Used to verify server responsiveness (PING/PONG).
+
+2. Forced Reconnection
+{
+  "event": "bts:request_reconnect",
+  "channel": "",
+  "data": ""
+}
+
+Reconnect within a few seconds to avoid disconnection.
+
+3. Maximum Connection Age
+Max duration: 90 days
+After that → automatic disconnect → reconnect required

+ 54 - 0
src/exec_mcp/bitstamp_fx.py

@@ -0,0 +1,54 @@
+from __future__ import annotations
+
+from datetime import datetime, timezone
+
+import requests
+
+from .storage import get_connection
+
+BITSTAMP_BASE_URL = "https://www.bitstamp.net"
+FX_REFRESH_SECONDS = 15 * 60
+
+
+def fetch_eur_usd() -> dict:
+    response = requests.get(f"{BITSTAMP_BASE_URL}/api/v2/eur_usd/", timeout=30)
+    response.raise_for_status()
+    return response.json()
+
+
+def save_eur_usd(payload: dict) -> None:
+    captured_at = datetime.now(timezone.utc).isoformat()
+    with get_connection() as conn:
+        conn.execute(
+            """
+            INSERT INTO bitstamp_fx_rates (pair, buy, sell, payload_json, captured_at)
+            VALUES (?, ?, ?, ?, ?)
+            ON CONFLICT(pair) DO UPDATE SET
+                buy=excluded.buy,
+                sell=excluded.sell,
+                payload_json=excluded.payload_json,
+                captured_at=excluded.captured_at
+            """,
+            (
+                "eur_usd",
+                str(payload.get("buy", "")),
+                str(payload.get("sell", "")),
+                __import__("json").dumps(payload),
+                captured_at,
+            ),
+        )
+        conn.commit()
+
+
+def load_eur_usd() -> dict | None:
+    with get_connection() as conn:
+        row = conn.execute("SELECT buy, sell, payload_json FROM bitstamp_fx_rates WHERE pair = ?", ("eur_usd",)).fetchone()
+    if row is None:
+        return None
+    return __import__("json").loads(row["payload_json"])
+
+
+def refresh_eur_usd() -> dict:
+    payload = fetch_eur_usd()
+    save_eur_usd(payload)
+    return payload

+ 56 - 0
src/exec_mcp/bitstamp_metadata.py

@@ -0,0 +1,56 @@
+from __future__ import annotations
+
+import requests
+
+from .storage import get_connection
+
+BITSTAMP_BASE_URL = "https://www.bitstamp.net"
+METADATA_REFRESH_SECONDS = 24 * 60 * 60
+
+
+def fetch_currencies() -> list[dict]:
+    response = requests.get(f"{BITSTAMP_BASE_URL}/api/v2/currencies/", timeout=30)
+    response.raise_for_status()
+    return response.json()
+
+
+def fetch_markets() -> list[dict]:
+    response = requests.get(f"{BITSTAMP_BASE_URL}/api/v2/markets/", timeout=30)
+    response.raise_for_status()
+    return response.json()
+
+
+def save_metadata(kind: str, payload: list[dict]) -> None:
+    with get_connection() as conn:
+        conn.execute("DELETE FROM bitstamp_metadata WHERE kind = ?", (kind,))
+        for item in payload:
+            conn.execute(
+                "INSERT INTO bitstamp_metadata (kind, item_key, payload_json) VALUES (?, ?, ?)",
+                (kind, _item_key(kind, item), __import__("json").dumps(item)),
+            )
+        conn.commit()
+
+
+def load_metadata(kind: str) -> list[dict]:
+    with get_connection() as conn:
+        rows = conn.execute(
+            "SELECT payload_json FROM bitstamp_metadata WHERE kind = ? ORDER BY item_key ASC",
+            (kind,),
+        ).fetchall()
+    return [__import__("json").loads(row["payload_json"]) for row in rows]
+
+
+def refresh_metadata() -> dict:
+    currencies = fetch_currencies()
+    markets = fetch_markets()
+    save_metadata("currencies", currencies)
+    save_metadata("markets", markets)
+    return {"currencies": len(currencies), "markets": len(markets)}
+
+
+def _item_key(kind: str, item: dict) -> str:
+    if kind == "currencies":
+        return str(item.get("code") or item.get("currency") or item.get("id") or "")
+    if kind == "markets":
+        return str(item.get("name") or item.get("pair") or item.get("url_symbol") or item.get("id") or "")
+    return str(item.get("id") or item.get("name") or "")

+ 92 - 0
src/exec_mcp/bitstamp_ws.py

@@ -0,0 +1,92 @@
+from __future__ import annotations
+
+import asyncio
+import json
+from datetime import datetime, timezone
+
+import websockets
+
+from . import repo
+
+WS_URL = "wss://ws.bitstamp.net"
+WS_RECONNECT_SECONDS = 5
+WS_HEARTBEAT_SECONDS = 15
+QUOTE_CURRENCY = "usd"
+
+
+async def ws_main(stop_event: asyncio.Event) -> None:
+    while not stop_event.is_set():
+        try:
+            await _run_once(stop_event)
+        except asyncio.CancelledError:
+            raise
+        except Exception:
+            await asyncio.sleep(WS_RECONNECT_SECONDS)
+
+
+def get_watched_markets() -> list[str]:
+    accounts = repo.list_accounts(enabled_only=True)
+    assets = set()
+    for account in accounts:
+        account_id = account["id"]
+        # Prefer normalized balances if present; fall back to raw payload later.
+        # This stays exchange-specific to Bitstamp.
+        info = None
+        try:
+            from .services_bitstamp import fetch_account_balance
+            info = fetch_account_balance(account_id)
+        except Exception:
+            continue
+        for item in info.get("balances", []):
+            asset = str(item.get("asset_code", "")).lower()
+            if asset and asset != QUOTE_CURRENCY:
+                assets.add(asset)
+    return sorted(f"{asset}{QUOTE_CURRENCY}" for asset in assets)
+
+
+async def _run_once(stop_event: asyncio.Event) -> None:
+    async with websockets.connect(WS_URL, ping_interval=None) as ws:
+        markets = get_watched_markets()
+        for market in markets:
+            await ws.send(json.dumps({"event": "bts:subscribe", "data": {"channel": f"live_trades_{market}"}}))
+
+        last_heartbeat = asyncio.get_event_loop().time()
+        while not stop_event.is_set():
+            try:
+                message = await asyncio.wait_for(ws.recv(), timeout=WS_HEARTBEAT_SECONDS)
+            except asyncio.TimeoutError:
+                await ws.send(json.dumps({"event": "bts:heartbeat"}))
+                last_heartbeat = asyncio.get_event_loop().time()
+                continue
+
+            payload = json.loads(message)
+            _handle_message(payload)
+
+
+def _handle_message(payload: dict) -> None:
+    event = payload.get("event")
+    data = payload.get("data") or {}
+    if event != "trade":
+        return
+
+    channel = str(payload.get("channel", ""))
+    market = channel.replace("live_trades_", "")
+    price = data.get("price")
+    if not market or price is None:
+        return
+
+    captured_at = datetime.now(timezone.utc).isoformat()
+    from .storage import get_connection
+    with get_connection() as conn:
+        conn.execute(
+            """
+            INSERT INTO bitstamp_live_prices (market, price, payload_json, captured_at)
+            VALUES (?, ?, ?, ?)
+            ON CONFLICT(market) DO UPDATE SET
+                price=excluded.price,
+                payload_json=excluded.payload_json,
+                captured_at=excluded.captured_at
+            """,
+            (market, str(price), json.dumps(payload), captured_at),
+        )
+        conn.commit()

+ 11 - 0
src/exec_mcp/repo.py

@@ -175,6 +175,17 @@ def cache_put(cache_key: str, payload: dict, ttl_seconds: int) -> None:
         conn.commit()
 
 
+def get_latest_price(market: str) -> float | None:
+    with get_connection() as conn:
+        row = conn.execute("SELECT price FROM bitstamp_live_prices WHERE market = ?", (market.lower(),)).fetchone()
+    if row is None:
+        return None
+    try:
+        return float(row["price"])
+    except (TypeError, ValueError):
+        return None
+
+
 def save_balance_snapshot(*, account_id: str, asset_code: str, balance: float, balance_value: float | None = None, value_currency: str | None = None) -> str:
     snapshot_id = str(uuid4())
     captured_at = utc_now_iso()

+ 37 - 1
src/exec_mcp/server.py

@@ -1,6 +1,7 @@
 from __future__ import annotations
 
 from contextlib import asynccontextmanager
+import asyncio
 
 from fastapi import FastAPI, Form, HTTPException
 from fastapi.responses import HTMLResponse, RedirectResponse
@@ -9,15 +10,50 @@ from fastmcp import FastMCP
 from .models import AccountView
 from . import repo
 from .services_bitstamp import fetch_account_info as fetch_remote_account_info
+from .bitstamp_metadata import METADATA_REFRESH_SECONDS, refresh_metadata
+from .bitstamp_fx import FX_REFRESH_SECONDS, refresh_eur_usd
+from .bitstamp_ws import ws_main
 from .storage import init_db
 
 mcp = FastMCP("exec-mcp")
 
 
+async def _metadata_refresh_loop() -> None:
+    while True:
+        try:
+            refresh_metadata()
+        except Exception:
+            pass
+        await asyncio.sleep(METADATA_REFRESH_SECONDS)
+
+
+async def _fx_refresh_loop() -> None:
+    while True:
+        try:
+            refresh_eur_usd()
+        except Exception:
+            pass
+        await asyncio.sleep(FX_REFRESH_SECONDS)
+
+
 @asynccontextmanager
 async def lifespan(_: FastAPI):
     init_db()
-    yield
+    try:
+        refresh_metadata()
+    except Exception:
+        pass
+    stop_event = asyncio.Event()
+    metadata_task = asyncio.create_task(_metadata_refresh_loop())
+    fx_task = asyncio.create_task(_fx_refresh_loop())
+    ws_task = asyncio.create_task(ws_main(stop_event))
+    try:
+        yield
+    finally:
+        stop_event.set()
+        metadata_task.cancel()
+        fx_task.cancel()
+        ws_task.cancel()
 
 
 app = FastAPI(title="exec-mcp", lifespan=lifespan)

+ 50 - 27
src/exec_mcp/services_bitstamp.py

@@ -6,6 +6,7 @@ except ModuleNotFoundError:  # allows test runs without the optional dependency
     bitstamp = None  # type: ignore
 
 from . import repo
+from .bitstamp_fx import load_eur_usd
 
 BALANCE_CACHE_TTL_SECONDS = 20
 ACCOUNT_INFO_CACHE_TTL_SECONDS = 30
@@ -27,25 +28,28 @@ def _build_trading_client(account_id: str):
     )
 
 
-def _normalize_account_balance_payload(payload: dict, account_id: str) -> list[dict]:
-    snapshots: list[dict] = []
-    for key, value in payload.items():
-        if key.endswith("_balance") and isinstance(value, (int, float, str)):
-            asset_code = key.removesuffix("_balance").upper()
-            try:
-                balance = float(value)
-            except ValueError:
-                continue
-            snapshots.append(
-                {
-                    "account_id": account_id,
-                    "asset_code": asset_code,
-                    "balance": balance,
-                    "balance_value": None,
-                    "value_currency": None,
-                }
-            )
-    return snapshots
+def _normalize_account_balances_payload(payload: list[dict], account_id: str) -> list[dict]:
+    balances: list[dict] = []
+    for item in payload:
+        currency = item.get("currency")
+        if not currency:
+            continue
+        try:
+            total = float(item.get("total", 0) or 0)
+            available = float(item.get("available", 0) or 0)
+            reserved = float(item.get("reserved", 0) or 0)
+        except (TypeError, ValueError):
+            continue
+        balances.append(
+            {
+                "account_id": account_id,
+                "asset_code": str(currency).upper(),
+                "available": available,
+                "reserved": reserved,
+                "total": total,
+            }
+        )
+    return balances
 
 
 def fetch_account_balance(account_id: str) -> dict:
@@ -55,13 +59,10 @@ def fetch_account_balance(account_id: str) -> dict:
         return cached
 
     client = _build_trading_client(account_id)
-    payload = client.account_balance()
-    normalized = _normalize_account_balance_payload(payload, account_id)
+    payload = client._post("account_balances/", return_json=True, version=2)
+    normalized = _normalize_account_balances_payload(payload, account_id)
 
-    for snapshot in normalized:
-        repo.save_balance_snapshot(**snapshot)
-
-    result = {"source": "bitstamp", "cached": False, "payload": payload, "normalized": normalized}
+    result = {"source": "bitstamp", "cached": False, "balances": normalized, "payload": payload}
     repo.cache_put(cache_key, result, BALANCE_CACHE_TTL_SECONDS)
     return result
 
@@ -75,6 +76,27 @@ def fetch_account_info(account_id: str) -> dict:
     account = repo.get_account(account_id)
     balance = fetch_account_balance(account_id)
 
+    valued_balances = []
+    total_value_usd = 0.0
+    for item in balance["balances"]:
+        asset = item["asset_code"].lower()
+        total = float(item["total"])
+        if asset == "usd":
+            value_usd = total
+        else:
+            value_usd = None
+            market = f"{asset}usd"
+            price = repo.get_latest_price(market)
+            if price is not None:
+                value_usd = total * price
+            elif asset == "eur":
+                fx = load_eur_usd()
+                if fx and fx.get("sell") is not None:
+                    value_usd = total * float(fx["sell"])
+        if value_usd is not None:
+            total_value_usd += value_usd
+        valued_balances.append({**item, "value_currency": "USD", "value_usd": value_usd})
+
     result = {
         "id": account["id"],
         "display_name": account["display_name"],
@@ -83,8 +105,9 @@ def fetch_account_info(account_id: str) -> dict:
         "description": account["description"],
         "enabled": account["enabled"],
         "metadata": account["metadata"],
-        "balance": balance["payload"],
-        "balance_normalized": balance["normalized"],
+        "balances": valued_balances,
+        "total_value_usd": total_value_usd,
+        "raw_balance": balance["payload"],
     }
 
     repo.cache_put(cache_key, result, ACCOUNT_INFO_CACHE_TTL_SECONDS)

+ 22 - 0
src/exec_mcp/storage.py

@@ -72,6 +72,28 @@ def init_db() -> None:
                 fetched_at TEXT NOT NULL,
                 expires_at TEXT NOT NULL
             );
+
+            CREATE TABLE IF NOT EXISTS bitstamp_metadata (
+                kind TEXT NOT NULL,
+                item_key TEXT NOT NULL,
+                payload_json TEXT NOT NULL,
+                PRIMARY KEY (kind, item_key)
+            );
+
+            CREATE TABLE IF NOT EXISTS bitstamp_live_prices (
+                market TEXT PRIMARY KEY,
+                price TEXT NOT NULL,
+                payload_json TEXT NOT NULL,
+                captured_at TEXT NOT NULL
+            );
+
+            CREATE TABLE IF NOT EXISTS bitstamp_fx_rates (
+                pair TEXT PRIMARY KEY,
+                buy TEXT NOT NULL,
+                sell TEXT NOT NULL,
+                payload_json TEXT NOT NULL,
+                captured_at TEXT NOT NULL
+            );
             """
         )
         conn.commit()