Merge pull request #76 from sirius0xdev/feat/trade-dashboard-service

feat: add trade dashboard service (FastAPI + Tailscale)
This commit is contained in:
sirius0xdev 2026-05-02 18:03:17 -04:00 committed by GitHub
commit e1aaa7a12f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 840 additions and 48 deletions

View file

@ -1,24 +1,24 @@
apiVersion: ENC[AES256_GCM,data:txA=,iv:gCFjwoYIBFCTXOTBjND+corfessRLGUh313T8aVsO0M=,tag:V/SjrvWG2vLrFTBP9CpWNA==,type:str] apiVersion: v1
kind: ENC[AES256_GCM,data:ZgEuMlxy,iv:Wp4ZPGH4ID7t8BGEsnJ/Rm7wIVm9rWPQYLfj3CJmo5s=,tag:bd1KCKO6eKgsJtJ4YcWW4A==,type:str] kind: Secret
metadata: metadata:
name: ENC[AES256_GCM,data:0iXVLY1JDCujP/JQWELIr5dP4uSB,iv:Das8czGXBlxz+hC8KWh9jZwXJCQ12v+P6tSL4DUL1S4=,tag:l4kKkLd4LvRxECg31ob2fg==,type:str] name: hermes-db-credentials
namespace: ENC[AES256_GCM,data:p7hF20bNI6CI,iv:FNB+sXTcgKQdE/1MZuYlxNukMSx5mWNXqVOtnxbvOCo=,tag:lOmXwn+jF3JapOGPWzEz2w==,type:str] namespace: customer1
type: ENC[AES256_GCM,data:NLgmDHre,iv:xMs4To5h+yPgQuYOVP2pu53cA1yqsh8d91trr/gvAWg=,tag:1FNpJ4VqHwfXP5L2T0jG1w==,type:str] type: Opaque
stringData: stringData:
username: ENC[AES256_GCM,data:Kyn8uqkj,iv:0BMWC+W8RiAOhKeaWwCwpoxug0t5nHBRPqRfeGZPaF0=,tag:ECYB03gU8kk5mWKu5ERbEg==,type:str] username: ENC[AES256_GCM,data:R3DLxC5/,iv:Q9026dVs2OTUzSldYtDh1cMP/6qxW68pbhphJhwlk5Y=,tag:ISs06+LqVP/aR/oxYP0K/Q==,type:str]
password: ENC[AES256_GCM,data:iWw+TY9t68laPLRstPN7gybhyUP3bz6lpRTSnSdAgW0=,iv:hFOncdpn0AoQ5JywZxkwnrOnE+jb0rFIeoU2FcTnkqY=,tag:f+rjg2kfGNTfR3gb8kVRBA==,type:str] password: ENC[AES256_GCM,data:u0bmdtnkLmrECk3N0LrO6T7Uwy7iqAfkgfK4TqS6H3k=,iv:6FYk+vHKr6jjizTkZChUsYQAKKNgA7dP6vgDETg9Lcg=,tag:FQ/hpK93OTbID9OT8XfzHA==,type:str]
sops: sops:
age: age:
- recipient: age1uuxf066xuuqgvjppxfcmqkwfcufnwp3wcwnl9h20g9k4l8nkw9jsaungf7 - recipient: age1z674fe9c0whe6g55l9lpm6vssx0l9xyzw5rv42tucwua25gvvg6q42t77v
enc: | enc: |
-----BEGIN AGE ENCRYPTED FILE----- -----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBRc2haZVlrUEJUV2F5bHFm YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBRcVcvVjJZeDdBWFZ2Nmox
WG5qV2xVYzl4UE5aM3VBNE9JUnJ4YVI5V2dzCkVJM2NDZDd6MjZsdElYR09aRkgw YnVrVXBpUkhDUFNIMWMySnNtcXkyRU9tNnhFClhCWEFrSStneTYwVFZhbE9sUHlh
MXZ5anlGVW1laHl3T2k0M2ZiUHZ3cjgKLS0tIG9od1JtU1lJY0ZrSUdtWWMxV1FM OHNsQWtjb1VyMnE1cGM1UWZKKytXWE0KLS0tIDVDclVnTFFTbWJ6SXdFblZBTkl4
Vk92L1NWVGpkMWRUdmNpV1AxS1RSMzQKjITIDbSQp+aaKDzR48ENFmuY+dMTYPaQ UEpBUU1ldWZVc0Jadm1pUHVOaW04bnMKHWfeHUikSfN9+Q27eD1xsqbXbZ8H3T2X
1P8zng4fQptC9RBvps327Z5kMIAtS52zYeoYgBwgdXNtTsaoO4ddmA== MKVildkb3jtUIJvA1FS+8RU0LkhmsoxKtE1ox0XQ66dAZPa8UVgLQA==
-----END AGE ENCRYPTED FILE----- -----END AGE ENCRYPTED FILE-----
lastmodified: "2026-05-02T21:29:40Z" lastmodified: "2026-05-02T22:00:51Z"
mac: ENC[AES256_GCM,data:ioHm0Go1hjO4VYKhEiu3e2Pdz+FzwDjlzQ5rWNbmuoCRW3bdqXpIvGNMyALBX7e+UCo+PwlNWiu3JrhszxdLptsySdCpzz0z04CZoQCbej66xdi4U49VvMVCJyZaRP5ZGFysXQghz0m0z4QoAcMSyZZGh8f8PnLewxORiXwrS7c=,iv:HXjO4oABWJCSUHN4dQiMXV1dFR0bxpDYSJXhBQ4G970=,tag:LIIY8pv+0/U/IfaB2D8vuQ==,type:str] mac: ENC[AES256_GCM,data:zOHEP8m1lGrcPMfpZ6aEBnQWfe1pAXBWk0z85ghkMxgM/a5pp24RvEzprRQBMpx36tnd+Xk3oYwCwTUVv0Y5xm7ZFK0tGSbXbKHrqNoOYPHPnbhG3GB7kIRcxlK4FJXy4BAPHJSUv5MrfIAmLj4Fa5XCbsDeqRWazyytUnrDNvc=,iv:SBsIMSE44I9wdcSmCg7KBetdnyjj63oFol5K2+54Y78=,tag:LeBp0JWAmjCvhcPcwOUvhw==,type:str]
unencrypted_suffix: _unencrypted encrypted_regex: ^(data|stringData)$
version: 3.12.2 version: 3.12.2

