Merge pull request #123 from sirius0xdev/refactor/move-trade-dashboard

refactor: remove trade-dashboard app code and rays-new-deployment.yaml
This commit is contained in:
sirius0xdev 2026-05-21 21:45:26 -04:00 committed by GitHub
commit 84e85d1432
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 648 additions and 955 deletions

View file

@ -1,52 +0,0 @@
name: Build and Push Trade Dashboard
on:
push:
branches: [master]
paths:
- 'trade-dashboard/**'
workflow_dispatch:
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository_owner }}/trade-dashboard
permissions:
contents: read
packages: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha,prefix=
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: trade-dashboard/
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

View file

@ -0,0 +1,303 @@
# Telegram Webhook Failure Analysis - Deep Dive (Container-Level)
**Date:** 2026-05-10
**Repo:** https://github.com/sirius0xdev/gcloud-lab
**Cluster:** devops-lab (GKE), Namespace: customer1
**Webhook URL:** https://ws.siriusdevops.com/telegram/webhook/default
---
## Status Update
**TLS certificates ARE provisioned** in GCP admin console. The Gateway certmap annotation is working.
The real issues are in the **container configuration** and **missing probes**.
---
## Root Cause #1: NO Liveness/Readiness Probes (CONFIRMED CRITICAL)
**The hermes-agent deployment has ZERO probes defined.**
This means:
- Kubernetes marks pods "Ready" immediately after container start
- The Gateway routes webhook traffic before the process has bound to port 9118
- During restarts, there is no graceful drain
- **A crashed or hung pod stays in the endpoint list forever**
### The /health endpoint problem
The Telegram webhook server runs on **port 9118** via python-telegram-bot's `start_webhook()`. This internally starts an aiohttp server that **ONLY registers the webhook path** (`/telegram/webhook/default`). It does NOT expose a `/health` endpoint.
So a probe like this would FAIL:
```yaml
# THIS WON'T WORK - port 9118 has no /health
readinessProbe:
httpGet:
path: /health
port: 9118
```
### What DOES have a health endpoint?
The **generic webhook adapter** on **port 8644** IS a Hermes-managed server. From the source code, it exposes `/health`. This IS enabled via `WEBHOOK_ENABLED: "true"`.
### The Fix
Add probes targeting port 8644 (the generic webhook server that IS healthy when the gateway is running):
```yaml
readinessProbe:
httpGet:
path: /health
port: 8644
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
livenessProbe:
httpGet:
path: /health
port: 8644
initialDelaySeconds: 30
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
```
Alternatively, enable the API server on port 8642 and probe there (it also has `/health`).
---
## Root Cause #2: NO Resource Limits (HIGH)
The **new deployment dropped all resource limits** that existed in the old deployment.
**Old deployment (`deployment.yaml`):**
```yaml
resources:
requests:
memory: 2Gi
cpu: "1"
limits:
memory: 3Gi
cpu: "2"
```
**New deployment (`new-deployment.yaml`):**
```yaml
# NO resources block for the hermes-agent container
```
Only the `hermes-webui` sidecar has limits (500Mi-1Gi memory, 100m-500m CPU).
### Impact
- The hermes-agent container can consume unbounded memory
- With `agent.max_turns: 90` and `gateway_timeout: 1800` (30 min), a single agent run can eat massive memory
- Pod may be OOMKilled by the node, but K8s won't restart it gracefully without probes
- **This is a likely cause of intermittent failures**
### Fix
Add resource limits back:
```yaml
resources:
requests:
memory: 2Gi
cpu: "1"
limits:
memory: 4Gi
cpu: "2"
```
---
## Root Cause #3: Path Handling Concern (MEDIUM)
**Source code** (`/opt/hermes/gateway/platforms/telegram.py:1213`):
```python
webhook_path = urlparse(webhook_url).path or "/telegram"
```
For `TELEGRAM_WEBHOOK_URL=https://ws.siriusdevops.com/telegram/webhook/default`:
- `webhook_path` = `/telegram/webhook/default`
- PTB's aiohttp server registers a handler at exactly `/telegram/webhook/default` on port 9118
**HTTPRoute** (`hermes-webhook.yaml`):
```yaml
rules:
- matches:
- path:
type: PathPrefix
value: /telegram/webhook
backendRefs:
- name: http-tele-webhook
port: 9118
```
**The question:** Does the GKE Gateway strip the `/telegram/webhook` prefix before forwarding?
- If it does NOT strip: Pod receives `/telegram/webhook/default` -> **OK**
- If it DOES strip to `/default`: Pod receives `/default` -> **404**
- If it strips to `/`: Pod receives `/` -> **404**
Most GatewayAPI implementations pass the full original path by default, but some ingress controllers strip the matched prefix. **Verify this on the running cluster.**
---
## Root Cause #4: API Server Not Enabled (MEDIUM)
The old deployment had:
```yaml
- name: API_SERVER_ENABLED
value: "true"
- name: API_SERVER_HOST
value: "0.0.0.0"
- name: API_SERVER_PORT
value: "8642"
- name: API_SERVER_KEY
valueFrom: {secretKeyRef: ...}
- name: API_SERVER_MODEL_NAME
value: "hermes-agent"
```
**These are ALL absent from `new-deployment.yaml`.** Port 8642 is declared as a containerPort but nothing listens on it because `API_SERVER_ENABLED` is not set.
The API server exposes `/health` and `/health/detailed` endpoints. Without it, you lose a convenient health check and the API server interface.
---
## Other Findings
### 5. TELEGRAM_WEBHOOK_ENABLED is Ignored (INFO)
The code only checks if `TELEGRAM_WEBHOOK_URL` is set (non-empty). It does NOT read `TELEGRAM_WEBHOOK_ENABLED`. The env var in the deployment is a dead config — set but ignored.
### 6. vLLM Service Name Mismatch (INFO)
```yaml
OPENAI_BASE_URL: "http://openclaw-brain-service.customer1.svc.cluster.local:8000/v1"
```
But the active vLLM deployment (`rtx6000-vllm.yaml`) creates a service named `rtx6000-brain-service`. If Hermes ever switches from xAI to the openai provider, local vLLM is unreachable.
### 7. SOPS Secrets (VERIFY)
Both `hermes-secret.yaml` and `tele-webhook.yaml` are SOPS-encrypted. Verify they are decrypted in the cluster:
```bash
kubectl get secret hermes-secrets -n customer1 -o jsonpath='{.data.TELEGRAM_BOT_TOKEN}' | base64 -d
kubectl get secret telegram-webhook -n customer1 -o jsonpath='{.data.TELEGRAM_WEBHOOK_SECRET}' | base64 -d
```
### 8. Container Env Vars Summary
| Variable | Value | Notes |
|----------|-------|-------|
| TELEGRAM_WEBHOOK_URL | https://ws.siriusdevops.com/telegram/webhook/default | OK |
| TELEGRAM_WEBHOOK_PORT | 9118 | OK |
| TELEGRAM_WEBHOOK_SECRET | From SOPS secret | Verify decrypted |
| TELEGRAM_WEBHOOK_ENABLED | "true" | **Ignored by code** |
| TELEGRAM_BOT_TOKEN | From SOPS secret | Verify decrypted |
| TELEGRAM_ALLOWED_USERS | 7528130947 | OK |
| WEBHOOK_ENABLED | "true" | OK (enables port 8644 /health) |
| WEBHOOK_PORT | 8644 | OK |
| API_SERVER_ENABLED | **NOT SET** | Port 8642 has no listener |
| HERMES_MODEL_PROVIDER | xai | OK (uses Grok) |
| HERMES_MODEL | grok-4.20-0309-reasoning | OK |
---
## Recommended Fix (Priority Order)
### P0 - Add Probes (will detect and restart hung/crashed pods)
Add to `new-deployment.yaml` under the hermes-agent container spec:
```yaml
readinessProbe:
httpGet:
path: /health
port: 8644
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
livenessProbe:
httpGet:
path: /health
port: 8644
initialDelaySeconds: 30
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
```
### P1 - Add Resource Limits (prevents OOM kills)
```yaml
resources:
requests:
memory: 2Gi
cpu: "1"
limits:
memory: 4Gi
cpu: "2"
```
### P2 - Verify Path Handling
Test if the Gateway passes the full path:
```bash
# From inside the pod, check what the webhook server receives
kubectl exec -n customer1 deploy/hermes-agent -- curl -s http://localhost:9118/telegram/webhook/default -X POST -H "Content-Type: application/json" -d '{}'
```
### P3 - Enable API Server (optional, gives /health on 8642)
Add back the API_SERVER_ENABLED env var if you want the API server health endpoint.
---
## Diagnostic Commands
Run these on the cluster RIGHT NOW to confirm the current state:
```bash
# 1. Check pod status and restart count
kubectl get pods -n customer1 -l app=hermes-agent -o wide
# 2. Check events for OOMKilled or probe failures
kubectl describe pod -n customer1 -l app=hermes-agent | grep -A5 -i 'oom\|probe\|restart'
# 3. Check if the webhook port is actually listening
kubectl exec -n customer1 deploy/hermes-agent -- ss -tlnp | grep 9118
# 4. Check if port 8644 health endpoint works
kubectl exec -n customer1 deploy/hermes-agent -- curl -s http://localhost:8644/health
# 5. Check logs for webhook startup messages
kubectl logs -n customer1 deploy/hermes-agent --tail=50 | grep -i 'webhook\|listening\|9118'
# 6. Verify SOPS secrets are decrypted
kubectl get secret hermes-secrets -n customer1 -o jsonpath='{.data.TELEGRAM_BOT_TOKEN}' | base64 -d && echo
kubectl get secret telegram-webhook -n customer1 -o jsonpath='{.data.TELEGRAM_WEBHOOK_SECRET}' | base64 -d && echo
# 7. Check if the webhook is registered with Telegram (using the pod's bot token)
BOT_TOKEN=$(kubectl get secret hermes-secrets -n customer1 -o jsonpath='{.data.TELEGRAM_BOT_TOKEN}' | base64 -d)
curl -s "https://api.telegram.org/bot${BOT_TOKEN}/getWebhookInfo" | python3 -m json.tool
# 8. Test the full path through the Gateway
curl -v https://ws.siriusdevops.com/telegram/webhook/default -X POST -H "Content-Type: application/json" -d '{}' 2>&1
# 9. Check container memory usage
kubectl top pod -n customer1 -l app=hermes-agent 2>/dev/null || echo "metrics-server not available"
# 10. Check resource limits on the container
kubectl get pod -n customer1 -l app=hermes-agent -o jsonpath='{.items[0].spec.containers[0].resources}'
```
---
## Files to Modify
1. `apps/base/customer1/hermes-agent/new-deployment.yaml` - Add probes + resource limits
2. Optionally: Re-enable API server env vars in `new-deployment.yaml`

