update entrypoint.sh to improve script execution logic and README.md for clarity; add mcp and httpx to requirements

This commit is contained in:
Lukas Pierchalla
2026-09-20 12:07:52 +02:00
parent e7ecdfcd36
commit 9b4e351f79
4 changed files with 220 additions and 75 deletions
+129 -37
View File
@@ -1,12 +1,13 @@
# scalable-sync # scalable-sync
Synchronisiert Scalable Capital Broker-Transaktionen nach Wealthfolio. Synchronisiert Scalable Capital Broker-Transaktionen nach Wealthfolio via MCP.
## Voraussetzungen ## Voraussetzungen
- Docker + Docker Compose - Docker + Docker Compose
- Wealthfolio mit MCP aktiviert (`WF_MCP_ENABLED=true`) - Wealthfolio mit MCP aktiviert (`WF_MCP_ENABLED=true`)
- Scalable Capital: Agentic Investing aktiviert (Profil → Sicherheit) - Scalable Capital: Agentic Investing aktiviert (Profil → Sicherheit)
- `sc`-CLI auf dem Host installiert (siehe unten)
## Dateien ## Dateien
@@ -14,44 +15,83 @@ Synchronisiert Scalable Capital Broker-Transaktionen nach Wealthfolio.
|---|---| |---|---|
| `Dockerfile` | Container-Image | | `Dockerfile` | Container-Image |
| `compose.yaml` | Docker Compose Config | | `compose.yaml` | Docker Compose Config |
| `.env` | Alle Einstellungen | | `example.env` | Vorlage für `.env` |
| `scalable_sync.py` | Sync-Adapter | | `scalable_sync.py` | Sync-Adapter |
| `requirements.txt` | Python-Abhängigkeiten | | `requirements.txt` | Python-Abhängigkeiten |
## Setup ## Setup
```bash ### 1. Scalable CLI auf dem Host installieren
# Dateien in ein Verzeichnis deiner Wahl kopieren, z. B.:
# /opt/scalable-sync/ oder ~/docker/scalable-sync/
**Linux / WSL:**
```bash
curl -fsSL -o /tmp/sc.tar.gz \
"[https://github.com/ScalableCapital/scalable-cli/releases/download/v1.0.0/sc-v1.0.0-linux-x86_64-gnu.tar.gz](https://github.com/ScalableCapital/scalable-cli/releases/download/v1.0.0/sc-v1.0.0-linux-x86_64-gnu.tar.gz)"
curl -fsSL -o /tmp/SHA256SUMS \
"[https://github.com/ScalableCapital/scalable-cli/releases/download/v1.0.0/sc-v1.0.0-SHA256SUMS](https://github.com/ScalableCapital/scalable-cli/releases/download/v1.0.0/sc-v1.0.0-SHA256SUMS)"
cd /tmp && grep "linux-x86_64-gnu.tar.gz" SHA256SUMS | awk '{print $1 " sc.tar.gz"}' | sha256sum -c -
tar -xzf sc.tar.gz
sudo mv sc-v1.0.0-linux-x86_64-gnu/sc /usr/local/bin/sc
sudo chmod +x /usr/local/bin/sc
sc --version
```
### 2. Login auf dem Host (einmalig)
WSL/Linux hat keinen Keyring — daher `session_backend = "file"` setzen:
```bash
mkdir -p ~/.config/scalable-cli
cat > ~/.config/scalable-cli/config.toml <<'EOF'
[auth]
session_backend = "file"
signing_key_backend = "file"
EOF
sc login --local-read-only
```
### 3. .env anlegen
```bash
cp example.env .env
# WF_MCP_URL, WF_MCP_TOKEN, WF_ACCOUNT_ID, ASSET_MAP, PUID, PGID anpassen
```
### 4. Bind-Mount in compose.yaml anpassen
```yaml
volumes:
- scalable-sync-data:/app/data
- /home/<dein-user>/.config/scalable-cli:/tmp/sc-config:ro
```
### 5. Testen und starten
```bash
docker compose build docker compose build
# 1. Einmalig einloggen (Session wird im Volume gespeichert) # Session testen
docker compose run --rm -it scalable-sync sc login --local-read-only
# 2. Test
docker compose run --rm scalable-sync sc whoami docker compose run --rm scalable-sync sc whoami
docker compose run --rm scalable-sync sc broker holdings --json | jq . docker compose run --rm scalable-sync sc broker holdings
# 3. Dry-Run # Dry-Run
docker compose run --rm scalable-sync python /app/scalable_sync.py --once --dry-run docker compose run --rm scalable-sync python /app/scalable_sync.py --once --dry-run
# 4. Start # Start
docker compose up -d docker compose up -d
``` ```
## .env ## .env Referenz
```bash ```bash
# Wealthfolio
WF_MCP_URL=http://wealthfolio:8088/mcp WF_MCP_URL=http://wealthfolio:8088/mcp
WF_MCP_TOKEN=wf_mcp_dein_token_hier WF_MCP_TOKEN=wf_mcp_dein_token_hier
WF_ACCOUNT_ID=dein-konto-uuid WF_ACCOUNT_ID=dein-konto-uuid
# Optional: Scalable Portfolio-ID (bei mehreren Portfolios)
SC_PORTFOLIO_ID=
# Sync
SYNC_INTERVAL_HOURS=6 SYNC_INTERVAL_HOURS=6
SC_OVERLAP_DAYS=14 SC_OVERLAP_DAYS=14
SC_SETTLED_ONLY=true SC_SETTLED_ONLY=true
@@ -59,36 +99,34 @@ SC_SETTLED_ONLY=true
# ISIN → Wealthfolio-Symbol # ISIN → Wealthfolio-Symbol
ASSET_MAP={"IE00B4L5Y983":"EUNL.DE","IE00B3RBWM25":"VWCE.DE"} ASSET_MAP={"IE00B4L5Y983":"EUNL.DE","IE00B3RBWM25":"VWCE.DE"}
# Container-User
PUID=1000 PUID=1000
PGID=1000 PGID=1000
``` ```
### WF_ACCOUNT_ID finden ### WF_ACCOUNT_ID finden
Wealthfolio → Accounts → Konto anklicken → ID aus der URL kopieren (`/accounts/abc123-def456/...`). Wealthfolio → Accounts → Konto anklicken → ID aus der URL kopieren.
### ASSET_MAP ### ASSET_MAP
JSON-Objekt: `"ISIN": "SYMBOL"`. Wealthfolio-Symbol unter Assets suchen. JSON-Objekt `"ISIN": "Symbol"`. ISINs ermitteln mit:
```bash
sc broker holdings
```
### SC_PORTFOLIO_ID ### SC_PORTFOLIO_ID
Nur bei mehreren Scalable-Portfolios nötig. Ermitteln mit: Nur bei mehreren Scalable-Portfolios. Ermitteln mit:
```bash ```bash
docker compose run --rm scalable-sync sc broker context show --json docker compose run --rm scalable-sync sc broker context show
``` ```
## Volumes ## Session erneuern
Docker legt diese Volumes automatisch an — kein manuelles `mkdir` nötig. ```bash
sc login --local-read-only
| Volume | Inhalt | Zweck | docker compose restart scalable-sync
|---|---|---| ```
| `scalable-sync-data` | state.db, audit.jsonl, dead-letter.jsonl | Sync-Status und Logs |
| `scalable-sync-config` | config.toml, session.json | Scalable CLI Session |
**Wichtig:** Ohne `scalable-sync-config` geht die Session bei `docker compose down` verloren.
## Befehle ## Befehle
@@ -97,15 +135,69 @@ Docker legt diese Volumes automatisch an — kein manuelles `mkdir` nötig.
| `docker compose run --rm scalable-sync python /app/scalable_sync.py --once --dry-run` | Testlauf ohne Schreiben | | `docker compose run --rm scalable-sync python /app/scalable_sync.py --once --dry-run` | Testlauf ohne Schreiben |
| `docker compose run --rm scalable-sync python /app/scalable_sync.py --once` | Einmaliger Sync | | `docker compose run --rm scalable-sync python /app/scalable_sync.py --once` | Einmaliger Sync |
| `docker compose logs -f` | Logs anzeigen | | `docker compose logs -f` | Logs anzeigen |
| `docker compose down && docker compose up -d` | Neustart (Session bleibt erhalten) | | `docker compose down && docker compose up -d` | Neustart |
## Volumes
| Volume | Inhalt | Zweck |
|---|---|---|
| `scalable-sync-data` | state.db, audit.jsonl, dead-letter.jsonl | Sync-Status und Logs |
Die Session liegt auf dem Host unter `~/.config/scalable-cli/` und wird als read-only Bind-Mount eingehängt.
## Troubleshooting ## Troubleshooting
| Problem | Lösung | | Problem | Lösung |
|---|---| |---|---|
| "Not logged in" | `docker compose run --rm -it scalable-sync sc login --local-read-only` | | `sc whoami` schlägt fehl | Session abgelaufen — `sc login --local-read-only` wiederholen, dann `docker compose restart` |
| Keine Transaktionen | `SC_SETTLED_ONLY` und `SC_OVERLAP_DAYS` prüfen | | Keine Transaktionen | `SC_SETTLED_ONLY` und `SC_OVERLAP_DAYS` prüfen |
| Unmapped ISIN | ISIN in `ASSET_MAP` ergänzen | | Unmapped ISIN | ISIN in `ASSET_MAP` ergänzen |
| Mehrere Portfolios | `SC_PORTFOLIO_ID` setzen | | Mehrere Portfolios | `SC_PORTFOLIO_ID` in `.env` setzen |
| Doppelte Activities | Prüfen ob `WF_ACCOUNT_ID` korrekt ist | | Doppelte Activities | `WF_ACCOUNT_ID` prüfen |
| Session nach Neustart weg | `scalable-sync-config` Volume in compose.yaml prüfen |
## Wealthfolio-Vorbereitung
### 1. MCP aktivieren
In der Wealthfolio `compose.yaml`:
```yaml
environment:
WF_MCP_ENABLED: "true"
WF_MCP_TOKEN: "wf_mcp_dein_geheimer_token"
```
Danach Wealthfolio neu starten.
### 2. Account anlegen
Wealthfolio → **Accounts** → **Add Account**:
| Feld | Wert |
|---|---|
| Name | `Scalable Capital` (frei wählbar) |
| Type | `Brokerage` |
| Currency | `EUR` |
**WF_ACCOUNT_ID ermitteln:** Account anklicken → ID aus der URL kopieren:
````
/accounts/550e8400-e29b-41d4-a716-446655440000/ → diese UUID ist deine WF_ACCOUNT_ID
````
### 3. Assets anlegen
Jedes ETF muss in Wealthfolio als Asset existieren. Wealthfolio → **Assets** → Asset suchen oder neu anlegen.
ISINs deiner gehaltenen ETFs ermitteln:
```bash
sc broker holdings
```
Das **Symbol** in Wealthfolio (z. B. `EUNL.DE`) exakt in `ASSET_MAP` übernehmen:
```bash
ASSET_MAP={"IE00B4L5Y983":"EUNL.DE","IE00B3RBWM25":"VWCE.DE","DE0002635307":"EXSA.DE"}
```
> **Wichtig:** Symbol muss exakt übereinstimmen — am besten aus Wealthfolio kopieren, nicht raten.
+13 -1
View File
@@ -3,9 +3,21 @@ set -e
chown -R ${PUID:-1000}:${PGID:-1000} /app/data 2>/dev/null || true chown -R ${PUID:-1000}:${PGID:-1000} /app/data 2>/dev/null || true
if [ "$1" = "python" ] || [ "$1" = "/app/scalable_sync.py" ]; then # Wenn der erste Parameter "python" ist → shift und ggf. Skript-Pfad auch entfernen
if [ "$1" = "python" ]; then
shift
# Wenn das nächste Argument das Skript selbst ist → auch entfernen
if [ "$1" = "/app/scalable_sync.py" ]; then
shift
fi
exec gosu appuser python /app/scalable_sync.py "$@"
fi
# Wenn das Skript direkt als erster Parameter kommt
if [ "$1" = "/app/scalable_sync.py" ]; then
shift shift
exec gosu appuser python /app/scalable_sync.py "$@" exec gosu appuser python /app/scalable_sync.py "$@"
fi fi
# Sonst: Befehl direkt als appuser ausführen (z. B. sc login)
exec gosu appuser "$@" exec gosu appuser "$@"
+2 -1
View File
@@ -1 +1,2 @@
requests>=2.32.0 mcp
httpx
+76 -36
View File
@@ -10,14 +10,17 @@ import os
import sqlite3 import sqlite3
import subprocess import subprocess
import sys import sys
import tempfile
import time import time
from dataclasses import dataclass, asdict from dataclasses import dataclass, asdict
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from typing import Any, Optional from typing import Any, Optional
import asyncio
import httpx
from mcp import ClientSession
from mcp.client.streamable_http import streamable_http_client
import requests
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Config # Config
@@ -32,12 +35,8 @@ SC_SETTLED_ONLY = os.getenv("SC_SETTLED_ONLY", "true").lower() == "true"
SC_MAPPING_VERSION = int(os.getenv("SC_MAPPING_VERSION", "1")) SC_MAPPING_VERSION = int(os.getenv("SC_MAPPING_VERSION", "1"))
ASSET_MAP = json.loads(os.getenv("ASSET_MAP", "{}")) ASSET_MAP = json.loads(os.getenv("ASSET_MAP", "{}"))
# Scalable CLI Config: tmpfs-basiert, automatisch generiert # SC_CONFIG_DIR wird vom Entrypoint gesetzt und als read-only Bind-Mount eingehängt
SC_CONFIG_DIR = Path(os.getenv("SC_CONFIG_DIR", tempfile.mkdtemp(prefix="sc-config-"))) SC_CONFIG_DIR = Path(os.getenv("SC_CONFIG_DIR", "/tmp/sc-config"))
SC_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
(SC_CONFIG_DIR / "config.toml").write_text(
'[auth]\nsession_backend = "file"\nsigning_key_backend = "file"\n'
)
os.environ["SC_CONFIG_DIR"] = str(SC_CONFIG_DIR) os.environ["SC_CONFIG_DIR"] = str(SC_CONFIG_DIR)
DATA_DIR = Path("/app/data") DATA_DIR = Path("/app/data")
@@ -260,38 +259,74 @@ def map_to_wealthfolio(tx: dict) -> list[WealthfolioActivity]:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class WealthfolioMCP: class WealthfolioMCP:
def __init__(self, url, token): def __init__(self, url: str, token: str):
self.url = url self.url = url
self.session = requests.Session() self.token = token
self.session.headers.update({
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
"Accept": "application/json",
})
def _rpc(self, method, params=None): def _http_client(self) -> httpx.AsyncClient:
payload = {"jsonrpc": "2.0", "id": int(time.time() * 1000), "method": method} return httpx.AsyncClient(
if params: payload["params"] = params headers={
resp = self.session.post(self.url, json=payload, timeout=30) "Authorization": f"Bearer {self.token}",
resp.raise_for_status() "Accept": "application/json, text/event-stream",
return resp.json() },
timeout=30.0,
)
def ping(self): async def _connect_and_call(self, tool_name: str, arguments: dict):
async with self._http_client() as http_client:
async with streamable_http_client(
self.url,
http_client=http_client,
) as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
return await session.call_tool(tool_name, arguments)
async def _list_tools(self) -> list[str]:
async with self._http_client() as http_client:
async with streamable_http_client(
self.url,
http_client=http_client,
) as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
result = await session.list_tools()
return [tool.name for tool in result.tools]
def ping(self) -> bool:
try: try:
return self._rpc("ping").get("result") == "pong" tools = asyncio.run(self._list_tools())
except Exception as e: log.info("Available Wealthfolio MCP tools: %s", tools)
log.error("MCP ping failed: %s", e) return True
except Exception as exc:
log.exception("MCP initialization failed: %s", exc)
return False return False
def record_activities(self, activities): def record_activities(self, activities):
payload = [{ payload = [
"accountId": a.account_id, "date": a.date, "symbol": a.symbol, "activityType": a.activity_type, {
"quantity": a.quantity, "unitPrice": a.unit_price, "amount": a.amount, "accountId": activity.account_id,
"fee": a.fee, "tax": a.tax, "currency": a.currency, "date": activity.date,
"source": a.source, "externalId": a.external_id, "symbol": activity.symbol,
} for a in activities] "activityType": activity.activity_type,
return self._rpc("tools/call", {"name": "record_activities", "arguments": {"activities": payload}}) "quantity": activity.quantity,
"unitPrice": activity.unit_price,
"amount": activity.amount,
"fee": activity.fee,
"tax": activity.tax,
"currency": activity.currency,
"source": activity.source,
"externalId": activity.external_id,
}
for activity in activities
]
return asyncio.run(
self._connect_and_call(
"record_activities",
{"activities": payload},
)
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Sync # Sync
@@ -310,9 +345,13 @@ def reconcile(mcp):
def sync_once(dry_run=False): def sync_once(dry_run=False):
init_db() init_db()
mcp = WealthfolioMCP(WF_MCP_URL, WF_MCP_TOKEN) mcp = None
if not mcp.ping():
raise RuntimeError("Wealthfolio MCP unreachable") if not dry_run:
mcp = WealthfolioMCP(WF_MCP_URL, WF_MCP_TOKEN)
if not mcp.ping():
raise RuntimeError("Wealthfolio MCP unreachable")
from_time = datetime.now(timezone.utc) - timedelta(days=SC_OVERLAP_DAYS) from_time = datetime.now(timezone.utc) - timedelta(days=SC_OVERLAP_DAYS)
to_time = datetime.now(timezone.utc) to_time = datetime.now(timezone.utc)
@@ -365,7 +404,8 @@ def sync_once(dry_run=False):
if not cursor or not items: if not cursor or not items:
break break
reconcile(mcp) if not dry_run:
reconcile(mcp)
log.info("Sync complete: %s", stats) log.info("Sync complete: %s", stats)
return stats return stats