Compare commits

..
46 Commits
Author SHA1 Message Date
matitos 9d6028fbbf Add the Williams %R strategy
CI/CD / backend (push) Successful in 9m58s
CI/CD / frontend (push) Successful in 9m32s
CI/CD / build (push) Successful in 30s
CI/CD / cleanup (push) Successful in 5s
CI/CD / deploy (push) Successful in 34s
2026-09-26 18:44:00 +00:00
matitos d92f152f9b Add the Williams %R strategy
New williams_r indicator (in [-100, 0]) exposed as ctx.williams_r(period), and a
long-only mean-reversion strategy: enter when %R is at/below the oversold level,
exit when it reaches the overbought level, with an optional ATR stop. 11
strategies total. Adds indicator and strategy tests; smoke-tested on live
BTC/USDT 5m (backtest + parameter sweep).
2026-09-26 18:44:00 +00:00
matitos ef895b6b9c Aggregate the top status cards across strategies
CI/CD / backend (push) Successful in 9m59s
CI/CD / frontend (push) Successful in 9m32s
CI/CD / build (push) Successful in 20s
CI/CD / cleanup (push) Successful in 5s
CI/CD / deploy (push) Successful in 1m8s
2026-09-22 23:11:19 +00:00
matitos 57dcfe7adb Aggregate the top status cards across strategies
With several strategies the header cards showed only the first one. Expose
day_pnl and drawdown_pct per strategy and add aggregateStatus() (frontend) so the
cards show the whole account: summed cash/equity/P&L/trades/positions, worst
drawdown, and any halt. Single-strategy runs are unchanged. Adds tests.
2026-09-22 23:11:18 +00:00
matitos 657c473e82 Merge the single and multi strategy panels into one
CI/CD / backend (push) Successful in 10m2s
CI/CD / frontend (push) Successful in 9m31s
CI/CD / build (push) Successful in 15s
CI/CD / cleanup (push) Successful in 5s
CI/CD / deploy (push) Successful in 33s
2026-09-22 17:57:10 +00:00
matitos 50b22df948 Merge the single and multi strategy panels into one
The single "Strategy" panel and "Strategies (multi)" were redundant: the multi
panel with one row is exactly the single-strategy behaviour. Remove StrategyPanel
and make "Strategies" the one trading panel. Presets move into it (load adds a
row, per-row Save), and the header now reports "N strategies" when several run
instead of only the first. Deletes the obsolete component and its test; adds
preset tests to the multi-strategy suite.
2026-09-22 17:57:10 +00:00
matitos d4d864dc5c Observability: Prometheus metrics, log panel, runbook
CI/CD / backend (push) Successful in 9m56s
CI/CD / frontend (push) Successful in 9m31s
CI/CD / build (push) Successful in 19s
CI/CD / cleanup (push) Successful in 6s
CI/CD / deploy (push) Successful in 42s
2026-09-22 17:10:39 +00:00
matitos ac92dd5ac4 Observability: Prometheus metrics, log panel, runbook
- GET /api/metrics renders Prometheus text (equity, P&L, per-strategy
  equity/pnl/allocation/halted, info) with no client library.
- In-memory ring log handler (app/logs.py) exposed at GET /api/logs and a
  dashboard "Server logs" panel; Runtime mirrors bot events into the logger.
- docs/runbook.md covers deploys, the kill switch, resetting paper state and
  going live. Adds tests for metrics rendering and the log ring.
2026-09-22 17:10:38 +00:00
matitos c605a0f316 Add Supertrend and VWAP strategies plus a short-side example
CI/CD / backend (push) Successful in 5m49s
CI/CD / frontend (push) Successful in 5m19s
CI/CD / build (push) Successful in 14s
CI/CD / cleanup (push) Successful in 7s
CI/CD / deploy (push) Successful in 1m1s
2026-09-22 14:14:13 +00:00
matitos d1f66c95c4 Add Supertrend and VWAP strategies plus a short-side example
- New indicators: vwap (rolling/cumulative) and supertrend (line + direction),
  exposed on StrategyContext.
- New strategies: supertrend (flip-based, optional shorts), vwap_reversion
  (mean reversion below a rolling VWAP) and ema_cross_short (short-side
  example, mirroring the long EMA cross). 10 strategies total.
- Adds indicator and strategy tests; smoke-tested all three on live BTC/USDT
  5m data.
2026-09-22 14:14:12 +00:00
matitos c1a7fb9c6b Notification upgrades: lifecycle, configurable levels, summaries
CI/CD / backend (push) Successful in 6m14s
CI/CD / frontend (push) Successful in 5m7s
CI/CD / build (push) Successful in 18s
CI/CD / cleanup (push) Successful in 6s
CI/CD / deploy (push) Successful in 1m26s
2026-09-22 13:18:04 +00:00
matitos 2ca5032e7e Notification upgrades: lifecycle, configurable levels, summaries
- NOTIFY_LEVELS chooses which event levels are sent (default trade,error).
- NOTIFY_LIFECYCLE sends explicit start/stop messages; the kill switch always
  notifies at error level.
- SUMMARY_INTERVAL_MINUTES sends a periodic portfolio summary (equity, per
  strategy P&L, allocation, halted state) via a background task started with
  the run and stopped on stop/kill/shutdown.
- Wires the new vars plus the go-live vars through both compose files and the
  deploy workflow. Adds tests.
2026-09-22 13:18:02 +00:00
matitos ef3e6f346c Go-live gate: refuse unsafe live trading
CI/CD / backend (push) Successful in 6m9s
CI/CD / frontend (push) Successful in 5m14s
CI/CD / build (push) Successful in 22s
CI/CD / cleanup (push) Successful in 6s
CI/CD / deploy (push) Successful in 39s
2026-09-22 12:55:13 +00:00
matitos 0173e46cfb Go-live gate: refuse unsafe live trading
Setting MODE=live alone no longer starts trading. check_live_ready requires an
active risk limit, MAX_POSITION_PCT < 1.0, LIVE_CONFIRMATION exactly
"I UNDERSTAND THE RISK", a minimum balance, and exchange credentials. Enforced
in Runtime.start_many (both start paths) and surfaced at GET /api/live-check;
the dashboard shows a banner and blocks the start buttons. Adds tests.
2026-09-22 12:55:12 +00:00
matitos b60cddf13d Multi-strategy UI
CI/CD / backend (push) Successful in 7m12s
CI/CD / frontend (push) Successful in 5m8s
CI/CD / build (push) Successful in 18s
CI/CD / cleanup (push) Successful in 5s
CI/CD / deploy (push) Successful in 38s
2026-09-22 11:59:21 +00:00
matitos ce2b11a803 Multi-strategy UI
Adds a "Strategies (multi)" panel: add/remove strategy rows (strategy, symbol,
timeframe, per-strategy params and weight), start all, stop all and kill all,
plus a per-strategy status table (allocation, equity, P&L, halted). Polls
/api/bot/status alongside the existing status. Includes component tests.
2026-09-22 11:59:21 +00:00
matitos 3229a0e2aa Multi-strategy engine with per-strategy capital allocation
CI/CD / frontend (push) Successful in 5m21s
CI/CD / backend (push) Successful in 5m21s
CI/CD / build (push) Successful in 13s
CI/CD / cleanup (push) Successful in 5s
CI/CD / deploy (push) Successful in 49s
2026-09-21 20:10:28 +00:00
matitos 8b6cf8130c Multi-strategy engine with per-strategy capital allocation
Adds MultiStrategyManager: it runs several strategies concurrently, each with
its own TradingEngine seeded with starting_balance * allocation, so one
strategy's losses cannot eat into another's capital. Weights are normalised
across the run ([1,3] -> 25%/75%). Risk limits stay per strategy (each engine
owns a RiskManager).

New API: POST /api/bot/start-many and GET /api/bot/status. The existing
single-strategy endpoints delegate to the manager, so their behaviour is
unchanged. Adds manager tests (allocation, isolation, duplicate keys) and API
tests.
2026-09-21 20:10:28 +00:00
matitos e07536542f Show a loading state in the strategy dropdown
CI/CD / backend (push) Successful in 5m27s
CI/CD / frontend (push) Successful in 5m4s
CI/CD / build (push) Successful in 17s
CI/CD / cleanup (push) Successful in 5s
CI/CD / deploy (push) Successful in 51s
2026-09-21 18:26:02 +00:00
matitos 5a8c7ecc58 Show a loading state in the strategy dropdown
While the strategy list is being fetched the dropdown now shows a disabled
"Loading strategies…" option instead of an empty select, and "No strategies
available" once loading finishes with no results. Applies to the strategy,
backtest and sweep panels via a loading prop. Adds tests.
2026-09-21 18:26:01 +00:00
matitos def86d7f56 Retry the strategy list and make collapse obvious
CI/CD / backend (push) Successful in 5m14s
CI/CD / frontend (push) Successful in 5m16s
CI/CD / build (push) Successful in 17s
CI/CD / cleanup (push) Successful in 4s
CI/CD / deploy (push) Successful in 50s
2026-09-21 17:11:18 +00:00
matitos b9c69c546c Retry the strategy list and make collapse obvious
- The strategy/preset lists were loaded once with failures swallowed, so a
  single failed first request left the strategy dropdown empty forever. They
  are now retried every 3s until populated.
- Clearer collapse affordance: hover highlight, a hide/show hint, and a
  tooltip; Backtest and Parameter sweep start collapsed so the strategy
  selector and status are immediately visible.
- Adds an integration test that the strategy dropdown is populated and the
  panel actually collapses.