View file

@ -0,0 +1,345 @@
# Telegram Webhook Failure Analysis - Hermes Agent on GKE
**Date:** 2026-05-10
**Repo:** https://github.com/sirius0xdev/gcloud-lab
**Cluster:** devops-lab (GKE)
**Namespace:** customer1
**Webhook URL:** https://ws.siriusdevops.com/telegram/webhook/default
---
## Executive Summary
The Telegram webhook is failing because **there is no valid TLS certificate for `ws.siriusdevops.com`** at the Gateway layer. Telegram strictly requires HTTPS with a publicly-trusted certificate for webhook delivery. The Gateway listener declares `protocol: HTTPS` but has no `tls.certificateRefs` and relies on a GKE `CertMap` annotation referencing `gateway-cert-map` — a resource that **does not exist** in the repository. Cert-manager is configured but disconnected from the GatewayAPI setup (HTTP-01 solver points to a non-existent Traefik ingress class).
**TL;DR:** Telegram tries to POST to `https://ws.siriusdevops.com/...`, but the Gateway has no certificate to present during the TLS handshake. Connection fails before it ever reaches the hermes-agent pod.
---
## Root Cause #1: Missing TLS Certificate (CRITICAL)
### What's happening
**File:** `infrastructure/gatewayapi/apigateway.yaml`
```yaml
listeners:
- name: https
protocol: HTTPS
port: 443
allowedRoutes:
namespaces:
from: All
```
The listener says HTTPS but has **no `tls:` block**. It relies entirely on this annotation:
```yaml
annotations:
networking.gke.io/certmap: gateway-cert-map
```
### The problem
- **No `CertMap` or `CertMapEntry` resource** exists anywhere in the repository for `gateway-cert-map`
- Without it, GKE has no managed certificate to attach to the Gateway
- Telegram's webhook delivery gets a TLS handshake failure or no certificate
### Cert-manager is also broken
**File:** `infrastructure/controllers/base/certmanager/clusterissuer.yaml`
```yaml
solvers:
- http01:
ingress:
class: traefik
```
- HTTP-01 solver references `class: traefik`, but **no Traefik ingress controller exists** in the cluster
- **No `Certificate` CRs** exist for `ws.siriusdevops.com` or any other domain
- Cert-manager is completely disconnected from the GatewayAPI setup
### How to fix (choose ONE approach)
**Option A: GKE Managed Certificates (recommended for GatewayAPI)**
Create a `ManagedCertificate` + `BackendConfig` or `CertMap`/`CertMapEntry`:
```yaml
apiVersion: networking.gke.io/v1
kind: ManagedCertificate
metadata:
name: hermes-webhook-cert
namespace: customer1
spec:
domains:
- ws.siriusdevops.com
- brain.siriusdevops.com
- paaas.siriusdevops.com
```
Then add `tls.certificateRefs` to the Gateway listener:
```yaml
listeners:
- name: https
protocol: HTTPS
port: 443
tls:
certificateRefs:
- name: hermes-webhook-cert
group: networking.gke.io
```
**Option B: Fix Cert-manager with DNS-01**
Switch ClusterIssuer from HTTP-01/Traefik to DNS-01 (e.g., Cloudflare, GCP DNS, or Route53), then create `Certificate` resources for each domain.
---
## Root Cause #2: No Readiness/Liveness Probes (HIGH)
**File:** `apps/base/customer1/hermes-agent/new-deployment.yaml`
No readiness or liveness probes are defined on any hermes-agent deployment.
### Impact
- Pods are marked "Ready" immediately after container start
- Gateway routes traffic to the webhook port (9118) before the process has bound to it
- During restarts, traffic hits pods that haven't initialized
### Fix
Add probes to the deployment:
```yaml
readinessProbe:
httpGet:
path: /health
port: 9118
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet:
path: /health
port: 9118
initialDelaySeconds: 15
periodSeconds: 30
```
---
## Root Cause #3: SOPS Encrypted Secrets (HIGH)
**Files:**
- `apps/base/customer1/hermes-agent/hermes-secret.yaml` (SOPS encrypted)
- `apps/base/customer1/hermes-agent/tele-webhook.yaml` (SOPS encrypted)
These contain `TELEGRAM_BOT_TOKEN` and `TELEGRAM_WEBHOOK_SECRET`.
### Risk
- If the deployment pipeline (Flux/Kustomize controller) is **not decrypting SOPS secrets**, pods receive literal `ENC[...]` strings
- The bot token would be invalid, so even if TLS worked, Telegram auth would fail
- The webhook secret would not match, causing Telegram to reject payloads
### Verify
Run `kubectl get secret hermes-secrets -n customer1 -o yaml` and check if values are base64-encoded real tokens or `ENC[...]` strings.
---
## Secondary Issues
### 3a. Service Name Mismatch (vLLM Integration)
**File:** `apps/base/customer1/hermes-agent/new-deployment.yaml`
```yaml
OPENAI_BASE_URL: "http://openclaw-brain-service.customer1.svc.cluster.local:8000/v1"
```
But the active vLLM deployment (`rtx6000-vllm.yaml`) creates a service named **`rtx6000-brain-service`**.
- `OPENAI_BASE_URL` points to `openclaw-brain-service` which may not exist
- If Hermes ever switches to the `openai` provider (instead of `xai`), local vLLM is unreachable
- The model provider defaults to `HERMES_MODEL_PROVIDER: xai` (Grok external API)
### 3b. Duplicate HTTPRoute Deployment
The webhook HTTPRoute (`hermes-webhook.yaml`) is included in **two** kustomization trees:
1. `infrastructure/gatewayapi/gateway-routes/kustomization.yaml` -> deployed via Flux
2. `apps/base/customer1/hermes-agent/kustomization.yaml` -> deployed via Flux
Same resource (`http-telegram-webhook` in `customer1`) from two sources. This may cause Flux reconciliation conflicts.
### 3c. Empty HF_TOKEN in vLLM Deployments
All vLLM deployments have:
```yaml
- name: HF_TOKEN
value: ""
```
If the model `edp1096/Huihui-Qwen3.6-27B-abliterated-FP8` is a gated model on HuggingFace, it will fail to download.
### 3d. KEDA Scale-to-Zero
**File:** `infrastructure/gpus/base/keda-gpu-scaling/keda-vllm.yaml`
```yaml
minReplicaCount: 0
maxReplicaCount: 1
```
- vLLM scales to **zero** when idle
- First request after cold start incurs full model load time (30-60 seconds)
- For real-time Telegram responses, this causes visible latency
### 3e. PVC Name Collision
Both `rtx6000-vllm.yaml` and `a100-vllm.yaml` define a PVC named `vllm-model-qwen3.6-27b-uncensored` in namespace `customer1`. If both are ever active simultaneously, they conflict.
---
## Architecture Overview
```
Internet
|
v
[GKE External LB]
|
Gateway: external-http-gateway
(port 443/HTTPS, NO TLS cert!)
|
+-----------+-----------+
| | |
ws.siriusdevops.com brain.siriusdevops.com paaas.siriusdevops.com
| | |
v v v
/telegram/webhook / /
| | |
v v v
http-tele-webhook rtx6000- paaas-landing
:9118 brain- :8080
service:8000
|
v
hermes-agent pod
(ports: 8642, 8644, 9118)
|
v
Model Provider: xai (Grok via external API)
Fallback: OPENAI_BASE_URL -> openclaw-brain-service (MISMATCHED)
```
---
## vLLM Server Status
| Server | GPU | Model | Quantization | Status |
|--------|-----|-------|-------------|--------|
| RTX 6000 | 1x Pro 6000 (96GB) | edp1096/Huihui-Qwen3.6-27B-abliterated-FP8 | FP8 | **ACTIVE** |
| A100 | 1x A100 (80GB) | Youssofal/Qwen3.6-27B-Abliterated-Heretic-Uncensored-BF16 | BF16 | Commented out |
| L4 | 1x L4 (24GB) | p-e-w/Qwen3-8B-heretic | auto | Commented out |
| Gemma | 1x A100 (80GB) | coder3101/Qwen3.5-27B-heretic | BF16 | Not in kustomization |
**Note:** The architecture plan (`plans/AI_ARCHITECTURE.md`) describes a dual-tier L4 dispatcher + A100 deep thinker setup, but the active deployment only has RTX 6000.
---
## Action Plan (Priority Order)
### P0 - Fix TLS (will unblock Telegram webhooks)
1. **Create a `CertMap`/`CertMapEntry`** or **`ManagedCertificate`** resource for `ws.siriusdevops.com`
2. **Add `tls.certificateRefs`** to the Gateway listener in `apigateway.yaml`
3. Verify with: `curl -vI https://ws.siriusdevops.com/telegram/webhook/default`
4. If cert is valid, Telegram should start delivering webhooks
### P1 - Verify Secrets
5. Check if SOPS secrets are actually decrypted in the cluster
6. Run: `kubectl get secret hermes-secrets -n customer1 -o jsonpath='{.data.TELEGRAM_BOT_TOKEN}' | base64 -d`
### P2 - Add Probes
7. Add readiness/liveness probes to hermes-agent deployment
8. Redeploy
### P3 - Clean Up GatewayAPI
9. Remove duplicate `hermes-webhook.yaml` reference from one kustomization
10. Fix or remove Traefik-referencing ClusterIssuers
### P4 - Fix vLLM Integration
11. Fix `OPENAI_BASE_URL` service name or update kustomization to create `openclaw-brain-service`
12. Set real `HF_TOKEN` in vLLM deployments
13. Consider setting `minReplicaCount: 1` in KEDA for consistent response times
---
## Files Referenced
### GatewayAPI & TLS
- `infrastructure/gatewayapi/apigateway.yaml` - Gateway definition (missing TLS)
- `infrastructure/gatewayapi/gateway-routes/hermes-webhook.yaml` - Webhook HTTPRoute
- `infrastructure/gatewayapi/gateway-routes/route.yaml` - General route
- `infrastructure/controllers/base/certmanager/clusterissuer.yaml` - Cert-manager (Traefik mismatch)
### Hermes Agent Deployment
- `apps/base/customer1/hermes-agent/new-deployment.yaml` - Active deployment + webhook service
- `apps/base/customer1/hermes-agent/deployment.yaml` - Old deployment (no webhook config)
- `apps/base/customer1/hermes-agent/configmap.yaml` - Hermes config
- `apps/base/customer1/hermes-agent/hermes-secret.yaml` - SOPS encrypted secrets
- `apps/base/customer1/hermes-agent/tele-webhook.yaml` - SOPS encrypted webhook secret
- `apps/base/customer1/hermes-agent/kustomization.yaml` - Kustomize composition
### vLLM Infrastructure
- `infrastructure/gpus/base/vllm-servers/rtx6000-vllm.yaml` - Active vLLM server
- `infrastructure/gpus/base/keda-gpu-scaling/keda-vllm.yaml` - KEDA scaling
- `infrastructure/gpus/base/keda-gpu-scaling/vllm-route.yaml` - vLLM external route
### Architecture Plans
- `plans/AI_ARCHITECTURE.md` - AI architecture plan
- `plans/models-to-try.md` - Models being considered
---
## Quick Diagnostic Commands
Run these on the cluster to confirm findings:
```bash
# 1. Check if TLS cert exists for the webhook domain
curl -vI https://ws.siriusdevops.com/telegram/webhook/default 2>&1 | grep -E 'SSL|certificate|subject|issuer'
# 2. Check Gateway status
kubectl get gateway external-http-gateway -n customer1 -o yaml
# 3. Check HTTPRoute status (attached/programmed)
kubectl get httproute http-telegram-webhook -n customer1 -o yaml
# 4. Verify secrets are decrypted
kubectl get secret hermes-secrets -n customer1 -o jsonpath='{.data.TELEGRAM_BOT_TOKEN}' | base64 -d && echo
# 5. Check if pods are actually ready
kubectl get pods -n customer1 -l app=hermes-agent -o wide
# 6. Check CertMap exists
kubectl get certmap gateway-cert-map -A 2>/dev/null || echo "CertMap NOT FOUND"
# 7. Check managed certificates
kubectl get managedcertificate -A 2>/dev/null || echo "No ManagedCertificates"
# 8. Check cert-manager certificates
kubectl get certificate -A 2>/dev/null || echo "No Certificates"
# 9. Check vLLM pod status
kubectl get pods -n customer1 -l app=rtx6000-brain-vllm -o wide
# 10. Test internal webhook endpoint
kubectl exec -n customer1 deploy/hermes-agent -- curl -s http://localhost:9118/health || echo "Health check failed"
```

