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

+ + +
+
Today PnL
+
Week PnL
+
Month PnL
+
All Time
+
+ + +
+

Open Positions 0

+ + + +
SymbolDirEntryQtyExchangeOpenedAction
+
+ + +
+

Open New Position

+
+ + + + + + +
+
+ + +
+

Trade History

+ + + +
SymbolDirEntryExitQtyPnLExchangeClosed
+
+ + + + 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/