View file

@ -1,24 +1,24 @@
apiVersion: ENC[AES256_GCM,data:2mQ=,iv:LNl1ueDj/seVjsPVQ20/CQJnbEr7Fd/BqMogFKbWgCo=,tag:fVqYeJV9BqKWifP1idQKjg==,type:str] apiVersion: v1
kind: ENC[AES256_GCM,data:44E4DRzE,iv:zIn6ZIrZm44IJuLFERN+WTgctZ/04KkmxURtjsUFUXs=,tag:DbRfwktJT4jdc4TR39QYqQ==,type:str] kind: Secret
metadata: metadata:
name: ENC[AES256_GCM,data:rQCpZs4/HHeRnO0OgCr65HhYK84W,iv:dUiKDy3zsp7EO+wd/QI0W0M8dPHYKSDzmCP80Y+XIIM=,tag:bKWz+FI/PtCewuSaVB1cUA==,type:str] name: memory-db-credentials
namespace: ENC[AES256_GCM,data:cGonbW6DupwQ,iv:6EGZKJeC77OSgUikYPRRkAPraIdRLFLaQ/FD2+C4gco=,tag:TSl9wo35AMfjU62gcq0lsg==,type:str] namespace: customer1
type: ENC[AES256_GCM,data:xXPsacph,iv:u2HydlElviV+Pvs7nsZutnQbK2TIRm88uk7u9/fJels=,tag:h8f7Dy8JpvAJzUWwzjsMUw==,type:str] type: Opaque
stringData: stringData:
username: ENC[AES256_GCM,data:VF38x+hU,iv:epSlTbTaVeOkpUZkLQ5QHuidsh5JEQkPz0HpqYQIvLI=,tag:HjZ/F29xirkNR3IMzyA+Dw==,type:str] username: ENC[AES256_GCM,data:qCoYuXNK,iv:cZIJ9IgPWBnfGsNDFIT3T63v+jzxKMrTDcbY4R9ZXDk=,tag:THcqn7xE3UOFmq/YGEj8zg==,type:str]
password: ENC[AES256_GCM,data:Wu015YXUwNMuIIAUZ4Vd7BDfQS137VuQynL4e7rqtw8=,iv:7xA4FdSOXPJTwkA7guYR1IhPHjlXXDjtCaRcdGJ52kQ=,tag:kw/oxGWhPeXDkBKvxg3vog==,type:str] password: ENC[AES256_GCM,data:S594bfUc+w83/M6eoMuzWaTboTmbBOjq5WEy+ExNzG8=,iv:JGLwfvei/SmMaLUneZ20/Pp8WsnWP+Ih87sTIyllDFA=,tag:VlH+4Wopp2z9J4MTSDYH2g==,type:str]
sops: sops:
age: age:
- recipient: age1uuxf066xuuqgvjppxfcmqkwfcufnwp3wcwnl9h20g9k4l8nkw9jsaungf7 - recipient: age1z674fe9c0whe6g55l9lpm6vssx0l9xyzw5rv42tucwua25gvvg6q42t77v
enc: | enc: |
-----BEGIN AGE ENCRYPTED FILE----- -----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBsMkE4N2R3bTVRUWtZdkVi YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBXQU1PbFNMWTMyM2dxUlpp
N1Q4ZEo5ZHdmZjFNdVAydHdMeXkzZURFZVFBCmJxWnVLQ0F6OU52Y3FTa0N3Y2ZU eTRHbnRXSnk2bDR4VVZycmFUcUVZYVRvUTJnCkFuQURyMTQ0Y0RwUDZNQWpFRFMx
M2trTjdZL3E3R1lXa29lOWlGTVFCenMKLS0tIHVhVllpTXNzQ0hSblhnYW56eHIv Q0ZHRHdzN2VCeU1vcWx6dzFKT3cwQk0KLS0tIDZYYkxoSGk1RmF5VjJkK09IejJ1
OW9sRDJLcU54TmdXdGpsTCtlUlR5NmMK7n5nIwAzxJgvJw9VqStiCVCE9wuNzMX2 SmR0TGlHOGZad2NNa1grUVNGQnZNUFUKHAJhqYReMS+w5gjQqqHTWafy6etxLpDP
NaREHasP+XtJpB3AGkNgwBr/LW7cZKs5uvy8vKUFqhqpk/hcDsHjhg== mRujATlpk85g60GeKB7mwWvvX82SpaOZ9GJ+LePjpXwdclIOusofhg==
-----END AGE ENCRYPTED FILE----- -----END AGE ENCRYPTED FILE-----
lastmodified: "2026-05-02T21:29:40Z" lastmodified: "2026-05-02T22:00:51Z"
mac: ENC[AES256_GCM,data:1vcokwfS2/qfWqMWpfTl1wmsgMcx9tUbMv7IP13lzBc1j7YirW4xs+a52hxGFityPJgWvp4/XwdUbt06l33IF7dykN6vT9lFK4792Kx6KzdmCqP2eawtT2GKT1ewfmO1Mjr/YJ77o1qmaek7BPwNv+8vlDg0Wlerbt0jsy2SqCI=,iv:O98sE1/kNbUYaljxDevO9Ow0CbDz7VWcTCRY3wvwF6s=,tag:Yy1kzaKc1JCW6Wkny7ITHg==,type:str] mac: ENC[AES256_GCM,data:5EPPuVDZNXYE50rnXQyxajV4Zs0gZX+Lu5Zf5oERLs/TqFEBTQkew3hMMLuMflg3WQME7f9vcOVIYNAXL93rUCt28KHDAxv5WSNyBIZdTuZvfsYK6+we41wjdehvTf4SqsX2KznBVJKIO65ypNUtzrW/rJCj440UwWpgm/B2+/0=,iv:umvqqa7eSVgsbTopdOz0vkJdVPQxa66SFQx9dDxovmg=,tag:SJmrzPoX4gdrdTNEj8qe1g==,type:str]
unencrypted_suffix: _unencrypted encrypted_regex: ^(data|stringData)$
version: 3.12.2 version: 3.12.2

