diff --git a/apps/base/customer1/hermes-db/hermes-db-credentials.yaml b/apps/base/customer1/hermes-db/hermes-db-credentials.yaml
index 93a207c..2ca9f99 100644
--- a/apps/base/customer1/hermes-db/hermes-db-credentials.yaml
+++ b/apps/base/customer1/hermes-db/hermes-db-credentials.yaml
@@ -1,24 +1,24 @@
-apiVersion: ENC[AES256_GCM,data:txA=,iv:gCFjwoYIBFCTXOTBjND+corfessRLGUh313T8aVsO0M=,tag:V/SjrvWG2vLrFTBP9CpWNA==,type:str]
-kind: ENC[AES256_GCM,data:ZgEuMlxy,iv:Wp4ZPGH4ID7t8BGEsnJ/Rm7wIVm9rWPQYLfj3CJmo5s=,tag:bd1KCKO6eKgsJtJ4YcWW4A==,type:str]
+apiVersion: v1
+kind: Secret
metadata:
- name: ENC[AES256_GCM,data:0iXVLY1JDCujP/JQWELIr5dP4uSB,iv:Das8czGXBlxz+hC8KWh9jZwXJCQ12v+P6tSL4DUL1S4=,tag:l4kKkLd4LvRxECg31ob2fg==,type:str]
- namespace: ENC[AES256_GCM,data:p7hF20bNI6CI,iv:FNB+sXTcgKQdE/1MZuYlxNukMSx5mWNXqVOtnxbvOCo=,tag:lOmXwn+jF3JapOGPWzEz2w==,type:str]
-type: ENC[AES256_GCM,data:NLgmDHre,iv:xMs4To5h+yPgQuYOVP2pu53cA1yqsh8d91trr/gvAWg=,tag:1FNpJ4VqHwfXP5L2T0jG1w==,type:str]
+ name: hermes-db-credentials
+ namespace: customer1
+type: Opaque
stringData:
- username: ENC[AES256_GCM,data:Kyn8uqkj,iv:0BMWC+W8RiAOhKeaWwCwpoxug0t5nHBRPqRfeGZPaF0=,tag:ECYB03gU8kk5mWKu5ERbEg==,type:str]
- password: ENC[AES256_GCM,data:iWw+TY9t68laPLRstPN7gybhyUP3bz6lpRTSnSdAgW0=,iv:hFOncdpn0AoQ5JywZxkwnrOnE+jb0rFIeoU2FcTnkqY=,tag:f+rjg2kfGNTfR3gb8kVRBA==,type:str]
+ username: ENC[AES256_GCM,data:R3DLxC5/,iv:Q9026dVs2OTUzSldYtDh1cMP/6qxW68pbhphJhwlk5Y=,tag:ISs06+LqVP/aR/oxYP0K/Q==,type:str]
+ password: ENC[AES256_GCM,data:u0bmdtnkLmrECk3N0LrO6T7Uwy7iqAfkgfK4TqS6H3k=,iv:6FYk+vHKr6jjizTkZChUsYQAKKNgA7dP6vgDETg9Lcg=,tag:FQ/hpK93OTbID9OT8XfzHA==,type:str]
sops:
age:
- - recipient: age1uuxf066xuuqgvjppxfcmqkwfcufnwp3wcwnl9h20g9k4l8nkw9jsaungf7
+ - recipient: age1z674fe9c0whe6g55l9lpm6vssx0l9xyzw5rv42tucwua25gvvg6q42t77v
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
- YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBRc2haZVlrUEJUV2F5bHFm
- WG5qV2xVYzl4UE5aM3VBNE9JUnJ4YVI5V2dzCkVJM2NDZDd6MjZsdElYR09aRkgw
- MXZ5anlGVW1laHl3T2k0M2ZiUHZ3cjgKLS0tIG9od1JtU1lJY0ZrSUdtWWMxV1FM
- Vk92L1NWVGpkMWRUdmNpV1AxS1RSMzQKjITIDbSQp+aaKDzR48ENFmuY+dMTYPaQ
- 1P8zng4fQptC9RBvps327Z5kMIAtS52zYeoYgBwgdXNtTsaoO4ddmA==
+ YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBRcVcvVjJZeDdBWFZ2Nmox
+ YnVrVXBpUkhDUFNIMWMySnNtcXkyRU9tNnhFClhCWEFrSStneTYwVFZhbE9sUHlh
+ OHNsQWtjb1VyMnE1cGM1UWZKKytXWE0KLS0tIDVDclVnTFFTbWJ6SXdFblZBTkl4
+ UEpBUU1ldWZVc0Jadm1pUHVOaW04bnMKHWfeHUikSfN9+Q27eD1xsqbXbZ8H3T2X
+ MKVildkb3jtUIJvA1FS+8RU0LkhmsoxKtE1ox0XQ66dAZPa8UVgLQA==
-----END AGE ENCRYPTED FILE-----
- lastmodified: "2026-05-02T21:29:40Z"
- mac: ENC[AES256_GCM,data:ioHm0Go1hjO4VYKhEiu3e2Pdz+FzwDjlzQ5rWNbmuoCRW3bdqXpIvGNMyALBX7e+UCo+PwlNWiu3JrhszxdLptsySdCpzz0z04CZoQCbej66xdi4U49VvMVCJyZaRP5ZGFysXQghz0m0z4QoAcMSyZZGh8f8PnLewxORiXwrS7c=,iv:HXjO4oABWJCSUHN4dQiMXV1dFR0bxpDYSJXhBQ4G970=,tag:LIIY8pv+0/U/IfaB2D8vuQ==,type:str]
- unencrypted_suffix: _unencrypted
+ lastmodified: "2026-05-02T22:00:51Z"
+ mac: ENC[AES256_GCM,data:zOHEP8m1lGrcPMfpZ6aEBnQWfe1pAXBWk0z85ghkMxgM/a5pp24RvEzprRQBMpx36tnd+Xk3oYwCwTUVv0Y5xm7ZFK0tGSbXbKHrqNoOYPHPnbhG3GB7kIRcxlK4FJXy4BAPHJSUv5MrfIAmLj4Fa5XCbsDeqRWazyytUnrDNvc=,iv:SBsIMSE44I9wdcSmCg7KBetdnyjj63oFol5K2+54Y78=,tag:LeBp0JWAmjCvhcPcwOUvhw==,type:str]
+ encrypted_regex: ^(data|stringData)$
version: 3.12.2
diff --git a/apps/base/customer1/hermes-db/memory-db-credentials.yaml b/apps/base/customer1/hermes-db/memory-db-credentials.yaml
index 1151b82..7094884 100644
--- a/apps/base/customer1/hermes-db/memory-db-credentials.yaml
+++ b/apps/base/customer1/hermes-db/memory-db-credentials.yaml
@@ -1,24 +1,24 @@
-apiVersion: ENC[AES256_GCM,data:2mQ=,iv:LNl1ueDj/seVjsPVQ20/CQJnbEr7Fd/BqMogFKbWgCo=,tag:fVqYeJV9BqKWifP1idQKjg==,type:str]
-kind: ENC[AES256_GCM,data:44E4DRzE,iv:zIn6ZIrZm44IJuLFERN+WTgctZ/04KkmxURtjsUFUXs=,tag:DbRfwktJT4jdc4TR39QYqQ==,type:str]
+apiVersion: v1
+kind: Secret
metadata:
- name: ENC[AES256_GCM,data:rQCpZs4/HHeRnO0OgCr65HhYK84W,iv:dUiKDy3zsp7EO+wd/QI0W0M8dPHYKSDzmCP80Y+XIIM=,tag:bKWz+FI/PtCewuSaVB1cUA==,type:str]
- namespace: ENC[AES256_GCM,data:cGonbW6DupwQ,iv:6EGZKJeC77OSgUikYPRRkAPraIdRLFLaQ/FD2+C4gco=,tag:TSl9wo35AMfjU62gcq0lsg==,type:str]
-type: ENC[AES256_GCM,data:xXPsacph,iv:u2HydlElviV+Pvs7nsZutnQbK2TIRm88uk7u9/fJels=,tag:h8f7Dy8JpvAJzUWwzjsMUw==,type:str]
+ name: memory-db-credentials
+ namespace: customer1
+type: Opaque
stringData:
- username: ENC[AES256_GCM,data:VF38x+hU,iv:epSlTbTaVeOkpUZkLQ5QHuidsh5JEQkPz0HpqYQIvLI=,tag:HjZ/F29xirkNR3IMzyA+Dw==,type:str]
- password: ENC[AES256_GCM,data:Wu015YXUwNMuIIAUZ4Vd7BDfQS137VuQynL4e7rqtw8=,iv:7xA4FdSOXPJTwkA7guYR1IhPHjlXXDjtCaRcdGJ52kQ=,tag:kw/oxGWhPeXDkBKvxg3vog==,type:str]
+ username: ENC[AES256_GCM,data:qCoYuXNK,iv:cZIJ9IgPWBnfGsNDFIT3T63v+jzxKMrTDcbY4R9ZXDk=,tag:THcqn7xE3UOFmq/YGEj8zg==,type:str]
+ password: ENC[AES256_GCM,data:S594bfUc+w83/M6eoMuzWaTboTmbBOjq5WEy+ExNzG8=,iv:JGLwfvei/SmMaLUneZ20/Pp8WsnWP+Ih87sTIyllDFA=,tag:VlH+4Wopp2z9J4MTSDYH2g==,type:str]
sops:
age:
- - recipient: age1uuxf066xuuqgvjppxfcmqkwfcufnwp3wcwnl9h20g9k4l8nkw9jsaungf7
+ - recipient: age1z674fe9c0whe6g55l9lpm6vssx0l9xyzw5rv42tucwua25gvvg6q42t77v
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
- YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBsMkE4N2R3bTVRUWtZdkVi
- N1Q4ZEo5ZHdmZjFNdVAydHdMeXkzZURFZVFBCmJxWnVLQ0F6OU52Y3FTa0N3Y2ZU
- M2trTjdZL3E3R1lXa29lOWlGTVFCenMKLS0tIHVhVllpTXNzQ0hSblhnYW56eHIv
- OW9sRDJLcU54TmdXdGpsTCtlUlR5NmMK7n5nIwAzxJgvJw9VqStiCVCE9wuNzMX2
- NaREHasP+XtJpB3AGkNgwBr/LW7cZKs5uvy8vKUFqhqpk/hcDsHjhg==
+ YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBXQU1PbFNMWTMyM2dxUlpp
+ eTRHbnRXSnk2bDR4VVZycmFUcUVZYVRvUTJnCkFuQURyMTQ0Y0RwUDZNQWpFRFMx
+ Q0ZHRHdzN2VCeU1vcWx6dzFKT3cwQk0KLS0tIDZYYkxoSGk1RmF5VjJkK09IejJ1
+ SmR0TGlHOGZad2NNa1grUVNGQnZNUFUKHAJhqYReMS+w5gjQqqHTWafy6etxLpDP
+ mRujATlpk85g60GeKB7mwWvvX82SpaOZ9GJ+LePjpXwdclIOusofhg==
-----END AGE ENCRYPTED FILE-----
- lastmodified: "2026-05-02T21:29:40Z"
- mac: ENC[AES256_GCM,data:1vcokwfS2/qfWqMWpfTl1wmsgMcx9tUbMv7IP13lzBc1j7YirW4xs+a52hxGFityPJgWvp4/XwdUbt06l33IF7dykN6vT9lFK4792Kx6KzdmCqP2eawtT2GKT1ewfmO1Mjr/YJ77o1qmaek7BPwNv+8vlDg0Wlerbt0jsy2SqCI=,iv:O98sE1/kNbUYaljxDevO9Ow0CbDz7VWcTCRY3wvwF6s=,tag:Yy1kzaKc1JCW6Wkny7ITHg==,type:str]
- unencrypted_suffix: _unencrypted
+ lastmodified: "2026-05-02T22:00:51Z"
+ mac: ENC[AES256_GCM,data:5EPPuVDZNXYE50rnXQyxajV4Zs0gZX+Lu5Zf5oERLs/TqFEBTQkew3hMMLuMflg3WQME7f9vcOVIYNAXL93rUCt28KHDAxv5WSNyBIZdTuZvfsYK6+we41wjdehvTf4SqsX2KznBVJKIO65ypNUtzrW/rJCj440UwWpgm/B2+/0=,iv:umvqqa7eSVgsbTopdOz0vkJdVPQxa66SFQx9dDxovmg=,tag:SJmrzPoX4gdrdTNEj8qe1g==,type:str]
+ encrypted_regex: ^(data|stringData)$
version: 3.12.2
diff --git a/apps/base/customer1/hermes-db/trading-db-credentials.yaml b/apps/base/customer1/hermes-db/trading-db-credentials.yaml
index ad1da95..9ab17a9 100644
--- a/apps/base/customer1/hermes-db/trading-db-credentials.yaml
+++ b/apps/base/customer1/hermes-db/trading-db-credentials.yaml
@@ -1,24 +1,24 @@
-apiVersion: ENC[AES256_GCM,data:GSk=,iv:EtxocdVlzVEPk6rHozVdjwOgjWqZNdJfG2PWrVRF1Ik=,tag:pBINgiS3tVGO6iQ0seax/g==,type:str]
-kind: ENC[AES256_GCM,data:T5+vJT+t,iv:GEuW09nF9g3ZFOtFx6kJfgkx+U80QYXkVMQDJoAKh7E=,tag:OTsAfg7FiOoe4C6eyZCLyQ==,type:str]
+apiVersion: v1
+kind: Secret
metadata:
- name: ENC[AES256_GCM,data:iM8e0gyP/bheft8exNWIuLAu9AObog==,iv:73wEhFh6OVB+vZEvYY0uEQj4U7NgDoOstWmka57J72Y=,tag:vRTgvDqoX3ccZDIwybiH0w==,type:str]
- namespace: ENC[AES256_GCM,data:65gK9YhIlftj,iv:uZC14McBjB1LTMhalbPH2KJinstaaZyvlVDDe2FvKCE=,tag:j2yuQ6bD5VRYhAV0JhxflQ==,type:str]
-type: ENC[AES256_GCM,data:rjywm84O,iv:Yp9tttUgySGPSMRiSmuMff544kp0+k09t0Q8jGHWjiM=,tag:xgsEUcmI20kfJVsEU+zTBQ==,type:str]
+ name: trading-db-credentials
+ namespace: customer1
+type: Opaque
stringData:
- username: ENC[AES256_GCM,data:MHbgLPzGjQ==,iv:hwINFxef3rnIetuD1aICeTly2Y9oVieQoRHTfKtEAyk=,tag:hnAirU6ib5aOpRf6JyYZpA==,type:str]
- password: ENC[AES256_GCM,data:jrxJhvglCEa+Kwed1mrtT+JnrpmQ4leVMOv1jVVcGw8=,iv:jHyO1uw3eo9uJfwQBZcG/UdVwnpkkr1fsrb9uFCE7B8=,tag:V4VeDoD/I0RE62x2pIFiOA==,type:str]
+ username: ENC[AES256_GCM,data:fqozOGjMOA==,iv:+7yvkH0+PTQEqXbd2teyN+VqOLJBDPs03EzUBdOMgdw=,tag:a8wZr/hCbA5xd1tY7OYFAA==,type:str]
+ password: ENC[AES256_GCM,data:JBfMrq2fsQHGAaw5qGFcjrQc7ID+v1f4pq9uvZxup5o=,iv:CgcXAovSEHrtGmyFzlG8qMwsYhijec4w+CBtX26KgVI=,tag:Y1niqpPBZiDtwWQKipjjNA==,type:str]
sops:
age:
- - recipient: age1uuxf066xuuqgvjppxfcmqkwfcufnwp3wcwnl9h20g9k4l8nkw9jsaungf7
+ - recipient: age1z674fe9c0whe6g55l9lpm6vssx0l9xyzw5rv42tucwua25gvvg6q42t77v
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
- YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBnM0RsNm5UTCsxdWJJRjdp
- d1ZZcitVZ1p3aGgzY3YvWVk5aVZIUzZxSWpnCmk1S0NwUXpqWWw1bUoxbW5Kcm9X
- cjdlb3pqbkpvQm95K2lxY1JtNlRRZm8KLS0tIERyZFl5M0hJUDdxZlBTaGxGbm1z
- M0U5ajRJeTI3TmltOWFoR1R3Sk1LQlkKht8ze+iqhdwucRbqiNMAnwvs+idhs159
- JBnY8Y90O1UBAB/yUoACVjGf32d3c6iCSOi2L35FopTjXNREgcU5wQ==
+ YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBVM3h1ajJsdkwxeG10ZklX
+ S0V5dVVpYm5rUmtHZVVzcFJITEhpaHlLVmw0Ckg4UTFoblZMUUo4RDZraXZ3bFJX
+ MXduaFc3eW1Hd0Y0K292ZzVzdW5OK00KLS0tIFFFejMxSkN0cDQrbjhLcVBhb25H
+ LzBTZ0pLbUJDUmsyN1JiTUFWUVU2dUEKogDQhc16qijRDOR3dVpOhTcm7TBXblhR
+ jn8rx6WODXrHTVqkRzJV7iYCvSb/mHCyRoBC5WvoUsNQTa4u2FtBkg==
-----END AGE ENCRYPTED FILE-----
- lastmodified: "2026-05-02T21:29:40Z"
- mac: ENC[AES256_GCM,data:Bjl+US5pStZ+xiCJ1l1xtDl4bVldkqme5fxMQN5WNsvalo/bKXElUpxVLf52n5jWcZjkjNHWuM407db3NQmSwXqz1o6+qmwIgfv3hz3JViUh0JSiSX/tDz8+yLEQ7fPVkvehxfWaZN2wJhyHhuPvkOmz9Fm+YSxqbw9DaWr2BkI=,iv:83n0XewztuAnIdb/CLPmseQLLbo/uw+rD8t67lxD1J8=,tag:KiKtZKPCX5BRfJfZ8xdDkg==,type:str]
- unencrypted_suffix: _unencrypted
+ lastmodified: "2026-05-02T22:00:51Z"
+ mac: ENC[AES256_GCM,data:BNXMSqTw3tTnSaUTF2vHmg7NM/eA3DuomPKrbTQR2X4TKJtdj5KNs5RbC9DeWktFze1QtT3nreTAm5jAHAz4wjWGIjBgqPy2KBy/ivXOGQJlCf80fiBwHLYcF4J10MC7yRp/yiTOInAVhCwH+B+KHgCtHlvlOhRiKcrgwvdsB38=,iv:zOpLrG43GOynzWSR+90SC/xQwUckOjn03povK6/7z4A=,tag:AH9Gc1aqpHLnZ99FLZ2U2w==,type:str]
+ encrypted_regex: ^(data|stringData)$
version: 3.12.2
diff --git a/apps/base/customer1/trade-dashboard/Dockerfile b/apps/base/customer1/trade-dashboard/Dockerfile
new file mode 100644
index 0000000..6968d40
--- /dev/null
+++ b/apps/base/customer1/trade-dashboard/Dockerfile
@@ -0,0 +1,18 @@
+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
+
+CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
diff --git a/apps/base/customer1/trade-dashboard/alembic.ini b/apps/base/customer1/trade-dashboard/alembic.ini
new file mode 100644
index 0000000..60ba74e
--- /dev/null
+++ b/apps/base/customer1/trade-dashboard/alembic.ini
@@ -0,0 +1,36 @@
+[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
diff --git a/apps/base/customer1/trade-dashboard/alembic/env.py b/apps/base/customer1/trade-dashboard/alembic/env.py
new file mode 100644
index 0000000..e474716
--- /dev/null
+++ b/apps/base/customer1/trade-dashboard/alembic/env.py
@@ -0,0 +1,61 @@
+"""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()
+
+
+if context.is_offline_mode():
+ run_migrations_offline()
+else:
+ from sqlalchemy.ext.asyncio import run_async # noqa: E402
+ run_async(run_migrations_online())
diff --git a/apps/base/customer1/trade-dashboard/alembic/script.py.mako b/apps/base/customer1/trade-dashboard/alembic/script.py.mako
new file mode 100644
index 0000000..d458e51
--- /dev/null
+++ b/apps/base/customer1/trade-dashboard/alembic/script.py.mako
@@ -0,0 +1,2 @@
+# Alembic migration script - DO NOT EDIT MANUALLY
+# Use: alembic revision --autogenerate -m "description"
diff --git a/apps/base/customer1/trade-dashboard/alembic/versions/001_initial.py b/apps/base/customer1/trade-dashboard/alembic/versions/001_initial.py
new file mode 100644
index 0000000..51ed0d7
--- /dev/null
+++ b/apps/base/customer1/trade-dashboard/alembic/versions/001_initial.py
@@ -0,0 +1,43 @@
+"""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.create_enum("position_direction", "long", "short", schema="public", create_type=True)
+
+ 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")
diff --git a/apps/base/customer1/trade-dashboard/app/database.py b/apps/base/customer1/trade-dashboard/app/database.py
new file mode 100644
index 0000000..2101b78
--- /dev/null
+++ b/apps/base/customer1/trade-dashboard/app/database.py
@@ -0,0 +1,25 @@
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
+from sqlalchemy import MetaData
+
+# Connection to hermes-pgdb CNPG cluster
+DATABASE_URL = (
+ f"postgresql+asyncpg://{db_user}:{db_pass}"
+ f"@hermes-pgdb-rw.customer1.svc.cluster.local:5432/trading_data"
+).format(
+ db_user="trading",
+ db_pass="TRADING_DB_PASSWORD", # overridden by env
+)
+
+import os
+
+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()
diff --git a/apps/base/customer1/trade-dashboard/app/main.py b/apps/base/customer1/trade-dashboard/app/main.py
new file mode 100644
index 0000000..a2be8ef
--- /dev/null
+++ b/apps/base/customer1/trade-dashboard/app/main.py
@@ -0,0 +1,253 @@
+"""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():
+ import alembic.config
+ cfg = alembic.config.AlembicConfig(
+ str(Path(__file__).parent.parent / "alembic.ini")
+ )
+ alembic.config.main.main(command="upgrade", args=["head"], config=cfg)
+
+
+if __name__ == "__main__":
+ import uvicorn
+ uvicorn.run(app, host="0.0.0.0", port=8000)
diff --git a/apps/base/customer1/trade-dashboard/app/models.py b/apps/base/customer1/trade-dashboard/app/models.py
new file mode 100644
index 0000000..8169975
--- /dev/null
+++ b/apps/base/customer1/trade-dashboard/app/models.py
@@ -0,0 +1,21 @@
+from sqlalchemy import Column, String, Numeric, Enum, DateTime, JSON, func
+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),
+)
diff --git a/apps/base/customer1/trade-dashboard/app/requirements.txt b/apps/base/customer1/trade-dashboard/app/requirements.txt
new file mode 100644
index 0000000..51462ea
--- /dev/null
+++ b/apps/base/customer1/trade-dashboard/app/requirements.txt
@@ -0,0 +1,7 @@
+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
diff --git a/apps/base/customer1/trade-dashboard/app/schemas.py b/apps/base/customer1/trade-dashboard/app/schemas.py
new file mode 100644
index 0000000..f911657
--- /dev/null
+++ b/apps/base/customer1/trade-dashboard/app/schemas.py
@@ -0,0 +1,70 @@
+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
+
diff --git a/apps/base/customer1/trade-dashboard/app/static/index.html b/apps/base/customer1/trade-dashboard/app/static/index.html
new file mode 100644
index 0000000..8795aba
--- /dev/null
+++ b/apps/base/customer1/trade-dashboard/app/static/index.html
@@ -0,0 +1,158 @@
+
+
+
+
+
+Trade Dashboard
+
+
+
+📊 Trade Dashboard
+
+
+
+
+
+
+
Open Positions 0
+
+ | Symbol | Dir | Entry | Qty | Exchange | Opened | Action |
+
+
+
+
+
+
+
Open New Position
+
+
+
+
+
+
Trade History
+
+ | Symbol | Dir | Entry | Exit | Qty | PnL | Exchange | Closed |
+
+
+
+
+
+
+
diff --git a/apps/base/customer1/trade-dashboard/configmap.yaml b/apps/base/customer1/trade-dashboard/configmap.yaml
new file mode 100644
index 0000000..6d1616c
--- /dev/null
+++ b/apps/base/customer1/trade-dashboard/configmap.yaml
@@ -0,0 +1,10 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: trade-dashboard-config
+ namespace: customer1
+data:
+ DB_USER: "trading"
+ DB_HOST: "hermes-pgdb-rw.customer1.svc.cluster.local"
+ DB_PORT: "5432"
+ DB_NAME: "trading_data"
diff --git a/apps/base/customer1/trade-dashboard/deployment.yaml b/apps/base/customer1/trade-dashboard/deployment.yaml
new file mode 100644
index 0000000..ec5e0bb
--- /dev/null
+++ b/apps/base/customer1/trade-dashboard/deployment.yaml
@@ -0,0 +1,58 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: trade-dashboard
+ namespace: customer1
+ labels:
+ app: trade-dashboard
+spec:
+ replicas: 1
+ strategy:
+ type: Recreate
+ selector:
+ matchLabels:
+ app: trade-dashboard
+ template:
+ metadata:
+ labels:
+ app: trade-dashboard
+ annotations:
+ checksum/config: trade-dashboard-config
+ spec:
+ terminationGracePeriodSeconds: 30
+ containers:
+ - name: dashboard
+ image: us-central1-docker.pkg.dev/devops-lab-cluster/customer1/trade-dashboard:latest
+ imagePullPolicy: Always
+ ports:
+ - containerPort: 8000
+ name: http
+ resources:
+ requests:
+ cpu: 100m
+ memory: 128Mi
+ limits:
+ cpu: 500m
+ memory: 512Mi
+ envFrom:
+ - configMapRef:
+ name: trade-dashboard-config
+ env:
+ - name: DB_PASSWORD
+ valueFrom:
+ secretKeyRef:
+ name: trading-db-credentials
+ key: password
+ startupProbe:
+ httpGet:
+ path: /api/health
+ port: 8000
+ initialDelaySeconds: 15
+ periodSeconds: 5
+ failureThreshold: 6
+ livenessProbe:
+ httpGet:
+ path: /api/health
+ port: 8000
+ initialDelaySeconds: 30
+ periodSeconds: 30
diff --git a/apps/base/customer1/trade-dashboard/kustomization.yaml b/apps/base/customer1/trade-dashboard/kustomization.yaml
new file mode 100644
index 0000000..b08a552
--- /dev/null
+++ b/apps/base/customer1/trade-dashboard/kustomization.yaml
@@ -0,0 +1,8 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+
+resources:
+ - deployment.yaml
+ - service.yaml
+ - configmap.yaml
+ - tsproxy.yaml
diff --git a/apps/base/customer1/trade-dashboard/service.yaml b/apps/base/customer1/trade-dashboard/service.yaml
new file mode 100644
index 0000000..2a94199
--- /dev/null
+++ b/apps/base/customer1/trade-dashboard/service.yaml
@@ -0,0 +1,12 @@
+apiVersion: v1
+kind: Service
+metadata:
+ name: trade-dashboard
+ namespace: customer1
+spec:
+ selector:
+ app: trade-dashboard
+ ports:
+ - port: 80
+ targetPort: 8000
+ name: http
diff --git a/apps/base/customer1/trade-dashboard/tsproxy.yaml b/apps/base/customer1/trade-dashboard/tsproxy.yaml
new file mode 100644
index 0000000..b748657
--- /dev/null
+++ b/apps/base/customer1/trade-dashboard/tsproxy.yaml
@@ -0,0 +1,9 @@
+apiVersion: tailscale.com/v1
+kind: TsProxy
+metadata:
+ name: trade-dashboard
+ namespace: customer1
+spec:
+ serviceName: trade-dashboard
+ servicePort: 80
+ hostname: trade-dashboard
diff --git a/apps/staging/customer1/kustomization.yaml b/apps/staging/customer1/kustomization.yaml
index 974b250..7691ed9 100644
--- a/apps/staging/customer1/kustomization.yaml
+++ b/apps/staging/customer1/kustomization.yaml
@@ -9,3 +9,4 @@ resources:
- ../../base/customer1/paaas-landing/
- ../../base/customer1/hermes-agent/
- ../../base/customer1/hermes-db/
+ - ../../base/customer1/trade-dashboard/