View file

@ -1,217 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: rays-hermes-agent
namespace: customer1
spec:
replicas: 1
selector:
matchLabels:
app: rays-hermes-agent
template:
metadata:
labels:
app: rays-hermes-agent
spec:
# 1. Pod-level security context to ensure volumes inherit the right group
shareProcessNamespace: true
securityContext:
fsGroup: 1000
# 2. Define our shared bridge volumes
volumes:
- name: hermes-home
persistentVolumeClaim:
claimName: rays-hermes-agent-pvc
- name: hermes-agent-src
emptyDir: {}
- name: hermes-workspace
emptyDir: {}
- name: hermes-webui-app
emptyDir: {}
- name: hermes-configmap
configMap:
name: hermes-config
initContainers:
# 3. K8s workaround: Copy the agent source code into the shared emptyDir
- name: copy-agent-source
image: nousresearch/hermes-agent:latest
command:
- "sh"
- "-c"
- |
cp -a /opt/hermes/. /shared-src/ && chown -R 1024:1000 /shared-src /shared-home
if [ -f /tmp/hermes/config.yaml ]; then
cp -f /tmp/hermes/config.yaml /shared-home/config.yaml
fi
mkdir -p /shared-home/.local/bin
echo '#!/bin/sh' > /shared-home/.local/bin/gh
echo 'exit 1' >> /shared-home/.local/bin/gh
chmod +x /shared-home/.local/bin/gh
securityContext:
runAsUser: 0 # Run as root briefly to copy and fix permissions
runAsNonRoot: false
volumeMounts:
- name: hermes-agent-src
mountPath: /shared-src
- name: hermes-home
mountPath: /shared-home
- name: hermes-configmap
mountPath: /tmp/hermes/config.yaml
subPath: config.yaml
containers:
# ==========================================
# CONTAINER 1: HERMES AGENT
# ==========================================
- name: rays-hermes-agent
image: nousresearch/hermes-agent:latest
args: ["gateway", "run"]
ports:
- containerPort: 8642
env:
- name: PATH
value: "/home/hermes/.hermes/.local/bin:/opt/hermes/.venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
- name: HOME
value: "/home/hermes/.hermes"
- name: HERMES_HOME
value: "/home/hermes/.hermes"
- name: HERMES_UID
value: "1024"
- name: HERMES_GID
value: "1000"
- name: TELEGRAM_BOT_TOKEN
valueFrom:
secretKeyRef:
name: hermes-secrets
key: TELEGRAM_BOT_TOKEN
- name: XAI_API_KEY
valueFrom:
secretKeyRef:
name: xai-apikey
key: XAI_API_KEY
- name: TELEGRAM_ALLOWED_USERS
value: "7105451284"
# === Local vLLM (OpenAI-compatible) ===
- name: OPENAI_BASE_URL
value: "http://openclaw-brain-service.customer1.svc.cluster.local:8000/v1" # ← adjust if your service name differs
- name: HERMES_MODEL_PROVIDER
value: xai
- name: HERMES_MODEL
value: grok-4.20-0309-reasoning
- name: OPENAI_API_KEY
value: "dummy"
volumeMounts:
- name: hermes-home
mountPath: /home/hermes/.hermes
- name: hermes-agent-src
mountPath: /opt/hermes
securityContext:
runAsUser: 1024
runAsGroup: 1000
runAsNonRoot: true
allowPrivilegeEscalation: true
# ==========================================
# CONTAINER 2: HERMES WEBUI
# ==========================================
- name: hermes-webui
image: ghcr.io/nesquena/hermes-webui:latest
ports:
- containerPort: 8787
env:
- name: HOME
value: "/home/hermeswebui/.hermes"
- name: HERMES_HOME
value: "/home/hermeswebui/.hermes"
- name: HERMES_WEBUI_HOST
value: "0.0.0.0"
- name: HERMES_WEBUI_PORT
value: "8787"
- name: HERMES_WEBUI_STATE_DIR
value: "/home/hermeswebui/.hermes/webui"
- name: WANTED_UID
value: "1024"
- name: WANTED_GID
value: "1000"
- name: HERMES_SKIP_CHMOD
value: "1"
volumeMounts:
- name: hermes-home
mountPath: /home/hermeswebui/.hermes
# This is where the WebUI looks for the agent source code to run `uv pip install`
- name: hermes-agent-src
mountPath: /home/hermeswebui/.hermes/hermes-agent
- name: hermes-workspace
mountPath: /workspace
- name: hermes-webui-app
mountPath: /app
resources:
requests:
memory: 500Mi
cpu: "100m"
limits:
memory: 1Gi
cpu: "500m"
securityContext:
runAsUser: 1024
runAsGroup: 1000
runAsNonRoot: true
allowPrivilegeEscalation: true
readOnlyRootFilesystem: false
seccompProfile:
type: RuntimeDefault
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: rays-hermes-agent-pvc
namespace: customer1
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 25Gi
---
apiVersion: v1
kind: Service
metadata:
name: rays-hermes-webui-service
namespace: customer1
annotations:
tailscale.com/expose: "true"
tailscale.com/hostname: "rays-hermes-webui"
tailscale.com/tags: "tag:k8s-operator"
tailscale.com/ports: "http:8787"
spec:
type: ClusterIP
selector:
app: hermes-agent
ports:
- port: 8787
targetPort: 8787
name: http