View file

@ -1,24 +1,24 @@
apiVersion: ENC[AES256_GCM,data:GSk=,iv:EtxocdVlzVEPk6rHozVdjwOgjWqZNdJfG2PWrVRF1Ik=,tag:pBINgiS3tVGO6iQ0seax/g==,type:str] apiVersion: v1
kind: ENC[AES256_GCM,data:T5+vJT+t,iv:GEuW09nF9g3ZFOtFx6kJfgkx+U80QYXkVMQDJoAKh7E=,tag:OTsAfg7FiOoe4C6eyZCLyQ==,type:str] kind: Secret
metadata: metadata:
name: ENC[AES256_GCM,data:iM8e0gyP/bheft8exNWIuLAu9AObog==,iv:73wEhFh6OVB+vZEvYY0uEQj4U7NgDoOstWmka57J72Y=,tag:vRTgvDqoX3ccZDIwybiH0w==,type:str] name: trading-db-credentials
namespace: ENC[AES256_GCM,data:65gK9YhIlftj,iv:uZC14McBjB1LTMhalbPH2KJinstaaZyvlVDDe2FvKCE=,tag:j2yuQ6bD5VRYhAV0JhxflQ==,type:str] namespace: customer1
type: ENC[AES256_GCM,data:rjywm84O,iv:Yp9tttUgySGPSMRiSmuMff544kp0+k09t0Q8jGHWjiM=,tag:xgsEUcmI20kfJVsEU+zTBQ==,type:str] type: Opaque
stringData: stringData:
username: ENC[AES256_GCM,data:MHbgLPzGjQ==,iv:hwINFxef3rnIetuD1aICeTly2Y9oVieQoRHTfKtEAyk=,tag:hnAirU6ib5aOpRf6JyYZpA==,type:str] username: ENC[AES256_GCM,data:fqozOGjMOA==,iv:+7yvkH0+PTQEqXbd2teyN+VqOLJBDPs03EzUBdOMgdw=,tag:a8wZr/hCbA5xd1tY7OYFAA==,type:str]
password: ENC[AES256_GCM,data:jrxJhvglCEa+Kwed1mrtT+JnrpmQ4leVMOv1jVVcGw8=,iv:jHyO1uw3eo9uJfwQBZcG/UdVwnpkkr1fsrb9uFCE7B8=,tag:V4VeDoD/I0RE62x2pIFiOA==,type:str] password: ENC[AES256_GCM,data:JBfMrq2fsQHGAaw5qGFcjrQc7ID+v1f4pq9uvZxup5o=,iv:CgcXAovSEHrtGmyFzlG8qMwsYhijec4w+CBtX26KgVI=,tag:Y1niqpPBZiDtwWQKipjjNA==,type:str]
sops: sops:
age: age:
- recipient: age1uuxf066xuuqgvjppxfcmqkwfcufnwp3wcwnl9h20g9k4l8nkw9jsaungf7 - recipient: age1z674fe9c0whe6g55l9lpm6vssx0l9xyzw5rv42tucwua25gvvg6q42t77v
enc: | enc: |
-----BEGIN AGE ENCRYPTED FILE----- -----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBnM0RsNm5UTCsxdWJJRjdp YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBVM3h1ajJsdkwxeG10ZklX
d1ZZcitVZ1p3aGgzY3YvWVk5aVZIUzZxSWpnCmk1S0NwUXpqWWw1bUoxbW5Kcm9X S0V5dVVpYm5rUmtHZVVzcFJITEhpaHlLVmw0Ckg4UTFoblZMUUo4RDZraXZ3bFJX
cjdlb3pqbkpvQm95K2lxY1JtNlRRZm8KLS0tIERyZFl5M0hJUDdxZlBTaGxGbm1z MXduaFc3eW1Hd0Y0K292ZzVzdW5OK00KLS0tIFFFejMxSkN0cDQrbjhLcVBhb25H
M0U5ajRJeTI3TmltOWFoR1R3Sk1LQlkKht8ze+iqhdwucRbqiNMAnwvs+idhs159 LzBTZ0pLbUJDUmsyN1JiTUFWUVU2dUEKogDQhc16qijRDOR3dVpOhTcm7TBXblhR
JBnY8Y90O1UBAB/yUoACVjGf32d3c6iCSOi2L35FopTjXNREgcU5wQ== jn8rx6WODXrHTVqkRzJV7iYCvSb/mHCyRoBC5WvoUsNQTa4u2FtBkg==
-----END AGE ENCRYPTED FILE----- -----END AGE ENCRYPTED FILE-----
lastmodified: "2026-05-02T21:29:40Z" lastmodified: "2026-05-02T22:00:51Z"
mac: ENC[AES256_GCM,data:Bjl+US5pStZ+xiCJ1l1xtDl4bVldkqme5fxMQN5WNsvalo/bKXElUpxVLf52n5jWcZjkjNHWuM407db3NQmSwXqz1o6+qmwIgfv3hz3JViUh0JSiSX/tDz8+yLEQ7fPVkvehxfWaZN2wJhyHhuPvkOmz9Fm+YSxqbw9DaWr2BkI=,iv:83n0XewztuAnIdb/CLPmseQLLbo/uw+rD8t67lxD1J8=,tag:KiKtZKPCX5BRfJfZ8xdDkg==,type:str] mac: ENC[AES256_GCM,data:BNXMSqTw3tTnSaUTF2vHmg7NM/eA3DuomPKrbTQR2X4TKJtdj5KNs5RbC9DeWktFze1QtT3nreTAm5jAHAz4wjWGIjBgqPy2KBy/ivXOGQJlCf80fiBwHLYcF4J10MC7yRp/yiTOInAVhCwH+B+KHgCtHlvlOhRiKcrgwvdsB38=,iv:zOpLrG43GOynzWSR+90SC/xQwUckOjn03povK6/7z4A=,tag:AH9Gc1aqpHLnZ99FLZ2U2w==,type:str]
unencrypted_suffix: _unencrypted encrypted_regex: ^(data|stringData)$
version: 3.12.2 version: 3.12.2

View file

@ -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"]

View file

@ -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

View file

@ -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())

View file

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

View file

@ -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")

View file

@ -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()

View file

@ -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)

View file

@ -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),
)

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,158 @@
<!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>

View file

@ -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"

View file

@ -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

View file

@ -0,0 +1,8 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- deployment.yaml
- service.yaml
- configmap.yaml
- tsproxy.yaml

View file

@ -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

View file

@ -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

View file

@ -9,3 +9,4 @@ resources:
- ../../base/customer1/paaas-landing/ - ../../base/customer1/paaas-landing/
- ../../base/customer1/hermes-agent/ - ../../base/customer1/hermes-agent/
- ../../base/customer1/hermes-db/ - ../../base/customer1/hermes-db/
- ../../base/customer1/trade-dashboard/