2026-09-21 17:11:18 +00:00
matitos 9eddd66a82 Collapse the strategy, backtest and sweep panels
CI/CD / backend (push) Successful in 5m14s
CI/CD / frontend (push) Successful in 5m9s
CI/CD / build (push) Successful in 17s
CI/CD / cleanup (push) Successful in 5s
CI/CD / deploy (push) Successful in 50s
2026-09-21 16:40:54 +00:00
matitos 1b44e456c6 Collapse the strategy, backtest and sweep panels
Adds a reusable CollapsiblePanel (chevron header, optional summary shown when
collapsed, state persisted in localStorage) and wraps the Strategy, Backtest
and Parameter sweep panels with it, so any single part of the dashboard can be
focused on quickly. Panels stay mounted (v-show) so their form/results state is
preserved across collapse. Includes component tests.
2026-09-21 16:40:48 +00:00
matitos fedcd07ffd Don't let a failing initial fetch block the socket/polling fallback
CI/CD / backend (push) Successful in 5m32s
CI/CD / frontend (push) Successful in 5m7s
CI/CD / build (push) Successful in 20s
CI/CD / cleanup (push) Successful in 5s
CI/CD / deploy (push) Successful in 40s
2026-09-21 15:57:51 +00:00
matitos cf54ff8b45 Don't let a failing initial fetch block the socket/polling fallback
onMounted awaited the initial REST calls before setting up the socket and the
6s polling fallback, so any rejected request aborted the handler and left the
badge stuck on "connecting". Set up the socket and fallback first, make the
initial loads best-effort, and log [ws] connect/open/close for debugging.
2026-09-21 15:57:50 +00:00
matitos 7057a5999e Show client and server build versions in the footer
CI/CD / backend (push) Successful in 5m20s
CI/CD / frontend (push) Successful in 5m6s
CI/CD / build (push) Successful in 19s
CI/CD / cleanup (push) Successful in 5s
CI/CD / deploy (push) Successful in 40s
2026-09-21 14:47:04 +00:00
matitos cc602423b9 Show client and server build versions in the footer
Adds a footer with the client build SHA and the server /api/version value, a
mismatch warning, and the socket error text, so it is unambiguous whether the
browser is running a stale bundle and why the live feed fell back to polling.
2026-09-21 14:47:03 +00:00
matitos 2b8f6bf385 Force-recreate containers on deploy and verify the served build
CI/CD / backend (push) Successful in 6m8s
CI/CD / frontend (push) Successful in 4m59s
CI/CD / build (push) Successful in 19s
CI/CD / cleanup (push) Successful in 7s
CI/CD / deploy (push) Successful in 1m31s
2026-09-21 12:12:29 +00:00
matitos c121a4190a Force-recreate containers on deploy and verify the served build
The frontend container was serving the very first build (498-byte index.html)
through every later deploy: `docker compose up -d` did not replace a
config-identical service. Deploy now uses `build --pull` and
`up --force-recreate`, the frontend image writes a .build-version marker, and
the workflow fails the deploy if the served marker != the commit SHA.
2026-09-21 12:12:28 +00:00
matitos 1428220210 Self-heal a stale cached bundle via a build version
CI/CD / frontend (push) Successful in 5m15s
CI/CD / backend (push) Successful in 5m26s
CI/CD / build (push) Successful in 39s
CI/CD / cleanup (push) Successful in 7s
CI/CD / deploy (push) Successful in 1m22s
2026-09-21 00:16:49 +00:00
matitos 7b97f010c9 Self-heal a stale cached bundle via a build version
Phones kept running old JS because their cached index.html pointed at an old
hashed asset. Both images now bake the commit SHA as BUILD_VERSION; the backend
exposes it at GET /api/version and the client reloads once with a cache-busting
query when its own baked version differs. Guarded by sessionStorage so it cannot
loop. /api/version is unauthenticated like /api/health.
2026-09-21 00:16:43 +00:00
matitos 7ad14449ce Never hang on connecting: time out the socket and fall back
CI/CD / backend (push) Successful in 5m53s
CI/CD / frontend (push) Successful in 5m11s
CI/CD / build (push) Successful in 21s
CI/CD / cleanup (push) Successful in 6s
CI/CD / deploy (push) Successful in 20s
2026-09-20 23:43:13 +00:00
matitos ed14019052 Never hang on "connecting": time out the socket and fall back
iOS Safari can leave a blocked wss:// upgrade hanging without firing
onclose/onerror, so the badge sat on "connecting". The socket now times out a
slow handshake after 5s, reports at most one failure per attempt, and the app
commits to polling after a 6s grace period if the socket never opens.
2026-09-20 23:43:12 +00:00
matitos 5af879bc2f Present REST polling as a normal live state
CI/CD / backend (push) Successful in 5m23s
CI/CD / frontend (push) Successful in 4m55s
CI/CD / build (push) Successful in 13s
CI/CD / cleanup (push) Successful in 5s
CI/CD / deploy (push) Successful in 24s
2026-09-20 14:01:38 +00:00
matitos 8bb2836d7e Present REST polling as a normal live state
Some browsers (notably iOS Safari on an auth-gated page) abort the wss://
upgrade even though the server accepts it. Rather than showing a perpetual
"reconnecting", stop retrying after three failures, poll every 3s, and label
the badge "live (polling)" in green. The socket error is kept as a tooltip.
2026-09-20 13:47:42 +00:00
matitos 43831d31f6 Merge pull request: Fall back to REST polling when the WebSocket cannot connect
CI/CD / backend (push) Successful in 6m1s
CI/CD / frontend (push) Successful in 5m18s
CI/CD / build (push) Successful in 16s
CI/CD / cleanup (push) Successful in 5s
CI/CD / deploy (push) Successful in 17s
2026-09-20 13:00:52 +00:00
matitos bf7eda3f6a Fall back to REST polling when the WebSocket cannot connect
CI/CD / backend (pull_request) Successful in 6m6s
CI/CD / frontend (pull_request) Successful in 5m15s
CI/CD / build (pull_request) Skipped
CI/CD / deploy (pull_request) Skipped
CI/CD / cleanup (pull_request) Skipped
On some mobile networks/browsers the wss:// upgrade is blocked even though the
page loads (the server side is verified working: a browser-style handshake gets
101). After three socket failures the header now shows "polling (5s)" instead
of a misleading perpetual "reconnecting", since the 5s REST poll keeps all data
correct. The socket error handler also surfaces the browser's message.
2026-09-20 12:48:10 +00:00
matitos 07c17fbd2c Merge pull request #7: Stop caching index.html and speed up socket reconnect
CI/CD / backend (push) Successful in 5m42s
CI/CD / frontend (push) Successful in 5m15s
CI/CD / build (push) Successful in 17s
CI/CD / cleanup (push) Successful in 4s
CI/CD / deploy (push) Successful in 16s
2026-09-20 00:10:52 +00:00
matitos c63753e592 Stop caching index.html and speed up socket reconnect
CI/CD / backend (pull_request) Successful in 5m41s
CI/CD / frontend (pull_request) Successful in 5m18s
CI/CD / build (pull_request) Skipped
CI/CD / deploy (pull_request) Skipped
CI/CD / cleanup (pull_request) Skipped
Phones offer no easy hard-refresh, so a cached index.html kept serving an old
bundle after a deploy (the live feed showed "reconnecting"). index.html and the
SPA catch-all now send Cache-Control: no-store, while hashed /assets stay
immutable. Also retry the first socket reconnect after 1s (was 2s) and surface
the close code in the header so a failure is actionable on a phone.
2026-09-19 23:52:54 +00:00
matitos 6df44520fa Merge pull request 'Add trailing stops and parameter sweeps' (#6) from feat/trailing-stops-sweep into main
CI/CD / backend (push) Successful in 5m51s
CI/CD / frontend (push) Successful in 5m4s
CI/CD / build (push) Successful in 18s
CI/CD / cleanup (push) Successful in 4s
CI/CD / deploy (push) Successful in 31s
2026-09-20 01:18:51 +02:00
matitos 9dc00eba95 Add trailing stops and parameter sweeps
CI/CD / backend (pull_request) Successful in 6m6s
CI/CD / frontend (pull_request) Successful in 5m17s
CI/CD / build (pull_request) Skipped
CI/CD / deploy (pull_request) Skipped
CI/CD / cleanup (pull_request) Skipped
Trailing stops: strategies can pass trailing_stop_pct to enter_long/enter_short.
advance_trailing_stop in app/engine/portfolio.py ratchets the stop with the price
(up for longs, down for shorts) and never loosens; the engine persists the
ratcheted level and the backtester applies the same logic. Position gains a
trailing_stop_pct column (additive migration). momentum exposes it as a param.

Parameter sweeps: app/backtest/sweep.py runs a cartesian grid through the same
backtester and ranks by return minus max drawdown. POST /api/sweep plus a
dashboard Parameter sweep panel.
2026-09-19 23:04:20 +00:00
matitos d80e7b155e Merge pull request 'Make the API token fully optional' (#5) from fix/optional-api-token into main
CI/CD / backend (push) Successful in 5m30s
CI/CD / frontend (push) Successful in 5m5s
CI/CD / build (push) Successful in 25s
CI/CD / cleanup (push) Successful in 5s
CI/CD / deploy (push) Successful in 34s
2026-09-19 16:35:35 +02:00
matitos 2a88f55689 Make the API token fully optional
CI/CD / backend (pull_request) Successful in 5m36s
CI/CD / frontend (pull_request) Successful in 5m12s
CI/CD / build (pull_request) Skipped
CI/CD / deploy (pull_request) Skipped
CI/CD / cleanup (pull_request) Skipped
The deploy job hard-failed without CRYPTO_API_TOKEN, and /api/ws (which skips
Basic auth) relied on that token. Drop the requirement: the app now works with
no token at all, matching the backend guard that was always a no-op when unset.
When a token is configured it still protects /api and /api/ws; when it is not,
/api/ws is as open as the rest of the API, which the Traefik Basic auth still
guards for everything else. Adds tests for the tokenless API and WebSocket.
2026-09-19 14:31:42 +00:00
matitos ba7a69c1ce Merge pull request 'Add a friendly GET /api/ws response' (#4) from chore/retrigger-deploy into main
CI/CD / backend (push) Successful in 5m24s
CI/CD / frontend (push) Successful in 5m14s
CI/CD / build (push) Successful in 54s
CI/CD / cleanup (push) Successful in 7s
CI/CD / deploy (push) Failing after 12s
2026-09-19 15:58:16 +02:00
matitos 386137c90e Add a friendly GET /api/ws response
CI/CD / backend (pull_request) Canceled after 0s
CI/CD / frontend (pull_request) Canceled after 0s
CI/CD / build (pull_request) Canceled after 0s
CI/CD / deploy (pull_request) Canceled after 0s
CI/CD / cleanup (pull_request) Canceled after 0s
A plain browser hit on the socket URL now returns a JSON hint instead of a
confusing 404/426. Companion to the Basic-auth bypass on /api/ws.
2026-09-19 13:41:55 +00:00
60 changed files with 3751 additions and 179 deletions

No files matched your search

+17 -2
View File
@@ -33,6 +33,13 @@ EXCHANGE_API_SECRET=
# Set true for testnet/sandbox where supported
EXCHANGE_SANDBOX=false
# --- Go-live gate (required for MODE=live) ---
# Live trading only starts when this is exactly "I UNDERSTAND THE RISK".
# Setting MODE=live alone is intentionally not enough.
LIVE_CONFIRMATION=
# Refuse to start live below this account size (quote currency).
MIN_LIVE_BALANCE=100
# --- Risk controls (0 disables a limit) ---
# Halt + flatten if the day's loss reaches this amount in quote currency
MAX_DAILY_LOSS=0
@@ -44,8 +51,10 @@ MAX_POSITION_PCT=1.0
MAX_TRADES_PER_DAY=0
# --- API token (optional) ---
# When set, /api requires this token. The frontend nginx injects it server-side,
# so the browser never sees it. Leave empty to disable.
# When set, /api and the /api/ws live feed require this token. The frontend
# nginx injects it server-side, so the browser never sees it. Leave empty for
# no token; the app works fully without it. (The Traefik Basic auth still
# guards the UI in the deployed stack.)
API_TOKEN=
API_TOKEN_HEADER=X-API-Token
@@ -56,6 +65,12 @@ TELEGRAM_BOT_TOKEN=
TELEGRAM_CHAT_ID=
# Generic webhook: receives POST {"level": "...", "text": "..."}
NOTIFY_WEBHOOK_URL=
# Event levels to notify on (comma separated): trade, error, warning, info.
NOTIFY_LEVELS=trade,error
# Send explicit start/stop messages.
NOTIFY_LIFECYCLE=true
# Periodic portfolio summary every N minutes (0 disables).
SUMMARY_INTERVAL_MINUTES=0
# --- Web ---
# Comma separated list, or * in dev
+36 -11
View File
@@ -125,6 +125,11 @@ jobs:
CRYPTO_TELEGRAM_BOT_TOKEN: ${{ secrets.CRYPTO_TELEGRAM_BOT_TOKEN }}
CRYPTO_TELEGRAM_CHAT_ID: ${{ vars.CRYPTO_TELEGRAM_CHAT_ID || secrets.CRYPTO_TELEGRAM_CHAT_ID }}
CRYPTO_NOTIFY_WEBHOOK_URL: ${{ secrets.CRYPTO_NOTIFY_WEBHOOK_URL }}
CRYPTO_NOTIFY_LEVELS: ${{ vars.CRYPTO_NOTIFY_LEVELS || 'trade,error' }}
CRYPTO_NOTIFY_LIFECYCLE: ${{ vars.CRYPTO_NOTIFY_LIFECYCLE || 'true' }}
CRYPTO_SUMMARY_INTERVAL_MINUTES: ${{ vars.CRYPTO_SUMMARY_INTERVAL_MINUTES || '0' }}
CRYPTO_LIVE_CONFIRMATION: ${{ secrets.CRYPTO_LIVE_CONFIRMATION }}
CRYPTO_MIN_LIVE_BALANCE: ${{ vars.CRYPTO_MIN_LIVE_BALANCE || '100' }}
# Optional API bearer token (injected server-side by the frontend nginx).
CRYPTO_API_TOKEN: ${{ secrets.CRYPTO_API_TOKEN }}
CRYPTO_API_TOKEN_HEADER: ${{ vars.CRYPTO_API_TOKEN_HEADER || 'X-API-Token' }}
@@ -133,6 +138,8 @@ jobs:
# Persistent data root: repo var wins, else derive from the org
# PERSISTENT_MOUNT (absolute HOST path), else the workspace fallback.
CRYPTO_DATA_DIR: ${{ vars.CRYPTO_DATA_DIR || (vars.PERSISTENT_MOUNT && format('{0}/crypto_trading', vars.PERSISTENT_MOUNT)) || './data' }}
# Stamped into both images so the client can detect a stale cached bundle.
CRYPTO_BUILD_VERSION: ${{ github.sha }}
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
steps:
@@ -147,13 +154,13 @@ jobs:
echo "::error::CRYPTO_FRONTEND_AUTH_USERS is not set (repo/org variable or secret); refusing to deploy"
exit 1
fi
# /api/ws bypasses Basic auth (browsers don't reliably replay
# credentials on a WebSocket upgrade) and relies on the API token
# instead. Refuse to ship if that token is missing, or the live feed
# would be unauthenticated.
# CRYPTO_API_TOKEN is optional. When unset, the API and the /api/ws
# live feed are unauthenticated beyond the Traefik Basic auth that
# still guards everything except /api/ws. Set it to require a bearer
# token on /api (nginx injects it server-side, so the browser never
# sees it).
if [ -z "$CRYPTO_API_TOKEN" ]; then
echo "::error::CRYPTO_API_TOKEN must be set because /api/ws skips Basic auth; refusing to deploy"
exit 1
echo "Note: CRYPTO_API_TOKEN is not set; /api and /api/ws will not require a bearer token."
fi
- name: Log in to Docker Hub (optional)
@@ -176,14 +183,19 @@ jobs:
done
built=0
for i in 1 2 3 4 5; do
if docker compose build; then built=1; break; fi
# --pull refreshes base images; the build args carry the new commit
# so the cache is invalidated per release.
if docker compose build --pull; then built=1; break; fi
echo "docker compose build failed (attempt $i); retrying in 30s"
sleep 30
done
[ "$built" = "1" ] || { echo "::error::compose build failed after retries"; exit 1; }
# Bring the new stack up and wait for healthchecks. No pre-`down`, so
# the old containers keep serving until the new ones are healthy.
if ! docker compose up -d --remove-orphans --wait --wait-timeout 300; then
# --force-recreate guarantees the containers are replaced with the
# freshly built images. Without it a config-identical service can be
# left running the previous image (stale UI after a deploy).
# No pre-`down`, so the old containers keep serving until the new ones
# are healthy.
if ! docker compose up -d --remove-orphans --force-recreate --wait --wait-timeout 300; then
echo "::error::New stack is unhealthy; rolling back to previous images"
docker compose down --remove-orphans || true
for img in crypto_trading-backend crypto_trading-frontend; do
@@ -191,7 +203,7 @@ jobs:
docker tag "${img}:prev" "$img"
fi
done
docker compose up -d --remove-orphans --wait --wait-timeout 300 || true
docker compose up -d --remove-orphans --force-recreate --wait --wait-timeout 300 || true
exit 1
fi
@@ -200,6 +212,19 @@ jobs:
docker compose ps
docker compose logs --tail=80
- name: Verify the deployed frontend matches this commit
run: |
# The container must serve the build stamped with this commit's SHA.
# Without this, a config-identical service could keep an old image and
# serve a stale UI even though the deploy "succeeded".
want="${CRYPTO_BUILD_VERSION:-unknown}"
got="$(docker compose exec -T frontend cat /usr/share/nginx/html/.build-version 2>/dev/null | tr -d '[:space:]' || true)"
echo "expected=${want} served=${got:-<missing>}"
if [ "$want" != "unknown" ] && [ "$got" != "$want" ]; then
echo "::error::frontend container is serving a stale build; failing deploy"
exit 1
fi
- name: Health check (HTTP)
run: |
probe_backend() {
+36 -6
View File
@@ -92,13 +92,17 @@ class MyStrategy(Strategy):
| --- | --- |
| Price / candles | `ctx.price`, `ctx.candles`, `ctx.closes`, `ctx.high(n)`, `ctx.low(n)` |
| Portfolio | `ctx.is_flat`, `ctx.side`, `ctx.position`, `ctx.equity`, `ctx.cash` |
| Indicators | `ctx.sma(p)`, `ctx.ema(p)`, `ctx.rsi(p)`, `ctx.atr(p)`, `ctx.std(p)` |
| Orders | `ctx.enter_long(size, stop_loss, take_profit, reason)`, `ctx.enter_short(...)`, `ctx.close(reason)` |
| Indicators | `ctx.sma(p)`, `ctx.ema(p)`, `ctx.rsi(p)`, `ctx.atr(p)`, `ctx.std(p)`, `ctx.macd()`, `ctx.roc(p)`, `ctx.vwap(p)`, `ctx.supertrend(p, mult)`, `ctx.williams_r(p)` |
| Orders | `ctx.enter_long(size, stop_loss, take_profit, trailing_stop_pct, reason)`, `ctx.enter_short(...)`, `ctx.close(reason)` |
Notes:
- `size` is a fraction of equity (1.0 = all-in). It is optional; defaults to 1.0.
- `stop_loss` / `take_profit` are absolute prices; the engine enforces them.
- `trailing_stop_pct` (e.g. `0.02` for 2%) trails the stop with the price and
never loosens: for longs it ratchets up with the high, for shorts down with the
low. The engine and backtester both call `advance_trailing_stop`, which is the
only place that math lives.
- Only one position is open at a time. Intents that conflict are ignored (logged
via the engine status, not raised).
- Raise nothing for "not enough data" — just `return` early, as above.
@@ -123,6 +127,26 @@ candles you control and asserts the emitted intents. See
- Portfolio invariant: `equity = cash ± qty * mark`, realized P&L accumulates on
close, and every order/equity point/event is persisted.
## Go-live gate
`app/engine/live_guard.py` (`check_live_ready`) is the single place that decides
whether live trading may start. It is enforced in `Runtime.start_many` (so both
the single and multi start paths are covered) and surfaced via
`GET /api/live-check`. Never bypass it: if you add a new way to start trading,
call `check_live_ready` first. `LIVE_CONFIRMATION` must equal
`"I UNDERSTAND THE RISK"` — keep it a phrase, not a boolean.
## Observability
- `app/metrics.py` renders Prometheus text at `GET /api/metrics` straight from
the manager status — no client library. Keep new series out of hot loops.
- `app/logs.py` keeps a small in-memory ring of log records, exposed at
`GET /api/logs` and shown in the dashboard. `Runtime._log_event` mirrors bot
events into the logger so the panel is useful (uvicorn's own loggers do not
propagate to the root handler).
- `docs/runbook.md` is the operations guide; update it when deploy or
recovery steps change.
## Risk controls & notifications
- `app/engine/risk.py` (`RiskManager`) tracks daily loss, drawdown, position cap
@@ -164,10 +188,10 @@ real migration step.
depend on `GuardedRuntimeDep`, not `RuntimeDep` (which no longer exists).
- `/api/ws` skips Traefik Basic auth via a dedicated higher-priority router
(`crypto-trading-ws`) because browsers don't reliably replay Basic creds on a
WS upgrade. The WS is gated by `API_TOKEN` instead (header, injected by nginx,
or `?token=` for manual clients) — see `websocket_endpoint`. The deploy job
fails closed if `CRYPTO_API_TOKEN` is unset. Keep the `/api/ws` block in
`nginx.conf` separate from `/api/` so upgrade headers are only set there.
WS upgrade. When `API_TOKEN` is set the WS requires it (header, injected by
nginx, or `?token=` for manual clients); when unset the path is open, matching
the rest of `/api`. `CRYPTO_API_TOKEN` is optional. Keep the `/api/ws` block
in `nginx.conf` separate from `/api/` so upgrade headers are only set there.
## Backtesting
@@ -184,6 +208,12 @@ Sharpe), the equity curve and the trade list. The dashboard's **Backtest** panel
renders all of it. Add a strategy-focused test in `tests/test_backtest.py` if the
behaviour is non-obvious.
`POST /api/sweep` runs a cartesian grid of params through the same backtester
(`app/backtest/sweep.py`) and returns the top N rows, ranked by
`return − max_drawdown` (see `_score`). The dashboard's **Parameter sweep** panel
drives it. Like backtests, sweeps share `app/engine/portfolio.py` with the live
engine, so a winning parameter set behaves the same live.
## Commands
Backend (run from `backend/`):
+60 -4
View File
@@ -10,6 +10,9 @@ drop in one Python file, run it, and watch positions and P&L live.
- **Market data:** Binance public REST (no API key needed).
- **Backtesting:** replay historical candles through the same strategy interface,
with return/drawdown/win-rate/Sharpe metrics — so what you test is what runs.
- **Parameter sweeps:** grid-search a strategy's params and rank the results.
- **Trailing stops:** strategies can trail a stop with the price (never loosening),
enforced identically in backtest, paper and live.
> **Paper trading is the default.** Live mode places real orders and is opt-in.
> This is experimental software. Never trade more than you can afford to lose.
@@ -136,9 +139,35 @@ auth, not instead of it.
`/api/ws` is special: browsers do not reliably replay Basic-auth credentials on
a WebSocket upgrade (which left the live feed stuck on "reconnecting"), so
Traefik serves that one path on a separate higher-priority router **without**
Basic auth. It is still gated by `API_TOKEN`, which nginx injects on the
handshake. Because of this the deploy **requires** `CRYPTO_API_TOKEN` to be set —
without it the WebSocket would be unauthenticated.
Basic auth. If `API_TOKEN` is set, the WebSocket still requires it (nginx
injects it on the handshake). If `API_TOKEN` is unset, `/api/ws` is as open as
the rest of `/api`, which is fine for a trusted/private deployment but is the
reason to set a token if the host is exposed.
## Going live
Setting `MODE=live` alone is **not** enough. `GET /api/live-check` (and the
dashboard banner) validate a preflight before any live order can be placed; live
trading is blocked unless *all* of these hold:
- at least one risk limit is active (`MAX_DAILY_LOSS`, `MAX_DRAWDOWN_PCT` or
`MAX_TRADES_PER_DAY`);
- `MAX_POSITION_PCT < 1.0` (a single position can never be the whole account);
- `LIVE_CONFIRMATION` is exactly `I UNDERSTAND THE RISK`;
- the starting balance is at least `MIN_LIVE_BALANCE`;
- exchange API credentials are set.
A blocked live start returns `400` with the reasons, and the dashboard shows a
banner listing them. Practise the kill switch in paper mode first.
## Multi-strategy
The **Strategies (multi)** panel runs several strategies at once, each with its
own capital slice. Set a **weight** per strategy; weights are normalised across
the run (`[1, 3]` gives 25% / 75%) and each strategy starts with
`starting_balance × allocation` as its own cash, so one strategy's losses cannot
drain another's. Risk limits apply per strategy, and **Kill all** flattens and
halts every one.
## Risk controls
@@ -162,6 +191,15 @@ best-effort: a failing channel never affects trading. For Telegram, create a bot
with [@BotFather](https://t.me/BotFather), message it once, then read the chat id
from `https://api.telegram.org/bot<token>/getUpdates`.
Additional controls:
- `NOTIFY_LEVELS` (default `trade,error`) — which event levels to send; add
`warning` or `info`.
- `NOTIFY_LIFECYCLE` (default `true`) — explicit start/stop messages; the kill
switch always notifies.
- `SUMMARY_INTERVAL_MINUTES` (default `0`) — periodic portfolio summary (equity,
per-strategy P&L and allocation).
## Adding a strategy
Drop a file in `backend/app/engine/strategies/` — it is auto-discovered and
@@ -169,7 +207,9 @@ appears in the UI immediately. See **[AGENTS.md](AGENTS.md)** for the full
template and the `StrategyContext` API.
Bundled strategies: `sma_cross`, `ema_cross`, `macd_trend`,
`bollinger_reversion`, `donchian_breakout`, `momentum`, `rsi_reversion`.
`bollinger_reversion`, `donchian_breakout`, `momentum`, `rsi_reversion`,
`supertrend`, `vwap_reversion`, `ema_cross_short` (short-side example),
`williams_r`.
## API
@@ -178,9 +218,15 @@ Bundled strategies: `sma_cross`, `ema_cross`, `macd_trend`,
| `GET` | `/api/status` | Bot status, equity, P&L |
| `GET` | `/api/strategies` | Available strategies + parameter specs |
| `POST` | `/api/bot/start` | Start a strategy |
| `POST` | `/api/bot/start-many` | Start several strategies with per-strategy allocations |
| `GET` | `/api/bot/status` | Per-strategy status for a multi-strategy run |
| `GET` | `/api/live-check` | Go-live preflight (blockers for live trading) |
| `GET` | `/api/metrics` | Prometheus text exposition |
| `GET` | `/api/logs` | Recent backend log lines (in-memory ring) |
| `POST` | `/api/bot/stop` | Stop the bot |
| `POST` | `/api/bot/kill` | Kill switch: flatten + halt |
| `POST` | `/api/backtest` | Backtest a strategy on historical candles |
| `POST` | `/api/sweep` | Grid-sweep strategy params, ranked |
| `GET` | `/api/positions` | Positions (with unrealized P&L when open) |
| `GET` | `/api/orders` | Order history |
| `GET` | `/api/events` | Activity log |
@@ -191,6 +237,16 @@ Bundled strategies: `sma_cross`, `ema_cross`, `macd_trend`,
| `DELETE` | `/api/presets/{id}` | Delete a preset |
| `WS` | `/api/ws` | Live status + event stream |
## Observability
- `GET /api/metrics` exposes Prometheus metrics (`crypto_equity`,
`crypto_total_pnl`, per-strategy `crypto_strategy_equity`/`_pnl`/`_allocation`/
`_halted`, `crypto_info`). Point the existing Prometheus scrape at it.
- The dashboard's **Server logs** panel shows recent backend log lines
(in-memory ring buffer), useful alongside **Activity**.
- See [docs/runbook.md](docs/runbook.md) for operations (deploys, kill switch,
resetting paper state, going live).
## Tests & lint
```bash
+4
View File
@@ -5,6 +5,10 @@ ENV PYTHONUNBUFFERED=1 \
WORKDIR /app
# Build stamp exposed via /api/version so the frontend can detect a stale cache.
ARG BUILD_VERSION=unknown
ENV BUILD_VERSION=${BUILD_VERSION}
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
+79 -1
View File
@@ -4,10 +4,20 @@ import asyncio
from typing import Annotated
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect
from fastapi import (
APIRouter,
Depends,
HTTPException,
Request,
Response,
WebSocket,
WebSocketDisconnect,
)
from app.config import Settings
from app.engine.strategies import get_strategy, list_strategies, strategy_spec
from app.logs import get_log_handler
from app.metrics import render_metrics
from app.models import OrderSide, Position, PositionStatus
from app.runtime import Runtime
from app.schemas import (
@@ -18,13 +28,17 @@ from app.schemas import (
EventOut,
KillRequest,
ManualOrderRequest,
MultiStatusOut,
OrderOut,
PositionOut,
StartManyRequest,
StartRequest,
StatusOut,
StrategyConfigIn,
StrategyConfigOut,
StrategyOut,
SweepRequest,
SweepResult,
)
router = APIRouter(prefix="/api")
@@ -61,6 +75,28 @@ def health() -> dict[str, str]:
return {"status": "ok"}
@router.get("/metrics")
def metrics(runtime: GuardedRuntimeDep) -> Response:
"""Prometheus text exposition, unauthenticated so a scraper can reach it."""
status = runtime.multi_status()
body = render_metrics(status, runtime.settings.mode, runtime.settings.exchange)
return Response(content=body, media_type="text/plain; version=0.0.4")
@router.get("/logs")
def logs(runtime: GuardedRuntimeDep, limit: int = 100) -> dict:
handler = get_log_handler()
entries = handler.snapshot(limit) if handler is not None else []
return {"entries": entries}
@router.get("/version")
def version(request: Request) -> dict[str, str]:
"""Build version of the running backend, used by the client to self-heal
from a stale cached bundle after a deploy."""
return {"version": getattr(request.app.state, "build_version", "unknown")}
@router.get("/strategies", response_model=list[StrategyOut])
def strategies() -> list[StrategyOut]:
return [strategy_spec(cls) for cls in list_strategies()]
@@ -87,6 +123,27 @@ async def start_bot(payload: StartRequest, runtime: GuardedRuntimeDep) -> Status
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.get("/bot/status", response_model=MultiStatusOut)
def multi_status(runtime: GuardedRuntimeDep) -> MultiStatusOut:
return runtime.multi_status()
@router.get("/live-check")
def live_check(runtime: GuardedRuntimeDep) -> dict:
"""Preflight for live trading: whether the config is safe enough."""
return runtime.live_check()
@router.post("/bot/start-many", response_model=MultiStatusOut)
async def start_many(payload: StartManyRequest, runtime: GuardedRuntimeDep) -> MultiStatusOut:
try:
return await runtime.start_many(payload)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except (ValueError, RuntimeError) as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("/bot/stop", response_model=StatusOut)
async def stop_bot(runtime: GuardedRuntimeDep) -> StatusOut:
return await runtime.stop()
@@ -139,6 +196,18 @@ def delete_preset(config_id: int, runtime: GuardedRuntimeDep) -> dict[str, bool]
return {"deleted": True}
@router.post("/sweep", response_model=SweepResult)
async def run_sweep_endpoint(payload: SweepRequest, runtime: GuardedRuntimeDep) -> SweepResult:
try:
return await runtime.run_sweep(payload)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except httpx.HTTPError as exc:
raise HTTPException(status_code=502, detail=f"market data error: {exc}") from exc
def _position_out(position: Position, price: float | None) -> PositionOut:
out = PositionOut.model_validate(position)
if position.status == PositionStatus.OPEN and price is not None:
@@ -191,6 +260,15 @@ async def close_position(
return engine.status()
@router.get("/ws")
def websocket_info() -> dict[str, str]:
"""A plain browser hit on the socket URL gets a clear hint, not a 404/426."""
return {
"detail": "This endpoint is a WebSocket. Connect with a WebSocket client.",
"path": "/api/ws",
}
@router.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket) -> None:
runtime: Runtime = websocket.app.state.runtime
+10 -1
View File
@@ -2,5 +2,14 @@ from __future__ import annotations
from app.backtest.backtester import Backtester
from app.backtest.metrics import compute_metrics, max_drawdown_pct, sharpe_ratio
from app.backtest.sweep import SweepRow, expand_grid, run_sweep
__all__ = ["Backtester", "compute_metrics", "max_drawdown_pct", "sharpe_ratio"]
__all__ = [
"Backtester",
"SweepRow",
"compute_metrics",
"expand_grid",
"max_drawdown_pct",
"run_sweep",
"sharpe_ratio",
]
+10
View File
@@ -7,6 +7,7 @@ from app.broker.paper import PaperBroker
from app.engine.portfolio import (
OpenPosition,
account_equity,
advance_trailing_stop,
apply_close,
apply_open,
order_qty,
@@ -91,6 +92,14 @@ class Backtester:
for i, candle in enumerate(candles):
mark = candle.close
if position is not None:
if position.trailing_stop_pct > 0:
position.stop_loss = advance_trailing_stop(
position.side,
position.stop_loss,
position.trailing_stop_pct,
candle.high,
candle.low,
)
hit = risk_exit(
position.side,
position.stop_loss,
@@ -130,6 +139,7 @@ class Backtester:
entry_fee=fill.fee,
stop_loss=intent.stop_loss,
take_profit=intent.take_profit,
trailing_stop_pct=intent.trailing_stop_pct,
entry_ts=candle.ts,
)
+60
View File
@@ -0,0 +1,60 @@
from __future__ import annotations
import itertools
from collections.abc import Sequence
from dataclasses import dataclass
from typing import Any
from app.backtest.backtester import Backtester
from app.engine.strategies import get_strategy
from app.engine.strategies.base import Strategy
from app.schemas import Candle
@dataclass(slots=True)
class SweepRow:
params: dict[str, Any]
metrics: dict[str, Any]
def expand_grid(grid: dict[str, Sequence[Any]]) -> list[dict[str, Any]]:
"""Cartesian product of a param grid into a list of concrete param dicts."""
if not grid:
return [{}]
names = list(grid)
return [dict(zip(names, values, strict=True)) for values in itertools.product(*grid.values())]
def _score(metrics: dict[str, Any]) -> float:
"""Rank by return, penalising drawdown. Simple, transparent, sortable."""
return float(metrics.get("total_return_pct", 0.0)) - float(metrics.get("max_drawdown_pct", 0.0))
def run_sweep(
strategy_key: str,
base_params: dict[str, Any],
grid: dict[str, Sequence[Any]],
candles: Sequence[Candle],
symbol: str,
timeframe: str,
*,
fee_bps: float = 10.0,
slippage_bps: float = 5.0,
starting_balance: float = 10_000.0,
top_n: int = 20,
) -> list[SweepRow]:
"""Backtest every combination in ``grid`` (merged over ``base_params``)."""
strategy_cls: type[Strategy] = get_strategy(strategy_key)
backtester = Backtester(
fee_bps=fee_bps, slippage_bps=slippage_bps, starting_balance=starting_balance
)
rows: list[SweepRow] = []
for params in expand_grid(grid):
merged = {**base_params, **params}
strategy = strategy_cls(merged)
result = backtester.run(candles, strategy, symbol, timeframe)
rows.append(SweepRow(params=merged, metrics=result.metrics.model_dump()))
rows.sort(key=lambda row: _score(row.metrics), reverse=True)
return rows[:top_n]
+18
View File
@@ -39,6 +39,13 @@ class Settings(BaseSettings):
exchange_api_secret: str = ""
exchange_sandbox: bool = False
# --- Go-live gate ---
# Live trading only starts when this is exactly "I UNDERSTAND THE RISK".
# It is deliberately not a boolean: setting MODE=live alone is not enough.
live_confirmation: str = ""
# Refuse to start live below this account size (quote currency).
min_live_balance: float = 100.0
# --- Risk controls (0 disables a limit) ---
# Absolute loss in quote currency since the start of the UTC day.
max_daily_loss: float = 0.0
@@ -53,6 +60,13 @@ class Settings(BaseSettings):
telegram_bot_token: str = ""
telegram_chat_id: str = ""
notify_webhook_url: str = ""
# Event levels to notify on. Defaults to trades and errors; add "info" to
# include start/stop messages, or "warning" for blocked entries.
notify_levels: str = "trade,error"
# Explicit start/stop lifecycle messages (separate from event levels).
notify_lifecycle: bool = True
# Periodic portfolio summary, in minutes (0 disables).
summary_interval_minutes: int = 0
# Optional bearer token required on /api requests (defense-in-depth behind
# the Traefik Basic auth). Empty disables the check.
@@ -73,6 +87,10 @@ class Settings(BaseSettings):
def cors_origin_list(self) -> list[str]:
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
@property
def notify_level_set(self) -> set[str]:
return {level.strip().lower() for level in self.notify_levels.split(",") if level.strip()}
@property
def is_live(self) -> bool:
return self.mode == "live"
+1
View File
@@ -18,6 +18,7 @@ _settings = get_settings()
# added here as (table, column, DDL type) tuples.
_ADDITIVE_COLUMNS: list[tuple[str, str, str]] = [
("strategy_configs", "name", "VARCHAR(80) DEFAULT ''"),
("positions", "trailing_stop_pct", "FLOAT DEFAULT 0"),
]
+19
View File
@@ -10,6 +10,7 @@ from app.broker.base import Broker
from app.config import Settings
from app.engine.portfolio import (
account_equity,
advance_trailing_stop,
apply_close,
apply_open,
order_qty,
@@ -202,6 +203,7 @@ class TradingEngine:
size=intent.size if intent.size is not None else 1.0,
stop_loss=intent.stop_loss,
take_profit=intent.take_profit,
trailing_stop_pct=intent.trailing_stop_pct,
reason=intent.reason or intent.action,
)
@@ -213,6 +215,7 @@ class TradingEngine:
stop_loss: float | None,
take_profit: float | None,
reason: str,
trailing_stop_pct: float = 0.0,
qty_override: float | None = None,
) -> None:
price = self.last_price
@@ -250,6 +253,7 @@ class TradingEngine:
entry_fee=fill.fee,
stop_loss=stop_loss,
take_profit=take_profit,
trailing_stop_pct=trailing_stop_pct,
)
self.store.record_order(
position_id=self.position.id,
@@ -317,6 +321,21 @@ class TradingEngine:
position = self.position
if position is None:
return
# Ratchet a trailing stop before testing levels, so a bar that both
# advances the trail and reverses is handled consistently.
if position.trailing_stop_pct and position.trailing_stop_pct > 0:
advanced = advance_trailing_stop(
position.side,
position.stop_loss,
position.trailing_stop_pct,
candle.high,
candle.low,
)
if advanced is not None and advanced != position.stop_loss:
position.stop_loss = advanced
self.store.update_stop_loss(position.id, advanced)
hit = risk_exit(
position.side,
position.stop_loss,
+97
View File
@@ -151,6 +151,103 @@ def roc(values: Sequence[float], period: int = 10) -> list[Number]:
return out
def williams_r(candles: Sequence[Candle], period: int = 14) -> list[Number]:
"""Williams %R, in [-100, 0]. Below -80 is oversold, above -20 overbought."""
if period <= 0:
raise ValueError("period must be positive")
out: list[Number] = [None] * len(candles)
for i in range(period - 1, len(candles)):
window = candles[i - period + 1 : i + 1]
highest = max(candle.high for candle in window)
lowest = min(candle.low for candle in window)
if highest == lowest:
out[i] = -50.0
else:
out[i] = (highest - candles[i].close) / (highest - lowest) * -100
return out
def typical_price(candle: Candle) -> float:
return (candle.high + candle.low + candle.close) / 3
def vwap(candles: Sequence[Candle], period: int | None = None) -> list[Number]:
"""Volume-weighted average price, rolling over ``period`` bars.
With ``period=None`` it is cumulative from the first candle.
"""
out: list[Number] = [None] * len(candles)
pv_sum = 0.0
volume_sum = 0.0
window: list[tuple[float, float]] = []
for i, candle in enumerate(candles):
price = typical_price(candle)
volume = candle.volume
pv_sum += price * volume
volume_sum += volume
if period is not None:
window.append((price, volume))
if len(window) > period:
old_price, old_volume = window.pop(0)
pv_sum -= old_price * old_volume
volume_sum -= old_volume
if len(window) < period:
continue
if volume_sum:
out[i] = pv_sum / volume_sum
return out
def supertrend(
candles: Sequence[Candle],
period: int = 10,
multiplier: float = 3.0,
) -> tuple[list[float | None], list[bool | None]]:
"""Supertrend line and trend direction (True = uptrend) per candle."""
atr_series = atr(candles, period)
line: list[float | None] = [None] * len(candles)
up: list[bool | None] = [None] * len(candles)
final_upper: float | None = None
final_lower: float | None = None
prev_close: float | None = None
direction_up = True
for i, candle in enumerate(candles):
atr_value = atr_series[i]
if atr_value is None:
continue
midpoint = (candle.high + candle.low) / 2
basic_upper = midpoint + multiplier * atr_value
basic_lower = midpoint - multiplier * atr_value
if final_upper is None or final_lower is None:
final_upper, final_lower = basic_upper, basic_lower
direction_up = candle.close >= midpoint
else:
assert prev_close is not None
final_upper = (
basic_upper
if basic_upper < final_upper or prev_close > final_upper
else final_upper
)
final_lower = (
basic_lower
if basic_lower > final_lower or prev_close < final_lower
else final_lower
)
if direction_up:
direction_up = candle.close >= final_lower
else:
direction_up = candle.close > final_upper
prev_close = candle.close
line[i] = final_lower if direction_up else final_upper
up[i] = direction_up
return line, up
def last(series: Sequence[Number]) -> Number:
for value in reversed(series):
if value is not None:
+62
View File
@@ -0,0 +1,62 @@
from __future__ import annotations
from dataclasses import dataclass, field
from app.config import Settings
# The exact phrase required in LIVE_CONFIRMATION before live trading starts.
LIVE_CONFIRMATION_PHRASE = "I UNDERSTAND THE RISK"
@dataclass(slots=True)
class LiveCheck:
"""Result of the go-live preflight."""
ok: bool
mode: str
# Each failure is a human-readable reason live trading is blocked.
blockers: list[str] = field(default_factory=list)
def as_dict(self) -> dict:
return {"ok": self.ok, "mode": self.mode, "blockers": self.blockers}
def check_live_ready(settings: Settings, starting_balance: float | None = None) -> LiveCheck:
"""Validate that the configuration is safe enough to trade real money.
Paper mode always passes. Live mode must satisfy every guard; the point is
that ``MODE=live`` alone is not sufficient to start live trading.
"""
if settings.mode != "live":
return LiveCheck(ok=True, mode=settings.mode)
blockers: list[str] = []
has_limit = (
settings.max_daily_loss > 0
or settings.max_drawdown_pct > 0
or settings.max_trades_per_day > 0
)
if not has_limit:
blockers.append(
"no active risk limit: set MAX_DAILY_LOSS, MAX_DRAWDOWN_PCT or MAX_TRADES_PER_DAY"
)
if settings.max_position_pct >= 1.0:
blockers.append(
"MAX_POSITION_PCT must be below 1.0 so a single position cannot be the whole account"
)
if settings.live_confirmation != LIVE_CONFIRMATION_PHRASE:
blockers.append(f'LIVE_CONFIRMATION must be exactly "{LIVE_CONFIRMATION_PHRASE}"')
balance = starting_balance if starting_balance is not None else settings.starting_balance
if balance < settings.min_live_balance:
blockers.append(
f"starting balance {balance:g} is below MIN_LIVE_BALANCE {settings.min_live_balance:g}"
)
if not settings.exchange_api_key or not settings.exchange_api_secret:
blockers.append("EXCHANGE_API_KEY and EXCHANGE_API_SECRET are required for live")
return LiveCheck(ok=not blockers, mode=settings.mode, blockers=blockers)
+185
View File
@@ -0,0 +1,185 @@
from __future__ import annotations
import asyncio
from collections.abc import Callable
from app.broker import build_broker
from app.broker.base import Broker
from app.config import Settings
from app.engine.engine import Publish, TradingEngine
from app.engine.strategies import get_strategy
from app.market import build_market_data
from app.market.base import MarketData
from app.schemas import (
MultiStatusOut,
StartManyRequest,
StrategySpec,
StrategyStatusOut,
)
from app.store import Store
FeedFactory = Callable[[Settings], MarketData]
BrokerFactory = Callable[[Settings], Broker]
def normalise_weights(specs: list[StrategySpec]) -> list[float]:
"""Turn relative weights into allocations that sum to exactly 1.0."""
total = sum(max(0.0, spec.weight) for spec in specs)
if total <= 0:
return [1 / len(specs)] * len(specs)
return [max(0.0, spec.weight) / total for spec in specs]
class StrategyRun:
"""One strategy's engine plus the allocation it was given."""
def __init__(self, spec: StrategySpec, engine: TradingEngine, allocation: float) -> None:
self.spec = spec
self.engine = engine
self.allocation = allocation
@property
def key(self) -> str:
return self.spec.strategy_key
class MultiStrategyManager:
"""Runs several strategies concurrently, each on its own capital slice.
Every strategy gets an independent :class:`TradingEngine` seeded with
``starting_balance * allocation``, so one strategy's losses cannot eat into
another's capital. Risk limits apply per strategy (each engine owns its own
RiskManager); the manager aggregates status and can halt everything at once.
"""
def __init__(
self,
*,
store: Store,
settings: Settings,
feed_factory: FeedFactory = build_market_data,
broker_factory: BrokerFactory = build_broker,
) -> None:
self.store = store
self.settings = settings
self._feed_factory = feed_factory
self._broker_factory = broker_factory
self.runs: list[StrategyRun] = []
self.starting_balance = settings.starting_balance
self._publish_cb: Publish | None = None
self._task: asyncio.Task | None = None
def set_publisher(self, publish: Publish | None) -> None:
self._publish_cb = publish
@property
def running(self) -> bool:
return bool(self.runs) and any(run.engine.running for run in self.runs)
@property
def last_price(self) -> float | None:
for run in self.runs:
if run.engine.last_price is not None:
return run.engine.last_price
return None
async def start(self, request: StartManyRequest) -> MultiStatusOut:
await self.stop()
starting_balance = float(request.starting_balance or self.settings.starting_balance)
allocations = normalise_weights(request.strategies)
self.starting_balance = starting_balance
self.runs = []
for spec, allocation in zip(request.strategies, allocations, strict=True):
strategy_cls = get_strategy(spec.strategy_key)
# Validate params early so a bad request fails before any orders.
strategy_cls(spec.params)
engine = TradingEngine(
feed=self._feed_factory(self.settings),
broker=self._broker_factory(self.settings),
store=self.store,
settings=self.settings,
)
engine.set_publisher(self._publish_cb)
await engine.start(
spec.strategy_key,
spec.params,
symbol=spec.symbol,
timeframe=spec.timeframe,
starting_balance=starting_balance * allocation,
)
self.runs.append(StrategyRun(spec, engine, allocation))
return self.status()
async def stop(self) -> MultiStatusOut:
for run in self.runs:
await run.engine.stop()
self.runs = []
return self.status()
async def kill(self, reason: str = "manual kill switch") -> MultiStatusOut:
for run in self.runs:
await run.engine.kill(reason)
return self.status()
async def shutdown(self) -> None:
for run in self.runs:
await run.engine.shutdown()
self.runs = []
def status(self) -> MultiStatusOut:
total_equity = 0.0
per_strategy: list[StrategyStatusOut] = []
last_tick = None
for run in self.runs:
engine_status = run.engine.status()
total_equity += engine_status.equity
if engine_status.last_tick and (
last_tick is None or engine_status.last_tick > last_tick
):
last_tick = engine_status.last_tick
per_strategy.append(
StrategyStatusOut(
strategy_key=engine_status.strategy_key or run.key,
strategy_name=engine_status.strategy_name,
params=engine_status.params,
symbol=engine_status.symbol,
timeframe=engine_status.timeframe,
allocation=run.allocation,
cash=engine_status.cash,
equity=engine_status.equity,
realized_pnl=engine_status.realized_pnl,
unrealized_pnl=engine_status.unrealized_pnl,
total_pnl=engine_status.total_pnl,
open_positions=engine_status.open_positions,
halted=engine_status.halted,
halt_reason=engine_status.halt_reason,
trades_today=engine_status.trades_today,
day_pnl=engine_status.day_pnl,
drawdown_pct=engine_status.drawdown_pct,
error=engine_status.error,
)
)
return MultiStatusOut(
running=self.running,
mode=self.settings.mode,
exchange=self.settings.exchange,
price=self.last_price,
starting_balance=self.starting_balance,
equity=total_equity,
total_pnl=total_equity - self.starting_balance,
strategies=per_strategy,
last_tick=last_tick,
)
def engine_at(self, index: int) -> TradingEngine | None:
if 0 <= index < len(self.runs):
return self.runs[index].engine
return None
__all__ = ["MultiStrategyManager", "StrategyRun", "normalise_weights"]
+24
View File
@@ -18,9 +18,33 @@ class OpenPosition:
entry_fee: float = 0.0
stop_loss: float | None = None
take_profit: float | None = None
# Distance to trail the stop below the high (long) / above the low (short),
# as a fraction of price (0.02 = 2%). 0 disables trailing.
trailing_stop_pct: float = 0.0
entry_ts: int = 0
def advance_trailing_stop(
side: PositionSide,
stop_loss: float | None,
trailing_stop_pct: float,
high: float,
low: float,
) -> float | None:
"""Ratchet a trailing stop toward the price; never loosens.
For a long the stop trails ``pct`` below the running high; for a short it
trails ``pct`` above the running low. Returns the (possibly unchanged) stop.
"""
if trailing_stop_pct <= 0:
return stop_loss
if side == PositionSide.LONG:
candidate = high * (1 - trailing_stop_pct)
return candidate if stop_loss is None else max(stop_loss, candidate)
candidate = low * (1 + trailing_stop_pct)
return candidate if stop_loss is None else min(stop_loss, candidate)
def apply_open(cash: float, side: PositionSide, qty: float, price: float, fee: float) -> float:
"""Cash after opening a position (buying long / selling short)."""
notional = qty * price
+33 -3
View File
@@ -5,7 +5,18 @@ from collections.abc import Sequence
from dataclasses import dataclass
from typing import Any, ClassVar, Literal, Protocol
from app.engine.indicators import atr, ema, macd, roc, rolling_std, rsi, sma
from app.engine.indicators import (
atr,
ema,
macd,
roc,
rolling_std,
rsi,
sma,
supertrend,
vwap,
williams_r,
)
from app.models import PositionSide
from app.schemas import Candle, ParamSpec, StrategyOut
@@ -26,6 +37,7 @@ class Intent:
size: float | None = None
stop_loss: float | None = None
take_profit: float | None = None
trailing_stop_pct: float = 0.0
reason: str = ""
@@ -115,24 +127,42 @@ class StrategyContext:
def roc(self, period: int = 10) -> float | None:
return roc(self.closes, period)[-1]
def vwap(self, period: int | None = None) -> float | None:
return vwap(self._candles, period)[-1]
def williams_r(self, period: int = 14) -> float | None:
return williams_r(self._candles, period)[-1]
def supertrend(
self, period: int = 10, multiplier: float = 3.0
) -> tuple[float | None, bool | None]:
line, up = supertrend(self._candles, period, multiplier)
return line[-1], up[-1]
# --- order intents ------------------------------------------------
def enter_long(
self,
size: float | None = None,
stop_loss: float | None = None,
take_profit: float | None = None,
trailing_stop_pct: float = 0.0,
reason: str = "",
) -> None:
self._intents.append(Intent("enter_long", size, stop_loss, take_profit, reason))
self._intents.append(
Intent("enter_long", size, stop_loss, take_profit, trailing_stop_pct, reason)
)
def enter_short(
self,
size: float | None = None,
stop_loss: float | None = None,
take_profit: float | None = None,
trailing_stop_pct: float = 0.0,
reason: str = "",
) -> None:
self._intents.append(Intent("enter_short", size, stop_loss, take_profit, reason))
self._intents.append(
Intent("enter_short", size, stop_loss, take_profit, trailing_stop_pct, reason)
)
def close(self, reason: str = "") -> None:
self._intents.append(Intent("close", reason=reason))
@@ -0,0 +1,70 @@
from __future__ import annotations
from typing import ClassVar
from app.engine.indicators import atr, ema
from app.engine.strategies.base import Strategy, StrategyContext, register
from app.models import PositionSide
@register
class EmaCrossShort(Strategy):
"""Short-side example: sell EMA cross-downs, cover on cross-up."""
key: ClassVar[str] = "ema_cross_short"
name: ClassVar[str] = "EMA Crossover (short)"
description: ClassVar[str] = (
"Open a short when the fast EMA crosses below the slow EMA and cover when "
"it crosses back above. Demonstrates short trades; optional ATR stop."
)
default_params: ClassVar[dict] = {
"fast": 9,
"slow": 21,
"atr_period": 14,
"atr_stop_mult": 2.0,
"size": 1.0,
}
param_help: ClassVar[dict] = {
"fast": "Fast EMA period",
"slow": "Slow EMA period",
"atr_period": "ATR lookback for the stop",
"atr_stop_mult": "Stop distance in ATRs (0 disables)",
"size": "Fraction of equity per short",
}
param_bounds: ClassVar[dict] = {
"fast": (2, 100),
"slow": (3, 300),
"atr_stop_mult": (0.0, 10.0),
"size": (0.01, 1.0),
}
def on_candle(self, ctx: StrategyContext) -> None:
fast_period = int(self.params["fast"])
slow_period = int(self.params["slow"])
if len(ctx.closes) < slow_period + 2:
return
fast = ema(ctx.closes, fast_period)
slow = ema(ctx.closes, slow_period)
f_prev, f_now = fast[-2], fast[-1]
s_prev, s_now = slow[-2], slow[-1]
if None in (f_prev, f_now, s_prev, s_now):
return
crossed_down = f_prev >= s_prev and f_now < s_now
crossed_up = f_prev <= s_prev and f_now > s_now
if ctx.is_flat and crossed_down:
stop = None
mult = float(self.params["atr_stop_mult"])
if mult > 0:
atr_value = atr(ctx.candles, int(self.params["atr_period"]))[-1]
if atr_value:
stop = ctx.price + mult * atr_value
ctx.enter_short(
size=float(self.params["size"]),
stop_loss=stop,
reason="EMA cross down",
)
elif ctx.side == PositionSide.SHORT and crossed_up:
ctx.close(reason="EMA cross up")
+5 -1
View File
@@ -21,19 +21,22 @@ class Momentum(Strategy):
"entry_threshold": 0.01,
"atr_period": 14,
"atr_stop_mult": 2.0,
"trailing_stop_pct": 0.0,
"size": 1.0,
}
param_help: ClassVar[dict] = {
"period": "ROC lookback in bars",
"entry_threshold": "Minimum ROC to enter (0.01 = 1%)",
"atr_period": "ATR lookback for the stop",
"atr_period": "ATR lookback for the initial stop",
"atr_stop_mult": "Stop distance in ATRs (0 disables)",
"trailing_stop_pct": "Trailing stop distance as a fraction of price (0 disables)",
"size": "Fraction of equity per trade",
}
param_bounds: ClassVar[dict] = {
"period": (2, 200),
"entry_threshold": (0.0, 1.0),
"atr_stop_mult": (0.0, 10.0),
"trailing_stop_pct": (0.0, 0.5),
"size": (0.01, 1.0),
}
@@ -52,6 +55,7 @@ class Momentum(Strategy):
ctx.enter_long(
size=float(self.params["size"]),
stop_loss=stop,
trailing_stop_pct=float(self.params["trailing_stop_pct"]),
reason=f"ROC {value:.2%} above threshold",
)
elif ctx.side == PositionSide.LONG and value < 0:
@@ -0,0 +1,63 @@
from __future__ import annotations
from typing import ClassVar
from app.engine.indicators import supertrend as supertrend_series
from app.engine.strategies.base import Strategy, StrategyContext, register
from app.models import PositionSide
@register
class Supertrend(Strategy):
"""Trend following on the Supertrend indicator, long or short."""
key: ClassVar[str] = "supertrend"
name: ClassVar[str] = "Supertrend"
description: ClassVar[str] = (
"Go long when Supertrend flips to an uptrend and short when it flips to "
"a downtrend. Uses the Supertrend line as a stop."
)
default_params: ClassVar[dict] = {
"period": 10,
"multiplier": 3.0,
"allow_shorts": False,
"size": 1.0,
}
param_help: ClassVar[dict] = {
"period": "ATR period for the Supertrend bands",
"multiplier": "ATR multiplier (band width)",
"allow_shorts": "Also take short trades in downtrends",
"size": "Fraction of equity per trade",
}
param_bounds: ClassVar[dict] = {
"period": (2, 100),
"multiplier": (0.5, 10.0),
"size": (0.01, 1.0),
}
def on_candle(self, ctx: StrategyContext) -> None:
period = int(self.params["period"])
multiplier = float(self.params["multiplier"])
if len(ctx.candles) < period + 2:
return
line_series, up_series = supertrend_series(ctx.candles, period, multiplier)
line, up = line_series[-1], up_series[-1]
prev_up = up_series[-2]
if line is None or up is None or prev_up is None:
return
allow_shorts = bool(self.params.get("allow_shorts", False))
size = float(self.params["size"])
if ctx.side == PositionSide.LONG and not up:
ctx.close(reason="Supertrend turned down")
return
if ctx.side == PositionSide.SHORT and up:
ctx.close(reason="Supertrend turned up")
return
if ctx.is_flat and up and not prev_up:
ctx.enter_long(size=size, stop_loss=line, reason="Supertrend flipped up")
elif ctx.is_flat and allow_shorts and not up and prev_up:
ctx.enter_short(size=size, stop_loss=line, reason="Supertrend flipped down")
@@ -0,0 +1,63 @@
from __future__ import annotations
from typing import ClassVar
from app.engine.strategies.base import Strategy, StrategyContext, register
from app.models import PositionSide
@register
class VwapReversion(Strategy):
"""Mean reversion around VWAP, long-only."""
key: ClassVar[str] = "vwap_reversion"
name: ClassVar[str] = "VWAP Reversion"
description: ClassVar[str] = (
"Open long when price falls this far below a rolling VWAP and exit when "
"it returns to VWAP. Optional ATR stop."
)
default_params: ClassVar[dict] = {
"period": 20,
"deviation_pct": 0.005,
"atr_period": 14,
"atr_stop_mult": 2.0,
"size": 1.0,
}
param_help: ClassVar[dict] = {
"period": "Rolling VWAP window in bars",
"deviation_pct": "Entry distance below VWAP (0.005 = 0.5%)",
"atr_period": "ATR lookback for the stop",
"atr_stop_mult": "Stop distance in ATRs (0 disables)",
"size": "Fraction of equity per trade",
}
param_bounds: ClassVar[dict] = {
"period": (2, 200),
"deviation_pct": (0.0, 0.2),
"atr_stop_mult": (0.0, 10.0),
"size": (0.01, 1.0),
}
def on_candle(self, ctx: StrategyContext) -> None:
period = int(self.params["period"])
if len(ctx.candles) < period + 1:
return
reference = ctx.vwap(period)
if reference is None or reference <= 0:
return
threshold = reference * (1 - float(self.params["deviation_pct"]))
if ctx.is_flat and ctx.price < threshold:
stop = None
mult = float(self.params["atr_stop_mult"])
if mult > 0:
atr_value = ctx.atr(int(self.params["atr_period"]))
if atr_value:
stop = ctx.price - mult * atr_value
ctx.enter_long(
size=float(self.params["size"]),
stop_loss=stop,
reason="price below VWAP band",
)
elif ctx.side == PositionSide.LONG and ctx.price >= reference:
ctx.close(reason="price returned to VWAP")
@@ -0,0 +1,61 @@
from __future__ import annotations
from typing import ClassVar
from app.engine.strategies.base import Strategy, StrategyContext, register
from app.models import PositionSide
@register
class WilliamsR(Strategy):
"""Williams %R mean reversion, long-only."""
key: ClassVar[str] = "williams_r"
name: ClassVar[str] = "Williams %R"
description: ClassVar[str] = (
"Enter long when Williams %R is oversold (at or below the oversold "
"level) and exit when it becomes overbought. Optional ATR stop."
)
default_params: ClassVar[dict] = {
"period": 14,
"oversold": -80.0,
"overbought": -20.0,
"atr_period": 14,
"atr_stop_mult": 2.0,
"size": 1.0,
}
param_help: ClassVar[dict] = {
"period": "Williams %R lookback",
"oversold": "Oversold level (e.g. -80)",
"overbought": "Overbought level (e.g. -20)",
"atr_period": "ATR lookback for the stop",
"atr_stop_mult": "Stop distance in ATRs (0 disables)",
"size": "Fraction of equity per trade",
}
param_bounds: ClassVar[dict] = {
"period": (2, 100),
"oversold": (-100.0, -50.0),
"overbought": (-50.0, 0.0),
"atr_stop_mult": (0.0, 10.0),
"size": (0.01, 1.0),
}
def on_candle(self, ctx: StrategyContext) -> None:
value = ctx.williams_r(int(self.params["period"]))
if value is None:
return
if ctx.is_flat and value <= float(self.params["oversold"]):
stop = None
mult = float(self.params["atr_stop_mult"])
if mult > 0:
atr_value = ctx.atr(int(self.params["atr_period"]))
if atr_value:
stop = ctx.price - mult * atr_value
ctx.enter_long(
size=float(self.params["size"]),
stop_loss=stop,
reason=f"Williams %R {value:.1f} oversold",
)
elif ctx.side == PositionSide.LONG and value >= float(self.params["overbought"]):
ctx.close(reason=f"Williams %R {value:.1f} overbought")
+55
View File
@@ -0,0 +1,55 @@
from __future__ import annotations
import logging
from collections import deque
from datetime import UTC, datetime
class RingLogHandler(logging.Handler):
"""Keeps the most recent log records in memory for the dashboard.
Deliberately small and dependency-free: the goal is a debugging aid, not a
log store. Attach once at startup.
"""
def __init__(self, capacity: int = 200) -> None:
super().__init__()
self.records: deque[dict] = deque(maxlen=capacity)
def emit(self, record: logging.LogRecord) -> None:
try:
self.records.append(
{
"time": datetime.fromtimestamp(record.created, UTC).isoformat(),
"level": record.levelname.lower(),
"logger": record.name,
"message": record.getMessage(),
}
)
except Exception: # never let logging break the app
self.handleError(record)
def snapshot(self, limit: int = 100) -> list[dict]:
items = list(self.records)
return items[-limit:]
_handler: RingLogHandler | None = None
def install_log_capture(capacity: int = 200) -> RingLogHandler:
"""Attach the ring handler to the root logger (idempotent)."""
global _handler
if _handler is not None:
return _handler
_handler = RingLogHandler(capacity)
_handler.setLevel(logging.INFO)
root = logging.getLogger()
root.addHandler(_handler)
if root.level > logging.INFO or root.level == logging.NOTSET:
root.setLevel(logging.INFO)
return _handler
def get_log_handler() -> RingLogHandler | None:
return _handler
+6
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import os
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
@@ -9,6 +10,7 @@ from fastapi.middleware.cors import CORSMiddleware
from app.api.routes import router
from app.config import Settings, get_settings
from app.db import init_db
from app.logs import install_log_capture
from app.runtime import Runtime
@@ -21,12 +23,16 @@ def create_app(
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
install_log_capture()
init_db()
app.state.runtime = runtime
yield
await runtime.shutdown()
app = FastAPI(title=settings.app_name, lifespan=lifespan)
# Baked at build time (see Dockerfile); the frontend compares its own
# version against this to self-heal from a stale cache.
app.state.build_version = os.environ.get("BUILD_VERSION", "unknown")
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origin_list,
+96
View File
@@ -0,0 +1,96 @@
from __future__ import annotations
from app.schemas import MultiStatusOut
# Rendered as Prometheus text exposition format (no client library needed).
# The route serves this at GET /api/metrics for the existing Prometheus scrape.
def _escape(value: str) -> str:
return value.replace("\\", "\\\\").replace('"', '\\"')
def _metric(
name: str, help_text: str, value: float | int, labels: dict[str, str] | None = None
) -> str:
lines = [f"# HELP {name} {help_text}", f"# TYPE {name} gauge"]
if labels:
rendered = ",".join(f'{k}="{_escape(v)}"' for k, v in labels.items())
lines.append(f"{name}{{{rendered}}} {value}")
else:
lines.append(f"{name} {value}")
return "\n".join(lines)
def render_metrics(status: MultiStatusOut, mode: str, exchange: str) -> str:
"""Render the current portfolio state as Prometheus metrics."""
parts = [
_metric("crypto_running", "1 when the bot is running", int(status.running)),
_metric("crypto_equity", "Total account equity", status.equity),
_metric("crypto_starting_balance", "Starting balance", status.starting_balance),
_metric("crypto_total_pnl", "Total profit and loss", status.total_pnl),
_metric(
"crypto_open_positions",
"Number of open positions across strategies",
sum(s.open_positions for s in status.strategies),
),
_metric(
"crypto_strategies",
"Number of configured strategies",
len(status.strategies),
),
]
if status.price is not None:
parts.append(_metric("crypto_price", "Latest mark price", status.price))
# Common labels shared by the per-strategy families.
lines = [
"# HELP crypto_strategy_equity Equity per strategy",
"# TYPE crypto_strategy_equity gauge",
]
for s in status.strategies:
labels = {"strategy": s.strategy_key, "symbol": s.symbol}
rendered = ",".join(f'{k}="{_escape(v)}"' for k, v in labels.items())
lines.append(f"crypto_strategy_equity{{{rendered}}} {s.equity}")
parts.append("\n".join(lines))
lines = [
"# HELP crypto_strategy_pnl Total P&L per strategy",
"# TYPE crypto_strategy_pnl gauge",
]
for s in status.strategies:
labels = {"strategy": s.strategy_key, "symbol": s.symbol}
rendered = ",".join(f'{k}="{_escape(v)}"' for k, v in labels.items())
lines.append(f"crypto_strategy_pnl{{{rendered}}} {s.total_pnl}")
parts.append("\n".join(lines))
lines = [
"# HELP crypto_strategy_allocation Fraction of the balance per strategy",
"# TYPE crypto_strategy_allocation gauge",
]
for s in status.strategies:
labels = {"strategy": s.strategy_key}
rendered = ",".join(f'{k}="{_escape(v)}"' for k, v in labels.items())
lines.append(f"crypto_strategy_allocation{{{rendered}}} {s.allocation}")
parts.append("\n".join(lines))
lines = [
"# HELP crypto_strategy_halted 1 when a strategy is halted",
"# TYPE crypto_strategy_halted gauge",
]
for s in status.strategies:
labels = {"strategy": s.strategy_key}
rendered = ",".join(f'{k}="{_escape(v)}"' for k, v in labels.items())
lines.append(f"crypto_strategy_halted{{{rendered}}} {int(s.halted)}")
parts.append("\n".join(lines))
info_labels = {"mode": mode, "exchange": exchange}
rendered = ",".join(f'{k}="{_escape(v)}"' for k, v in info_labels.items())
parts.append(
"# HELP crypto_info Build information\n"
"# TYPE crypto_info gauge\n"
f"crypto_info{{{rendered}}} 1"
)
return "\n".join(parts) + "\n"
+1
View File
@@ -67,6 +67,7 @@ class Position(Base):
)
stop_loss: Mapped[float | None] = mapped_column(Float, nullable=True)
take_profit: Mapped[float | None] = mapped_column(Float, nullable=True)
trailing_stop_pct: Mapped[float] = mapped_column(Float, default=0.0)
entry_fee: Mapped[float] = mapped_column(Float, default=0.0)
exit_price: Mapped[float | None] = mapped_column(Float, nullable=True)
exit_fee: Mapped[float] = mapped_column(Float, default=0.0)
+175 -32
View File
@@ -6,20 +6,41 @@ from collections.abc import Callable
from contextlib import suppress
from typing import Any
from app.backtest import Backtester
from app.backtest import Backtester, run_sweep
from app.broker import build_broker
from app.broker.base import Broker
from app.config import Settings
from app.engine.engine import TradingEngine
from app.engine.live_guard import check_live_ready
from app.engine.manager import MultiStrategyManager
from app.engine.strategies import get_strategy
from app.market import build_market_data
from app.market.base import MarketData
from app.notify import Notifier, build_notifier
from app.schemas import BacktestRequest, BacktestResult, StatusOut
from app.schemas import (
BacktestRequest,
BacktestResult,
MultiStatusOut,
StartManyRequest,
StatusOut,
StrategySpec,
SweepRequest,
SweepResult,
SweepRowOut,
)
from app.store import Store
logger = logging.getLogger(__name__)
_LOG_LEVELS = {
"debug": logging.DEBUG,
"info": logging.INFO,
"trade": logging.INFO,
"warning": logging.WARNING,
"error": logging.ERROR,
"critical": logging.CRITICAL,
}
FeedFactory = Callable[[Settings], MarketData]
BrokerFactory = Callable[[Settings], Broker]
NotifierFactory = Callable[[Settings], Notifier | None]
@@ -44,10 +65,23 @@ class Runtime:
self._feed_factory = feed_factory
self._broker_factory = broker_factory
self.notifier = notifier if notifier is not None else notifier_factory(settings)
self.notify_levels = notify_levels or {"trade", "error"}
self.engine: TradingEngine | None = None
self.notify_levels = notify_levels or settings.notify_level_set or {"trade", "error"}
self.manager = MultiStrategyManager(
store=self.store,
settings=settings,
feed_factory=feed_factory,
broker_factory=broker_factory,
)
self.manager.set_publisher(self._broadcast)
self._subscribers: set[asyncio.Queue] = set()
self._notify_tasks: set[asyncio.Task] = set()
self._summary_task: asyncio.Task | None = None
# The first (usually only) engine, kept for the single-strategy API and
# backwards compatibility with existing callers and tests.
@property
def engine(self) -> TradingEngine | None:
return self.manager.runs[0].engine if self.manager.runs else None
# --- pub/sub ------------------------------------------------------
def subscribe(self) -> asyncio.Queue:
@@ -66,19 +100,66 @@ class Runtime:
with suppress(asyncio.QueueFull):
queue.put_nowait(payload)
if payload.get("type") == "event":
self._log_event(payload["data"])
self._maybe_notify(payload["data"])
def _maybe_notify(self, event: dict[str, Any]) -> None:
if self.notifier is None or event.get("level") not in self.notify_levels:
return
def _log_event(self, event: dict[str, Any]) -> None:
"""Mirror bot events into the logger so the dashboard log view is useful."""
level = str(event.get("level", "info")).lower()
message = event.get("message", "")
logger.log(_LOG_LEVELS.get(level, logging.INFO), "%s", message)
def _maybe_notify(self, event: dict[str, Any]) -> None:
if event.get("level") not in self.notify_levels:
return
self.notify_now(event.get("level", "info"), event.get("message", ""))
def notify_now(self, level: str, message: str) -> None:
"""Send a notification, ignoring level filtering (best-effort)."""
if self.notifier is None:
return
try:
task = asyncio.create_task(self.notifier.send(event["level"], message))
task = asyncio.create_task(self.notifier.send(level, message))
except RuntimeError: # no running loop (e.g. sync test context)
return
self._notify_tasks.add(task)
task.add_done_callback(self._notify_tasks.discard)
def _start_summary_loop(self) -> None:
minutes = self.settings.summary_interval_minutes
if minutes <= 0 or self.notifier is None:
return
self._stop_summary_loop()
self._summary_task = asyncio.create_task(self._summary_loop(minutes * 60))
def _stop_summary_loop(self) -> None:
if self._summary_task is not None:
self._summary_task.cancel()
self._summary_task = None
async def _summary_loop(self, interval_seconds: float) -> None:
try:
while True:
await asyncio.sleep(interval_seconds)
self.notify_now("summary", self.summary_message())
except asyncio.CancelledError:
raise
def summary_message(self) -> str:
status = self.manager.status()
lines = [
f"Portfolio: equity {status.equity:,.2f} "
f"({status.total_pnl:+,.2f}) across {len(status.strategies)} strategies"
]
for s in status.strategies:
state = "HALTED" if s.halted else ("running" if status.running else "stopped")
lines.append(
f"- {s.strategy_name or s.strategy_key} "
f"({s.allocation * 100:.0f}%): {s.equity:,.2f} ({s.total_pnl:+,.2f}) "
f"[{state}]"
)
return "\n".join(lines)
# --- lifecycle ----------------------------------------------------
async def start(
self,
@@ -89,36 +170,63 @@ class Runtime:
timeframe: str | None = None,
starting_balance: float | None = None,
) -> StatusOut:
if self.engine is not None and self.engine.running:
await self.stop()
engine = TradingEngine(
feed=self._feed_factory(self.settings),
broker=self._broker_factory(self.settings),
store=self.store,
settings=self.settings,
)
engine.set_publisher(self._broadcast)
self.engine = engine
await engine.start(
strategy_key,
params,
await self.start_many(
StartManyRequest(
strategies=[
StrategySpec(
strategy_key=strategy_key,
params=params or {},
symbol=symbol,
timeframe=timeframe,
)
],
starting_balance=starting_balance,
)
return engine.status()
)
engine = self.engine
return engine.status() if engine is not None else self._idle_status()
async def start_many(self, request: StartManyRequest) -> MultiStatusOut:
check = check_live_ready(
self.settings,
request.starting_balance or self.settings.starting_balance,
)
if not check.ok:
raise ValueError("live trading blocked: " + "; ".join(check.blockers))
status = await self.manager.start(request)
if self.settings.notify_lifecycle:
names = ", ".join(s.strategy_name or s.strategy_key for s in status.strategies)
self.notify_now(
"info",
f"Started {len(status.strategies)} strategy(ies) in {self.settings.mode} "
f"mode: {names}",
)
self._start_summary_loop()
return status
def live_check(self) -> dict:
return check_live_ready(self.settings).as_dict()
async def stop(self) -> StatusOut:
if self.engine is not None:
await self.engine.stop()
return self.engine.status()
return self._idle_status()
had_runs = bool(self.manager.runs)
await self.manager.stop()
self._stop_summary_loop()
if had_runs and self.settings.notify_lifecycle:
self.notify_now("info", "Stopped all strategies")
engine = self.engine
return engine.status() if engine is not None else self._idle_status()
async def kill(self, reason: str = "manual kill switch") -> StatusOut:
if self.engine is not None:
await self.engine.kill(reason)
return self.engine.status()
return self._idle_status()
had_runs = bool(self.manager.runs)
await self.manager.kill(reason)
self._stop_summary_loop()
if had_runs:
self.notify_now("error", f"KILL SWITCH: {reason}")
engine = self.engine
return engine.status() if engine is not None else self._idle_status()
def multi_status(self) -> MultiStatusOut:
return self.manager.status()
async def run_backtest(self, request: BacktestRequest) -> BacktestResult:
symbol = request.symbol or self.settings.symbol
@@ -139,9 +247,44 @@ class Runtime:
)
return backtester.run(candles, strategy, symbol, timeframe)
async def run_sweep(self, request: SweepRequest) -> SweepResult:
symbol = request.symbol or self.settings.symbol
timeframe = request.timeframe or self.settings.timeframe
strategy_cls = get_strategy(request.strategy_key)
feed = self._feed_factory(self.settings)
try:
candles = await feed.fetch_candles(symbol, timeframe, request.limit)
finally:
await feed.close()
rows = run_sweep(
request.strategy_key,
request.base_params,
request.grid,
candles,
symbol,
timeframe,
fee_bps=self.settings.fee_bps,
slippage_bps=self.settings.slippage_bps,
starting_balance=request.starting_balance or self.settings.starting_balance,
top_n=request.top_n,
)
combinations = 1
for values in request.grid.values():
combinations *= max(1, len(values))
return SweepResult(
strategy_key=request.strategy_key,
strategy_name=strategy_cls.name or request.strategy_key,
symbol=symbol,
timeframe=timeframe,
combinations=combinations,
rows=[SweepRowOut(params=row.params, metrics=row.metrics) for row in rows],
)
async def shutdown(self) -> None:
if self.engine is not None:
await self.engine.shutdown()
self._stop_summary_loop()
await self.manager.shutdown()
if self.notifier is not None:
await self.notifier.close()
+79
View File
@@ -134,6 +134,18 @@ class StrategyConfigOut(BaseModel):
created_at: datetime
class StrategySpec(BaseModel):
"""One strategy in a multi-strategy run."""
strategy_key: str
params: dict[str, Any] = Field(default_factory=dict)
symbol: str | None = None
timeframe: str | None = None
# Fraction of the account allocated to this strategy (relative weights are
# normalised across the run, so [1, 3] gives 25% / 75%).
weight: float = Field(default=1.0, gt=0)
class StartRequest(BaseModel):
strategy_key: str
symbol: str | None = None
@@ -142,6 +154,46 @@ class StartRequest(BaseModel):
starting_balance: float | None = None
class StartManyRequest(BaseModel):
strategies: list[StrategySpec] = Field(min_length=1)
starting_balance: float | None = None
class StrategyStatusOut(BaseModel):
"""Per-strategy status inside a multi-strategy run."""
strategy_key: str
strategy_name: str | None
params: dict[str, Any] = Field(default_factory=dict)
symbol: str
timeframe: str
allocation: float
cash: float
equity: float
realized_pnl: float
unrealized_pnl: float
total_pnl: float
open_positions: int
halted: bool = False
halt_reason: str | None = None
trades_today: int = 0
day_pnl: float = 0.0
drawdown_pct: float = 0.0
error: str | None = None
class MultiStatusOut(BaseModel):
running: bool
mode: str
exchange: str
price: float | None = None
starting_balance: float
equity: float
total_pnl: float
strategies: list[StrategyStatusOut] = Field(default_factory=list)
last_tick: datetime | None = None
class ManualOrderRequest(BaseModel):
side: Literal["buy", "sell"]
qty: float | None = None
@@ -206,3 +258,30 @@ class BacktestResult(BaseModel):
equity_curve: list[BacktestPointOut]
trades: list[BacktestTradeOut]
error: str | None = None
class SweepRequest(BaseModel):
strategy_key: str
base_params: dict[str, Any] = Field(default_factory=dict)
# param name -> list of candidate values (cartesian product)
grid: dict[str, list[Any]] = Field(default_factory=dict)
symbol: str | None = None
timeframe: str | None = None
limit: int = Field(default=500, ge=20, le=1000)
starting_balance: float | None = None
top_n: int = Field(default=20, ge=1, le=100)
class SweepRowOut(BaseModel):
params: dict[str, Any]
metrics: BacktestMetricsOut
class SweepResult(BaseModel):
strategy_key: str
strategy_name: str
symbol: str
timeframe: str
combinations: int
rows: list[SweepRowOut]
error: str | None = None
+10
View File
@@ -33,6 +33,7 @@ class Store:
entry_fee: float,
stop_loss: float | None = None,
take_profit: float | None = None,
trailing_stop_pct: float = 0.0,
) -> Position:
with self._session_factory() as session:
position = Position(
@@ -44,6 +45,7 @@ class Store:
entry_fee=entry_fee,
stop_loss=stop_loss,
take_profit=take_profit,
trailing_stop_pct=trailing_stop_pct,
status=PositionStatus.OPEN,
)
session.add(position)
@@ -88,6 +90,14 @@ class Store:
session.commit()
return len(positions)
def update_stop_loss(self, position_id: int, stop_loss: float) -> None:
with self._session_factory() as session:
position = session.get(Position, position_id)
if position is None:
return
position.stop_loss = stop_loss
session.commit()
def open_position_for(self, strategy_key: str) -> Position | None:
with self._session_factory() as session:
return session.scalars(
+94
View File
@@ -9,6 +9,13 @@ from app.market.fake import FakeMarketData
from app.runtime import Runtime
def test_version_endpoint(runtime: Runtime) -> None:
app = create_app(runtime.settings, runtime)
with TestClient(app) as client:
assert client.get("/api/version").status_code == 200
assert "version" in client.get("/api/version").json()
def test_health_and_strategies(runtime: Runtime) -> None:
app = create_app(runtime.settings, runtime)
with TestClient(app) as client:
@@ -62,6 +69,32 @@ def test_backtest_endpoint(runtime: Runtime, market: FakeMarketData) -> None:
assert data["metrics"]["num_trades"] >= 1
def test_sweep_endpoint(runtime: Runtime, market: FakeMarketData) -> None:
market.candles = FakeMarketData.sine_wave(200).candles
app = create_app(runtime.settings, runtime)
with TestClient(app) as client:
response = client.post(
"/api/sweep",
json={
"strategy_key": "sma_cross",
"grid": {"fast": [3, 5], "slow": [10, 20]},
"limit": 200,
},
)
assert response.status_code == 200
data = response.json()
assert data["combinations"] == 4
assert 1 <= len(data["rows"]) <= 4
assert "total_return_pct" in data["rows"][0]["metrics"]
def test_sweep_unknown_strategy_404(runtime: Runtime) -> None:
app = create_app(runtime.settings, runtime)
with TestClient(app) as client:
response = client.post("/api/sweep", json={"strategy_key": "nope"})
assert response.status_code == 404
def test_backtest_unknown_strategy_404(runtime: Runtime) -> None:
app = create_app(runtime.settings, runtime)
with TestClient(app) as client:
@@ -78,6 +111,44 @@ def test_backtest_limit_validation(runtime: Runtime) -> None:
)
def test_start_many_and_multi_status(runtime: Runtime, market: FakeMarketData) -> None:
app = create_app(runtime.settings, runtime)
with TestClient(app) as client:
started = client.post(
"/api/bot/start-many",
json={
"strategies": [
{"strategy_key": "sma_cross", "params": {"fast": 2, "slow": 4}, "weight": 1},
{"strategy_key": "ema_cross", "params": {"fast": 2, "slow": 4}, "weight": 3},
],
"starting_balance": 10000,
},
)
assert started.status_code == 200
body = started.json()
assert body["running"] is True
assert len(body["strategies"]) == 2
assert [s["allocation"] for s in body["strategies"]] == [0.25, 0.75]
status = client.get("/api/bot/status").json()
assert status["running"] is True
assert len(status["strategies"]) == 2
assert status["equity"] == 10000
# Per-strategy fields the dashboard aggregates must be present.
assert {"day_pnl", "drawdown_pct"} <= set(status["strategies"][0])
client.post("/api/bot/stop")
def test_start_many_rejects_unknown_strategy(runtime: Runtime) -> None:
app = create_app(runtime.settings, runtime)
with TestClient(app) as client:
response = client.post(
"/api/bot/start-many", json={"strategies": [{"strategy_key": "nope"}]}
)
assert response.status_code == 404
def test_kill_endpoint(runtime: Runtime) -> None:
app = create_app(runtime.settings, runtime)
with TestClient(app) as client:
@@ -145,6 +216,29 @@ def test_api_token_enforced(runtime: Runtime) -> None:
assert client.get("/api/health").status_code == 200
def test_websocket_connects_without_any_token(runtime: Runtime) -> None:
# API_TOKEN is unset by default: the API and the live feed must work with no
# token at all, even when (as nginx does) an empty token header is sent.
runtime.settings.api_token = ""
app = create_app(runtime.settings, runtime)
with (
TestClient(app) as client,
client.websocket_connect("/api/ws", headers={"X-API-Token": ""}) as websocket,
):
message = websocket.receive_json()
assert message["type"] == "status"
assert message["data"]["running"] is False
def test_api_works_without_token(runtime: Runtime) -> None:
runtime.settings.api_token = ""
app = create_app(runtime.settings, runtime)
with TestClient(app) as client:
assert client.get("/api/status").status_code == 200
assert client.get("/api/strategies").status_code == 200
assert client.post("/api/backtest", json={"strategy_key": "sma_cross"}).status_code != 401
def test_websocket_rejects_missing_token(runtime: Runtime) -> None:
runtime.settings.api_token = "secret"
app = create_app(runtime.settings, runtime)
+30
View File
@@ -82,6 +82,36 @@ def test_stop_loss_is_honoured() -> None:
assert result.metrics.final_equity < 10_000
class TrailStrategy(Strategy):
key: ClassVar[str] = "test_trail"
name: ClassVar[str] = "Test Trail"
default_params: ClassVar[dict] = {}
def on_candle(self, ctx: StrategyContext) -> None:
if ctx.is_flat and not getattr(self, "_entered", False):
self._entered = True
ctx.enter_long(size=1.0, trailing_stop_pct=0.10)
def test_trailing_stop_locks_in_profit() -> None:
# Entry at 100, rise to 150, then a reversal. A 10% trail should exit near
# 135 (150 * 0.9) instead of round-tripping to the entry.
backtester = Backtester(fee_bps=0, slippage_bps=0, starting_balance=10_000)
candles = candles_from_closes([100, 120, 150, 130, 125, 120])
candles[2] = Candle(ts=2, open=120, high=150, low=120, close=150, volume=1.0)
candles[3] = Candle(ts=3, open=150, high=150, low=130, close=130, volume=1.0)
candles[4] = Candle(ts=4, open=130, high=132, low=125, close=125, volume=1.0)
candles[5] = Candle(ts=5, open=125, high=128, low=120, close=120, volume=1.0)
result = backtester.run(candles, TrailStrategy(), "BTC/USDT", "1h")
assert result.metrics.num_trades == 1
trade = result.trades[0]
assert "stop loss" in trade.reason
# 99 units, exit at the trailing stop (135), well above entry 100.
assert trade.exit_price == pytest.approx(135.0)
assert result.metrics.final_equity > 10_000
def test_strategy_error_is_surfaced() -> None:
class Boom(Strategy):
key = "test_boom"
+91
View File
@@ -0,0 +1,91 @@
from __future__ import annotations
import pytest
from app.config import Settings
from app.engine.live_guard import LIVE_CONFIRMATION_PHRASE, check_live_ready
def live_settings(**overrides) -> Settings:
base = {
"mode": "live",
"max_daily_loss": 100.0,
"max_position_pct": 0.5,
"live_confirmation": LIVE_CONFIRMATION_PHRASE,
"exchange_api_key": "k",
"exchange_api_secret": "s",
"starting_balance": 1000.0,
"min_live_balance": 100.0,
}
base.update(overrides)
return Settings(**base)
def test_paper_mode_always_ok() -> None:
check = check_live_ready(Settings(mode="paper", max_position_pct=1.0))
assert check.ok is True
assert check.blockers == []
def test_live_ok_when_fully_configured() -> None:
check = check_live_ready(live_settings())
assert check.ok is True
def test_live_blocked_without_any_risk_limit() -> None:
check = check_live_ready(
live_settings(max_daily_loss=0, max_drawdown_pct=0, max_trades_per_day=0)
)
assert check.ok is False
assert any("risk limit" in b for b in check.blockers)
def test_live_blocked_when_position_cap_is_full_account() -> None:
check = check_live_ready(live_settings(max_position_pct=1.0))
assert check.ok is False
assert any("MAX_POSITION_PCT" in b for b in check.blockers)
def test_live_blocked_without_confirmation_phrase() -> None:
check = check_live_ready(live_settings(live_confirmation=""))
assert check.ok is False
assert any("LIVE_CONFIRMATION" in b for b in check.blockers)
def test_live_blocked_below_min_balance() -> None:
check = check_live_ready(live_settings(starting_balance=50.0))
assert check.ok is False
assert any("MIN_LIVE_BALANCE" in b for b in check.blockers)
def test_live_blocked_without_credentials() -> None:
check = check_live_ready(live_settings(exchange_api_key="", exchange_api_secret=""))
assert check.ok is False
assert any("EXCHANGE_API_KEY" in b for b in check.blockers)
async def test_start_many_refuses_live_without_confirmation(settings) -> None:
from app.runtime import Runtime
from app.schemas import StartManyRequest, StrategySpec
settings.mode = "live"
settings.exchange_api_key = "k"
settings.exchange_api_secret = "s"
settings.max_position_pct = 1.0 # unsafe
runtime = Runtime(settings)
with pytest.raises(ValueError, match="live trading blocked"):
await runtime.start_many(
StartManyRequest(strategies=[StrategySpec(strategy_key="sma_cross")])
)
def test_live_check_endpoint(runtime) -> None:
from fastapi.testclient import TestClient
from app.main import create_app
app = create_app(runtime.settings, runtime)
with TestClient(app) as client:
body = client.get("/api/live-check").json()
assert body["ok"] is True # paper mode
assert body["mode"] == "paper"
+139
View File
@@ -0,0 +1,139 @@
from __future__ import annotations
import pytest
from app.engine.manager import MultiStrategyManager, normalise_weights
from app.runtime import Runtime
from app.schemas import StartManyRequest, StrategySpec
def test_normalise_weights() -> None:
specs = [StrategySpec(strategy_key="a", weight=1), StrategySpec(strategy_key="b", weight=3)]
assert normalise_weights(specs) == [0.25, 0.75]
def test_normalise_weights_single() -> None:
assert normalise_weights([StrategySpec(strategy_key="a")]) == [1.0]
def test_weights_must_be_positive() -> None:
from pydantic import ValidationError
with pytest.raises(ValidationError):
StrategySpec(strategy_key="a", weight=0)
def test_normalise_weights_guards_zero_total() -> None:
# The API rejects non-positive weights, but the helper still guards against
# a zero total (e.g. if it is ever called with raw values).
class Dummy:
def __init__(self, weight: float) -> None:
self.weight = weight
assert normalise_weights([Dummy(0), Dummy(0)]) == [0.5, 0.5]
async def test_start_many_seeds_each_engine_with_its_allocation(settings, store, market) -> None:
runtime = Runtime(
settings, store, feed_factory=lambda _s: market, broker_factory=lambda _s: _paper()
)
status = await runtime.start_many(
StartManyRequest(
strategies=[
StrategySpec(strategy_key="sma_cross", params={"fast": 2, "slow": 4}, weight=1),
StrategySpec(strategy_key="ema_cross", params={"fast": 2, "slow": 4}, weight=3),
],
starting_balance=10_000,
)
)
assert status.running is True
assert [s.allocation for s in status.strategies] == pytest.approx([0.25, 0.75])
# Each engine starts with its slice of the balance as cash.
assert [s.cash for s in status.strategies] == pytest.approx([2_500, 7_500])
assert status.starting_balance == pytest.approx(10_000)
assert status.equity == pytest.approx(10_000)
await runtime.shutdown()
async def test_strategies_are_isolated(settings, store, market) -> None:
"""A loss in one strategy must not touch another's cash."""
runtime = Runtime(
settings, store, feed_factory=lambda _s: market, broker_factory=lambda _s: _paper()
)
await runtime.start_many(
StartManyRequest(
strategies=[
StrategySpec(strategy_key="sma_cross", weight=1),
StrategySpec(strategy_key="ema_cross", weight=1),
],
starting_balance=10_000,
)
)
first, second = runtime.manager.runs[0].engine, runtime.manager.runs[1].engine
first.last_price = 100.0
second.last_price = 100.0
await first.manual_order(_buy(), qty=10, quote_amount=None, reason="lose")
first.last_price = 90.0
await first.close_current_position("loss")
assert first.cash == pytest.approx(4_900.0)
assert second.cash == pytest.approx(5_000.0)
await runtime.shutdown()
async def test_stop_clears_runs(settings, store, market) -> None:
runtime = Runtime(
settings, store, feed_factory=lambda _s: market, broker_factory=lambda _s: _paper()
)
await runtime.start_many(StartManyRequest(strategies=[StrategySpec(strategy_key="sma_cross")]))
await runtime.stop()
assert runtime.multi_status().running is False
assert runtime.multi_status().strategies == []
await runtime.shutdown()
async def test_duplicate_strategy_keys_are_allowed(settings, store, market) -> None:
runtime = Runtime(
settings, store, feed_factory=lambda _s: market, broker_factory=lambda _s: _paper()
)
status = await runtime.start_many(
StartManyRequest(
strategies=[
StrategySpec(strategy_key="sma_cross", params={"fast": 2, "slow": 10}),
StrategySpec(strategy_key="sma_cross", params={"fast": 5, "slow": 20}),
]
)
)
assert len(status.strategies) == 2
assert status.strategies[0].params["fast"] == 2
assert status.strategies[1].params["fast"] == 5
await runtime.shutdown()
async def test_bad_params_rejected_before_starting(settings, store, market) -> None:
manager = MultiStrategyManager(
store=store,
settings=settings,
feed_factory=lambda _s: market,
broker_factory=lambda _s: _paper(),
)
with pytest.raises(ValueError):
await manager.start(
StartManyRequest(
strategies=[StrategySpec(strategy_key="sma_cross", params={"nope": 1})]
)
)
assert manager.runs == []
def _paper():
from app.broker.paper import PaperBroker
return PaperBroker(fee_bps=0, slippage_bps=0)
def _buy():
from app.models import OrderSide
return OrderSide.BUY
+120
View File
@@ -0,0 +1,120 @@
from __future__ import annotations
import pytest
from app.engine.indicators import supertrend, vwap
from app.engine.strategies import get_strategy
from app.engine.strategies.base import StrategyContext
from app.models import PositionSide
from app.schemas import Candle
def candles(closes: list[float], volume: float = 1.0) -> list[Candle]:
return [
Candle(ts=i, open=c, high=c * 1.001, low=c * 0.999, close=c, volume=volume)
for i, c in enumerate(closes)
]
def ctx(closes: list[float], volume: float = 1.0, position=None) -> StrategyContext:
cs = candles(closes, volume)
return StrategyContext(candles=cs, position=position, equity=1000.0, cash=1000.0)
# --- indicators -----------------------------------------------------------
def test_vwap_equals_typical_price_when_flat() -> None:
cs = candles([100.0, 100.0, 100.0])
series = vwap(cs)
# Typical price equals the close for symmetric high/low here only if equal;
# with high/low spread the VWAP is the volume-weighted typical price.
assert series[-1] is not None
assert series[-1] == pytest.approx(100.0, abs=0.5)
def test_vwap_weights_by_volume() -> None:
cs = [
Candle(ts=0, open=100, high=100, low=100, close=100, volume=1),
Candle(ts=1, open=200, high=200, low=200, close=200, volume=3),
]
series = vwap(cs)
assert series[-1] == pytest.approx((100 * 1 + 200 * 3) / 4)
def test_vwap_rolling_window() -> None:
cs = [
Candle(ts=0, open=100, high=100, low=100, close=100, volume=1),
Candle(ts=1, open=100, high=100, low=100, close=100, volume=1),
Candle(ts=2, open=200, high=200, low=200, close=200, volume=1),
]
series = vwap(cs, period=2)
assert series[0] is None
assert series[1] == pytest.approx(100.0)
assert series[2] == pytest.approx(150.0)
def test_supertrend_direction_flips() -> None:
rising = [float(i) for i in range(1, 60)]
_, up_rising = supertrend(candles(rising), 10, 3.0)
assert up_rising[-1] is True
falling = [float(i) for i in range(60, 1, -1)]
_, up_falling = supertrend(candles(falling), 10, 3.0)
assert up_falling[-1] is False
def test_supertrend_line_trails_price() -> None:
rising = [float(i) for i in range(1, 60)]
line, up = supertrend(candles(rising), 10, 3.0)
assert line[-1] is not None and up[-1] is True
assert line[-1] < rising[-1] # the stop sits below price in an uptrend
# --- strategies -----------------------------------------------------------
def test_new_strategies_registered() -> None:
from app.engine.strategies import list_strategies
keys = {c.key for c in list_strategies()}
assert {"supertrend", "vwap_reversion", "ema_cross_short"} <= keys
def test_ema_cross_short_opens_a_short() -> None:
strategy = get_strategy("ema_cross_short")({"fast": 2, "slow": 4, "atr_stop_mult": 0})
# Fast EMA above the slow then crossing below: mirror of the long case.
c = ctx([10, 10, 10, 10, 10, 10, 10, 10, 13, 11, 8])
strategy.on_candle(c)
intents = c.take_intents()
assert intents and intents[0].action == "enter_short"
assert intents[0].stop_loss is None
def test_ema_cross_short_covers_on_cross_up() -> None:
from app.models import Position
strategy = get_strategy("ema_cross_short")({"fast": 2, "slow": 4, "atr_stop_mult": 0})
position = Position(
strategy_key="ema_cross_short",
symbol="BTC/USDT",
side=PositionSide.SHORT,
qty=1.0,
entry_price=100.0,
)
c = ctx([10, 10, 10, 10, 10, 10, 10, 10, 7, 9, 12], position=position)
strategy.on_candle(c)
intents = c.take_intents()
assert intents and intents[0].action == "close"
def test_vwap_reversion_runs_without_error() -> None:
strategy = get_strategy("vwap_reversion")({"period": 5, "deviation_pct": 0.01})
c = ctx([100, 100, 100, 100, 100, 90])
strategy.on_candle(c)
intents = c.take_intents()
assert intents and intents[0].action == "enter_long"
def test_supertrend_runs_without_error() -> None:
strategy = get_strategy("supertrend")({})
c = ctx([100 + i for i in range(40)])
strategy.on_candle(c)
# No assertion on intents (depends on the flip); just must not raise.
assert isinstance(c.take_intents(), list)
+98
View File
@@ -66,3 +66,101 @@ async def test_runtime_ignores_info_events() -> None:
runtime._broadcast({"type": "event", "data": {"level": "info", "message": "started"}})
await asyncio.gather(*list(runtime._notify_tasks))
assert recorder.messages == []
async def test_notify_levels_are_configurable() -> None:
recorder = RecordingNotifier()
settings = Settings(telegram_bot_token="", notify_webhook_url="", notify_levels="info,trade")
runtime = Runtime(settings, notifier=recorder)
runtime._broadcast({"type": "event", "data": {"level": "info", "message": "started"}})
await asyncio.gather(*list(runtime._notify_tasks))
assert recorder.messages == [("info", "started")]
async def test_start_and_stop_notify_lifecycle(settings, store, market) -> None:
from app.broker.paper import PaperBroker
from app.schemas import StartManyRequest, StrategySpec
settings.notify_lifecycle = True
recorder = RecordingNotifier()
runtime = Runtime(
settings,
store,
feed_factory=lambda _s: market,
broker_factory=lambda _s: PaperBroker(fee_bps=0, slippage_bps=0),
notifier=recorder,
)
await runtime.start_many(StartManyRequest(strategies=[StrategySpec(strategy_key="sma_cross")]))
await runtime.stop()
await asyncio.gather(*list(runtime._notify_tasks))
levels = [level for level, _ in recorder.messages]
assert "info" in levels
assert any("Started" in msg for _, msg in recorder.messages)
assert any("Stopped" in msg for _, msg in recorder.messages)
await runtime.shutdown()
async def test_kill_notifies(settings, store, market) -> None:
from app.broker.paper import PaperBroker
from app.schemas import StartManyRequest, StrategySpec
recorder = RecordingNotifier()
runtime = Runtime(
settings,
store,
feed_factory=lambda _s: market,
broker_factory=lambda _s: PaperBroker(fee_bps=0, slippage_bps=0),
notifier=recorder,
)
await runtime.start_many(StartManyRequest(strategies=[StrategySpec(strategy_key="sma_cross")]))
await runtime.kill("panic")
await asyncio.gather(*list(runtime._notify_tasks))
assert any(level == "error" and "KILL SWITCH" in msg for level, msg in recorder.messages)
await runtime.shutdown()
async def test_summary_message_lists_strategies(settings, store, market) -> None:
from app.broker.paper import PaperBroker
from app.schemas import StartManyRequest, StrategySpec
runtime = Runtime(
settings,
store,
feed_factory=lambda _s: market,
broker_factory=lambda _s: PaperBroker(fee_bps=0, slippage_bps=0),
notifier=RecordingNotifier(),
)
await runtime.start_many(
StartManyRequest(
strategies=[
StrategySpec(strategy_key="sma_cross", weight=1),
StrategySpec(strategy_key="ema_cross", weight=3),
],
starting_balance=10000,
)
)
message = runtime.summary_message()
assert "Portfolio" in message
assert "SMA Crossover" in message
assert "EMA Crossover" in message
assert "75%" in message # the weighted strategy's allocation
await runtime.shutdown()
async def test_summary_loop_runs(settings, store, market) -> None:
from app.broker.paper import PaperBroker
settings.summary_interval_minutes = 1 # -> 60s; we only check it starts
recorder = RecordingNotifier()
runtime = Runtime(
settings,
store,
feed_factory=lambda _s: market,
broker_factory=lambda _s: PaperBroker(fee_bps=0, slippage_bps=0),
notifier=recorder,
)
runtime._start_summary_loop()
assert runtime._summary_task is not None
runtime._stop_summary_loop()
assert runtime._summary_task is None
+107
View File
@@ -0,0 +1,107 @@
from __future__ import annotations
import logging
from app.logs import RingLogHandler
from app.metrics import render_metrics
from app.schemas import MultiStatusOut, StrategyStatusOut
def make_status(**overrides) -> MultiStatusOut:
strategy = StrategyStatusOut(
strategy_key="sma_cross",
strategy_name="SMA Crossover",
params={},
symbol="BTC/USDT",
timeframe="5m",
allocation=0.5,
cash=5000,
equity=5100,
realized_pnl=100,
unrealized_pnl=0,
total_pnl=100,
open_positions=1,
halted=False,
trades_today=2,
)
base = {
"running": True,
"mode": "paper",
"exchange": "binance",
"price": 100.0,
"starting_balance": 10000,
"equity": 10100,
"total_pnl": 100,
"strategies": [strategy],
}
base.update(overrides)
return MultiStatusOut(**base)
def test_render_metrics_contains_core_series() -> None:
body = render_metrics(make_status(), "paper", "binance")
assert "crypto_running 1" in body
assert "crypto_equity 10100" in body
assert "crypto_total_pnl 100" in body
assert "crypto_open_positions 1" in body
assert 'crypto_strategy_equity{strategy="sma_cross",symbol="BTC/USDT"} 5100' in body
assert 'crypto_strategy_allocation{strategy="sma_cross"} 0.5' in body
assert 'crypto_info{mode="paper",exchange="binance"} 1' in body
def test_render_metrics_omits_price_when_none() -> None:
body = render_metrics(make_status(price=None), "paper", "binance")
assert "crypto_price" not in body
def test_ring_log_handler_captures() -> None:
handler = RingLogHandler(capacity=5)
logger = logging.getLogger("test.ring")
logger.addHandler(handler)
logger.setLevel(logging.INFO)
try:
logger.info("hello %s", "world")
entries = handler.snapshot()
assert entries[-1]["message"] == "hello world"
assert entries[-1]["level"] == "info"
finally:
logger.removeHandler(handler)
def test_ring_log_handler_respects_capacity() -> None:
handler = RingLogHandler(capacity=3)
logger = logging.getLogger("test.ring.cap")
logger.addHandler(handler)
logger.setLevel(logging.INFO)
try:
for i in range(10):
logger.info("m%d", i)
entries = handler.snapshot()
assert len(entries) == 3
assert entries[-1]["message"] == "m9"
finally:
logger.removeHandler(handler)
def test_metrics_endpoint(runtime) -> None:
from fastapi.testclient import TestClient
from app.main import create_app
app = create_app(runtime.settings, runtime)
with TestClient(app) as client:
response = client.get("/api/metrics")
assert response.status_code == 200
assert "crypto_running" in response.text
def test_logs_endpoint(runtime) -> None:
from fastapi.testclient import TestClient
from app.main import create_app
app = create_app(runtime.settings, runtime)
with TestClient(app) as client:
response = client.get("/api/logs")
assert response.status_code == 200
assert "entries" in response.json()
+28
View File
@@ -4,6 +4,7 @@ import pytest
from app.engine.portfolio import (
account_equity,
advance_trailing_stop,
apply_close,
apply_open,
order_qty,
@@ -57,3 +58,30 @@ def test_risk_exit_short() -> None:
assert risk_exit(PositionSide.SHORT, 110, 90, high=111, low=100) == ("stop loss", 110)
assert risk_exit(PositionSide.SHORT, 110, 90, high=100, low=89) == ("take profit", 90)
assert risk_exit(PositionSide.SHORT, None, None, high=100, low=95) is None
def test_trailing_stop_long_ratchets_up_only() -> None:
# First update seeds the stop 2% below the high.
stop = advance_trailing_stop(PositionSide.LONG, None, 0.02, high=100, low=95)
assert stop == pytest.approx(98.0)
# A higher high raises it.
stop = advance_trailing_stop(PositionSide.LONG, stop, 0.02, high=110, low=100)
assert stop == pytest.approx(107.8)
# A pullback never loosens it.
stop = advance_trailing_stop(PositionSide.LONG, stop, 0.02, high=105, low=100)
assert stop == pytest.approx(107.8)
def test_trailing_stop_short_ratchets_down_only() -> None:
stop = advance_trailing_stop(PositionSide.SHORT, None, 0.02, high=100, low=95)
assert stop == pytest.approx(96.9)
stop = advance_trailing_stop(PositionSide.SHORT, stop, 0.02, high=95, low=90)
assert stop == pytest.approx(91.8)
# A bounce never loosens the short's stop.
stop = advance_trailing_stop(PositionSide.SHORT, stop, 0.02, high=95, low=92)
assert stop == pytest.approx(91.8)
def test_trailing_stop_disabled_is_noop() -> None:
assert advance_trailing_stop(PositionSide.LONG, 90.0, 0.0, high=100, low=80) == 90.0
assert advance_trailing_stop(PositionSide.LONG, None, 0.0, high=100, low=80) is None
+65
View File
@@ -0,0 +1,65 @@
from __future__ import annotations
from app.backtest.sweep import expand_grid, run_sweep
from app.schemas import Candle
def candles_from_closes(closes: list[float]) -> list[Candle]:
return [Candle(ts=i, open=c, high=c, low=c, close=c, volume=1.0) for i, c in enumerate(closes)]
def test_expand_grid() -> None:
grid = {"fast": [2, 3], "slow": [10, 20]}
combos = expand_grid(grid)
assert len(combos) == 4
assert {"fast": 2, "slow": 10} in combos
assert {"fast": 3, "slow": 20} in combos
def test_expand_grid_empty() -> None:
assert expand_grid({}) == [{}]
def test_expand_grid_single_axis() -> None:
assert expand_grid({"x": [1, 2, 3]}) == [{"x": 1}, {"x": 2}, {"x": 3}]
def test_run_sweep_ranks_and_merges_base_params() -> None:
# A trending series so SMA crossover trades.
prices = [100 + i for i in range(60)]
rows = run_sweep(
"sma_cross",
base_params={"size": 1.0},
grid={"fast": [2, 5], "slow": [10, 20]},
candles=candles_from_closes(prices),
symbol="BTC/USDT",
timeframe="1h",
top_n=10,
)
assert len(rows) == 4
# Every row carries the merged base params.
assert all(row.params["size"] == 1.0 for row in rows)
# Sorted by composite score descending.
scores = [row.metrics["total_return_pct"] - row.metrics["max_drawdown_pct"] for row in rows]
assert scores == sorted(scores, reverse=True)
def test_run_sweep_top_n_truncates() -> None:
prices = [100 + i for i in range(60)]
rows = run_sweep(
"sma_cross",
base_params={},
grid={"fast": [2, 3, 4], "slow": [10, 15, 20]},
candles=candles_from_closes(prices),
symbol="BTC/USDT",
timeframe="1h",
top_n=3,
)
assert len(rows) == 3
def test_run_sweep_unknown_strategy() -> None:
import pytest
with pytest.raises(KeyError):
run_sweep("nope", {}, {}, candles_from_closes([1, 2, 3]), "BTC/USDT", "1h")
+87
View File
@@ -0,0 +1,87 @@
from __future__ import annotations
import pytest
from app.engine.indicators import williams_r
from app.engine.strategies import get_strategy
from app.engine.strategies.base import StrategyContext
from app.models import Position, PositionSide
from app.schemas import Candle
def candles(closes: list[float]) -> list[Candle]:
return [
Candle(ts=i, open=c, high=c * 1.001, low=c * 0.999, close=c, volume=1.0)
for i, c in enumerate(closes)
]
def ctx(closes: list[float], position=None) -> StrategyContext:
return StrategyContext(candles=candles(closes), position=position, equity=1000.0, cash=1000.0)
# --- indicator ------------------------------------------------------------
def test_williams_r_bounds() -> None:
series = williams_r(candles([100, 102, 98, 101, 99, 103, 97, 100]), 5)
for value in series:
if value is not None:
assert -100.0 <= value <= 0.0
def test_williams_r_rising_is_overbought() -> None:
rising = [float(i) for i in range(1, 30)]
value = williams_r(candles(rising), 14)[-1]
assert value is not None and value > -50 # near the top of the range
def test_williams_r_falling_is_oversold() -> None:
falling = [float(i) for i in range(30, 1, -1)]
value = williams_r(candles(falling), 14)[-1]
assert value is not None and value < -50 # near the bottom of the range
def test_williams_r_flat_series_is_neutral() -> None:
flat = [100.0] * 20
assert williams_r(candles(flat), 14)[-1] == pytest.approx(-50.0)
# --- strategy -------------------------------------------------------------
def test_williams_r_registered() -> None:
from app.engine.strategies import list_strategies
assert "williams_r" in {c.key for c in list_strategies()}
def test_enters_long_when_oversold() -> None:
strategy = get_strategy("williams_r")({"period": 5, "oversold": -80, "atr_stop_mult": 0})
falling = [100 - i for i in range(15)]
c = ctx([float(x) for x in falling])
strategy.on_candle(c)
intents = c.take_intents()
assert intents and intents[0].action == "enter_long"
def test_exits_when_overbought() -> None:
strategy = get_strategy("williams_r")({"period": 5, "overbought": -20, "atr_stop_mult": 0})
position = Position(
strategy_key="williams_r",
symbol="BTC/USDT",
side=PositionSide.LONG,
qty=1.0,
entry_price=100.0,
)
rising = [100 + i for i in range(15)]
c = ctx([float(x) for x in rising], position=position)
strategy.on_candle(c)
intents = c.take_intents()
assert intents and intents[0].action == "close"
def test_atr_stop_is_set_when_enabled() -> None:
strategy = get_strategy("williams_r")({"period": 5, "oversold": -80, "atr_stop_mult": 2.0})
falling = [float(100 - i) for i in range(15)]
c = ctx(falling)
strategy.on_candle(c)
intents = c.take_intents()
assert intents and intents[0].action == "enter_long"
assert intents[0].stop_loss is not None
+13 -2
View File
@@ -5,7 +5,10 @@ name: crypto_trading_local
services:
backend:
build: ./backend
build:
context: ./backend
args:
BUILD_VERSION: ${BUILD_VERSION:-unknown}
container_name: crypto-trading-backend-local
restart: unless-stopped
environment:
@@ -29,6 +32,11 @@ services:
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN:-}
- TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID:-}
- NOTIFY_WEBHOOK_URL=${NOTIFY_WEBHOOK_URL:-}
- NOTIFY_LEVELS=${NOTIFY_LEVELS:-trade,error}
- NOTIFY_LIFECYCLE=${NOTIFY_LIFECYCLE:-true}
- SUMMARY_INTERVAL_MINUTES=${SUMMARY_INTERVAL_MINUTES:-0}
- LIVE_CONFIRMATION=${LIVE_CONFIRMATION:-}
- MIN_LIVE_BALANCE=${MIN_LIVE_BALANCE:-100}
- API_TOKEN=${API_TOKEN:-}
- API_TOKEN_HEADER=${API_TOKEN_HEADER:-X-API-Token}
volumes:
@@ -37,7 +45,10 @@ services:
- "8000:8000"
frontend:
build: ./frontend
build:
context: ./frontend
args:
BUILD_VERSION: ${BUILD_VERSION:-unknown}
container_name: crypto-trader-frontend-local
restart: unless-stopped
depends_on:
+16 -6
View File
@@ -2,7 +2,10 @@ name: crypto_trading
services:
backend:
build: ./backend
build:
context: ./backend
args:
BUILD_VERSION: ${CRYPTO_BUILD_VERSION:-unknown}
image: crypto_trading-backend
container_name: crypto-trading-backend
restart: unless-stopped
@@ -39,6 +42,11 @@ services:
- TELEGRAM_BOT_TOKEN=${CRYPTO_TELEGRAM_BOT_TOKEN:-}
- TELEGRAM_CHAT_ID=${CRYPTO_TELEGRAM_CHAT_ID:-}
- NOTIFY_WEBHOOK_URL=${CRYPTO_NOTIFY_WEBHOOK_URL:-}
- NOTIFY_LEVELS=${CRYPTO_NOTIFY_LEVELS:-trade,error}
- NOTIFY_LIFECYCLE=${CRYPTO_NOTIFY_LIFECYCLE:-true}
- SUMMARY_INTERVAL_MINUTES=${CRYPTO_SUMMARY_INTERVAL_MINUTES:-0}
- LIVE_CONFIRMATION=${CRYPTO_LIVE_CONFIRMATION:-}
- MIN_LIVE_BALANCE=${CRYPTO_MIN_LIVE_BALANCE:-100}
- API_TOKEN=${CRYPTO_API_TOKEN:-}
- API_TOKEN_HEADER=${CRYPTO_API_TOKEN_HEADER:-X-API-Token}
volumes:
@@ -56,7 +64,10 @@ services:
- internal
frontend:
build: ./frontend
build:
context: ./frontend
args:
BUILD_VERSION: ${CRYPTO_BUILD_VERSION:-unknown}
image: crypto_trading-frontend
container_name: crypto-trading-frontend
restart: unless-stopped
@@ -98,10 +109,9 @@ services:
- "traefik.http.middlewares.crypto-trading-auth.basicauth.users=${CRYPTO_FRONTEND_AUTH_USERS:?CRYPTO_FRONTEND_AUTH_USERS is required (htpasswd string, e.g. htpasswd -nbB user pass)}"
# Browsers do not reliably replay Basic-auth credentials on a WebSocket
# upgrade, which left the live feed stuck "reconnecting". Give /api/ws its
# own higher-priority router that skips Basic auth; the backend still
# requires the API token, which nginx injects server-side. When no API
# token is configured this path is unauthenticated, so only use it on a
# trusted network.
# own higher-priority router that skips Basic auth so the feed connects.
# If CRYPTO_API_TOKEN is set, the backend still requires it (nginx injects
# it server-side); if it is unset, /api/ws is as open as the rest of /api.
- "traefik.http.routers.crypto-trading-ws.rule=Host(`crypto.${BASE_DOMAIN:-matitos.org}`) && PathPrefix(`/api/ws`)"
- "traefik.http.routers.crypto-trading-ws.entrypoints=websecure"
- "traefik.http.routers.crypto-trading-ws.tls.certresolver=myresolvercd"
+59
View File
@@ -0,0 +1,59 @@
# Operations runbook
Practical steps for running the deployed bot. Host paths are examples — adjust
to the deploy host.
## Find the stack
```bash
docker compose ps
docker compose logs --tail=200 backend
docker compose logs -f frontend
```
The backend exposes `GET /api/metrics` (Prometheus text format) for the existing
Prometheus/Grafana stack; scrape it alongside the other services. Useful series:
`crypto_running`, `crypto_equity`, `crypto_total_pnl`, `crypto_open_positions`,
`crypto_strategy_equity`, `crypto_strategy_pnl`, `crypto_strategy_halted`,
`crypto_info`.
## Stop / kill trading
- **Dashboard:** the **Kill all** button (or Stop all) flattens and halts.
- **API:** `curl -X POST -H "X-API-Token: $TOKEN" https://<host>/api/bot/kill`
- **Hard stop:** `docker compose stop backend` stops the process entirely. It
does **not** close open positions — use the API kill first if you care.
## Something is wrong
1. `docker compose ps` — is the service `healthy`?
2. `docker compose logs --tail=200 backend` — look for `error`/`Traceback`.
3. In the dashboard, open **Server logs** (shows the in-memory ring of recent
log lines) and **Activity** (trades, halts, errors).
4. `curl https://<host>/api/live-check` — if live is blocked, the reasons are
listed.
## A deploy went out but the UI looks stale
The deploy job fails if the frontend container's build stamp does not match the
commit; if you see stale UI and the job passed, hard-refresh or open
incognito. The footer shows `client` vs `server` versions — they must match.
## Reset paper state
```bash
docker compose stop backend
# remove the SQLite file on the host mount
rm -f <CRYPTO_DATA_DIR>/trader.db
docker compose start backend
```
## Going live (do this deliberately)
1. Rehearse the kill switch in paper mode.
2. Set `MAX_DAILY_LOSS`, `MAX_DRAWDOWN_PCT` and/or `MAX_TRADES_PER_DAY`, and
`MAX_POSITION_PCT < 1.0`.
3. Set `MIN_LIVE_BALANCE` and add exchange credentials.
4. Set `LIVE_CONFIRMATION="I UNDERSTAND THE RISK"`.
5. Deploy, confirm `/api/live-check` returns `ok: true`, then start with a
small balance and watch it.
+9
View File
@@ -1,5 +1,9 @@
FROM node:22-alpine AS build
# Same value as the backend's BUILD_VERSION so the client can compare them.
ARG BUILD_VERSION=unknown
ENV VITE_BUILD_VERSION=${BUILD_VERSION}
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund
@@ -15,4 +19,9 @@ ENV API_TOKEN=
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/templates/default.conf.template
# Marker so the deploy can verify the served build matches the commit.
ARG BUILD_VERSION=unknown
RUN printf '%s' "${BUILD_VERSION}" > /usr/share/nginx/html/.build-version
EXPOSE 80
+17 -2
View File
@@ -4,13 +4,28 @@ server {
root /usr/share/nginx/html;
index index.html;
# Hashed Vite assets are immutable; cache them hard.
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
# The HTML entry point must never be cached, or a phone (which offers no
# easy hard-refresh) keeps loading an old bundle after a deploy. The
# catch-all serves index.html for client-side routes, so it gets the same
# no-store header.
location = /index.html {
add_header Cache-Control "no-store";
}
location / {
add_header Cache-Control "no-store";
try_files $uri $uri/ /index.html;
}
# WebSocket feed. Kept in its own block so the upgrade headers are only set
# here, and so the API token is injected server-side on the handshake (the
# browser cannot set headers on a WebSocket).
# here, and so the optional API token is injected server-side on the
# handshake (the browser cannot set headers on a WebSocket).
location /api/ws {
proxy_pass http://backend:8000;
proxy_http_version 1.1;
+196 -35
View File
@@ -1,13 +1,16 @@
<script setup>
import { onMounted, onUnmounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { aggregateStatus } from './aggregate'
import * as api from './api'
import BacktestPanel from './components/BacktestPanel.vue'
import EquityChart from './components/EquityChart.vue'
import EventLog from './components/EventLog.vue'
import LogsPanel from './components/LogsPanel.vue'
import MultiStrategyPanel from './components/MultiStrategyPanel.vue'
import OrdersTable from './components/OrdersTable.vue'
import PositionsTable from './components/PositionsTable.vue'
import StatusCards from './components/StatusCards.vue'
import StrategyPanel from './components/StrategyPanel.vue'
import SweepPanel from './components/SweepPanel.vue'
const status = ref({
running: false,
@@ -28,16 +31,59 @@ const status = ref({
})
const strategies = ref([])
const strategiesLoading = ref(true)
const presets = ref([])
const multiStatus = ref({ running: false, strategies: [], total_pnl: 0, equity: 0 })
const liveCheck = ref({ ok: true, mode: 'paper', blockers: [] })
const runningLabel = computed(() => {
const list = multiStatus.value.strategies || []
if (!list.length) return 'Idle'
if (list.length === 1) {
return `Running: ${list[0].strategy_name || list[0].strategy_key}`
}
return `Running: ${list.length} strategies`
})
const displayStatus = computed(() => aggregateStatus(status.value, multiStatus.value))
const scopeLabel = computed(() => {
const list = multiStatus.value.strategies || []
if (!list.length) {
return status.value.symbol ? `${status.value.symbol} ${status.value.timeframe}` : ''
}
const first = list[0]
const sameScope = list.every(
(s) => s.symbol === first.symbol && s.timeframe === first.timeframe,
)
return sameScope ? `${first.symbol} ${first.timeframe}` : `${list.length} pairs`
})
const positions = ref([])
const orders = ref([])
const events = ref([])
const equityPoints = ref([])
const busy = ref(false)
const connected = ref(false)
const socketError = ref('')
const socketFailures = ref(0)
const pollingFallback = ref(false)
// Some mobile browsers (notably iOS Safari with an auth-gated page) abort or
// silently hang the wss:// upgrade even when the server accepts it. We fall
// back to the REST poll after a few failures, or after a short grace period if
// the socket never opens at all, and present it as a normal live state.
const usePolling = computed(() => pollingFallback.value || socketFailures.value >= 3)
// Build versions shown in the footer. The client value is baked in at build
// time; the server value comes from /api/version. A mismatch means the browser
// is running a stale cached bundle.
const clientVersion = __BUILD_VERSION__
const serverVersion = ref('')
let socket
let pollTimer
let statusTimer
let graceTimer
let listTimer
async function refreshAll() {
const [p, o, e, q] = await Promise.all([
@@ -53,7 +99,12 @@ async function refreshAll() {
}
async function loadStrategies() {
strategiesLoading.value = true
try {
strategies.value = await api.getStrategies()
} finally {
strategiesLoading.value = false
}
}
async function loadPresets() {
@@ -70,10 +121,26 @@ async function handleDeletePreset(id) {
await loadPresets()
}
async function handleStart(payload) {
async function refreshMulti() {
multiStatus.value = await api.getMultiStatus()
}
async function refreshLiveCheck() {
liveCheck.value = await api.getLiveCheck()
}
async function handleStartMany(payload) {
if (liveCheck.value.mode === 'live' && !liveCheck.value.ok) {
status.value = {
...status.value,
error: 'Live trading blocked: ' + liveCheck.value.blockers.join('; '),
}
return
}
busy.value = true
try {
status.value = await api.startBot(payload)
multiStatus.value = await api.startMany(payload)
status.value = await api.getStatus()
await refreshAll()
} catch (error) {
status.value = { ...status.value, error: error.response?.data?.detail || error.message }
@@ -82,23 +149,24 @@ async function handleStart(payload) {
}
}
async function handleStop() {
async function handleStopMany() {
busy.value = true
try {
status.value = await api.stopBot()
await api.stopBot()
status.value = await api.getStatus()
await refreshMulti()
await refreshAll()
} finally {
busy.value = false
}
}
async function handleKill() {
async function handleKillMany() {
busy.value = true
try {
status.value = await api.killBot('manual kill switch')
status.value = await api.killBot('manual kill switch (all)')
await refreshMulti()
await refreshAll()
} catch (error) {
status.value = { ...status.value, error: error.response?.data?.detail || error.message }
} finally {
busy.value = false
}
@@ -122,24 +190,86 @@ function handleMessage(message) {
}
}
onMounted(async () => {
await Promise.all([
loadStrategies(),
loadPresets(),
api.getStatus().then((s) => (status.value = s)),
refreshAll(),
])
// If the server is running a newer build than this (possibly cached) bundle,
// reload once with a cache-busting query so phones pick up deploys without a
// manual hard-refresh. Guarded so it can never loop.
async function ensureFreshBuild() {
try {
const { version } = await api.getVersion()
serverVersion.value = version || ''
if (!version || version === 'unknown') return
if (version === __BUILD_VERSION__) return
const key = `reloaded:${version}`
if (sessionStorage.getItem(key)) return
sessionStorage.setItem(key, '1')
const url = new URL(window.location.href)
url.searchParams.set('v', version)
window.location.replace(url.toString())
} catch {
// ignore; not worth blocking the app
}
}
onMounted(() => {
ensureFreshBuild()
// Set up the socket and the polling fallback first, so a failing initial
// fetch can never prevent them (that left the badge stuck on "connecting").
socket = api.openSocket({
onMessage: handleMessage,
onOpen: () => (connected.value = true),
onOpen: () => {
connected.value = true
socketError.value = ''
},
onClose: () => (connected.value = false),
onError: (code) => {
socketFailures.value += 1
if (code === 1008) socketError.value = 'rejected by the server (token/auth)'
else socketError.value = `socket closed (code ${code})`
},
})
pollTimer = setInterval(refreshAll, 5000)
// If the socket hasn't opened shortly after load, commit to polling so the
// badge never hangs on "connecting".
graceTimer = setTimeout(() => {
if (!connected.value) pollingFallback.value = true
}, 6000)
// Initial loads are best-effort.
api.getStatus().then((s) => (status.value = s)).catch(() => {})
refreshAll().catch(() => {})
refreshMulti().catch(() => {})
// The strategy list and presets rarely change, so load them once but keep
// retrying until they arrive: a single failed first request used to leave the
// strategy dropdown empty forever.
loadStrategies()
loadPresets()
// Poll fast enough that the UI feels live even when it is the only channel.
pollTimer = setInterval(() => refreshAll().catch(() => {}), 3000)
statusTimer = setInterval(
() => {
api.getStatus().then((s) => (status.value = s)).catch(() => {})
refreshMulti().catch(() => {})
refreshLiveCheck().catch(() => {})
},
3000,
)
// Retry the static lists until non-empty (covers a failed first load and a
// backend that was still starting up).
listTimer = setInterval(() => {
if (!strategies.value.length) loadStrategies()
if (!presets.value.length) loadPresets()
if (strategies.value.length && presets.value.length) clearInterval(listTimer)
}, 3000)
})
onUnmounted(() => {
socket?.close()
clearInterval(pollTimer)
clearInterval(statusTimer)
clearInterval(listTimer)
clearTimeout(graceTimer)
})
</script>
@@ -154,55 +284,86 @@ onUnmounted(() => {
>
{{ status.mode }}
</span>
<span class="flex items-center gap-1.5 text-xs text-slate-400">
<span class="flex items-center gap-1.5 text-xs text-slate-400" :title="socketError || ''">
<span
class="h-2 w-2 rounded-full"
:class="connected ? 'bg-emerald-400' : 'bg-slate-500'"
:class="connected || usePolling ? 'bg-emerald-400' : 'bg-slate-500'"
></span>
{{ connected ? 'live' : 'reconnecting' }}
<template v-if="connected">live</template>
<template v-else-if="usePolling">live (polling)</template>
<template v-else>connecting…</template>
</span>
</div>
<div class="text-xs text-slate-400">
<span v-if="status.strategy_name">Running: {{ status.strategy_name }}</span>
<span v-else>Idle</span>
<span v-if="status.symbol"> · {{ status.symbol }} {{ status.timeframe }}</span>
<span>{{ runningLabel }}</span>
<span v-if="scopeLabel"> · {{ scopeLabel }}</span>
</div>
</header>
<div
v-if="status.halted"
v-if="displayStatus.halted"
class="rounded-lg border border-rose-500 bg-rose-500/20 p-3 text-sm font-semibold text-rose-100"
>
HALTED — {{ status.halt_reason }}. Trading is stopped and any open position was
HALTED — {{ displayStatus.halt_reason }}. Trading is stopped and any open position was
flattened. Restart the bot to resume (limits reset).
</div>
<div
v-if="liveCheck.mode === 'live' && !liveCheck.ok"
class="rounded-lg border border-amber-500 bg-amber-500/15 p-3 text-sm text-amber-100"
>
<p class="font-semibold">Live trading is blocked by the go-live checks:</p>
<ul class="mt-1 list-disc pl-5">
<li v-for="(b, i) in liveCheck.blockers" :key="i">{{ b }}</li>
</ul>
</div>
<div v-if="status.error" class="rounded-lg border border-rose-500/40 bg-rose-500/10 p-3 text-sm text-rose-200">
{{ status.error }}
</div>
<StatusCards :status="status" />
<StatusCards :status="displayStatus" />
<StrategyPanel
<MultiStrategyPanel
:strategies="strategies"
:status="status"
:busy="busy"
:multi-status="multiStatus"
:presets="presets"
@start="handleStart"
@stop="handleStop"
@kill="handleKill"
:busy="busy"
:loading="strategiesLoading"
@start-many="handleStartMany"
@stop="handleStopMany"
@kill="handleKillMany"
@save-preset="handleSavePreset"
@delete-preset="handleDeletePreset"
/>
<BacktestPanel :strategies="strategies" :status="status" />
<BacktestPanel :strategies="strategies" :status="status" :loading="strategiesLoading" />
<SweepPanel :strategies="strategies" :status="status" :loading="strategiesLoading" />
<div class="grid gap-4 lg:grid-cols-2">
<EquityChart :points="equityPoints" />
<EventLog :events="events" />
</div>
<LogsPanel />
<PositionsTable :positions="positions" @close="handleClose" />
<OrdersTable :orders="orders" />
<footer class="flex flex-wrap items-center gap-x-4 gap-y-1 border-t border-edge/60 pt-3 text-xs text-slate-500">
<span>client <span class="font-mono text-slate-400">{{ clientVersion }}</span></span>
<span>
server
<span class="font-mono text-slate-400">{{ serverVersion || '…' }}</span>
</span>
<span
v-if="serverVersion && serverVersion !== clientVersion"
class="text-amber-300"
>
version mismatch — reloading to update
</span>
<span v-else-if="serverVersion" class="text-emerald-400">up to date</span>
<span v-if="socketError" class="text-amber-300">socket: {{ socketError }}</span>
</footer>
</div>
</template>
+34
View File
@@ -0,0 +1,34 @@
/**
* Top-of-dashboard status. With one strategy it is that strategy's status;
* with several, the per-strategy numbers are aggregated for the whole account.
*/
export function aggregateStatus(status, multiStatus) {
const list = multiStatus?.strategies || []
if (list.length <= 1) return status
const sum = (field) => list.reduce((acc, s) => acc + (s[field] || 0), 0)
const halted = list.filter((s) => s.halted)
return {
...status,
running: multiStatus.running,
price: multiStatus.price ?? status.price,
cash: sum('cash'),
equity: multiStatus.equity,
starting_balance: multiStatus.starting_balance,
realized_pnl: sum('realized_pnl'),
unrealized_pnl: sum('unrealized_pnl'),
total_pnl: multiStatus.total_pnl,
day_pnl: sum('day_pnl'),
// Worst individual strategy drawdown; the portfolio peak is not tracked.
drawdown_pct: Math.max(0, ...list.map((s) => s.drawdown_pct || 0)),
trades_today: sum('trades_today'),
open_positions: sum('open_positions'),
halted: halted.length > 0,
halt_reason:
halted
.map((s) => s.halt_reason)
.filter(Boolean)
.join('; ') || null,
}
}
+87
View File
@@ -0,0 +1,87 @@
import { describe, expect, it } from 'vitest'
import { aggregateStatus } from './aggregate'
const SINGLE = { equity: 100, total_pnl: 5, strategies: [] }
function strategy(overrides = {}) {
return {
strategy_key: 'sma_cross',
symbol: 'BTC/USDT',
timeframe: '5m',
allocation: 0.5,
cash: 1000,
equity: 1000,
realized_pnl: 0,
unrealized_pnl: 0,
total_pnl: 0,
open_positions: 0,
halted: false,
halt_reason: null,
trades_today: 0,
day_pnl: 0,
drawdown_pct: 0,
...overrides,
}
}
describe('aggregateStatus', () => {
it('returns the plain status when there is at most one strategy', () => {
expect(aggregateStatus(SINGLE, { strategies: [] })).toBe(SINGLE)
expect(aggregateStatus(SINGLE, { strategies: [strategy()] })).toBe(SINGLE)
})
it('sums the per-strategy numbers', () => {
const multi = {
running: true,
equity: 2100,
starting_balance: 2000,
total_pnl: 100,
price: 123,
strategies: [
strategy({ cash: 1000, equity: 1100, realized_pnl: 100, unrealized_pnl: 0, total_pnl: 100, trades_today: 2, open_positions: 1, day_pnl: 40 }),
strategy({ cash: 1000, equity: 1000, realized_pnl: 0, unrealized_pnl: 0, total_pnl: 0, trades_today: 0, open_positions: 0, day_pnl: -10 }),
],
}
const result = aggregateStatus(SINGLE, multi)
expect(result.cash).toBe(2000)
expect(result.equity).toBe(2100)
expect(result.realized_pnl).toBe(100)
expect(result.total_pnl).toBe(100)
expect(result.day_pnl).toBe(30)
expect(result.trades_today).toBe(2)
expect(result.open_positions).toBe(1)
expect(result.price).toBe(123)
expect(result.running).toBe(true)
})
it('reports the worst drawdown and any halt', () => {
const multi = {
running: true,
equity: 2000,
starting_balance: 2000,
total_pnl: 0,
price: null,
strategies: [
strategy({ drawdown_pct: 3, halted: false }),
strategy({ drawdown_pct: 7.5, halted: true, halt_reason: 'daily loss limit hit' }),
],
}
const result = aggregateStatus(SINGLE, multi)
expect(result.drawdown_pct).toBe(7.5)
expect(result.halted).toBe(true)
expect(result.halt_reason).toBe('daily loss limit hit')
})
it('falls back to the plain price when multi has none', () => {
const multi = {
running: false,
equity: 2000,
starting_balance: 2000,
total_pnl: 0,
price: null,
strategies: [strategy(), strategy()],
}
const result = aggregateStatus({ ...SINGLE, price: 99 }, multi)
expect(result.price).toBe(99)
})
})
+50 -6
View File
@@ -2,12 +2,18 @@ import axios from 'axios'
const http = axios.create({ baseURL: '/api', timeout: 10000 })
export const getVersion = () => http.get('/version').then((r) => r.data)
export const getStatus = () => http.get('/status').then((r) => r.data)
export const getMultiStatus = () => http.get('/bot/status').then((r) => r.data)
export const getLiveCheck = () => http.get('/live-check').then((r) => r.data)
export const startMany = (payload) => http.post('/bot/start-many', payload).then((r) => r.data)
export const getStrategies = () => http.get('/strategies').then((r) => r.data)
export const getPositions = () => http.get('/positions').then((r) => r.data)
export const getOrders = () => http.get('/orders').then((r) => r.data)
export const getEvents = () => http.get('/events').then((r) => r.data)
export const getEquity = () => http.get('/equity').then((r) => r.data)
export const getLogs = (limit = 100) =>
http.get('/logs', { params: { limit } }).then((r) => r.data)
export const startBot = (payload) => http.post('/bot/start', payload).then((r) => r.data)
export const stopBot = () => http.post('/bot/stop').then((r) => r.data)
@@ -19,11 +25,14 @@ export const placeOrder = (payload) => http.post('/orders', payload).then((r) =>
export const runBacktest = (payload) =>
http.post('/backtest', payload, { timeout: 60000 }).then((r) => r.data)
export const runSweep = (payload) =>
http.post('/sweep', payload, { timeout: 120000 }).then((r) => r.data)
export const getPresets = () => http.get('/presets').then((r) => r.data)
export const createPreset = (payload) => http.post('/presets', payload).then((r) => r.data)
export const deletePreset = (id) => http.delete(`/presets/${id}`).then((r) => r.data)
export function openSocket({ onMessage, onOpen, onClose }) {
export function openSocket({ onMessage, onOpen, onClose, onError }) {
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
const url = `${proto}://${window.location.host}/api/ws`
let socket
@@ -33,26 +42,61 @@ export function openSocket({ onMessage, onOpen, onClose }) {
const schedule = () => {
if (closed) return
// Back off from 2s up to 30s so a persistently failing socket (e.g. auth)
// does not hammer the server, while a brief blip still reconnects fast.
const delay = Math.min(2000 * 2 ** attempt, 30000)
// Retry at 1s, then back off up to 30s so a persistently failing socket
// does not hammer the server while a brief blip recovers immediately.
const delay = Math.min(1000 * 2 ** attempt, 30000)
attempt += 1
retryTimer = setTimeout(connect, delay)
}
const connect = () => {
if (closed) return
let opened = false
let reported = false
console.debug('[ws] connecting to', url)
socket = new WebSocket(url)
// Report at most one failure per attempt (Safari often fires error *and*
// close for the same bad handshake).
const reportFailure = (code, detail) => {
if (reported) return
reported = true
onError?.(code, detail)
}
// Some mobile browsers leave a blocked upgrade hanging without firing
// onclose/onerror. Treat a slow handshake as a failure so the caller can
// fall back to polling instead of waiting forever.
const openTimeout = setTimeout(() => {
if (!opened) {
reportFailure(0, 'handshake timed out')
socket.close()
}
}, 5000)
socket.onopen = () => {
opened = true
clearTimeout(openTimeout)
attempt = 0
console.debug('[ws] open')
onOpen?.()
}
socket.onmessage = (event) => onMessage?.(JSON.parse(event.data))
socket.onclose = () => {
socket.onclose = (event) => {
clearTimeout(openTimeout)
console.debug('[ws] close', event.code, event.reason)
onClose?.()
if (!opened) {
// 1008 = policy violation (token rejected); 1006 = abnormal close.
reportFailure(event.code, event.reason)
}
schedule()
}
socket.onerror = () => socket.close()
socket.onerror = (event) => {
clearTimeout(openTimeout)
if (!opened) reportFailure(0, event?.message || 'connection error')
socket.close()
}
}
connect()
+64
View File
@@ -0,0 +1,64 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it } from 'vitest'
import CollapsiblePanel from './components/CollapsiblePanel.vue'
describe('CollapsiblePanel', () => {
beforeEach(() => {
localStorage.clear()
})
it('renders the title and slot when open by default', () => {
const wrapper = mount(CollapsiblePanel, {
props: { title: 'Strategy' },
slots: { default: '<p>content</p>' },
})
expect(wrapper.text()).toContain('Strategy')
expect(wrapper.text()).toContain('content')
expect(wrapper.find('button').attributes('aria-expanded')).toBe('true')
})
it('hides the slot after clicking the header', async () => {
const wrapper = mount(CollapsiblePanel, {
props: { title: 'Strategy' },
slots: { default: '<p>content</p>' },
})
await wrapper.find('button').trigger('click')
expect(wrapper.find('button').attributes('aria-expanded')).toBe('false')
// v-show keeps the node but hides it.
expect(wrapper.find('div[style*="display: none"]').exists()).toBe(true)
})
it('shows the summary only when collapsed', async () => {
const wrapper = mount(CollapsiblePanel, {
props: { title: 'Backtest', summary: 'BTC/USDT 5m' },
slots: { default: '<p>content</p>' },
})
expect(wrapper.text()).not.toContain('BTC/USDT 5m')
await wrapper.find('button').trigger('click')
expect(wrapper.text()).toContain('BTC/USDT 5m')
})
it('persists the collapsed state in localStorage', async () => {
const wrapper = mount(CollapsiblePanel, {
props: { title: 'Backtest', storageKey: 'backtest' },
slots: { default: '<p>content</p>' },
})
await wrapper.find('button').trigger('click')
expect(localStorage.getItem('panel:backtest')).toBe('0')
// A fresh mount with the same key starts collapsed.
const remounted = mount(CollapsiblePanel, {
props: { title: 'Backtest', storageKey: 'backtest' },
slots: { default: '<p>content</p>' },
})
expect(remounted.find('button').attributes('aria-expanded')).toBe('false')
})
it('honours defaultOpen when nothing is stored', () => {
const wrapper = mount(CollapsiblePanel, {
props: { title: 'Strategy', defaultOpen: false },
slots: { default: '<p>content</p>' },
})
expect(wrapper.find('button').attributes('aria-expanded')).toBe('false')
})
})
+13 -9
View File
@@ -1,13 +1,15 @@
<script setup>
import { ref } from 'vue'
import { computed, ref } from 'vue'
import * as api from '../api'
import { fmtMoney, fmtPrice, pnlClass, signed } from '../format'
import CollapsiblePanel from './CollapsiblePanel.vue'
import EquityChart from './EquityChart.vue'
import StrategyForm from './StrategyForm.vue'
defineProps({
strategies: { type: Array, required: true },
status: { type: Object, required: true },
loading: { type: Boolean, default: false },
})
const running = ref(false)
@@ -15,6 +17,12 @@ const error = ref(null)
const result = ref(null)
const limit = ref(500)
const summary = computed(() => {
if (!result.value || result.value.error) return ''
const m = result.value.metrics
return `${result.value.symbol} ${result.value.timeframe} · ${signed(m.total_return_pct)}% · ${m.num_trades} trades`
})
async function run(payload) {
running.value = true
error.value = null
@@ -30,19 +38,14 @@ async function run(payload) {
</script>
<template>
<div class="card space-y-3">
<div class="flex items-center justify-between">
<h2 class="font-semibold">Backtest</h2>
<span v-if="result" class="text-xs text-slate-400">
{{ result.symbol }} {{ result.timeframe }} · {{ result.equity_curve.length }} candles
</span>
</div>
<CollapsiblePanel title="Backtest" storage-key="backtest" :summary="summary" :default-open="false">
<div class="space-y-3">
<StrategyForm
:strategies="strategies"
:busy="running"
:symbol="status.symbol"
:timeframe="status.timeframe"
:loading="loading"
submit-label="Run backtest"
@submit="run"
/>
@@ -137,4 +140,5 @@ async function run(payload) {
</div>
</template>
</div>
</CollapsiblePanel>
</template>
@@ -0,0 +1,69 @@
<script setup>
import { ref, watch } from 'vue'
const props = defineProps({
title: { type: String, required: true },
// Optional right-aligned summary shown in the header (visible when collapsed).
summary: { type: String, default: '' },
storageKey: { type: String, default: '' },
defaultOpen: { type: Boolean, default: true },
})
const emit = defineEmits(['toggle'])
function readInitial() {
if (!props.storageKey) return props.defaultOpen
try {
const stored = localStorage.getItem(`panel:${props.storageKey}`)
if (stored === '1') return true
if (stored === '0') return false
} catch {
// localStorage unavailable (private mode); fall back to the default
}
return props.defaultOpen
}
const open = ref(readInitial())
watch(open, (value) => {
emit('toggle', value)
if (!props.storageKey) return
try {
localStorage.setItem(`panel:${props.storageKey}`, value ? '1' : '0')
} catch {
// ignore
}
})
</script>
<template>
<section class="card">
<button
type="button"
class="-m-2 flex w-full items-center justify-between gap-3 rounded-lg p-2 text-left transition hover:bg-slate-700/40"
:aria-expanded="open"
:title="open ? 'Collapse' : 'Expand'"
@click="open = !open"
>
<span class="flex items-center gap-2 font-semibold">
<svg
class="h-3.5 w-3.5 shrink-0 text-slate-400 transition-transform"
:class="open ? 'rotate-90' : ''"
viewBox="0 0 16 16"
fill="currentColor"
aria-hidden="true"
>
<path d="M6 3.5 11 8l-5 4.5z" />
</svg>
{{ title }}
<span class="text-xs font-normal text-slate-500">{{ open ? 'hide' : 'show' }}</span>
</span>
<span v-if="!open && summary" class="truncate text-xs font-normal text-slate-400">
{{ summary }}
</span>
</button>
<div v-show="open" class="mt-3">
<slot />
</div>
</section>
</template>
+73
View File
@@ -0,0 +1,73 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue'
import * as api from '../api'
import { fmtTime } from '../format'
import CollapsiblePanel from './CollapsiblePanel.vue'
const entries = ref([])
const levelFilter = ref('all')
let timer
const levelClass = {
info: 'text-slate-300',
warning: 'text-amber-300',
error: 'text-rose-300',
critical: 'text-rose-400',
debug: 'text-slate-500',
}
const filtered = computed(() => {
if (levelFilter.value === 'all') return entries.value
return entries.value.filter((e) => e.level === levelFilter.value)
})
async function load() {
try {
const data = await api.getLogs(200)
entries.value = data.entries || []
} catch {
// ignore transient errors
}
}
onMounted(() => {
load()
timer = setInterval(load, 5000)
})
onUnmounted(() => clearInterval(timer))
</script>
<template>
<CollapsiblePanel
title="Server logs"
storage-key="logs"
:default-open="false"
:summary="`${entries.length} lines`"
>
<div class="space-y-2">
<div class="flex items-center gap-2">
<label class="space-y-1">
<span class="label">Level</span>
<select v-model="levelFilter" class="input">
<option value="all">all</option>
<option value="info">info</option>
<option value="warning">warning</option>
<option value="error">error</option>
</select>
</label>
</div>
<div class="max-h-72 space-y-0.5 overflow-y-auto font-mono text-xs">
<div v-for="(e, i) in filtered" :key="i" class="flex gap-2 border-b border-edge/40 py-0.5">
<span class="shrink-0 text-slate-500">{{ fmtTime(e.time) }}</span>
<span class="shrink-0" :class="levelClass[e.level] || 'text-slate-300'">
[{{ e.level }}]
</span>
<span class="shrink-0 text-slate-500">{{ e.logger }}</span>
<span :class="levelClass[e.level] || 'text-slate-300'">{{ e.message }}</span>
</div>
<div v-if="!filtered.length" class="py-6 text-center text-slate-500">No log lines</div>
</div>
</div>
</CollapsiblePanel>
</template>
@@ -0,0 +1,221 @@
<script setup>
import { computed, ref, watch } from 'vue'
import { fmtMoney, pnlClass, signed } from '../format'
import CollapsiblePanel from './CollapsiblePanel.vue'
import StrategyRowEditor from './StrategyRowEditor.vue'
const props = defineProps({
strategies: { type: Array, required: true },
multiStatus: { type: Object, required: true },
presets: { type: Array, default: () => [] },
busy: { type: Boolean, default: false },
loading: { type: Boolean, default: false },
})
const emit = defineEmits(['start-many', 'stop', 'kill', 'save-preset', 'delete-preset'])
const startingBalance = ref(10000)
// Each row: { strategy_key, symbol, timeframe, weight, params }
const rows = ref([])
function defaultsFor(key) {
const strategy = props.strategies.find((s) => s.key === key)
return Object.fromEntries((strategy?.params || []).map((p) => [p.name, p.default]))
}
function newRow(key, symbol, timeframe) {
return {
strategy_key: key,
symbol: symbol || 'BTC/USDT',
timeframe: timeframe || '5m',
weight: 1,
params: defaultsFor(key),
}
}
// Seed with one row once strategies load.
watch(
() => props.strategies,
(list) => {
if (!rows.value.length && list.length) {
rows.value = [newRow(list[0].key, props.multiStatus.symbol, props.multiStatus.timeframe)]
}
},
{ immediate: true },
)
const running = computed(() => props.multiStatus.running)
const allocations = computed(() => {
const total = rows.value.reduce((sum, r) => sum + (Number(r.weight) || 0), 0)
if (total <= 0) return rows.value.map(() => 0)
return rows.value.map((r) => (Number(r.weight) || 0) / total)
})
const summary = computed(() => {
const strategies = props.multiStatus.strategies || []
if (!strategies.length) return 'no strategies'
return `${strategies.length} strategies · ${signed(props.multiStatus.total_pnl)}`
})
function addRow() {
const key = props.strategies[0]?.key
if (key) rows.value.push(newRow(key, props.multiStatus.symbol, props.multiStatus.timeframe))
}
function removeRow(index) {
rows.value.splice(index, 1)
}
// Load a saved preset as an additional row.
function loadPreset(event) {
const preset = props.presets.find((p) => p.id === Number(event.target.value))
event.target.value = ''
if (!preset) return
rows.value.push({
strategy_key: preset.strategy_key,
symbol: preset.symbol,
timeframe: preset.timeframe,
weight: 1,
params: { ...preset.params },
})
}
function saveRow(row) {
const name = window.prompt('Preset name', row.strategy_key)
if (name === null) return
emit('save-preset', {
name: name || row.strategy_key,
strategy_key: row.strategy_key,
symbol: row.symbol,
timeframe: row.timeframe,
params: row.params,
})
}
function start() {
emit('start-many', {
strategies: rows.value.map((r) => ({
strategy_key: r.strategy_key,
params: r.params,
symbol: r.symbol,
timeframe: r.timeframe,
weight: Number(r.weight) || 1,
})),
starting_balance: Number(startingBalance.value),
})
}
function confirmKill() {
if (window.confirm('Kill switch: flatten all positions and halt every strategy?')) {
emit('kill')
}
}
</script>
<template>
<CollapsiblePanel title="Strategies" storage-key="strategies" :summary="summary">
<div class="space-y-3">
<div class="flex flex-wrap items-end gap-3">
<label class="space-y-1">
<span class="label">Starting balance</span>
<input
v-model="startingBalance"
type="number"
min="0"
step="any"
class="input w-40"
:disabled="running"
/>
</label>
<button class="btn-muted" :disabled="running || loading" @click="addRow">
Add strategy
</button>
<label v-if="presets.length" class="space-y-1">
<span class="label">Load preset</span>
<select class="input" :disabled="running || loading" @change="loadPreset">
<option value="">—</option>
<option v-for="p in presets" :key="p.id" :value="p.id">
{{ p.name || `${p.strategy_key} #${p.id}` }}
</option>
</select>
</label>
<button
class="btn-primary"
:disabled="busy || running || loading || !rows.length"
@click="start"
>
Start all
</button>
<button class="btn-danger" :disabled="busy || !running" @click="emit('stop')">
Stop all
</button>
<button
class="btn bg-rose-700 font-bold uppercase text-white hover:bg-rose-600"
:disabled="busy || (!running && !(multiStatus.strategies || []).length)"
@click="confirmKill"
>
Kill all
</button>
</div>
<div v-if="rows.length" class="space-y-2">
<StrategyRowEditor
v-for="(row, i) in rows"
:key="i"
v-model="rows[i]"
:strategies="strategies"
:disabled="running"
:allocation="allocations[i]"
@remove="removeRow(i)"
@save-preset="saveRow"
/>
</div>
<p v-else class="text-sm text-slate-400">
{{ loading ? 'Loading strategies…' : 'No strategies available' }}
</p>
<div v-if="(multiStatus.strategies || []).length" class="overflow-x-auto">
<table class="w-full text-left text-sm">
<thead class="label">
<tr>
<th class="pb-2">Strategy</th>
<th class="pb-2">Symbol</th>
<th class="pb-2 text-right">Alloc</th>
<th class="pb-2 text-right">Equity</th>
<th class="pb-2 text-right">Total P&amp;L</th>
<th class="pb-2">Status</th>
</tr>
</thead>
<tbody>
<tr v-for="(s, i) in multiStatus.strategies" :key="i" class="border-t border-edge/60">
<td class="py-1.5">{{ s.strategy_name || s.strategy_key }}</td>
<td class="py-1.5">{{ s.symbol }} {{ s.timeframe }}</td>
<td class="py-1.5 text-right tabular-nums">
{{ (s.allocation * 100).toFixed(1) }}%
</td>
<td class="py-1.5 text-right tabular-nums">{{ fmtMoney(s.equity) }}</td>
<td class="py-1.5 text-right tabular-nums" :class="pnlClass(s.total_pnl)">
{{ signed(s.total_pnl) }}
</td>
<td class="py-1.5">
<span
v-if="s.halted"
class="rounded px-1.5 py-0.5 text-xs bg-rose-500/20 text-rose-300"
:title="s.halt_reason || ''"
>
halted
</span>
<span
v-else
class="rounded px-1.5 py-0.5 text-xs"
:class="running ? 'bg-emerald-500/20 text-emerald-300' : 'bg-slate-600/40 text-slate-300'"
>
{{ running ? 'running' : 'stopped' }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</CollapsiblePanel>
</template>
+11 -1
View File
@@ -10,6 +10,9 @@ const props = defineProps({
symbol: { type: String, default: 'BTC/USDT' },
timeframe: { type: String, default: '5m' },
presets: { type: Array, default: () => [] },
// True while the strategy list is still being fetched, so the dropdown can
// show a loading state instead of appearing empty.
loading: { type: Boolean, default: false },
})
const emit = defineEmits(['submit', 'save-preset', 'delete-preset', 'load-preset'])
@@ -99,7 +102,14 @@ function submit() {
<div class="grid gap-2 sm:grid-cols-3">
<label class="space-y-1">
<span class="label">Strategy</span>
<select v-model="selectedKey" class="input" :disabled="disabled">
<select
v-model="selectedKey"
class="input"
:disabled="disabled || !strategies.length"
>
<option v-if="!strategies.length" value="" disabled>
{{ loading ? 'Loading strategies…' : 'No strategies available' }}
</option>
<option v-for="s in strategies" :key="s.key" :value="s.key">{{ s.name }}</option>
</select>
</label>
-57
View File
@@ -1,57 +0,0 @@
<script setup>
import StrategyForm from './StrategyForm.vue'
const props = defineProps({
strategies: { type: Array, required: true },
status: { type: Object, required: true },
busy: { type: Boolean, default: false },
presets: { type: Array, default: () => [] },
})
const emit = defineEmits(['start', 'stop', 'kill', 'save-preset', 'delete-preset'])
function confirmKill() {
if (window.confirm('Kill switch: flatten the position and halt trading?')) {
emit('kill')
}
}
</script>
<template>
<div class="card space-y-3">
<div class="flex items-center justify-between">
<h2 class="font-semibold">Strategy</h2>
<span
class="rounded-full px-2 py-0.5 text-xs font-medium"
:class="status.running ? 'bg-emerald-500/20 text-emerald-300' : 'bg-slate-600/40 text-slate-300'"
>
{{ status.running ? 'running' : 'stopped' }}
</span>
</div>
<StrategyForm
:strategies="strategies"
:disabled="status.running"
:busy="busy"
submit-label="Start bot"
:symbol="status.symbol"
:timeframe="status.timeframe"
:presets="presets"
@submit="emit('start', $event)"
@save-preset="emit('save-preset', $event)"
@delete-preset="emit('delete-preset', $event)"
/>
<div class="flex flex-wrap gap-2">
<button class="btn-danger" :disabled="busy || !status.running" @click="emit('stop')">
Stop bot
</button>
<button
class="btn bg-rose-700 font-bold uppercase text-white hover:bg-rose-600"
:disabled="busy || (!status.running && status.open_positions === 0)"
@click="confirmKill"
>
Kill switch
</button>
</div>
</div>
</template>
@@ -0,0 +1,121 @@
<script setup>
import { computed, ref, watch } from 'vue'
const props = defineProps({
modelValue: { type: Object, required: true },
strategies: { type: Array, required: true },
disabled: { type: Boolean, default: false },
// Share of the total balance this row will receive (0..1), for display.
allocation: { type: Number, default: 0 },
})
const emit = defineEmits(['update:modelValue', 'remove', 'save-preset'])
const row = computed(() => props.modelValue)
const selected = computed(() => props.strategies.find((s) => s.key === row.value.strategy_key))
function update(patch) {
emit('update:modelValue', { ...row.value, ...patch })
}
function coerce(param, raw) {
if (param.type === 'int') return parseInt(raw, 10)
if (param.type === 'float') return parseFloat(raw)
if (param.type === 'bool') return raw === true || raw === 'true'
return raw
}
function setParam(name, raw) {
const param = selected.value?.params.find((p) => p.name === name)
update({ params: { ...row.value.params, [name]: param ? coerce(param, raw) : raw } })
}
// When the strategy changes, reset params to its defaults.
watch(
() => row.value.strategy_key,
(key) => {
const strategy = props.strategies.find((s) => s.key === key)
if (strategy) {
const defaults = Object.fromEntries(strategy.params.map((p) => [p.name, p.default]))
update({ params: defaults })
}
},
)
</script>
<template>
<div class="rounded-lg border border-edge/60 p-3">
<div class="grid gap-2 sm:grid-cols-4">
<label class="space-y-1">
<span class="label">Strategy</span>
<select
class="input"
:value="row.strategy_key"
:disabled="disabled"
@change="update({ strategy_key: $event.target.value })"
>
<option v-for="s in strategies" :key="s.key" :value="s.key">{{ s.name }}</option>
</select>
</label>
<label class="space-y-1">
<span class="label">Symbol</span>
<input
class="input"
:value="row.symbol"
:disabled="disabled"
@input="update({ symbol: $event.target.value })"
/>
</label>
<label class="space-y-1">
<span class="label">Timeframe</span>
<input
class="input"
:value="row.timeframe"
:disabled="disabled"
@input="update({ timeframe: $event.target.value })"
/>
</label>
<label class="space-y-1">
<span class="label">Weight ({{ (allocation * 100).toFixed(1) }}%)</span>
<input
class="input"
type="number"
min="0.01"
step="any"
:value="row.weight"
:disabled="disabled"
@input="update({ weight: Number($event.target.value) })"
/>
</label>
</div>
<div v-if="selected && selected.params.length" class="mt-2 grid gap-2 sm:grid-cols-3 lg:grid-cols-4">
<label v-for="param in selected.params" :key="param.name" class="space-y-1">
<span class="label" :title="param.description">{{ param.name }}</span>
<select
v-if="param.type === 'bool'"
class="input"
:value="row.params[param.name]"
:disabled="disabled"
@change="setParam(param.name, $event.target.value)"
>
<option :value="true">true</option>
<option :value="false">false</option>
</select>
<input
v-else
class="input"
type="number"
step="any"
:value="row.params[param.name]"
:disabled="disabled"
@input="setParam(param.name, $event.target.value)"
/>
</label>
</div>
<div class="mt-2 flex justify-end gap-2">
<button class="btn-muted" :disabled="disabled" @click="emit('save-preset', row)">Save</button>
<button class="btn-muted" :disabled="disabled" @click="emit('remove')">Remove</button>
</div>
</div>
</template>
+179
View File
@@ -0,0 +1,179 @@
<script setup>
import { computed, ref, watch } from 'vue'
import * as api from '../api'
import { fmtMoney, pnlClass, signed } from '../format'
import CollapsiblePanel from './CollapsiblePanel.vue'
const props = defineProps({
strategies: { type: Array, required: true },
status: { type: Object, required: true },
loading: { type: Boolean, default: false },
})
const running = ref(false)
const error = ref(null)
const result = ref(null)
const selectedKey = ref('')
const limit = ref(500)
const topN = ref(20)
const symbol = ref(props.status.symbol || 'BTC/USDT')
const timeframe = ref(props.status.timeframe || '5m')
// param name -> comma-separated candidate values
const gridText = ref({})
const selected = computed(() => props.strategies.find((s) => s.key === selectedKey.value))
const summary = computed(() => {
if (!result.value) return ''
const top = result.value.rows[0]
const best = top ? ` · best ${signed(top.metrics.total_return_pct)}%` : ''
return `${result.value.combinations} combinations${best}`
})
watch(
() => props.strategies,
(list) => {
if (!selectedKey.value && list.length) selectedKey.value = list[0].key
},
{ immediate: true },
)
watch(selected, (strategy) => {
gridText.value = {}
if (strategy) {
// Seed each numeric param with a sensible two-value grid.
for (const p of strategy.params) {
if (p.type === 'int' || p.type === 'float') {
const v = Number(p.default)
gridText.value[p.name] = `${v},${p.type === 'int' ? v * 2 : (v * 2).toFixed(4)}`
}
}
}
})
function buildGrid() {
const grid = {}
for (const [name, raw] of Object.entries(gridText.value)) {
const values = String(raw)
.split(',')
.map((x) => x.trim())
.filter((x) => x !== '')
.map((x) => Number(x))
.filter((x) => !Number.isNaN(x))
if (values.length) grid[name] = values
}
return grid
}
async function run() {
running.value = true
error.value = null
try {
const grid = buildGrid()
if (!Object.keys(grid).length) {
error.value = 'Provide at least one parameter with candidate values.'
return
}
result.value = await api.runSweep({
strategy_key: selectedKey.value,
base_params: {},
grid,
symbol: symbol.value,
timeframe: timeframe.value,
limit: Number(limit.value),
top_n: Number(topN.value),
})
if (result.value.error) error.value = result.value.error
} catch (e) {
error.value = e.response?.data?.detail || e.message
} finally {
running.value = false
}
}
</script>
<template>
<CollapsiblePanel title="Parameter sweep" storage-key="sweep" :summary="summary" :default-open="false">
<div class="space-y-3">
<div class="grid gap-2 sm:grid-cols-4">
<label class="space-y-1">
<span class="label">Strategy</span>
<select v-model="selectedKey" class="input" :disabled="running || !strategies.length">
<option v-if="!strategies.length" value="" disabled>
{{ loading ? 'Loading strategies…' : 'No strategies available' }}
</option>
<option v-for="s in strategies" :key="s.key" :value="s.key">{{ s.name }}</option>
</select>
</label>
<label class="space-y-1">
<span class="label">Symbol</span>
<input v-model="symbol" class="input" :disabled="running" />
</label>
<label class="space-y-1">
<span class="label">Timeframe</span>
<input v-model="timeframe" class="input" :disabled="running" />
</label>
<label class="space-y-1">
<span class="label">Candles</span>
<input v-model="limit" type="number" min="20" max="1000" class="input" :disabled="running" />
</label>
</div>
<div v-if="selected" class="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
<label v-for="(raw, name) in gridText" :key="name" class="space-y-1">
<span class="label">{{ name }} (comma-separated)</span>
<input v-model="gridText[name]" class="input" :disabled="running" />
</label>
</div>
<div class="flex items-center gap-2">
<label class="flex items-center gap-2 text-sm text-slate-300">
Top N
<input v-model="topN" type="number" min="1" max="100" class="input w-20" :disabled="running" />
</label>
<button class="btn-primary" :disabled="running || !selectedKey" @click="run">
{{ running ? 'Running…' : 'Run sweep' }}
</button>
</div>
<p v-if="error" class="rounded-lg border border-rose-500/40 bg-rose-500/10 p-3 text-sm text-rose-200">
{{ error }}
</p>
<div v-if="result && result.rows.length" class="overflow-x-auto">
<table class="w-full text-left text-sm">
<thead class="label">
<tr>
<th class="pb-2">#</th>
<th class="pb-2">Params</th>
<th class="pb-2 text-right">Return</th>
<th class="pb-2 text-right">Max DD</th>
<th class="pb-2 text-right">Trades</th>
<th class="pb-2 text-right">Win rate</th>
<th class="pb-2 text-right">Sharpe</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, i) in result.rows" :key="i" class="border-t border-edge/60">
<td class="py-1.5 text-slate-400">{{ i + 1 }}</td>
<td class="py-1.5 font-mono text-xs">
<span v-for="(v, k) in row.params" :key="k" class="mr-2">{{ k }}={{ v }}</span>
</td>
<td class="py-1.5 text-right tabular-nums" :class="pnlClass(row.metrics.total_return_pct)">
{{ signed(row.metrics.total_return_pct) }}%
</td>
<td class="py-1.5 text-right tabular-nums text-amber-300">
-{{ fmtMoney(row.metrics.max_drawdown_pct) }}%
</td>
<td class="py-1.5 text-right tabular-nums">{{ row.metrics.num_trades }}</td>
<td class="py-1.5 text-right tabular-nums">{{ fmtMoney(row.metrics.win_rate_pct) }}%</td>
<td class="py-1.5 text-right tabular-nums">
{{ row.metrics.sharpe === null ? '—' : fmtMoney(row.metrics.sharpe) }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</CollapsiblePanel>
</template>
+168
View File
@@ -0,0 +1,168 @@
import { mount } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import MultiStrategyPanel from './components/MultiStrategyPanel.vue'
afterEach(() => {
vi.restoreAllMocks()
})
const STRATEGIES = [
{
key: 'sma_cross',
name: 'SMA Crossover',
description: '',
params: [{ name: 'fast', type: 'int', default: 10, description: '' }],
},
{
key: 'ema_cross',
name: 'EMA Crossover',
description: '',
params: [{ name: 'fast', type: 'int', default: 9, description: '' }],
},
]
const EMPTY_STATUS = { running: false, strategies: [], total_pnl: 0, equity: 0 }
const PRESETS = [
{
id: 1,
name: 'BTC fast',
strategy_key: 'ema_cross',
symbol: 'ETH/USDT',
timeframe: '15m',
params: { fast: 3 },
},
]
function mountPanel(multiStatus = EMPTY_STATUS, presets = []) {
return mount(MultiStrategyPanel, {
props: { strategies: STRATEGIES, multiStatus, presets, busy: false, loading: false },
})
}
describe('MultiStrategyPanel', () => {
it('seeds one row by default', () => {
const wrapper = mountPanel()
expect(wrapper.findAll('select')).toHaveLength(1)
})
it('adds and removes rows', async () => {
const wrapper = mountPanel()
const addBtn = wrapper.findAll('button').find((b) => b.text().includes('Add strategy'))
await addBtn.trigger('click')
expect(wrapper.findAll('select')).toHaveLength(2)
const removeBtn = wrapper.findAll('button').find((b) => b.text() === 'Remove')
await removeBtn.trigger('click')
expect(wrapper.findAll('select')).toHaveLength(1)
})
it('emits start-many with normalised weights and params', async () => {
const wrapper = mountPanel()
const addBtn = wrapper.findAll('button').find((b) => b.text().includes('Add strategy'))
await addBtn.trigger('click')
const startBtn = wrapper.findAll('button').find((b) => b.text().includes('Start all'))
await startBtn.trigger('click')
const payload = wrapper.emitted('start-many')[0][0]
expect(payload.strategies).toHaveLength(2)
expect(payload.strategies[0].weight).toBe(1)
expect(payload.strategies[0].params).toEqual({ fast: 10 })
expect(payload.starting_balance).toBe(10000)
})
it('shows the per-strategy status table', () => {
const wrapper = mountPanel({
running: true,
total_pnl: 42,
equity: 10042,
strategies: [
{
strategy_key: 'sma_cross',
strategy_name: 'SMA Crossover',
params: {},
symbol: 'BTC/USDT',
timeframe: '5m',
allocation: 0.4,
cash: 4000,
equity: 4200,
realized_pnl: 200,
unrealized_pnl: 0,
total_pnl: 200,
open_positions: 1,
halted: false,
halt_reason: null,
trades_today: 2,
error: null,
},
],
})
const text = wrapper.text()
expect(text).toContain('SMA Crossover')
expect(text).toContain('40.0%')
expect(text).toContain('running')
})
it('loads a preset as an additional row', async () => {
const wrapper = mountPanel(EMPTY_STATUS, PRESETS)
const presetSelect = wrapper.findAll('select').find((s) => s.text().includes('BTC fast'))
expect(presetSelect).toBeTruthy()
await presetSelect.setValue('1')
// A second row now exists, seeded from the preset.
expect(wrapper.findAll('select').length).toBeGreaterThan(1)
const inputs = wrapper.findAll('input')
expect(inputs.some((i) => i.element.value === 'ETH/USDT')).toBe(true)
expect(inputs.some((i) => i.element.value === '15m')).toBe(true)
})
it('emits save-preset with the row config', async () => {
vi.spyOn(window, 'prompt').mockReturnValue('my preset')
const wrapper = mountPanel()
const save = wrapper.findAll('button').find((b) => b.text() === 'Save')
await save.trigger('click')
const payload = wrapper.emitted('save-preset')[0][0]
expect(payload.name).toBe('my preset')
expect(payload.strategy_key).toBe('sma_cross')
expect(payload.params).toEqual({ fast: 10 })
})
it('does not save when the prompt is cancelled', async () => {
vi.spyOn(window, 'prompt').mockReturnValue(null)
const wrapper = mountPanel()
const save = wrapper.findAll('button').find((b) => b.text() === 'Save')
await save.trigger('click')
expect(wrapper.emitted('save-preset')).toBeUndefined()
})
it('marks a halted strategy', () => {
const wrapper = mountPanel({
running: true,
total_pnl: -10,
equity: 9990,
strategies: [
{
strategy_key: 'sma_cross',
strategy_name: 'SMA Crossover',
params: {},
symbol: 'BTC/USDT',
timeframe: '5m',
allocation: 1,
cash: 0,
equity: 9990,
realized_pnl: -10,
unrealized_pnl: 0,
total_pnl: -10,
open_positions: 0,
halted: true,
halt_reason: 'daily loss limit hit',
trades_today: 1,
error: null,
},
],
})
expect(wrapper.text()).toContain('halted')
})
})
+42
View File
@@ -0,0 +1,42 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import StrategyForm from './components/StrategyForm.vue'
function mountForm(props) {
return mount(StrategyForm, { props: { strategies: [], ...props } })
}
describe('StrategyForm strategy dropdown states', () => {
it('shows a loading option while the list is being fetched', () => {
const wrapper = mountForm({ loading: true })
const select = wrapper.find('select')
expect(select.text()).toContain('Loading strategies…')
expect(select.attributes('disabled')).toBeDefined()
expect(wrapper.find('option[value=""]').attributes('disabled')).toBeDefined()
})
it('shows a no-strategies option when loaded but empty', () => {
const wrapper = mountForm({ loading: false })
expect(wrapper.find('select').text()).toContain('No strategies available')
})
it('lists strategies and enables the select once loaded', () => {
const wrapper = mountForm({
loading: false,
strategies: [
{
key: 'sma_cross',
name: 'SMA Crossover',
description: '',
params: [{ name: 'fast', type: 'int', default: 10, description: '' }],
},
{ key: 'ema_cross', name: 'EMA Crossover', description: '', params: [] },
],
})
const select = wrapper.find('select')
expect(select.attributes('disabled')).toBeUndefined()
expect(select.findAll('option')).toHaveLength(2)
expect(select.text()).toContain('SMA Crossover')
expect(select.element.value).toBe('sma_cross')
})
})
+15
View File
@@ -1,7 +1,22 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { execSync } from 'node:child_process'
// Short git SHA (or a timestamp fallback) baked into the bundle so the client
// can detect when the server is running a newer build and reload itself.
let buildVersion = process.env.VITE_BUILD_VERSION || String(Date.now())
if (!process.env.VITE_BUILD_VERSION) {
try {
buildVersion = execSync('git rev-parse --short HEAD').toString().trim()
} catch {
// not a git checkout; keep the timestamp
}
}
export default defineConfig({
define: {
__BUILD_VERSION__: JSON.stringify(buildVersion),
},
plugins: [vue()],
server: {
port: 5173,