Merge pull request #76 from sirius0xdev/feat/trade-dashboard-service
feat: add trade dashboard service (FastAPI + Tailscale)
This commit is contained in:
commit
e1aaa7a12f
20 changed files with 840 additions and 48 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
18
apps/base/customer1/trade-dashboard/Dockerfile
Normal file
18
apps/base/customer1/trade-dashboard/Dockerfile
Normal 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"]
|
||||||
36
apps/base/customer1/trade-dashboard/alembic.ini
Normal file
36
apps/base/customer1/trade-dashboard/alembic.ini
Normal 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
|
||||||
61
apps/base/customer1/trade-dashboard/alembic/env.py
Normal file
61
apps/base/customer1/trade-dashboard/alembic/env.py
Normal 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())
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
# Alembic migration script - DO NOT EDIT MANUALLY
|
||||||
|
# Use: alembic revision --autogenerate -m "description"
|
||||||
|
|
@ -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")
|
||||||
25
apps/base/customer1/trade-dashboard/app/database.py
Normal file
25
apps/base/customer1/trade-dashboard/app/database.py
Normal 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()
|
||||||
253
apps/base/customer1/trade-dashboard/app/main.py
Normal file
253
apps/base/customer1/trade-dashboard/app/main.py
Normal 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)
|
||||||
21
apps/base/customer1/trade-dashboard/app/models.py
Normal file
21
apps/base/customer1/trade-dashboard/app/models.py
Normal 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),
|
||||||
|
)
|
||||||
7
apps/base/customer1/trade-dashboard/app/requirements.txt
Normal file
7
apps/base/customer1/trade-dashboard/app/requirements.txt
Normal 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
|
||||||
70
apps/base/customer1/trade-dashboard/app/schemas.py
Normal file
70
apps/base/customer1/trade-dashboard/app/schemas.py
Normal 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
|
||||||
|
|
||||||
158
apps/base/customer1/trade-dashboard/app/static/index.html
Normal file
158
apps/base/customer1/trade-dashboard/app/static/index.html
Normal 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>
|
||||||
10
apps/base/customer1/trade-dashboard/configmap.yaml
Normal file
10
apps/base/customer1/trade-dashboard/configmap.yaml
Normal 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"
|
||||||
58
apps/base/customer1/trade-dashboard/deployment.yaml
Normal file
58
apps/base/customer1/trade-dashboard/deployment.yaml
Normal 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
|
||||||
8
apps/base/customer1/trade-dashboard/kustomization.yaml
Normal file
8
apps/base/customer1/trade-dashboard/kustomization.yaml
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
|
kind: Kustomization
|
||||||
|
|
||||||
|
resources:
|
||||||
|
- deployment.yaml
|
||||||
|
- service.yaml
|
||||||
|
- configmap.yaml
|
||||||
|
- tsproxy.yaml
|
||||||
12
apps/base/customer1/trade-dashboard/service.yaml
Normal file
12
apps/base/customer1/trade-dashboard/service.yaml
Normal 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
|
||||||
9
apps/base/customer1/trade-dashboard/tsproxy.yaml
Normal file
9
apps/base/customer1/trade-dashboard/tsproxy.yaml
Normal 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
|
||||||
|
|
@ -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/
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue