├── .gitignore ├── README.md ├── api ├── .dockerignore ├── Dockerfile ├── connection.py ├── main.py ├── models.py ├── requirements.txt └── routes.py ├── app_preview_image1.png ├── data ├── main.py ├── models.py ├── nasdaq.csv └── requirements.txt ├── docker-compose.yml ├── final_image.png ├── image2-redistack.png ├── marketplace.json ├── redis-stack-image.png ├── redis-stack-stocks.png ├── redis_stacks.png ├── stream ├── .dockerignore ├── Dockerfile ├── alpaca.py ├── connection.py ├── main.py ├── models.py └── requirements.txt ├── testing ├── test.py └── topk.py └── ui ├── .dockerignore ├── .eslintrc.json ├── .gitignore ├── Dockerfile ├── README.md ├── components ├── ChartCard.tsx ├── Header.tsx ├── Info.tsx ├── NewsCard.tsx ├── RealtimeChart.tsx ├── Sidebar.tsx ├── Transition.tsx ├── TrendingCard.tsx ├── WatchlistCard.tsx └── header │ ├── Help.tsx │ ├── Notifications.tsx │ ├── SearchModal.tsx │ └── UserMenu.tsx ├── next-env.d.ts ├── next.config.js ├── package-lock.json ├── package.json ├── pages ├── _app.tsx └── index.tsx ├── postcss.config.js ├── public ├── favicon.ico ├── user-avatar-32.png └── vercel.svg ├── state └── index.ts ├── styles └── globals.css ├── tailwind.config.js ├── tsconfig.json └── utils ├── index.ts ├── isServerSide.ts └── useLayoutEffect.ts /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .next/ 3 | 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | *$py.class 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | pip-wheel-metadata/ 27 | share/python-wheels/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | MANIFEST 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | *.spec 38 | 39 | # Installer logs 40 | pip-log.txt 41 | pip-delete-this-directory.txt 42 | 43 | # Unit test / coverage reports 44 | htmlcov/ 45 | .tox/ 46 | .nox/ 47 | .coverage 48 | .coverage.* 49 | .cache 50 | nosetests.xml 51 | coverage.xml 52 | *.cover 53 | *.py,cover 54 | .hypothesis/ 55 | .pytest_cache/ 56 | 57 | # Translations 58 | *.mo 59 | *.pot 60 | 61 | # Django stuff: 62 | *.log 63 | local_settings.py 64 | db.sqlite3 65 | db.sqlite3-journal 66 | 67 | # Flask stuff: 68 | instance/ 69 | .webassets-cache 70 | 71 | # Scrapy stuff: 72 | .scrapy 73 | 74 | # Sphinx documentation 75 | docs/_build/ 76 | 77 | # PyBuilder 78 | target/ 79 | 80 | # Jupyter Notebook 81 | .ipynb_checkpoints 82 | 83 | # IPython 84 | profile_default/ 85 | ipython_config.py 86 | 87 | # pyenv 88 | .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | .env 109 | .venv 110 | .env.* 111 | env/ 112 | venv/ 113 | ENV/ 114 | env.bak/ 115 | venv.bak/ 116 | 117 | # Spyder project settings 118 | .spyderproject 119 | .spyproject 120 | 121 | # Rope project settings 122 | .ropeproject 123 | 124 | # mkdocs documentation 125 | /site 126 | 127 | # mypy 128 | .mypy_cache/ 129 | .dmypy.json 130 | dmypy.json 131 | 132 | # Pyre type checker 133 | .pyre/ 134 | 135 | # Makefile install checker 136 | .install.stamp 137 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | ![redis-stack-stocks](./redis-stack-stocks.png) 4 | 5 | 6 | This project demonstrates how you can use Redis Stack to create a real-time stock watchlist application. It uses several different features of Redis: 7 | 8 | - Sets 9 | - Pub/Sub 10 | - Hashes 11 | -Redis Time Series 12 | - RedisBloom 13 | - Redis JSON 14 | - Redis Search 15 | 16 | # Usage with Docker 17 | 18 | ## Requirements 19 | 20 | 1. Install Docker 21 | 22 | Set the following environment variables in a .env file in the root directory of the project: 23 | 24 | 1. `APCA_API_KEY_ID`: Your Alpaca API Key found on the Alpaca dashboard 25 | 1. `APCA_API_SECRET_KEY`: Your Alpaca API Secret found on the Alpaca dashboard 26 | 27 | ## Installation 28 | 29 | ``` 30 | $ docker-compose --env-file ./.env up -d 31 | ``` 32 | 33 | After the containers are up and running (for the first time), go into the `data` directory and run: 34 | 35 | ``` 36 | $ pip install -r requirements.txt 37 | $ python main.py 38 | ``` 39 | 40 | # Usage Locally 41 | 42 | ## Requirements 43 | 44 | 1. python 3.6+ 45 | 1. pip 46 | 47 | ## Environment Variables 48 | 49 | Create a `.env` file in the root directory of the project and set the following environment variables: 50 | 51 | ``` 52 | APCA_API_KEY_ID= 53 | APCA_API_SECRET_KEY= 54 | REDIS_URL= 55 | REDIS_OM_URL= 56 | ``` 57 | 58 | Create a `.env` file in the `ui` directory of the project and set the following envionrment variables: 59 | 60 | ``` 61 | NEXT_PUBLIC_API_URL=http://localhost:8000/api/1.0 62 | NEXT_PUBLIC_WS_URL=ws://localhost:8000 63 | ``` 64 | 65 | ## Installation 66 | 67 | From the root directory, run the following commands: 68 | 69 | ``` 70 | $ python -m venv ./.venv 71 | ``` 72 | 73 | ### Stream Service 74 | 75 | Run the following commands in the `stream` directory: 76 | 77 | ``` 78 | $ pip install -r requirements.txt 79 | $ python main.py 80 | ``` 81 | 82 | ### API Service 83 | 84 | Run the following commands in the `api` directory: 85 | 86 | ``` 87 | $ pip install -r requirements.txt 88 | $ uvicorn main:app 89 | ``` 90 | 91 | ### Web Service 92 | 93 | Run the following commands in the `web` directory: 94 | 95 | ``` 96 | $ npm install 97 | $ npm run dev 98 | ``` 99 | 100 | # Known Issues 101 | 102 | 1. There is a known issue with the Alpaca websocket API thread safety. You will find a workaround in the alpaca.py file. 103 | 104 | # Managed Hosting 105 | 106 | Redis offers [managed hosting for Redis Stack](https://redis.info/3tyWUYJ) for free, and you can even get $200 in credits towards a paid subscription by using code TIGER200. 107 | -------------------------------------------------------------------------------- /api/.dockerignore: -------------------------------------------------------------------------------- 1 | .env 2 | -------------------------------------------------------------------------------- /api/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.10.5-alpine 2 | 3 | WORKDIR /app 4 | ADD . /app 5 | 6 | # System deps: 7 | RUN apk update && apk add python3-dev \ 8 | gcc \ 9 | g++ \ 10 | libc-dev \ 11 | libffi-dev 12 | 13 | RUN pip install -r requirements.txt 14 | 15 | EXPOSE 8000 16 | 17 | CMD ["uvicorn", "main:app", "--host", "0.0.0.0"] 18 | -------------------------------------------------------------------------------- /api/connection.py: -------------------------------------------------------------------------------- 1 | 2 | import os 3 | import aioredis 4 | import redis 5 | 6 | 7 | redis_url = os.getenv("REDIS_URL", "redis://localhost:6379") 8 | db = aioredis.from_url(redis_url) 9 | db_sync = redis.from_url(redis_url) -------------------------------------------------------------------------------- /api/main.py: -------------------------------------------------------------------------------- 1 | import os 2 | from aredis_om.model import Migrator 3 | from fastapi import FastAPI 4 | from fastapi.middleware.cors import CORSMiddleware 5 | 6 | from routes import router 7 | 8 | app = FastAPI() 9 | app.add_middleware( 10 | CORSMiddleware, 11 | allow_origins=["*"], 12 | allow_credentials=True, 13 | allow_methods=["*"], 14 | allow_headers=["*"], 15 | ) 16 | redis_url = os.getenv("REDIS_URL", "redis://localhost:6379") 17 | 18 | app.include_router(router) 19 | 20 | procs = [] 21 | 22 | @app.on_event("startup") 23 | async def startup(): 24 | print("Starting API") 25 | await Migrator().run() 26 | -------------------------------------------------------------------------------- /api/models.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from aredis_om import Field, JsonModel, EmbeddedJsonModel 3 | 4 | 5 | class Image(EmbeddedJsonModel): 6 | size: str 7 | url: str 8 | 9 | 10 | class News(EmbeddedJsonModel): 11 | id: str 12 | headline: str 13 | author: str 14 | created_at: str 15 | updated_at: str 16 | summary: str 17 | url: str 18 | images: List[Image] 19 | symbols: List[str] 20 | source: str 21 | 22 | 23 | class Stock(JsonModel): 24 | @staticmethod 25 | async def add_news(symbol: str, news: News): 26 | stock = await Stock.get(symbol) 27 | 28 | if (stock is None): 29 | return 30 | 31 | stock.news.append(news) 32 | 33 | await stock.save() 34 | 35 | symbol: str = Field(index=True, full_text_search=True, sortable=True) 36 | name: str = Field(index=True, full_text_search=True) 37 | last_sale: str 38 | market_cap: str 39 | country: str 40 | ipo: str 41 | volume: str 42 | sector: str = Field(index=True, full_text_search=True) 43 | industry: str = Field(index=True, full_text_search=True) 44 | news: List[News] 45 | 46 | @classmethod 47 | def make_key(cls, part: str): 48 | return f"stocks:{part}" 49 | 50 | class Meta: 51 | index_name = "stocks:index" 52 | -------------------------------------------------------------------------------- /api/requirements.txt: -------------------------------------------------------------------------------- 1 | redis==4.3.3 2 | fastapi==0.78.0 3 | python-dotenv==0.20.0 4 | pydantic==1.9.1 5 | itsdangerous==2.1.2 6 | redis-om==0.0.27 7 | uvicorn==0.18.1 8 | pandas_market_calendars==3.5 9 | python-dateutil==2.8.2 10 | websockets==10.3 11 | -------------------------------------------------------------------------------- /api/routes.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import pytz 3 | import pandas_market_calendars as mcal 4 | import redis 5 | from dateutil.relativedelta import relativedelta 6 | from typing import List 7 | from fastapi import APIRouter, WebSocket 8 | from models import Stock 9 | from connection import db, db_sync 10 | 11 | router = APIRouter(prefix='/api/1.0') 12 | 13 | 14 | def get_last_market_close(): 15 | exchange = mcal.get_calendar('NASDAQ') 16 | today = datetime.datetime.now(pytz.timezone('US/Eastern')) 17 | today.strftime('%Y-%m-%d') 18 | valid_days = exchange.valid_days(start_date=( 19 | today - relativedelta(days=14)).strftime('%Y-%m-%d'), end_date=today.strftime('%Y-%m-%d')) 20 | last_day = valid_days[-2] 21 | close = exchange['market_close', last_day.strftime('%Y-%m-%d')] 22 | return datetime.datetime( 23 | year=last_day.year, 24 | month=last_day.month, 25 | day=last_day.day, 26 | hour=close.hour, 27 | minute=close.minute, 28 | second=0, 29 | microsecond=0, 30 | tzinfo=pytz.timezone('US/Eastern') 31 | ) 32 | 33 | @router.post('/watchlist/{symbol}') 34 | async def watch(symbol: str): 35 | if await db.sismember('watchlist', symbol): 36 | return {"status": "Already in watchlist"} 37 | else: 38 | await db.sadd('watchlist', symbol) 39 | return {"status": "Added to watchlist"} 40 | 41 | 42 | @router.delete('/watchlist/{symbol}') 43 | async def unwatch(symbol: str): 44 | if await db.sismember('watchlist', symbol): 45 | await db.srem('watchlist', symbol) 46 | return {"status": "Removed from watchlist"} 47 | else: 48 | return {"status": "Not in watchlist"} 49 | 50 | 51 | @router.get('/watchlist') 52 | async def watchlist(): 53 | members: List[str] = await db.smembers('watchlist') 54 | 55 | findQuery = Stock.find(( 56 | Stock.symbol << [m.decode('utf-8').upper() for m in members] 57 | )) 58 | 59 | findQuery.limit = 9000 60 | return await findQuery.sort_by('symbol').all() 61 | 62 | 63 | @router.get('/search/{query}') 64 | async def search(query: str): 65 | if not query: 66 | return [] 67 | 68 | findQuery = Stock.find(( 69 | Stock.symbol % f'{query}*' 70 | )) 71 | 72 | findQuery.limit = 9000 73 | return await findQuery.sort_by('symbol').all() 74 | 75 | 76 | @router.get('/bars/{symbol}') 77 | async def bars(symbol: str): 78 | now = datetime.datetime.now(datetime.timezone.utc) 79 | end = now - relativedelta(minutes=1) 80 | start = now - relativedelta(days=7) 81 | 82 | try: 83 | return db_sync.ts().revrange(f'stocks:{symbol.upper()}:bars:close', str(int( 84 | start.timestamp() * 1000)), str(int(end.timestamp() * 1000)), count=30) 85 | except: 86 | return [] 87 | 88 | 89 | @router.get('/close/{symbol}') 90 | async def close(symbol: str): 91 | time = get_last_market_close() 92 | 93 | try: 94 | results = db_sync.ts().revrange(f'stocks:{symbol.upper()}:bars:close', str(int( 95 | (time - relativedelta(days=3)).timestamp() * 1000)), str(int(time.timestamp() * 1000)), count=1) 96 | 97 | if len(results) > 0: 98 | return results[0] 99 | 100 | return [] 101 | except: 102 | return [] 103 | 104 | 105 | @router.get('/trade/{symbol}') 106 | async def trade(symbol: str): 107 | try: 108 | return db_sync.ts().get(f'stocks:{symbol.upper()}:trades:price') 109 | except: 110 | return [0, 0] 111 | 112 | 113 | @router.get('/trending') 114 | def trending(): 115 | try: 116 | return db_sync.topk().list('trending-stocks') 117 | except redis.exceptions.ResponseError as e: 118 | return [] 119 | 120 | 121 | @router.websocket_route('/trending') 122 | async def trending_stocks_ws(websocket: WebSocket): 123 | await websocket.accept() 124 | pubsub = db.pubsub() 125 | await pubsub.subscribe('trending-stocks') 126 | 127 | async for ev in pubsub.listen(): 128 | if ev['type'] == 'subscribe': 129 | continue 130 | 131 | trending: List[str] = db_sync.topk().list( 132 | 'trending-stocks', withcount=True) 133 | await websocket.send_json(trending) 134 | 135 | 136 | @router.websocket_route('/trade') 137 | async def trades_ws(websocket: WebSocket): 138 | await websocket.accept() 139 | pubsub = db.pubsub() 140 | await pubsub.subscribe('trade') 141 | 142 | async for ev in pubsub.listen(): 143 | if ev['type'] == 'subscribe': 144 | continue 145 | 146 | symbol = ev['data'].decode('utf-8').upper() 147 | trade = db_sync.ts().get(f'stocks:{symbol.upper()}:trades:price') 148 | await websocket.send_json({ 149 | 'symbol': symbol, 150 | 'trade': trade 151 | }) 152 | 153 | 154 | @router.websocket_route('/bars') 155 | async def bars_ws(websocket: WebSocket): 156 | await websocket.accept() 157 | pubsub = db.pubsub() 158 | await pubsub.subscribe('bar') 159 | 160 | async for ev in pubsub.listen(): 161 | if ev['type'] == 'subscribe': 162 | continue 163 | 164 | await websocket.send_text(ev['data'].decode('utf-8')) 165 | -------------------------------------------------------------------------------- /app_preview_image1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redis-developer/redis-stack-stocks/3fad37c2576a2fddf8cd0b86b4a1d8509a1f08a3/app_preview_image1.png -------------------------------------------------------------------------------- /data/main.py: -------------------------------------------------------------------------------- 1 | import csv 2 | from models import Stock 3 | from aredis_om.model import Migrator 4 | 5 | # Symbol,Name,Last Sale,Net Change,% Change,Market Cap,Country,IPO Year,Volume,Sector,Industry 6 | 7 | async def main(): 8 | await Migrator().run() 9 | with open('nasdaq.csv', newline='') as csvfile: 10 | reader = csv.DictReader(csvfile) 11 | for row in reader: 12 | await Stock( 13 | pk=row['Symbol'], 14 | symbol=row['Symbol'], 15 | name=row['Name'], 16 | last_sale=row['Last Sale'], 17 | market_cap=row['Market Cap'], 18 | country=row['Country'], 19 | ipo=row['IPO Year'], 20 | volume=row['Volume'], 21 | sector=row['Sector'], 22 | industry=row['Industry'], 23 | news=[] 24 | ).save() 25 | 26 | if __name__ == "__main__": 27 | import asyncio 28 | asyncio.run(main()) 29 | -------------------------------------------------------------------------------- /data/models.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from aredis_om import Field, JsonModel, EmbeddedJsonModel 3 | 4 | 5 | class Image(EmbeddedJsonModel): 6 | size: str 7 | url: str 8 | 9 | 10 | class News(EmbeddedJsonModel): 11 | id: str 12 | headline: str 13 | author: str 14 | created_at: str 15 | updated_at: str 16 | summary: str 17 | url: str 18 | images: List[Image] 19 | symbols: List[str] 20 | source: str 21 | 22 | 23 | class Stock(JsonModel): 24 | @staticmethod 25 | async def add_news(symbol: str, news: News): 26 | stock = await Stock.get(symbol) 27 | 28 | if (stock is None): 29 | return 30 | 31 | stock.news.append(news) 32 | 33 | await stock.save() 34 | 35 | symbol: str = Field(index=True, full_text_search=True, sortable=True) 36 | name: str = Field(index=True, full_text_search=True) 37 | last_sale: str 38 | market_cap: str 39 | country: str 40 | ipo: str 41 | volume: str 42 | sector: str = Field(index=True, full_text_search=True) 43 | industry: str = Field(index=True, full_text_search=True) 44 | news: List[News] 45 | 46 | @classmethod 47 | def make_key(cls, part: str): 48 | return f"stocks:{part}" 49 | 50 | class Meta: 51 | index_name = "stocks:index" 52 | -------------------------------------------------------------------------------- /data/requirements.txt: -------------------------------------------------------------------------------- 1 | redis==4.3.3 2 | redis-om==0.0.27 -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | services: 3 | redis: 4 | container_name: redis 5 | image: "redis/redis-stack:latest" 6 | ports: 7 | - 6379:6379 8 | deploy: 9 | replicas: 1 10 | restart_policy: 11 | condition: on-failure 12 | networks: 13 | - redis-stack-stocks 14 | api: 15 | container_name: api 16 | build: ./api 17 | ports: 18 | - 8000:8000 19 | environment: 20 | - REDIS_URL=redis://redis:6379 21 | - REDIS_OM_URL=redis://redis:6379 22 | networks: 23 | - redis-stack-stocks 24 | deploy: 25 | replicas: 1 26 | restart_policy: 27 | condition: on-failure 28 | depends_on: 29 | - redis 30 | stream: 31 | container_name: stream 32 | build: ./stream 33 | environment: 34 | - APCA_API_KEY_ID=${APCA_API_KEY_ID} 35 | - APCA_API_SECRET_KEY=${APCA_API_SECRET_KEY} 36 | - REDIS_URL=redis://redis:6379 37 | - REDIS_OM_URL=redis://redis:6379 38 | networks: 39 | - redis-stack-stocks 40 | deploy: 41 | replicas: 1 42 | restart_policy: 43 | condition: on-failure 44 | depends_on: 45 | - redis 46 | ui: 47 | container_name: ui 48 | build: 49 | context: ./ui 50 | args: 51 | - NEXT_PUBLIC_API_URL=http://localhost:8000/api/1.0 52 | - NEXT_PUBLIC_WS_URL=ws://localhost:8000 53 | ports: 54 | - 3000:3000 55 | deploy: 56 | replicas: 1 57 | restart_policy: 58 | condition: on-failure 59 | networks: 60 | redis-stack-stocks: {} 61 | -------------------------------------------------------------------------------- /final_image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redis-developer/redis-stack-stocks/3fad37c2576a2fddf8cd0b86b4a1d8509a1f08a3/final_image.png -------------------------------------------------------------------------------- /image2-redistack.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redis-developer/redis-stack-stocks/3fad37c2576a2fddf8cd0b86b4a1d8509a1f08a3/image2-redistack.png -------------------------------------------------------------------------------- /marketplace.json: -------------------------------------------------------------------------------- 1 | { 2 | "app_name": "Redis Stack Stocks", 3 | "description": "This project demonstrates how you can use Redis Stack to create a real-time stock watchlist application. It uses several different features of Redis Stack", 4 | "rank": 42, 5 | "featured": true, 6 | "type": "Full App", 7 | "contributed_by": "Redis", 8 | "repo_url": "https://github.com/redis-developer/redis-stack-stocks", 9 | "preview_image_url": "https://raw.githubusercontent.com/redis-developer/redis-stack-stocks/main/redis_stacks.png", 10 | "download_url": "https://github.com/redis-developer/redis-stack-stocks/archive/main.zip", 11 | "hosted_url": "", 12 | "quick_deploy": "false", 13 | "deploy_buttons": [], 14 | "language": ["JavaScript"], 15 | "redis_commands": [ 16 | "FT.CREATE", 17 | "FT.SEARCH", 18 | "TS.MADD", 19 | "TS.RANGE", 20 | "TS.GET", 21 | "TOPK.ADD", 22 | "TOPK.LIST", 23 | "TOPK.RESERVE", 24 | "SADD", 25 | "SMEMBERS", 26 | "SISMEMBER", 27 | "HSET", 28 | "PUBLISH", 29 | "PSUBSCRIBE", 30 | "PUNSUBSCRIBE", 31 | "EXPIRE" 32 | ], 33 | "redis_use_cases": ["real-time", "primary database"], 34 | "redis_features": ["JSON", "Search and Query", "Time Series", "Probabilistic"], 35 | "app_image_urls": [], 36 | "youtube_url": "https://www.youtube.com/watch?v=mUNFvyrsl8Q", 37 | "special_tags": [], 38 | "verticals": ["Financial", "Technology"], 39 | "markdown": "https://raw.githubusercontent.com/redis-developer/redis-stack-stocks/main/README.md" 40 | } 41 | -------------------------------------------------------------------------------- /redis-stack-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redis-developer/redis-stack-stocks/3fad37c2576a2fddf8cd0b86b4a1d8509a1f08a3/redis-stack-image.png -------------------------------------------------------------------------------- /redis-stack-stocks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redis-developer/redis-stack-stocks/3fad37c2576a2fddf8cd0b86b4a1d8509a1f08a3/redis-stack-stocks.png -------------------------------------------------------------------------------- /redis_stacks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redis-developer/redis-stack-stocks/3fad37c2576a2fddf8cd0b86b4a1d8509a1f08a3/redis_stacks.png -------------------------------------------------------------------------------- /stream/.dockerignore: -------------------------------------------------------------------------------- 1 | .env 2 | -------------------------------------------------------------------------------- /stream/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.10.5-alpine 2 | 3 | WORKDIR /app 4 | ADD . /app 5 | 6 | # System deps: 7 | RUN apk update && apk add python3-dev \ 8 | gcc \ 9 | g++ \ 10 | libc-dev \ 11 | libffi-dev 12 | 13 | RUN pip install -r requirements.txt 14 | 15 | EXPOSE 8000 16 | 17 | CMD ["python", "main.py"] 18 | -------------------------------------------------------------------------------- /stream/alpaca.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import datetime 3 | import logging 4 | import os 5 | import time 6 | import dateutil.parser as dp 7 | from dateutil.relativedelta import relativedelta 8 | from alpaca_trade_api.stream import Stream, Trade, Bar, Quote, NewsV2 9 | from alpaca_trade_api.common import URL 10 | from alpaca_trade_api.rest import REST, TimeFrame, TimeFrameUnit 11 | from models import News, Stock 12 | from connection import db, db_sync 13 | 14 | 15 | # This is a workaround to eliminate the threadsafe issue with the Alpaca SDK 16 | def noop(*args, **kws): 17 | return None 18 | 19 | 20 | def run_coroutine_threadsafe(coro, loop): 21 | asyncio.create_task(coro) 22 | return type('obj', (object,), {'result': noop}) 23 | 24 | 25 | asyncio.run_coroutine_threadsafe = run_coroutine_threadsafe 26 | # End Alpaca SDK workaround 27 | 28 | 29 | # define APCA_API_SECRET_KEY and APCA_API_KEY environment variables 30 | api = REST() 31 | 32 | ALPACA_API_KEY = os.getenv("APCA_API_KEY_ID") 33 | ALPACA_SECRET_KEY = os.getenv("APCA_API_SECRET_KEY") 34 | 35 | 36 | def get_historical_news(symbol): 37 | now = datetime.datetime.now(datetime.timezone.utc) 38 | end = now - relativedelta(minutes=16) 39 | start = now - relativedelta(days=8) 40 | values = api.get_news(symbol, start.isoformat(), end.isoformat(), limit=50) 41 | return [x._raw for x in values] 42 | 43 | 44 | def get_historical_trades(symbol): 45 | now = datetime.datetime.now(datetime.timezone.utc) 46 | end = now - relativedelta(minutes=16) 47 | start = now - relativedelta(minutes=76) 48 | values = api.get_trades(symbol, start.isoformat(), 49 | end.isoformat(), limit=50) 50 | 51 | return [{ 52 | "date": dp.parse(x._raw["t"]).timestamp(), 53 | "exchange": x._raw["x"], 54 | "price": x._raw["p"], 55 | "size": x._raw["s"], 56 | "conditions": x._raw["c"], 57 | "id": x._raw["i"], 58 | "tape": x._raw["z"] 59 | } for x in values] 60 | 61 | 62 | def get_historical_bars(symbol): 63 | now = datetime.datetime.now(datetime.timezone.utc) 64 | end = now - relativedelta(minutes=16) 65 | start = now - relativedelta(minutes=76) 66 | values = api.get_bars(symbol, TimeFrame( 67 | 1, TimeFrameUnit.Minute), start.isoformat(), end.isoformat(), limit=1000) 68 | return [{ 69 | "date": dp.parse(x._raw["t"]).timestamp(), 70 | "open": x._raw["o"], 71 | "high": x._raw["h"], 72 | "low": x._raw["l"], 73 | "close": x._raw["c"], 74 | "volume": x._raw["v"], 75 | } for x in values] 76 | 77 | 78 | def get_historical_quotes(symbol): 79 | now = datetime.datetime.now(datetime.timezone.utc) 80 | end = now - relativedelta(minutes=16) 81 | start = now - relativedelta(minutes=76) 82 | values = api.get_quotes(symbol, start.isoformat(), 83 | end.isoformat(), limit=1000) 84 | return [{ 85 | "date": dp.parse(x._raw["t"]).timestamp(), 86 | "ask_exchange": x._raw["ax"], 87 | "ask_price": x._raw["ap"], 88 | "ask_size": x._raw["as"], 89 | "bid_exchange": x._raw["bx"], 90 | "bid_price": x._raw["bp"], 91 | "bid_size": x._raw["bs"], 92 | "conditions": x._raw["c"], 93 | } for x in values] 94 | 95 | 96 | async def update_trade(trade: Trade): 97 | """ 98 | Sample trade object: 99 | { 100 | 'conditions': ['@'], 101 | 'exchange': 'V', 102 | 'id': 4864, 103 | 'price': 2923.175, 104 | 'size': 100, 105 | 'symbol': 'AMZN', 106 | 'tape': 'C', 107 | 'timestamp': 1646929983060642518 108 | } 109 | """ 110 | logging.log(logging.INFO, f'New trade for {trade.symbol}') 111 | logging.log(logging.DEBUG, str(trade)) 112 | ts = db_sync.ts() 113 | timestamp = str(int(trade.timestamp.timestamp() * 1000)) 114 | ts.madd([ 115 | ( 116 | f"stocks:{trade.symbol}:trades:price", 117 | timestamp, 118 | trade.price 119 | ), 120 | ( 121 | f"stocks:{trade.symbol}:trades:size", 122 | timestamp, 123 | trade.size 124 | )]) 125 | 126 | if (db_sync.exists('trending-stocks')): 127 | db_sync.topk().add('trending-stocks', trade.symbol) 128 | await db.publish('trending-stocks', 'updated') 129 | 130 | await db.publish('trade', trade.symbol) 131 | 132 | 133 | async def incoming_trade(trade: Trade): 134 | asyncio.create_task(update_trade(trade)) 135 | 136 | 137 | async def update_bar(bar: Bar): 138 | """ 139 | Sample bar object: 140 | { 141 | 'close': 150.76, 142 | 'high': 150.78, 143 | 'low': 150.61, 144 | 'open': 150.61, 145 | 'symbol': 'AAPL', 146 | 'timestamp': 1647282120000000000, 147 | 'trade_count': 27, 148 | 'volume': 2199, 149 | 'vwap': 150.71078 150 | } 151 | """ 152 | logging.log(logging.INFO, f'New bar for {bar.symbol}') 153 | logging.log(logging.DEBUG, str(bar)) 154 | ts = db_sync.ts() 155 | timestamp = str(int(bar.timestamp / 1000000)) 156 | ts.madd([ 157 | ( 158 | f"stocks:{bar.symbol}:bars:close", 159 | timestamp, 160 | bar.close 161 | ), 162 | ( 163 | f"stocks:{bar.symbol}:bars:high", 164 | timestamp, 165 | bar.high 166 | ), 167 | ( 168 | f"stocks:{bar.symbol}:bars:open", 169 | timestamp, 170 | bar.open 171 | ), 172 | ( 173 | f"stocks:{bar.symbol}:bars:low", 174 | timestamp, 175 | bar.low 176 | ), 177 | ( 178 | f"stocks:{bar.symbol}:bars:volume", 179 | timestamp, 180 | bar.volume 181 | )]) 182 | 183 | await db.publish('bar', bar.symbol) 184 | 185 | 186 | async def incoming_bar(bar: Bar): 187 | asyncio.create_task(update_bar(bar)) 188 | 189 | async def update_news(news: NewsV2): 190 | """ 191 | Sample news object: 192 | 193 | { 194 | "T": "n", 195 | "id": 24918784, 196 | "headline": "Corsair Reports Purchase Of Majority Ownership In iDisplay, No Terms Disclosed", 197 | "summary": "Corsair Gaming, Inc. (NASDAQ:CRSR) (“Corsair”), a leading global provider and innovator of high-performance gear for gamers and content creators, today announced that it acquired a 51% stake in iDisplay", 198 | "author": "Benzinga Newsdesk", 199 | "created_at": "2022-01-05T22:00:37Z", 200 | "updated_at": "2022-01-05T22:00:38Z", 201 | "url": "https://www.benzinga.com/m-a/22/01/24918784/corsair-reports-purchase-of-majority-ownership-in-idisplay-no-terms-disclosed", 202 | "content": "\u003cp\u003eCorsair Gaming, Inc. (NASDAQ:\u003ca class=\"ticker\" href=\"https://www.benzinga.com/stock/CRSR#NASDAQ\"\u003eCRSR\u003c/a\u003e) (\u0026ldquo;Corsair\u0026rdquo;), a leading global ...", 203 | "symbols": ["CRSR"], 204 | "source": "benzinga" 205 | } 206 | """ 207 | logging.log(logging.INFO, f'New news for {news.symbol}') 208 | logging.log(logging.DEBUG, str(news)) 209 | try: 210 | await Stock.add_news(news.symbol, News( 211 | id=news._raw['id'], 212 | headline=news._raw['headline'], 213 | author=news._raw['author'], 214 | created_at=news._raw['created_at'], 215 | updated_at=news._raw['updated_at'], 216 | summary=news._raw['summary'], 217 | url=news._raw['url'], 218 | images=news._raw['images'], 219 | symbols=news._raw['symbols'], 220 | source=news._raw['source'], 221 | )) 222 | except: 223 | pass 224 | 225 | async def incoming_news(news: NewsV2): 226 | asyncio.create_task(update_news(news)) 227 | 228 | 229 | async def update_quote(quote: Quote): 230 | """ 231 | Sample quote object: 232 | { 233 | 'ask_exchange': 'V', 234 | 'ask_price': 150.74, 235 | 'ask_size': 4, 236 | 'bid_exchange': 'V', 237 | 'bid_price': 150.4, 238 | 'bid_size': 2, 239 | 'conditions': ['R'], 240 | 'symbol': 'AAPL', 241 | 'tape': 'C', 242 | 'timestamp': 1647282073919284035 243 | } 244 | """ 245 | logging.log(logging.INFO, f'New quote for {quote.symbol}') 246 | logging.log(logging.DEBUG, str(quote)) 247 | ts = db_sync.ts() 248 | timestamp = str(int(quote.timestamp.timestamp() * 1000)) 249 | ts.madd([ 250 | ( 251 | f"stocks:{quote.symbol}:quotes:ask_price", 252 | timestamp, 253 | quote.ask_price 254 | ), 255 | ( 256 | f"stocks:{quote.symbol}:quotes:ask_size", 257 | timestamp, 258 | quote.ask_size 259 | ), 260 | ( 261 | f"stocks:{quote.symbol}:quotes:bid_price", 262 | timestamp, 263 | quote.bid_price 264 | ), 265 | ( 266 | f"stocks:{quote.symbol}:quotes:bid_size", 267 | timestamp, 268 | quote.bid_size 269 | )]) 270 | 271 | async def incoming_quote(quote: Quote): 272 | asyncio.create_task(update_quote(quote)) 273 | 274 | 275 | async def initialize_stock(symbol: str): 276 | stock = await Stock.get(symbol) 277 | 278 | if len(stock.news) == 0: 279 | stock.news = get_historical_news(symbol) 280 | 281 | await stock.save() 282 | 283 | trades = get_historical_trades(symbol) 284 | bars = get_historical_bars(symbol) 285 | ts = db_sync.ts() 286 | ts_keys = [ 287 | f"stocks:{symbol}:trades:price", 288 | f"stocks:{symbol}:trades:size", 289 | f"stocks:{symbol}:bars:open", 290 | f"stocks:{symbol}:bars:high", 291 | f"stocks:{symbol}:bars:low", 292 | f"stocks:{symbol}:bars:close", 293 | f"stocks:{symbol}:bars:volume", 294 | ] 295 | 296 | for key in ts_keys: 297 | if db_sync.exists(key): 298 | continue 299 | ts.create(key, duplicate_policy='last', labels={'symbol': symbol}) 300 | 301 | queries = [(f"stocks:{symbol}:trades:price", str( 302 | int(trade['date'] * 1000)), trade['price']) for trade in trades] 303 | queries += [(f"stocks:{symbol}:trades:size", 304 | str(int(trade['date'] * 1000)), trade['size']) for trade in trades] 305 | queries += [(f"stocks:{symbol}:bars:open", 306 | str(int(bar['date'] * 1000)), bar['open']) for bar in bars] 307 | queries += [(f"stocks:{symbol}:bars:high", 308 | str(int(bar['date'] * 1000)), bar['high']) for bar in bars] 309 | queries += [(f"stocks:{symbol}:bars:low", 310 | str(int(bar['date'] * 1000)), bar['low']) for bar in bars] 311 | queries += [(f"stocks:{symbol}:bars:close", 312 | str(int(bar['date'] * 1000)), bar['close']) for bar in bars] 313 | queries += [(f"stocks:{symbol}:bars:volume", 314 | str(int(bar['date'] * 1000)), bar['volume']) for bar in bars] 315 | 316 | ts.madd(queries) 317 | 318 | return stock 319 | 320 | conn = Stream(ALPACA_API_KEY, 321 | ALPACA_SECRET_KEY, 322 | base_url=URL('https://paper-api.alpaca.markets'), 323 | data_feed='iex') 324 | 325 | 326 | def connect(): 327 | global conn 328 | 329 | logging.log(logging.INFO, "Connecting to Alpaca") 330 | try: 331 | conn.run() 332 | except Exception as e: 333 | print(e) 334 | 335 | 336 | async def aioconnect(): 337 | global conn 338 | logging.log(logging.INFO, "Connecting to Alpaca") 339 | 340 | try: 341 | await conn._run_forever() 342 | except Exception as e: 343 | print(e) 344 | 345 | 346 | async def unsubscribe(*symbols: str): 347 | global conn 348 | conn.unsubscribe_trades(*symbols) 349 | conn.unsubscribe_bars(*symbols) 350 | conn.unsubscribe_news(*symbols) 351 | 352 | 353 | async def subscribe(*symbols: str): 354 | global conn 355 | logging.log(logging.INFO, f'Subscribing to {symbols}') 356 | for symbol in symbols: 357 | await initialize_stock(symbol) 358 | conn.subscribe_trades(incoming_trade, *symbols) 359 | conn.subscribe_bars(incoming_bar, *symbols) 360 | conn.subscribe_news(incoming_news, *symbols) 361 | logging.log(logging.INFO, f'Subscribed to {symbols}') 362 | 363 | 364 | watch_list = [] 365 | 366 | 367 | async def sync_watchlist(): 368 | try: 369 | global watch_list 370 | new_watch_list = await db.smembers('watchlist') 371 | 372 | unsubs = [s.decode('utf-8').upper() 373 | for s in set(watch_list) - set(new_watch_list)] 374 | subs = [s.decode('utf-8').upper() 375 | for s in set(new_watch_list) - set(watch_list)] 376 | 377 | if len(unsubs) > 0: 378 | await unsubscribe(*unsubs) 379 | time.sleep(3) 380 | 381 | if len(subs) > 0: 382 | await subscribe(*subs) 383 | time.sleep(3) 384 | 385 | watch_list = new_watch_list 386 | except Exception as e: 387 | print(e) 388 | -------------------------------------------------------------------------------- /stream/connection.py: -------------------------------------------------------------------------------- 1 | 2 | import os 3 | import aioredis 4 | import redis 5 | from dotenv import load_dotenv 6 | 7 | load_dotenv() 8 | 9 | redis_url = os.getenv("REDIS_URL", "redis://localhost:6379") 10 | db = aioredis.from_url(redis_url) 11 | db_sync = redis.from_url(redis_url) -------------------------------------------------------------------------------- /stream/main.py: -------------------------------------------------------------------------------- 1 | 2 | from alpaca import sync_watchlist, aioconnect 3 | from connection import db, db_sync 4 | import asyncio 5 | import logging 6 | 7 | 8 | async def reserve_topk(): 9 | db_sync.topk().reserve('trending-stocks', 12, 50, 4, 0.9) 10 | await db.expire('trending-stocks', 60) 11 | 12 | 13 | async def listen_for_events(): 14 | await db.config_set('notify-keyspace-events', 'KEsx') 15 | pubsub = db.pubsub() 16 | await pubsub.subscribe('__keyspace@0__:trending-stocks') 17 | await pubsub.subscribe('__keyspace@0__:watchlist') 18 | 19 | async for ev in pubsub.listen(): 20 | if ev['type'] == 'subscribe': 21 | continue 22 | 23 | if ev['data'] == b'expired': 24 | logging.log(logging.DEBUG, 'trending-stocks expired') 25 | await reserve_topk() 26 | elif ev['channel'] == b'__keyspace@0__:watchlist': 27 | logging.log(logging.DEBUG, 'watchlist updated') 28 | await sync_watchlist() 29 | 30 | 31 | async def main(): 32 | asyncio.create_task(aioconnect()) 33 | await asyncio.sleep(5) 34 | 35 | await db.delete('trending-stocks') 36 | await reserve_topk() 37 | await sync_watchlist() 38 | asyncio.create_task(listen_for_events()) 39 | while 1: 40 | await asyncio.sleep(60) 41 | 42 | 43 | if __name__ == '__main__': 44 | logging.basicConfig(format='%(asctime)s %(levelname)s %(message)s', 45 | level=logging.INFO) 46 | 47 | logging.log(logging.INFO, 'Starting up...') 48 | try: 49 | loop = asyncio.get_event_loop() 50 | loop.run_until_complete(main()) 51 | loop.close() 52 | except KeyboardInterrupt: 53 | pass 54 | -------------------------------------------------------------------------------- /stream/models.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from aredis_om import Field, JsonModel, EmbeddedJsonModel 3 | 4 | 5 | class Image(EmbeddedJsonModel): 6 | size: str 7 | url: str 8 | 9 | 10 | class News(EmbeddedJsonModel): 11 | id: str 12 | headline: str 13 | author: str 14 | created_at: str 15 | updated_at: str 16 | summary: str 17 | url: str 18 | images: List[Image] 19 | symbols: List[str] 20 | source: str 21 | 22 | 23 | class Stock(JsonModel): 24 | @staticmethod 25 | async def add_news(symbol: str, news: News): 26 | stock = await Stock.get(symbol) 27 | 28 | if (stock is None): 29 | return 30 | 31 | stock.news.append(news) 32 | 33 | await stock.save() 34 | 35 | symbol: str = Field(index=True, full_text_search=True, sortable=True) 36 | name: str = Field(index=True, full_text_search=True) 37 | last_sale: str 38 | market_cap: str 39 | country: str 40 | ipo: str 41 | volume: str 42 | sector: str = Field(index=True, full_text_search=True) 43 | industry: str = Field(index=True, full_text_search=True) 44 | news: List[News] 45 | 46 | @classmethod 47 | def make_key(cls, part: str): 48 | return f"stocks:{part}" 49 | 50 | class Meta: 51 | index_name = "stocks:index" 52 | -------------------------------------------------------------------------------- /stream/requirements.txt: -------------------------------------------------------------------------------- 1 | redis==4.3.3 2 | python-dotenv==0.20.0 3 | pydantic==1.9.1 4 | itsdangerous==2.1.2 5 | redis-om==0.0.27 6 | python-dateutil==2.8.2 7 | alpaca_trade_api==2.3.0 -------------------------------------------------------------------------------- /testing/test.py: -------------------------------------------------------------------------------- 1 | import os 2 | from alpaca_trade_api.stream import Stream, Trade 3 | from alpaca_trade_api.common import URL 4 | from alpaca_trade_api.rest import REST 5 | 6 | api = REST() 7 | ALPACA_API_KEY = os.getenv("APCA_API_KEY_ID") 8 | ALPACA_SECRET_KEY = os.getenv("APCA_API_SECRET_KEY") 9 | 10 | symbols = [ 11 | 'AAPL', 12 | 'GOOG', 13 | ] 14 | 15 | 16 | async def trade_callback(trade: Trade): 17 | """ 18 | Sample quote object: 19 | { 20 | 'conditions': ['@'], 21 | 'exchange': 'V', 22 | 'id': 4864, 23 | 'price': 2923.175, 24 | 'size': 100, 25 | 'symbol': 'AMZN', 26 | 'tape': 'C', 27 | 'timestamp': 1646929983060642518 28 | } 29 | """ 30 | print(f'Trade: {trade}') 31 | 32 | # Initiate Class Instance 33 | stream = Stream(ALPACA_API_KEY, 34 | ALPACA_SECRET_KEY, 35 | base_url=URL('https://paper-api.alpaca.markets'), 36 | data_feed='iex') 37 | 38 | 39 | async def run_realtime_topk(): 40 | # subscribing to event 41 | for symbol in symbols: 42 | stream.subscribe_trades(trade_callback, symbol) 43 | 44 | return await stream._run_forever() 45 | 46 | if __name__ == '__main__': 47 | import asyncio 48 | loop = asyncio.get_event_loop() 49 | loop.run_until_complete(run_realtime_topk()) 50 | loop.close() 51 | -------------------------------------------------------------------------------- /testing/topk.py: -------------------------------------------------------------------------------- 1 | import os 2 | import redis 3 | from alpaca_trade_api.stream import Stream, Trade 4 | from alpaca_trade_api.common import URL 5 | from alpaca_trade_api.rest import REST 6 | from redis import RedisError 7 | from aredis_om.model import NotFoundError 8 | from models import Stock, Trade as DBTrade 9 | 10 | redis_url = os.getenv("REDIS_URL", "redis://localhost:6379") 11 | r = redis.from_url(redis_url) 12 | # r.flushall() 13 | try: 14 | r.topk().reserve('topstocks', 5, 50, 4, 0.9) 15 | except: 16 | pass 17 | api = REST() 18 | symbols = [ 19 | 'AAPL', 20 | 'AMC', 21 | 'AMD', 22 | 'AMZN', 23 | 'CSCO', 24 | 'FB', 25 | 'GME', 26 | 'GOOG', 27 | 'MSFT', 28 | 'QCOM', 29 | 'SBUX', 30 | 'TSLA', 31 | ] 32 | 33 | 34 | async def create_ts(symbol: str): 35 | try: 36 | await r.ts().create(f"leaderboard:{symbol}:position", retention_msecs=1800000, duplicate_policy='last', labels={'symbol': symbol}) 37 | except Exception as e: 38 | pass 39 | 40 | try: 41 | await r.ts().create(f"leaderboard:{symbol}:price", retention_msecs=1800000, duplicate_policy='last', labels={'symbol': symbol}) 42 | except Exception as e: 43 | pass 44 | 45 | 46 | def get_trades(symbol: str): 47 | return api.get_trades(symbol, "2022-03-08T14:30:00Z", "2022-03-08T14:30:01Z").df 48 | 49 | 50 | async def trade_callback(trade: Trade): 51 | """ 52 | Sample quote object: 53 | { 54 | 'conditions': ['@'], 55 | 'exchange': 'V', 56 | 'id': 4864, 57 | 'price': 2923.175, 58 | 'size': 100, 59 | 'symbol': 'AMZN', 60 | 'tape': 'C', 61 | 'timestamp': 1646929983060642518 62 | } 63 | """ 64 | # await log_trade(trade) 65 | await create_ts(trade.symbol) 66 | r.ts().add(f"leaderboard:{trade.symbol}:price", str( 67 | int(trade.timestamp.timestamp() * 1000)), trade.price) 68 | response = r.topk().add('topstocks', trade.symbol) 69 | topItems = r.topk().list('topstocks') 70 | 71 | if (response[0] != None): 72 | r.ts().madd([ 73 | (f"leaderboard:{symbol}:position", str(int(trade.timestamp.timestamp() * 1000)), idx) for idx, symbol in enumerate(topItems) 74 | ]) 75 | 76 | print(f"\n\n") 77 | for idx, item in enumerate(topItems): 78 | price = r.ts().get(f"leaderboard:{item}:price") 79 | print( 80 | f"{idx+1}. {item} ({'N/A' if price is None else '${:,.2f}'.format(price[1])})") 81 | 82 | 83 | # Initiate Class Instance 84 | stream = Stream(base_url=URL('https://paper-api.alpaca.markets'), 85 | data_feed='iex') # <- replace to SIP if you have PRO subscription 86 | 87 | 88 | async def run_realtime_topk(): 89 | # subscribing to event 90 | for symbol in symbols: 91 | stream.subscribe_trades(trade_callback, symbol) 92 | 93 | return await stream._run_forever() 94 | 95 | async def log_trade(trade: Trade): 96 | try: 97 | dbStock = await Stock.get(f"{trade.symbol}:trades") 98 | except NotFoundError as e: 99 | dbStock = Stock( 100 | pk=f"{trade.symbol}:trades", 101 | symbol=trade.symbol, 102 | trades=[] 103 | ) 104 | 105 | dbStock.trades.append(DBTrade( 106 | date=trade.timestamp.timestamp(), 107 | exchange=trade.exchange, 108 | price=trade.price, 109 | size=trade.size, 110 | conditions=trade.conditions, 111 | id=trade.id, 112 | tape=trade.tape 113 | )) 114 | 115 | await dbStock.save() 116 | 117 | async def run_historical_topk(): 118 | try: 119 | for symbol in symbols: 120 | bars = get_trades(symbol) 121 | trades = [] 122 | print(f"\n{symbol}: {len(bars)}") 123 | for row in bars.itertuples(): 124 | trades.append(DBTrade( 125 | date=row.Index.timestamp(), 126 | exchange=row.exchange, 127 | price=row.price, 128 | size=row.size, 129 | conditions=row.conditions, 130 | id=row.id, 131 | tape=row.tape, 132 | )) 133 | response = r.topk().add('topstocks', symbol) 134 | 135 | if (response[0] != None): 136 | print(f"\n{symbol} evicted {response[0]}") 137 | 138 | try: 139 | dbStock = await Stock.get(symbol) 140 | dbStock.trades = trades 141 | except NotFoundError as e: 142 | dbStock = Stock( 143 | pk=symbol, 144 | symbol=symbol, 145 | trades=trades 146 | ) 147 | 148 | await dbStock.save() 149 | 150 | print("\nTOP 5 STOCKS:\n") 151 | topItems = r.topk().list('topstocks') 152 | for item in topItems: 153 | print(item) 154 | 155 | except RedisError as e: 156 | print(e) 157 | 158 | if __name__ == '__main__': 159 | import asyncio 160 | loop = asyncio.get_event_loop() 161 | loop.run_until_complete(run_realtime_topk()) 162 | loop.close() 163 | -------------------------------------------------------------------------------- /ui/.dockerignore: -------------------------------------------------------------------------------- 1 | .env 2 | -------------------------------------------------------------------------------- /ui/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /ui/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env.local 30 | .env.development.local 31 | .env.test.local 32 | .env.production.local 33 | 34 | # vercel 35 | .vercel 36 | 37 | # typescript 38 | *.tsbuildinfo 39 | -------------------------------------------------------------------------------- /ui/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:14.18.0-alpine 2 | 3 | ARG NEXT_PUBLIC_API_URL 4 | ARG NEXT_PUBLIC_WS_URL 5 | 6 | ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL 7 | ENV NEXT_PUBLIC_WS_URL=$NEXT_PUBLIC_WS_URL 8 | ENV PORT 3000 9 | 10 | # Create app directory 11 | RUN mkdir -p /usr/src/app 12 | WORKDIR /usr/src/app 13 | 14 | # Installing dependencies 15 | COPY package*.json /usr/src/app/ 16 | RUN npm install 17 | 18 | # Copying source files 19 | COPY . /usr/src/app 20 | 21 | # Building app 22 | RUN npm run build 23 | EXPOSE 3000 24 | 25 | # Running the app 26 | CMD ["npm", "start"] 27 | -------------------------------------------------------------------------------- /ui/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | ``` 12 | 13 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 14 | 15 | You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file. 16 | 17 | [API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`. 18 | 19 | The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. 20 | 21 | ## Learn More 22 | 23 | To learn more about Next.js, take a look at the following resources: 24 | 25 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 26 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 27 | 28 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 29 | 30 | ## Deploy on Vercel 31 | 32 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 33 | 34 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 35 | -------------------------------------------------------------------------------- /ui/components/ChartCard.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | import Info from "@components/Info"; 3 | import RealtimeChart from "@components/RealtimeChart"; 4 | 5 | // Import utilities 6 | import { tailwindConfig, hexToRGB } from "@utils"; 7 | import state from "@state"; 8 | import { ChartData } from "chart.js"; 9 | 10 | function ChartCard() { 11 | const stock = state.hooks.useCurrentStock(); 12 | const bars = state.hooks.useCurrentStockBars(); 13 | const stockInfo = state.hooks.useStockInfo({ 14 | key: stock?.symbol, 15 | }); 16 | 17 | const slicedData = bars?.map((bar) => bar[1]) ?? []; 18 | const slicedLabels = bars?.map((bar) => new Date(bar[0])) ?? []; 19 | 20 | const chartData: ChartData = { 21 | labels: slicedLabels, 22 | datasets: [ 23 | // Indigo line 24 | { 25 | data: slicedData, 26 | fill: true, 27 | backgroundColor: `rgba(${hexToRGB( 28 | (tailwindConfig()?.theme?.colors as any)?.blue[500] 29 | )}, 0.08)`, 30 | borderColor: (tailwindConfig()?.theme?.colors as any)?.indigo[500], 31 | borderWidth: 2, 32 | tension: 0, 33 | pointRadius: 0, 34 | pointHoverRadius: 3, 35 | pointBackgroundColor: (tailwindConfig()?.theme?.colors as any) 36 | ?.indigo[500], 37 | clip: 20 38 | }, 39 | ], 40 | }; 41 | 42 | return ( 43 |
44 |
45 |