View file

@ -1,18 +0,0 @@
FROM python:3.13-slim AS base
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc libpq-dev \
&& rm -rf /var/lib/apt/lists/*
COPY app/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ ./app/
COPY alembic.ini ./alembic.ini
COPY alembic/ ./alembic/
EXPOSE 8000
ENV PYTHONPATH=/app/app
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

View file

@ -1,36 +0,0 @@
[alembic]
script_location = alembic
sqlalchemy.url = postgresql+asyncpg://trading:CHANGE_ME@hermes-pgdb-rw.customer1.svc.cluster.local:5432/trading_data
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

View file

@ -1,62 +0,0 @@
"""Alembic environment configuration."""
import sys
from logging.config import fileConfig
from pathlib import Path
from alembic import context
from sqlalchemy import engine_from_config, pool
from sqlalchemy.ext.asyncio import AsyncEngine
sys.path.insert(0, str(Path(__file__).parent.parent / "app"))
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
from models import metadata # noqa: E402
target_metadata = metadata
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode."""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection):
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_migrations_online() -> None:
"""Run migrations in 'online' mode."""
connectable = AsyncEngine(
engine_from_config(
config.get_section(config.config_ini_section) or {},
prefix="sqlalchemy.",
poolclass=pool.NullPool,
future=True,
)
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
import asyncio
if context.is_offline_mode():
run_migrations_offline()
else:
asyncio.run(run_migrations_online())

View file

@ -1,2 +0,0 @@
# Alembic migration script - DO NOT EDIT MANUALLY
# Use: alembic revision --autogenerate -m "description"

View file

@ -1,43 +0,0 @@
"""initial schema — positions table
Revision ID: 001_initial
Create Date: 2026-05-02
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = "001_initial"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute('CREATE TYPE position_direction AS ENUM (\'long\', \'short\')')
op.create_table(
"positions",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
sa.Column("symbol", sa.String(32), nullable=False),
sa.Column("direction", postgresql.ENUM("long", "short", name="position_direction", create_type=False), nullable=False),
sa.Column("entry_price", sa.Numeric(precision=16, scale=8), nullable=False),
sa.Column("exit_price", sa.Numeric(precision=16, scale=8)),
sa.Column("quantity", sa.Numeric(precision=16, scale=8), nullable=False),
sa.Column("exchange", sa.String(32), nullable=False),
sa.Column("opened_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("closed_at", sa.DateTime(timezone=True)),
sa.Column("pnl", sa.Numeric(precision=16, scale=2)),
sa.Column("metadata", sa.JSON),
)
op.create_index(op.f("ix_positions_symbol"), "positions", ["symbol"])
op.create_index(op.f("ix_positions_exchange"), "positions", ["exchange"])
def downgrade() -> None:
op.drop_index(op.f("ix_positions_exchange"), table_name="positions")
op.drop_index(op.f("ix_positions_symbol"), table_name="positions")
op.drop_table("positions")
op.execute("DROP TYPE IF EXISTS position_direction")

View file

@ -1,16 +0,0 @@
import os
from sqlalchemy import MetaData
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
DB_USER = os.getenv("DB_USER", "trading")
DB_PASS = os.getenv("DB_PASSWORD", "")
DB_HOST = os.getenv("DB_HOST", "hermes-pgdb-rw.customer1.svc.cluster.local")
DB_PORT = os.getenv("DB_PORT", "5432")
DB_NAME = os.getenv("DB_NAME", "trading_data")
DATABASE_URL = f"postgresql+asyncpg://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}"
engine = create_async_engine(DATABASE_URL, echo=False, pool_size=5, max_overflow=10)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
metadata = MetaData()

View file

@ -1,253 +0,0 @@
"""Trade Dashboard — FastAPI service for tracking PnL and open positions."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from decimal import Decimal
from pathlib import Path
from uuid import UUID
from fastapi import FastAPI, HTTPException, Query
from fastapi.responses import FileResponse, HTMLResponse
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from database import async_session
from models import positions
from schemas import (
Direction,
PnLSnapshot,
PositionCreate,
PositionOut,
PositionUpdate,
WebhookTrade,
)
app = FastAPI(title="Trade Dashboard", version="0.1.0")
STATIC_DIR = Path(__file__).parent / "static"
# ── Helpers ─────────────────────────────────────────────────────────────
def position_to_out(row: dict) -> PositionOut:
return PositionOut(
id=row["id"],
symbol=row["symbol"],
direction=row["direction"],
entry_price=row["entry_price"],
exit_price=row["exit_price"],
quantity=row["quantity"],
exchange=row["exchange"],
opened_at=row["opened_at"],
closed_at=row["closed_at"],
pnl=row["pnl"],
metadata=row["metadata"],
)
# ── Health ─────────────────────────────────────────────────────────────
@app.get("/api/health")
async def health():
async with async_session() as session:
result = await session.execute(select(func.now()))
db_time = result.scalar()
return {"status": "ok", "db_time": db_time.isoformat()}
# ── Positions ────────────────────────────────────────────────────────────
@app.get("/api/positions", response_model=list[PositionOut])
async def list_positions(
open_only: bool = Query(True, description="Only show open positions"),
):
async with async_session() as session:
if open_only:
stmt = select(positions).where(positions.c.closed_at.is_(None)).order_by(positions.c.opened_at.desc())
else:
stmt = select(positions).order_by(positions.c.opened_at.desc())
rows = (await session.execute(stmt)).mappings().all()
return [position_to_out(r) for r in rows]
@app.post("/api/positions", status_code=201)
async def create_position(payload: PositionCreate):
async with async_session() as session:
values = payload.model_dump()
result = await session.execute(positions.insert().values(**values))
session.commit()
pk = result.inserted_primary_key[0]
return {"id": str(pk)}
@app.patch("/api/positions/{position_id}")
async def update_position(position_id: UUID, payload: PositionUpdate):
async with async_session() as session:
row = await session.execute(
select(positions).where(positions.c.id == position_id)
)
row = row.mappings().one_or_none()
if not row:
raise HTTPException(404, "Position not found")
updates = payload.model_dump(exclude_unset=True)
# Auto-compute PnL if closing
if "exit_price" in updates:
entry = row["entry_price"]
qty = row["quantity"]
exit_p = updates["exit_price"]
direction = row["direction"]
if direction == "long":
updates["pnl"] = float((exit_p - entry) * qty)
else:
updates["pnl"] = float((entry - exit_p) * qty)
updates["closed_at"] = datetime.now(timezone.utc)
await session.execute(
positions.update().where(positions.c.id == position_id).values(**updates)
)
session.commit()
return {"ok": True}
@app.delete("/api/positions/{position_id}")
async def close_position(position_id: UUID, exit_price: Decimal = Query(None)):
async with async_session() as session:
row = await session.execute(
select(positions).where(positions.c.id == position_id)
)
row = row.mappings().one_or_none()
if not row:
raise HTTPException(404, "Position not found")
if row["closed_at"]:
raise HTTPException(400, "Position already closed")
exit_p = exit_price or row["entry_price"] # breakeven default
entry = row["entry_price"]
qty = row["quantity"]
direction = row["direction"]
if direction == "long":
pnl = float((exit_p - entry) * qty)
else:
pnl = float((entry - exit_p) * qty)
await session.execute(
positions.update()
.where(positions.c.id == position_id)
.values(exit_price=exit_p, closed_at=datetime.now(timezone.utc), pnl=pnl)
)
session.commit()
return {"ok": True, "pnl": pnl, "exit_price": float(exit_p)}
# ── PnL ─────────────────────────────────────────────────────────────────
@app.get("/api/pnl", response_model=PnLSnapshot)
async def get_pnl():
async with async_session() as session:
now = datetime.now(timezone.utc)
today = now.replace(hour=0, minute=0, second=0, microsecond=0)
week_start = today - timedelta(days=now.weekday())
month_start = today.replace(day=1)
# Summary for closed trades
closed = select(
func.coalesce(func.sum(positions.c.pnl), 0).label("total"),
func.count(positions.c.id).label("count"),
).where(positions.c.closed_at.isnot(None))
result = (await session.execute(closed)).mappings().one()
all_time_pnl = float(result["total"])
total_trades = result["count"]
# PnL by period
def period_query(start):
return select(
func.coalesce(func.sum(positions.c.pnl), 0)
).where(
and_(
positions.c.closed_at.isnot(None),
positions.c.closed_at >= start,
)
)
today_pnl = float((await session.execute(period_query(today))).scalar())
week_pnl = float((await session.execute(period_query(week_start))).scalar())
month_pnl = float((await session.execute(period_query(month_start))).scalar())
# Open count
open_count = (await session.execute(
select(func.count()).where(positions.c.closed_at.is_(None))
)).scalar()
return PnLSnapshot(
today_pnl=Decimal(str(today_pnl)),
week_pnl=Decimal(str(week_pnl)),
month_pnl=Decimal(str(month_pnl)),
all_time_pnl=Decimal(str(all_time_pnl)),
total_trades=total_trades,
open_positions=open_count,
)
@app.get("/api/pnl/history", response_model=list[PositionOut])
async def pnl_history(
limit: int = Query(50, ge=1, le=500),
):
async with async_session() as session:
stmt = (
select(positions)
.where(positions.c.closed_at.isnot(None))
.order_by(positions.c.closed_at.desc())
.limit(limit)
)
rows = (await session.execute(stmt)).mappings().all()
return [position_to_out(r) for r in rows]
# ── Webhook (for scanner scripts) ───────────────────────────────────────
@app.post("/webhook/trade", status_code=201)
async def webhook_trade(payload: WebhookTrade):
meta = {"strategy": payload.strategy} if payload.strategy else {}
async with async_session() as session:
result = await session.execute(positions.insert().values(**{
"symbol": payload.symbol,
"direction": payload.direction,
"entry_price": payload.entry_price,
"quantity": payload.quantity,
"exchange": payload.exchange,
"metadata": meta,
}))
session.commit()
pk = result.inserted_primary_key[0]
return {"id": str(pk)}
# ── Frontend ────────────────────────────────────────────────────────────
@app.get("/", response_class=HTMLResponse)
async def index():
return FileResponse(str(STATIC_DIR / "index.html"))
# ── Startup: run Alembic migrations ──────────────────────────────────────
@app.on_event("startup")
async def startup():
from alembic import command
from alembic.config import Config
from pathlib import Path
alembic_cfg = Config(
str(Path(__file__).parent.parent / "alembic.ini")
)
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)

View file

@ -1,21 +0,0 @@
from sqlalchemy import Column, String, Numeric, Enum, DateTime, JSON, func, Table
from sqlalchemy.dialects.postgresql import UUID
import uuid
from database import metadata
positions = Table(
"positions",
metadata,
Column("id", UUID(as_uuid=True), primary_key=True, default=uuid.uuid4),
Column("symbol", String(32), nullable=False, index=True),
Column("direction", Enum("long", "short", name="position_direction"), nullable=False),
Column("entry_price", Numeric(precision=16, scale=8), nullable=False),
Column("exit_price", Numeric(precision=16, scale=8)),
Column("quantity", Numeric(precision=16, scale=8), nullable=False),
Column("exchange", String(32), nullable=False, index=True),
Column("opened_at", DateTime(timezone=True), server_default=func.now(), nullable=False),
Column("closed_at", DateTime(timezone=True)),
Column("pnl", Numeric(precision=16, scale=2)),
Column("metadata", JSON),
)

View file

@ -1,7 +0,0 @@
fastapi==0.115.0
uvicorn[standard]==0.32.0
sqlalchemy[asyncio]==2.0.35
asyncpg==0.30.0
alembic==1.14.0
pydantic==2.9.2
python-dotenv==1.0.1

View file

@ -1,70 +0,0 @@
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from enum import Enum
from typing import Optional
from uuid import UUID
from pydantic import BaseModel
class Direction(str, Enum):
long = "long"
short = "short"
# ─── Request schemas ──────────────────────────────────────────────
class PositionCreate(BaseModel):
symbol: str
direction: Direction
entry_price: Decimal
quantity: Decimal
exchange: str
metadata: Optional[dict] = None
class PositionUpdate(BaseModel):
entry_price: Optional[Decimal] = None
exit_price: Optional[Decimal] = None
quantity: Optional[Decimal] = None
metadata: Optional[dict] = None
class WebhookTrade(BaseModel):
"""Payload from automated scanner scripts."""
symbol: str
direction: Direction
entry_price: Decimal
quantity: Decimal
exchange: str
strategy: Optional[str] = None
# ─── Response schemas ─────────────────────────────────────────────
class PositionOut(BaseModel):
id: UUID
symbol: str
direction: Direction
entry_price: Decimal
exit_price: Optional[Decimal]
quantity: Decimal
exchange: str
opened_at: datetime
closed_at: Optional[datetime]
pnl: Optional[Decimal]
metadata: Optional[dict]
model_config = {"from_attributes": True}
class PnLSnapshot(BaseModel):
today_pnl: Decimal
week_pnl: Decimal
month_pnl: Decimal
all_time_pnl: Decimal
total_trades: int
open_positions: int

View file

@ -1,158 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Trade Dashboard</title>
<style>
:root {
--bg: #0d1117; --card: #161b22; --border: #30363d;
--text: #c9d1d9; --muted: #8b949e; --green: #3fb950;
--red: #f85149; --blue: #58a6ff; --accent: #1f6feb;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: var(--bg); color: var(--text); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 1rem; }
h1 { font-size: 1.5rem; margin-bottom: 1rem; color: var(--blue); }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; }
.card { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 1rem; }
.card .label { font-size: 0.75rem; color: var(--muted); text-transform: uppercase; }
.card .value { font-size: 1.5rem; font-weight: 600; margin-top: 0.25rem; }
.card .value.positive { color: var(--green); }
.card .value.negative { color: var(--red); }
table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
th, td { text-align: left; padding: 0.5rem; border-bottom: 1px solid var(--border); font-size: 0.85rem; }
th { color: var(--muted); font-weight: 500; }
.badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 0.75rem; font-weight: 600; }
.badge.long { background: #12261e; color: var(--green); }
.badge.short { background: #2a1215; color: var(--red); }
.btn { background: var(--accent); color: #fff; border: none; padding: 0.5rem 1rem; border-radius: 6px; cursor: pointer; font-size: 0.85rem; }
.btn:hover { opacity: 0.9; }
.btn.close { background: var(--red); font-size: 0.75rem; padding: 0.25rem 0.5rem; }
.section { margin-top: 2rem; }
.section h2 { font-size: 1.1rem; margin-bottom: 0.75rem; color: var(--blue); }
form { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 1rem; }
input, select { background: var(--card); border: 1px solid var(--border); color: var(--text); padding: 0.5rem; border-radius: 6px; font-size: 0.85rem; }
input:focus, select:focus { outline: 1px solid var(--accent); }
.tab-bar { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
.tab { padding: 0.5rem 1rem; border-radius: 6px; cursor: pointer; font-size: 0.85rem; background: var(--card); border: 1px solid var(--border); }
.tab.active { border-color: var(--accent); color: var(--blue); }
#loading { color: var(--muted); font-size: 0.85rem; }
@media (max-width: 600px) { .grid { grid-template-columns: 1fr 1fr; } }
</style>
</head>
<body>
<h1>📊 Trade Dashboard</h1>
<!-- PnL Summary Cards -->
<div class="grid" id="pnl-cards">
<div class="card"><div class="label">Today PnL</div><div class="value" id="today-pnl"></div></div>
<div class="card"><div class="label">Week PnL</div><div class="value" id="week-pnl"></div></div>
<div class="card"><div class="label">Month PnL</div><div class="value" id="month-pnl"></div></div>
<div class="card"><div class="label">All Time</div><div class="value" id="all-pnl"></div></div>
</div>
<!-- Open Positions -->
<div class="section">
<h2>Open Positions <span id="open-count" class="badge long">0</span></h2>
<table>
<thead><tr><th>Symbol</th><th>Dir</th><th>Entry</th><th>Qty</th><th>Exchange</th><th>Opened</th><th>Action</th></tr></thead>
<tbody id="positions-body"></tbody>
</table>
</div>
<!-- New Position Form -->
<div class="section">
<h2>Open New Position</h2>
<form id="open-form">
<input type="text" id="sym" placeholder="Symbol (e.g. SOL)" required>
<select id="dir"><option value="long">Long</option><option value="short">Short</option></select>
<input type="number" step="any" id="entry" placeholder="Entry Price" required>
<input type="number" step="any" id="qty" placeholder="Quantity" required>
<input type="text" id="exch" placeholder="Exchange (OKX, Bybit…)" required>
<button type="submit" class="btn">Open Position</button>
</form>
</div>
<!-- Trade History -->
<div class="section">
<h2>Trade History</h2>
<table>
<thead><tr><th>Symbol</th><th>Dir</th><th>Entry</th><th>Exit</th><th>Qty</th><th>PnL</th><th>Exchange</th><th>Closed</th></tr></thead>
<tbody id="history-body"></tbody>
</table>
</div>
<script>
const fmt = (n) => {
const v = parseFloat(n);
const cls = v >= 0 ? 'positive' : 'negative';
return `<span class="${cls}">${v >= 0 ? '+' : ''}${v.toFixed(2)}</span>`;
};
async function loadPnL() {
const r = await fetch('/api/pnl').then(x => x.json());
document.getElementById('today-pnl').innerHTML = fmt(r.today_pnl);
document.getElementById('week-pnl').innerHTML = fmt(r.week_pnl);
document.getElementById('month-pnl').innerHTML = fmt(r.month_pnl);
document.getElementById('all-pnl').innerHTML = fmt(r.all_time_pnl);
}
async function loadPositions() {
const positions = await fetch('/api/positions?open_only=true').then(x => x.json());
document.getElementById('open-count').textContent = positions.length;
const body = document.getElementById('positions-body');
body.innerHTML = positions.map(p => `
<tr>
<td>${p.symbol}</td>
<td><span class="badge ${p.direction}">${p.direction.toUpperCase()}</span></td>
<td>${p.entry_price}</td>
<td>${p.quantity}</td>
<td>${p.exchange}</td>
<td>${new Date(p.opened_at).toLocaleString()}</td>
<td><button class="btn close" onclick="closePos('${p.id}')">Close</button></td>
</tr>`).join('');
}
async function loadHistory() {
const history = await fetch('/api/pnl/history?limit=100').then(x => x.json());
const body = document.getElementById('history-body');
body.innerHTML = history.map(p => `
<tr>
<td>${p.symbol}</td>
<td><span class="badge ${p.direction}">${p.direction.toUpperCase()}</span></td>
<td>${p.entry_price}</td>
<td>${p.exit_price ?? '—'}</td>
<td>${p.quantity}</td>
<td>${p.pnl != null ? (parseFloat(p.pnl) >= 0 ? '+' : '') + parseFloat(p.pnl).toFixed(2) : '—'}</td>
<td>${p.exchange}</td>
<td>${p.closed_at ? new Date(p.closed_at).toLocaleString() : '—'}</td>
</tr>`).join('');
}
async function closePos(id) {
const price = prompt('Exit price:');
if (!price) return;
await fetch(`/api/positions/${id}?exit_price=${price}`, { method: 'DELETE' });
refresh();
}
document.getElementById('open-form').onsubmit = async (e) => {
e.preventDefault();
const body = {
symbol: document.getElementById('sym').value,
direction: document.getElementById('dir').value,
entry_price: parseFloat(document.getElementById('entry').value),
quantity: parseFloat(document.getElementById('qty').value),
exchange: document.getElementById('exch').value,
};
await fetch('/api/positions', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body) });
e.target.reset();
refresh();
};
async function refresh() { await loadPnL(); await loadPositions(); await loadHistory(); }
refresh();
setInterval(refresh, 30000); // auto-refresh every 30s
</script>
</body>
</html>