{stock?.symbol}

46 | 47 |
{stock?.name}
48 |
49 |
50 | {/* Chart built with Chart.js 3 */} 51 | {/* Change the height attribute to adjust the chart height */} 52 | 53 |
54 | ); 55 | } 56 | 57 | export default ChartCard; 58 | -------------------------------------------------------------------------------- /ui/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import SearchModal from "./header/SearchModal"; 3 | import Notifications from "./header/Notifications"; 4 | import Help from "./header/Help"; 5 | import UserMenu from "./header/UserMenu"; 6 | 7 | export interface HeaderProps { 8 | sidebarOpen: boolean; 9 | setSidebarOpen: (sidebarOpen: boolean) => void; 10 | } 11 | 12 | function Header({ sidebarOpen, setSidebarOpen }: HeaderProps) { 13 | 14 | return ( 15 |
16 |
17 |
18 | {/* Header: Left side */} 19 |
20 | {/* Hamburger button */} 21 | 38 |
39 | 40 | {/* Header: Right side */} 41 |
42 | 43 | 44 | {/* Divider */} 45 |
46 | 47 |
48 |
49 |
50 |
51 | ); 52 | } 53 | 54 | export default Header; 55 | -------------------------------------------------------------------------------- /ui/components/Info.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import Transition from "@components/Transition"; 3 | 4 | export interface InfoProps { 5 | children: React.ReactNode; 6 | className?: string; 7 | containerClassName?: string; 8 | } 9 | 10 | function Info({ children, className, containerClassName }: InfoProps) { 11 | const [infoOpen, setInfoOpen] = useState(false); 12 | 13 | return ( 14 |
setInfoOpen(true)} 17 | onMouseLeave={() => setInfoOpen(false)} 18 | onFocus={() => setInfoOpen(true)} 19 | onBlur={() => setInfoOpen(false)} 20 | > 21 | 34 |
35 | 46 | {children} 47 | 48 |
49 |
50 | ); 51 | } 52 | 53 | export default Info; 54 | -------------------------------------------------------------------------------- /ui/components/NewsCard.tsx: -------------------------------------------------------------------------------- 1 | import state from "@state"; 2 | import React from "react"; 3 | 4 | function NewsCard() { 5 | const stock = state.hooks.useCurrentStock(); 6 | 7 | return ( 8 |
9 |
10 |

11 | Recent News about {stock?.symbol} 12 |

13 |
14 |
15 | {/* Card content */} 16 | {/* "Today" group */} 17 |
18 | 51 |
52 |
53 |
54 | ); 55 | } 56 | 57 | export default NewsCard; 58 | -------------------------------------------------------------------------------- /ui/components/RealtimeChart.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef } from "react"; 2 | 3 | import { 4 | Chart, 5 | LineController, 6 | LineElement, 7 | Filler, 8 | PointElement, 9 | LinearScale, 10 | TimeScale, 11 | Tooltip, 12 | ChartData, 13 | } from "chart.js"; 14 | import "chartjs-adapter-moment"; 15 | 16 | // Import utilities 17 | import { tailwindConfig, formatValue } from "@utils"; 18 | import useLayoutEffect from "@utils/useLayoutEffect"; 19 | import { PriceInfo } from "@state"; 20 | 21 | Chart.register( 22 | LineController, 23 | LineElement, 24 | Filler, 25 | PointElement, 26 | LinearScale, 27 | TimeScale, 28 | Tooltip 29 | ); 30 | 31 | export interface RealtimeChartProps { 32 | width: number; 33 | height: number; 34 | data: ChartData; 35 | stockInfo: PriceInfo | null; 36 | } 37 | 38 | function RealtimeChart({ data, width, height, stockInfo }: RealtimeChartProps) { 39 | const canvas = useRef(null); 40 | const chartValue = useRef(null); 41 | const chartDeviation = useRef(null); 42 | 43 | useLayoutEffect(() => { 44 | const ctx = canvas.current; 45 | if (!ctx) return; 46 | 47 | // eslint-disable-next-line no-unused-vars 48 | const chart = new Chart(ctx, { 49 | type: "line", 50 | data: data, 51 | options: { 52 | layout: { 53 | padding: 20, 54 | }, 55 | scales: { 56 | y: { 57 | grid: { 58 | drawBorder: false, 59 | }, 60 | ticks: { 61 | maxTicksLimit: 10, 62 | callback: (value) => formatValue(value as number), 63 | }, 64 | }, 65 | x: { 66 | type: "time", 67 | time: { 68 | parser: "hh:mm:ss", 69 | unit: "minute", 70 | tooltipFormat: "MMM DD, H:mm:ss a", 71 | displayFormats: { 72 | second: "H:mm:ss", 73 | }, 74 | }, 75 | grid: { 76 | display: false, 77 | drawBorder: false, 78 | }, 79 | ticks: { 80 | autoSkipPadding: 48, 81 | maxRotation: 0, 82 | }, 83 | }, 84 | }, 85 | plugins: { 86 | legend: { 87 | display: false, 88 | }, 89 | tooltip: { 90 | titleFont: { 91 | weight: "600", 92 | }, 93 | callbacks: { 94 | label: (context) => formatValue(context.parsed.y), 95 | }, 96 | }, 97 | }, 98 | interaction: { 99 | intersect: false, 100 | mode: "nearest", 101 | }, 102 | animation: false, 103 | maintainAspectRatio: false, 104 | resizeDelay: 200, 105 | }, 106 | }); 107 | return () => chart.destroy(); 108 | // eslint-disable-next-line react-hooks/exhaustive-deps 109 | }, [data]); 110 | 111 | // Update header values 112 | useLayoutEffect(() => { 113 | if (!chartValue.current || !chartDeviation.current || !stockInfo) return; 114 | 115 | chartValue.current.innerHTML = `${ 116 | formatValue(stockInfo.lastPrice).replace('$', '') 117 | }`; 118 | if (stockInfo.change < 0) { 119 | chartDeviation.current.style.backgroundColor = ( 120 | tailwindConfig()?.theme?.colors as any 121 | )?.red[500]; 122 | } else { 123 | chartDeviation.current.style.backgroundColor = ( 124 | tailwindConfig()?.theme?.colors as any 125 | )?.green[500]; 126 | } 127 | chartDeviation.current.innerHTML = `${stockInfo.change > 0 ? "+" : ""}${stockInfo.change.toFixed( 128 | 2 129 | )}%`; 130 | }, [data, stockInfo]); 131 | 132 | return ( 133 | 134 |
135 |
136 |
137 | $57.81 138 |
139 |
143 |
144 |
145 |
146 | 152 |
153 |
154 | ); 155 | } 156 | 157 | export default RealtimeChart; 158 | -------------------------------------------------------------------------------- /ui/components/Sidebar.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef } from "react"; 2 | import { useRouter } from "next/router"; 3 | import Link from "next/link"; 4 | import useLayoutEffect from "@utils/useLayoutEffect"; 5 | import isServerSide from "@utils/isServerSide"; 6 | 7 | export interface SidebarProps { 8 | sidebarOpen: boolean; 9 | setSidebarOpen: (sidebarOpen: boolean) => void; 10 | } 11 | 12 | function Sidebar({ sidebarOpen, setSidebarOpen }: SidebarProps) { 13 | const pathname = useRouter().pathname; 14 | 15 | const trigger = useRef(null); 16 | const sidebar = useRef(null); 17 | 18 | const storedSidebarExpanded = !isServerSide() 19 | ? localStorage.getItem("sidebar-expanded") 20 | : "false"; 21 | const [sidebarExpanded, setSidebarExpanded] = useState( 22 | storedSidebarExpanded === null ? false : storedSidebarExpanded === "true" 23 | ); 24 | 25 | // close on click outside 26 | useLayoutEffect(() => { 27 | const clickHandler = ({ target }: MouseEvent) => { 28 | if (!sidebar.current || !trigger.current) return; 29 | if ( 30 | !sidebarOpen || 31 | sidebar.current.contains(target as Node) || 32 | trigger.current.contains(target as Node) 33 | ) 34 | return; 35 | setSidebarOpen(false); 36 | }; 37 | document.addEventListener("click", clickHandler); 38 | return () => document.removeEventListener("click", clickHandler); 39 | }); 40 | 41 | // close if the esc key is pressed 42 | useLayoutEffect(() => { 43 | const keyHandler = ({ key }: KeyboardEvent) => { 44 | if (!sidebarOpen || !(key === "Esc" || key === "Escape")) return; 45 | setSidebarOpen(false); 46 | }; 47 | document.addEventListener("keydown", keyHandler); 48 | return () => document.removeEventListener("keydown", keyHandler); 49 | }); 50 | 51 | useLayoutEffect(() => { 52 | localStorage.setItem("sidebar-expanded", `${sidebarExpanded}`); 53 | if (sidebarExpanded) { 54 | document.querySelector("body")?.classList.add("sidebar-expanded"); 55 | } else { 56 | document.querySelector("body")?.classList.remove("sidebar-expanded"); 57 | } 58 | }, [sidebarExpanded]); 59 | 60 | return ( 61 |
62 | {/* Sidebar backdrop (mobile only) */} 63 | 69 | 70 | {/* Sidebar */} 71 | 220 |
221 | ); 222 | } 223 | 224 | export default Sidebar; 225 | -------------------------------------------------------------------------------- /ui/components/Transition.tsx: -------------------------------------------------------------------------------- 1 | import useLayoutEffect from "@utils/useLayoutEffect"; 2 | import React, { useRef, useContext } from "react"; 3 | import { CSSTransition as ReactCSSTransition } from "react-transition-group"; 4 | 5 | interface TransitionContextParent { 6 | show?: boolean; 7 | isInitialRender?: boolean; 8 | appear?: boolean; 9 | } 10 | 11 | const TransitionContext = React.createContext({ 12 | parent: {} as TransitionContextParent, 13 | }); 14 | 15 | function useIsInitialRender() { 16 | const isInitialRender = useRef(true); 17 | useLayoutEffect(() => { 18 | isInitialRender.current = false; 19 | }, []); 20 | return isInitialRender.current; 21 | } 22 | 23 | export interface CSSTransitionProps { 24 | [key: string]: any; 25 | children?: React.ReactNode; 26 | show?: boolean; 27 | enter?: string; 28 | enterStart?: string; 29 | enterEnd?: string; 30 | leave?: string; 31 | leaveStart?: string; 32 | leaveEnd?: string; 33 | appear?: boolean; 34 | unmountOnExit?: boolean; 35 | tag?: React.ElementType; 36 | } 37 | 38 | function CSSTransition({ 39 | show, 40 | enter = "", 41 | enterStart = "", 42 | enterEnd = "", 43 | leave = "", 44 | leaveStart = "", 45 | leaveEnd = "", 46 | appear, 47 | unmountOnExit, 48 | tag = "div", 49 | children, 50 | ...rest 51 | }: CSSTransitionProps) { 52 | const enterClasses = enter.split(" ").filter((s) => s.length); 53 | const enterStartClasses = enterStart.split(" ").filter((s) => s.length); 54 | const enterEndClasses = enterEnd.split(" ").filter((s) => s.length); 55 | const leaveClasses = leave.split(" ").filter((s) => s.length); 56 | const leaveStartClasses = leaveStart.split(" ").filter((s) => s.length); 57 | const leaveEndClasses = leaveEnd.split(" ").filter((s) => s.length); 58 | const removeFromDom = unmountOnExit; 59 | 60 | function addClasses(node: Element, classes: string[]) { 61 | classes.length && node.classList.add(...classes); 62 | } 63 | 64 | function removeClasses(node: Element, classes: string[]) { 65 | classes.length && node.classList.remove(...classes); 66 | } 67 | 68 | const nodeRef = React.useRef(null); 69 | const Component = tag; 70 | 71 | return ( 72 | { 78 | nodeRef.current?.addEventListener("transitionend", done, false); 79 | }} 80 | onEnter={() => { 81 | if (!removeFromDom && !!nodeRef.current) nodeRef.current.style.display = ''; 82 | addClasses(nodeRef.current as Element, [...enterClasses, ...enterStartClasses]); 83 | }} 84 | onEntering={() => { 85 | removeClasses(nodeRef.current as Element, enterStartClasses); 86 | addClasses(nodeRef.current as Element, enterEndClasses); 87 | }} 88 | onEntered={() => { 89 | removeClasses(nodeRef.current as Element, [...enterEndClasses, ...enterClasses]); 90 | }} 91 | onExit={() => { 92 | addClasses(nodeRef.current as Element, [...leaveClasses, ...leaveStartClasses]); 93 | }} 94 | onExiting={() => { 95 | removeClasses(nodeRef.current as Element, leaveStartClasses); 96 | addClasses(nodeRef.current as Element, leaveEndClasses); 97 | }} 98 | onExited={() => { 99 | removeClasses(nodeRef.current as Element, [...leaveEndClasses, ...leaveClasses]); 100 | if (!removeFromDom && !!nodeRef.current) nodeRef.current.style.display = "none"; 101 | }} 102 | > 103 | 108 | {children} 109 | 110 | 111 | ); 112 | } 113 | 114 | export interface TransitionProps { 115 | [key: string]: any; 116 | show?: boolean; 117 | appear?: boolean; 118 | } 119 | 120 | function Transition({ show, appear, ...rest }: TransitionProps) { 121 | const { parent } = useContext(TransitionContext); 122 | const isInitialRender = useIsInitialRender(); 123 | const isChild = show === undefined; 124 | 125 | if (isChild) { 126 | return ( 127 | 132 | ); 133 | } 134 | 135 | return ( 136 | 145 | 146 | 147 | ); 148 | } 149 | 150 | export default Transition; 151 | -------------------------------------------------------------------------------- /ui/components/TrendingCard.tsx: -------------------------------------------------------------------------------- 1 | import state from "@state"; 2 | import React from "react"; 3 | import Info from "@components/Info"; 4 | 5 | function TrendingCard() { 6 | const trending = state.hooks.useTrendingStocks(); 7 | 8 | return ( 9 |
10 |
11 |

Trending Stocks

12 | 13 |
14 | Keeps track of the most frequently traded stocks in your watchlist 15 | over the last 60 seconds. 16 |
17 |
18 |
19 |
20 | {/* Table */} 21 |
22 | 23 | {/* Table header */} 24 | 25 | 26 | 29 | 32 | 33 | 34 | {/* Table body */} 35 | 36 | {trending?.map((symbol, index) => { 37 | if (index % 2 === 1) { 38 | return; 39 | } 40 | 41 | const colors = ["bg-green-200", "bg-blue-200", "bg-yellow-200"]; 42 | let color = colors[0]; 43 | 44 | if (index < 4) { 45 | color = colors[0]; 46 | } else if (index < 8) { 47 | color = colors[1]; 48 | } else { 49 | color = colors[2]; 50 | } 51 | 52 | return ( 53 | 54 | 61 | 68 | 69 | ); 70 | })} 71 | 72 |
27 |
Symbol
28 |
30 |
Score
31 |
55 |
56 |
57 | {symbol} 58 |
59 |
60 |
62 |
63 |
64 | {trending[index + 1]} 65 |
66 |
67 |
73 |
74 |
75 |
76 | ); 77 | } 78 | 79 | export default TrendingCard; 80 | -------------------------------------------------------------------------------- /ui/components/WatchlistCard.tsx: -------------------------------------------------------------------------------- 1 | import state, { Stock } from "@state"; 2 | import { formatValue } from "@utils"; 3 | import React, { useState } from "react"; 4 | import SearchModal from "./header/SearchModal"; 5 | 6 | interface WatchlistRowProps { 7 | stock: Stock; 8 | color?: string; 9 | onDelete: (symbol: string) => any; 10 | onClick: (stock: Stock) => any; 11 | } 12 | 13 | function WatchlistRow({ 14 | stock, 15 | color = "", 16 | onDelete, 17 | onClick, 18 | }: WatchlistRowProps) { 19 | const stockInfo = state.hooks.useStockInfo({ 20 | key: stock.symbol, 21 | }); 22 | 23 | return ( 24 | 25 | { 28 | onDelete(stock.symbol); 29 | }} 30 | > 31 |
32 |
33 |
34 | 35 | { 38 | onClick(stock); 39 | }} 40 | > 41 |
42 |
{stock.symbol}
43 |
44 | 45 | { 48 | onClick(stock); 49 | }} 50 | > 51 |
{stock.name}
52 | 53 | { 56 | onClick(stock); 57 | }} 58 | > 59 | 0 ? 'bg-green-500' : 'bg-red-500'}`}> 60 | {stockInfo && formatValue(stockInfo.lastPrice)} 61 | 62 | 63 | 64 | ); 65 | } 66 | 67 | function TrendingCard() { 68 | const currentStock = state.hooks.useCurrentStock(); 69 | const watchList = state.hooks.useWatchList(); 70 | const [searchModalOpen, setSearchModalOpen] = useState(false); 71 | 72 | return ( 73 |
74 |
75 |

Watchlist

76 | 102 | 108 |
109 |
110 | {/* Table */} 111 |
112 | 113 | {/* Table header */} 114 | 115 | 116 | 119 | 122 | 125 | 128 | 129 | 130 | {/* Table body */} 131 | 132 | {watchList?.map((stock) => { 133 | return ( 134 | 145 | ); 146 | })} 147 | 148 |
117 |
Actions
118 |
120 |
Symbol
121 |
123 |
Name
124 |
126 |
Price
127 |
149 |
150 |
151 |
152 | ); 153 | } 154 | 155 | export default TrendingCard; 156 | -------------------------------------------------------------------------------- /ui/components/header/Help.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef } from "react"; 2 | import Link from "next/link"; 3 | import Transition from "@components/Transition"; 4 | import useLayoutEffect from "@utils/useLayoutEffect"; 5 | 6 | function Help() { 7 | const [dropdownOpen, setDropdownOpen] = useState(false); 8 | 9 | const trigger = useRef(null); 10 | const dropdown = useRef(null); 11 | 12 | // close on click outside 13 | useLayoutEffect(() => { 14 | const clickHandler = ({ target }: MouseEvent) => { 15 | if ( 16 | !dropdownOpen || 17 | dropdown.current?.contains(target as Node) || 18 | trigger.current?.contains(target as Node) 19 | ) 20 | return; 21 | setDropdownOpen(false); 22 | }; 23 | document.addEventListener("click", clickHandler); 24 | return () => document.removeEventListener("click", clickHandler); 25 | }); 26 | 27 | // close if the esc key is pressed 28 | useLayoutEffect(() => { 29 | const keyHandler = ({ key }: KeyboardEvent) => { 30 | if (!dropdownOpen || !(key === "Esc" || key === "Escape")) return; 31 | setDropdownOpen(false); 32 | }; 33 | document.addEventListener("keydown", keyHandler); 34 | return () => document.removeEventListener("keydown", keyHandler); 35 | }); 36 | 37 | return ( 38 |
39 | 60 | 61 | 71 |
setDropdownOpen(true)} 74 | onBlur={() => setDropdownOpen(false)} 75 | > 76 |
77 | Need help? 78 |
79 | 130 |
131 |
132 |
133 | ); 134 | } 135 | 136 | export default Help; 137 | -------------------------------------------------------------------------------- /ui/components/header/Notifications.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef, useEffect } from "react"; 2 | import Link from "next/link"; 3 | import Transition from "@components/Transition"; 4 | 5 | function Notifications() { 6 | const [dropdownOpen, setDropdownOpen] = useState(false); 7 | 8 | const trigger = useRef(null); 9 | const dropdown = useRef(null); 10 | 11 | // close on click outside 12 | useEffect(() => { 13 | const clickHandler = ({ target }: MouseEvent) => { 14 | if ( 15 | !dropdownOpen || 16 | dropdown.current?.contains(target as Node) || 17 | trigger.current?.contains(target as Node) 18 | ) 19 | return; 20 | setDropdownOpen(false); 21 | }; 22 | document.addEventListener("click", clickHandler); 23 | return () => document.removeEventListener("click", clickHandler); 24 | }); 25 | 26 | // close if the esc key is pressed 27 | useEffect(() => { 28 | const keyHandler = ({ key }: KeyboardEvent) => { 29 | if (!dropdownOpen || !(key === "Esc" || key === "Escape")) return; 30 | setDropdownOpen(false); 31 | }; 32 | document.addEventListener("keydown", keyHandler); 33 | return () => document.removeEventListener("keydown", keyHandler); 34 | }); 35 | 36 | return ( 37 |
38 | 64 | 65 | 75 |
setDropdownOpen(true)} 78 | onBlur={() => setDropdownOpen(false)} 79 | > 80 |
81 | Notifications 82 |
83 | 145 |
146 |
147 |
148 | ); 149 | } 150 | 151 | export default Notifications; 152 | -------------------------------------------------------------------------------- /ui/components/header/SearchModal.tsx: -------------------------------------------------------------------------------- 1 | import useLayoutEffect from "@utils/useLayoutEffect"; 2 | import React, { useRef, useState } from "react"; 3 | import Link from "next/link"; 4 | import Transition from "@components/Transition"; 5 | import state from "@state"; 6 | 7 | export interface SearchModelProps { 8 | id: string; 9 | searchId: string; 10 | modalOpen: boolean; 11 | setModalOpen: (modalOpen: boolean) => void; 12 | } 13 | 14 | function SearchModal({ 15 | id, 16 | searchId, 17 | modalOpen, 18 | setModalOpen, 19 | }: SearchModelProps) { 20 | const modalContent = useRef(null); 21 | const searchInput = useRef(null); 22 | const searchResults = state.hooks.useSearchResults(); 23 | 24 | // close on click outside 25 | useLayoutEffect(() => { 26 | const clickHandler = ({ target }: MouseEvent) => { 27 | if (!modalOpen || modalContent.current?.contains(target as Node)) return; 28 | setModalOpen(false); 29 | }; 30 | document.addEventListener("click", clickHandler); 31 | return () => document.removeEventListener("click", clickHandler); 32 | }); 33 | 34 | // close if the esc key is pressed 35 | useLayoutEffect(() => { 36 | const keyHandler = ({ key }: KeyboardEvent) => { 37 | if (!modalOpen || !(key === "Esc" || key === "Escape")) return; 38 | setModalOpen(false); 39 | }; 40 | document.addEventListener("keydown", keyHandler); 41 | return () => document.removeEventListener("keydown", keyHandler); 42 | }); 43 | 44 | useLayoutEffect(() => { 45 | modalOpen && searchInput.current?.focus(); 46 | }, [modalOpen]); 47 | 48 | return ( 49 | <> 50 | {/* Modal backdrop */} 51 |