├── .github └── workflows │ └── main.yml ├── .gitignore ├── LICENSE ├── README.md ├── codecov.yml ├── docker-compose.yml └── fastapi-prophet ├── .coveragerc ├── .dockerignore ├── Dockerfile ├── Dockerfile.prod ├── app ├── __init__.py ├── api │ ├── __init__.py │ ├── crud.py │ ├── ping.py │ ├── predictions.py │ └── train.py ├── config.py ├── db.py ├── main.py ├── model.py ├── models │ ├── __init__.py │ ├── pydantic.py │ └── sqlalchemy.py ├── plots │ ├── GOOG_plot.png │ └── GOOG_plot_components.png ├── prediction_engine.py ├── trained │ └── GOOG.joblib └── utils.py ├── coverage.xml ├── db ├── Dockerfile └── create.sql ├── entrypoint.sh ├── pre-reqs.in ├── requirements-dev.in ├── requirements.in ├── setup.cfg ├── start.sh └── tests ├── __init__.py ├── conftest.py ├── test_ping.py └── test_predictions.py /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | 7 | env: 8 | IMAGE: ghcr.io/${{ github.repository }}/web-app 9 | 10 | jobs: 11 | 12 | build: 13 | name: Build Docker Image 14 | runs-on: ubuntu-18.04 15 | steps: 16 | - name: Checkout master 17 | uses: actions/checkout@v2 18 | 19 | - name: Set up Docker Buildx 20 | id: buildx 21 | uses: docker/setup-buildx-action@v1 22 | 23 | - name: Login to Registry 24 | uses: docker/login-action@v1 25 | with: 26 | registry: ghcr.io 27 | username: ${{ github.repository_owner }} 28 | password: ${{ secrets.CR_PAT }} 29 | 30 | - name: Build and Push to GhCR 31 | id: docker_build 32 | uses: docker/build-push-action@v2 33 | with: 34 | context: ./fastapi-prophet 35 | file: ./fastapi-prophet/Dockerfile.prod 36 | push: true 37 | tags: ${{ env.IMAGE }}:latest 38 | - name: Image digest 39 | run: echo ${{ steps.docker_build.outputs.digest }} 40 | 41 | test: 42 | name: Test Docker Image 43 | runs-on: ubuntu-18.04 44 | needs: build 45 | steps: 46 | - name: Checkout master 47 | uses: actions/checkout@v2 48 | 49 | - name: Login to Registry 50 | uses: docker/login-action@v1 51 | with: 52 | registry: ghcr.io 53 | username: ${{ github.repository_owner }} 54 | password: ${{ secrets.CR_PAT }} 55 | 56 | - name: Pull images 57 | run: docker pull ${{ env.IMAGE }}:latest || true 58 | 59 | - name: Run container 60 | run: | 61 | docker run \ 62 | -d \ 63 | --name prophet \ 64 | -v "$(pwd)"/reports:/reports \ 65 | -e PORT=8765 \ 66 | -e ENVIRONMENT=dev \ 67 | -e DATABASE_TEST_URL=sqlite:///sqlite.db \ 68 | -e DATABASE_URL=sqlite:///sqlite_prod.db \ 69 | -p 5003:8765 \ 70 | ${{ env.IMAGE }}:latest 71 | 72 | - name: Install requirements 73 | run: docker exec prophet pip install -r ./requirements-dev.in || true 74 | 75 | - name: Print logs 76 | run: docker logs prophet 77 | 78 | - name: Run tests and generate coverage report 79 | run: docker exec prophet pytest --cov-report=xml --cov=./ -p no:warnings -vv 80 | 81 | - name: Place coverage report in shared volume 82 | run: docker exec prophet bash -c "mv coverage.xml /reports/" 83 | 84 | - name: Upload coverage report to Codecov 85 | uses: codecov/codecov-action@v1 86 | with: 87 | token: ${{ secrets.CODECOV_PAT }} 88 | files: reports/coverage.xml 89 | directory: reports 90 | 91 | - name: Flake8 92 | run: docker exec prophet python -m flake8 . 93 | 94 | - name: Black 95 | run: docker exec prophet python -m black . --check 96 | 97 | - name: isort 98 | run: docker exec prophet python -m isort . --check-only 99 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | *.png 3 | *.joblib 4 | .coverage 5 | **/*/*.db 6 | coverage.xml 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Rafael Pardinas 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Stock Market predictions with Prophet and FastAPI 2 | 3 |

4 | 5 | 6 | Coverage 7 | 8 | 9 | 10 | Python 3.7 3.8 3.9 11 |

12 | Train prophet models and run stock market predictions for a given ticker 13 | 14 | ## Details 15 | * Dev dependencies 16 | * FastAPI: https://fastapi.tiangolo.com 17 | * Docker: https://docs.docker.com/ 18 | * SQLAlchemy-Core: https://docs.sqlalchemy.org/en/14/core 19 | * Databases (Async SQLAlchemy Core queries): https://github.com/encode/databases 20 | * fbprophet (Time Series forecasting): https://facebook.github.io/prophet 21 | * yfinance (market data downloader): https://github.com/ranaroussi/yfinance 22 | * Testing: 23 | * pytest: https://docs.pytest.org/en/stable 24 | * pytest-cov: https://github.com/pytest-dev/pytest-cov 25 | * Codecov: https://docs.codecov.io 26 | * Linting and Formatting: 27 | * black: https://github.com/psf/black 28 | * flake8: https://flake8.pycqa.org/en/latest 29 | * isort: https://pycqa.github.io/isort 30 | * Continuous Integration by Github Actions 31 | 32 | ## Actions 33 | * Train the model for a ticker 34 | * Check the weekly prediction for a previously trained ticker 35 | * Check the weekly prediction for all the trained tickers 36 | 37 | #### CRUD 38 | * Create prediction for that ticker 39 | * Read results for all tickers or for individual ones 40 | * Updating the info for a ticker should be done by creating a new one due to its time-sensitiveness 41 | * Delete a specific ticker 42 | 43 | ## Future additions 44 | * Authentication 45 | * Extra information fields based on third-party content 46 | * Add plots and images if available from source 47 | * Input training timeframe 48 | * User Interface 49 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | # default 6 | target: auto 7 | threshold: 2% 8 | paths: 9 | - "fastapi-prophet/app" 10 | 11 | ignore: 12 | - "fastapi-prophet/tests/conftest.py" 13 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | 3 | services: 4 | 5 | web: 6 | build: ./fastapi-prophet 7 | command: uvicorn app.main:app --reload --workers 1 --host 0.0.0.0 --port 8000 8 | volumes: 9 | - ./fastapi-prophet/:/usr/src/app/ 10 | ports: 11 | - 8006:8000 12 | environment: 13 | - ENVIRONMENT:dev 14 | - TESTING:0 15 | - DATABASE_URL=postgresql://prophet_fastapi:hello_fastapi@prophet-db:5432/prophet_dev 16 | - DATABASE_TEST_URL=postgresql://prophet_fastapi:hello_fastapi@prophet-db:5432/prophet_test 17 | depends_on: 18 | - prophet-db 19 | 20 | prophet-db: 21 | build: 22 | context: ./fastapi-prophet/db 23 | dockerfile: Dockerfile 24 | expose: 25 | - 5432 26 | environment: 27 | - POSTGRES_USER=prophet_fastapi 28 | - POSTGRES_PASSWORD=hello_fastapi 29 | 30 | test: 31 | build: 32 | context: ./fastapi-prophet 33 | dockerfile: ./Dockerfile.prod 34 | command: gunicorn --bind 0.0.0.0:8000 app.main:app -k uvicorn.workers.UvicornWorker 35 | volumes: 36 | - ./fastapi-prophet/:/usr/src/app/ 37 | ports: 38 | - 8004:8000 39 | environment: 40 | - PORT:8765 41 | - ENVIRONMENT:dev 42 | - TESTING:1 43 | - DATABASE_URL=sqlite:///sqlite_prod.db 44 | - DATABASE_TEST_URL=sqlite:///sqlite_test.db 45 | - CODECOV_TOKEN=ffff585d-c5e3-4327-8d28-295b2f5d7f17 46 | -------------------------------------------------------------------------------- /fastapi-prophet/.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = tests/* 3 | branch = True 4 | -------------------------------------------------------------------------------- /fastapi-prophet/.dockerignore: -------------------------------------------------------------------------------- 1 | .dockerignore 2 | Dockerfile 3 | Dockerfile.prod 4 | .coverage 5 | -------------------------------------------------------------------------------- /fastapi-prophet/Dockerfile: -------------------------------------------------------------------------------- 1 | ########### 2 | # BUILDER # 3 | ########### 4 | 5 | FROM python:3.8.7-slim-buster as builder 6 | 7 | RUN apt-get update \ 8 | && apt-get install -y --no-install-recommends build-essential \ 9 | && apt-get clean 10 | 11 | WORKDIR /usr/src/app 12 | 13 | RUN python -m venv /opt/venv 14 | # Make sure we use the virtualenv: 15 | ENV PATH="/opt/venv/bin:$PATH" 16 | 17 | RUN pip install --upgrade pip && pip install pip-tools 18 | COPY ./pre-reqs.in . 19 | COPY ./requirements.in . 20 | COPY ./requirements-dev.in . 21 | RUN pip-compile pre-reqs.in > pre-reqs.txt 22 | RUN pip-compile requirements.in > requirements.txt 23 | RUN pip-compile requirements-dev.in > requirements-dev.txt 24 | RUN pip-sync pre-reqs.txt requirements.txt requirements-dev.txt 25 | RUN pip install -r pre-reqs.txt 26 | RUN pip install -r requirements.txt 27 | RUN pip install -r requirements-dev.txt 28 | 29 | ######### 30 | # FINAL # 31 | ######### 32 | 33 | # pull official base image 34 | FROM python:3.8.7-slim-buster 35 | 36 | RUN apt-get update \ 37 | && apt-get install -y --no-install-recommends postgresql netcat \ 38 | && apt-get clean 39 | 40 | ## set environment variables 41 | ENV PYTHONDONTWRITEBYTECODE 1 42 | ENV PYTHONUNBUFFERED 1 43 | 44 | COPY --from=builder /opt/venv /opt/venv 45 | 46 | # Make sure we use the virtualenv: 47 | ENV PATH="/opt/venv/bin:$PATH" 48 | 49 | WORKDIR /usr/src/app 50 | 51 | COPY . . 52 | 53 | COPY ./entrypoint.sh . 54 | RUN chmod +x /usr/src/app/entrypoint.sh 55 | 56 | ENTRYPOINT ["/usr/src/app/entrypoint.sh"] 57 | -------------------------------------------------------------------------------- /fastapi-prophet/Dockerfile.prod: -------------------------------------------------------------------------------- 1 | ########### 2 | # BUILDER # 3 | ########### 4 | 5 | FROM python:3.8.7-slim-buster as builder 6 | 7 | RUN apt-get update \ 8 | && apt-get install -y --no-install-recommends build-essential \ 9 | && apt-get clean 10 | 11 | WORKDIR /usr/src/app 12 | 13 | # create venv 14 | RUN python -m venv /opt/venv 15 | # activate it 16 | ENV PATH="/opt/venv/bin:$PATH" 17 | 18 | RUN pip install --upgrade pip && pip install pip-tools 19 | COPY ./pre-reqs.in . 20 | COPY ./requirements.in . 21 | RUN pip-compile pre-reqs.in > pre-reqs.txt 22 | RUN pip-compile requirements.in > requirements.txt 23 | RUN pip-sync pre-reqs.txt requirements.txt 24 | RUN pip install -r pre-reqs.txt 25 | RUN pip install -r requirements.txt 26 | 27 | ######### 28 | # FINAL # 29 | ######### 30 | 31 | # pull official base image 32 | FROM python:3.8.7-slim-buster 33 | 34 | RUN apt-get update \ 35 | && apt-get install -y --no-install-recommends postgresql netcat curl \ 36 | && apt-get clean 37 | 38 | ## set environment variables 39 | ENV PYTHONDONTWRITEBYTECODE 1 40 | ENV PYTHONUNBUFFERED 1 41 | ENV ENVIRONMENT prod 42 | ENV TESTING 0 43 | 44 | COPY --from=builder /opt/venv /opt/venv 45 | 46 | # activate the venv 47 | ENV PATH="/opt/venv/bin:$PATH" 48 | 49 | RUN pip install --upgrade pip 50 | RUN pip install "uvicorn[standard]==0.13.1" "gunicorn==20.0.4" 51 | RUN pip install "aiosqlite==0.17.0" 52 | 53 | WORKDIR /usr/src/app 54 | 55 | COPY . . 56 | 57 | COPY ./start.sh . 58 | RUN chmod +x /usr/src/app/start.sh 59 | 60 | CMD ["/usr/src/app/start.sh"] 61 | -------------------------------------------------------------------------------- /fastapi-prophet/app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rafapi/fastapi-prophet/66814e7f3456f10515d627b1ce80accb1c4f095d/fastapi-prophet/app/__init__.py -------------------------------------------------------------------------------- /fastapi-prophet/app/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rafapi/fastapi-prophet/66814e7f3456f10515d627b1ce80accb1c4f095d/fastapi-prophet/app/api/__init__.py -------------------------------------------------------------------------------- /fastapi-prophet/app/api/crud.py: -------------------------------------------------------------------------------- 1 | from app.models.pydantic import StockIn 2 | from app.models.sqlalchemy import predictions 3 | 4 | 5 | async def post(payload: StockIn, database): 6 | query = predictions.insert().values(ticker=payload.ticker) 7 | return await database.execute(query=query) 8 | 9 | 10 | async def get(id: int, database): 11 | query = predictions.select().where(id == predictions.c.id) 12 | return await database.fetch_one(query=query) 13 | 14 | 15 | async def get_all(database): 16 | query = predictions.select() 17 | return await database.fetch_all(query=query) 18 | 19 | 20 | async def delete(id: int, database): 21 | query = predictions.delete().where(id == predictions.c.id) 22 | return await database.execute(query=query) 23 | -------------------------------------------------------------------------------- /fastapi-prophet/app/api/ping.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Depends 2 | 3 | from app.config import Settings, get_settings 4 | 5 | router = APIRouter() 6 | 7 | 8 | # health-check 9 | @router.get("/ping") 10 | async def pong(settings: Settings = Depends(get_settings)): 11 | return { 12 | "ping": "pong!", 13 | "environment": settings.environment, 14 | "testing": settings.testing, 15 | } 16 | -------------------------------------------------------------------------------- /fastapi-prophet/app/api/predictions.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pathlib 3 | from typing import List 4 | 5 | from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Path 6 | 7 | from app.api import crud 8 | from app.db import get_db 9 | from app.models.pydantic import PredictionSchema, StockIn, StockOut 10 | from app.prediction_engine import generate_prediction 11 | from app.utils import pred_to_dict 12 | 13 | router = APIRouter() 14 | 15 | 16 | BASE_DIR = pathlib.Path(__file__).resolve(strict=True).parent.parent 17 | TRAINED_DIR = pathlib.Path(BASE_DIR) / "trained" 18 | 19 | 20 | @router.post("/", response_model=StockOut, status_code=201) 21 | async def create_prediction( 22 | payload: StockIn, 23 | background_tasks: BackgroundTasks, 24 | database=Depends(get_db), 25 | ): 26 | model_file = TRAINED_DIR / f"{payload.ticker}.joblib" 27 | for entry in os.listdir(TRAINED_DIR): 28 | if os.path.isfile(os.path.join(TRAINED_DIR, entry)): 29 | print(entry) 30 | if not model_file.exists(): 31 | raise HTTPException( 32 | status_code=405, 33 | detail="Not Allowed. A model must be trained to process this request.", 34 | ) 35 | prediction_id = await crud.post(payload, database) 36 | 37 | background_tasks.add_task( 38 | generate_prediction, prediction_id, payload.ticker, database 39 | ) 40 | 41 | response_object = {"id": prediction_id, "ticker": payload.ticker} 42 | 43 | return response_object 44 | 45 | 46 | @router.get("/{id}/", response_model=PredictionSchema) 47 | async def get_prediction( 48 | id: int = Path(..., gt=0), database=Depends(get_db) 49 | ) -> PredictionSchema: 50 | prediction_items = await crud.get(id, database) 51 | if not prediction_items: 52 | raise HTTPException(status_code=404, detail="Prediction not found") 53 | 54 | return pred_to_dict(prediction_items) 55 | 56 | 57 | @router.get("/", response_model=List[PredictionSchema]) 58 | async def get_all_predictions( 59 | database=Depends(get_db), 60 | ) -> List[PredictionSchema]: 61 | prediction_items = await crud.get_all(database) 62 | if not prediction_items: 63 | raise HTTPException( 64 | status_code=404, detail="No predictions have been created at this time" 65 | ) 66 | return [pred_to_dict(pred) for pred in prediction_items] 67 | 68 | 69 | @router.delete("/{id}/", response_model=PredictionSchema) 70 | async def delete_prediction( 71 | id: int = Path(..., gt=0), database=Depends(get_db) 72 | ) -> PredictionSchema: 73 | prediction = await crud.get(id, database) 74 | if not prediction: 75 | raise HTTPException(status_code=404, detail="Prediction not found") 76 | 77 | await crud.delete(id, database) 78 | 79 | return pred_to_dict(prediction) 80 | -------------------------------------------------------------------------------- /fastapi-prophet/app/api/train.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, BackgroundTasks 2 | 3 | from app.model import train 4 | from app.models.pydantic import StockIn 5 | 6 | router = APIRouter() 7 | 8 | 9 | @router.post("/", status_code=201) 10 | async def train_prediction( 11 | payload: StockIn, 12 | background_tasks: BackgroundTasks, 13 | ): 14 | 15 | background_tasks.add_task(train, payload.ticker) 16 | 17 | return payload 18 | -------------------------------------------------------------------------------- /fastapi-prophet/app/config.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | from functools import lru_cache 4 | 5 | from pydantic import BaseSettings 6 | 7 | log = logging.getLogger(__name__) 8 | 9 | 10 | class Settings(BaseSettings): 11 | environment: str = os.getenv("ENVIRONMENT", "dev") 12 | testing: bool = os.getenv("TESTING", 0) 13 | database_url: str = os.getenv("DATABASE_URL") 14 | 15 | 16 | @lru_cache() 17 | def get_settings() -> BaseSettings: 18 | log.info("Loading config settings from the environment...") 19 | return Settings() 20 | -------------------------------------------------------------------------------- /fastapi-prophet/app/db.py: -------------------------------------------------------------------------------- 1 | # import os 2 | from typing import Optional 3 | 4 | from databases import Database 5 | from sqlalchemy import create_engine 6 | 7 | from app.config import get_settings 8 | 9 | database = Optional[Database] 10 | 11 | 12 | settings = get_settings() 13 | 14 | 15 | def get_eng(): 16 | db_url = settings.database_url 17 | if settings.testing: 18 | engine = create_engine(db_url, connect_args={"check_same_thread": False}) 19 | else: 20 | engine = create_engine(db_url) 21 | return engine 22 | 23 | 24 | def get_db() -> Database: 25 | global database 26 | db_url = settings.database_url 27 | if settings.testing: 28 | database = Database(db_url, force_rollback=True) 29 | else: 30 | database = Database(db_url) 31 | 32 | return database 33 | -------------------------------------------------------------------------------- /fastapi-prophet/app/main.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from fastapi import FastAPI 4 | 5 | from app.api import ping, predictions, train 6 | from app.db import get_db, get_eng 7 | from app.models.sqlalchemy import metadata 8 | 9 | log = logging.getLogger(__name__) 10 | 11 | 12 | def create_application() -> FastAPI: 13 | application = FastAPI() 14 | application.include_router(ping.router) 15 | application.include_router(predictions.router, prefix="/predict", tags=["predict"]) 16 | application.include_router(train.router, prefix="/train", tags=["train"]) 17 | return application 18 | 19 | 20 | metadata.create_all(get_eng()) 21 | database = get_db() 22 | app = create_application() 23 | 24 | 25 | @app.on_event("startup") 26 | async def startup(): 27 | log.info("Starting up...") 28 | await database.connect() 29 | 30 | 31 | @app.on_event("shutdown") 32 | async def shutdown(): 33 | log.info("Shutting down...") 34 | await database.disconnect() 35 | -------------------------------------------------------------------------------- /fastapi-prophet/app/model.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import pathlib 3 | 4 | import joblib 5 | import pandas as pd 6 | import yfinance as yf 7 | from prophet import Prophet 8 | 9 | BASE_DIR = pathlib.Path(__file__).resolve(strict=True).parent 10 | TRAINED_DIR = pathlib.Path(BASE_DIR) / "trained" 11 | PLOTS_DIR = pathlib.Path(BASE_DIR) / "plots" 12 | TODAY = datetime.date.today() 13 | 14 | 15 | def train(ticker="MSFT"): 16 | data = yf.download(ticker, "2021-02-01", TODAY.strftime("%Y-%m-%d")) 17 | data.head() 18 | data["Adj Close"].plot(title=f"{ticker} Stock Adjust Closing Price") 19 | 20 | df_forecast = data.copy() 21 | df_forecast.reset_index(inplace=True) 22 | df_forecast["ds"] = df_forecast["Date"] 23 | df_forecast["y"] = df_forecast["Adj Close"] 24 | df_forecast = df_forecast[["ds", "y"]] 25 | df_forecast 26 | 27 | model = Prophet() 28 | model.fit(df_forecast) 29 | 30 | joblib.dump(model, TRAINED_DIR / f"{ticker}.joblib") 31 | 32 | 33 | async def predict(ticker="MSFT", days=7): 34 | model_file = TRAINED_DIR / f"{ticker}.joblib" 35 | if not model_file.exists(): 36 | return False 37 | 38 | model = joblib.load(model_file) 39 | 40 | future = TODAY + datetime.timedelta(days=days) 41 | 42 | dates = pd.date_range( 43 | start="2020-01-01", 44 | end=future.strftime("%m/%d/%Y"), 45 | ) 46 | df = pd.DataFrame({"ds": dates}) 47 | 48 | forecast = model.predict(df) 49 | 50 | model.plot(forecast).savefig(PLOTS_DIR / f"{ticker}_plot.png") 51 | model.plot_components(forecast).savefig(PLOTS_DIR / f"{ticker}_plot_components.png") 52 | 53 | return forecast.tail(days).to_dict("records") 54 | 55 | 56 | def convert(prediction_list): 57 | output = {} 58 | for data in prediction_list: 59 | date = data["ds"].strftime("%m/%d/%Y") 60 | output[date] = data["trend"] 61 | return output 62 | -------------------------------------------------------------------------------- /fastapi-prophet/app/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rafapi/fastapi-prophet/66814e7f3456f10515d627b1ce80accb1c4f095d/fastapi-prophet/app/models/__init__.py -------------------------------------------------------------------------------- /fastapi-prophet/app/models/pydantic.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from pydantic import BaseModel 4 | 5 | 6 | class StockIn(BaseModel): 7 | ticker: str 8 | 9 | 10 | class StockOut(StockIn): 11 | id: int 12 | 13 | 14 | class PredictionSchema(StockOut): 15 | prediction: dict 16 | created_date: datetime 17 | 18 | class Config: 19 | orm_mode = True 20 | -------------------------------------------------------------------------------- /fastapi-prophet/app/models/sqlalchemy.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, DateTime, Integer, MetaData, String, Table 2 | from sqlalchemy.sql import func 3 | 4 | metadata = MetaData() 5 | 6 | predictions = Table( 7 | "predictions", 8 | metadata, 9 | Column("id", Integer, primary_key=True), 10 | Column("ticker", String(6)), 11 | Column("prediction", String), 12 | Column("created_date", DateTime, default=func.now(), nullable=False), 13 | ) 14 | -------------------------------------------------------------------------------- /fastapi-prophet/app/plots/GOOG_plot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rafapi/fastapi-prophet/66814e7f3456f10515d627b1ce80accb1c4f095d/fastapi-prophet/app/plots/GOOG_plot.png -------------------------------------------------------------------------------- /fastapi-prophet/app/plots/GOOG_plot_components.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rafapi/fastapi-prophet/66814e7f3456f10515d627b1ce80accb1c4f095d/fastapi-prophet/app/plots/GOOG_plot_components.png -------------------------------------------------------------------------------- /fastapi-prophet/app/prediction_engine.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from fastapi import HTTPException 4 | 5 | from app.model import convert, predict 6 | from app.models.pydantic import StockIn 7 | from app.models.sqlalchemy import predictions 8 | 9 | 10 | async def generate_prediction(id: int, ticker: StockIn, database): 11 | prediction_list = await predict(ticker) 12 | if not prediction_list: 13 | raise HTTPException(status_code=404, detail="No train model found") 14 | prediction_data = json.dumps(convert(prediction_list)) 15 | 16 | query = ( 17 | predictions.update() 18 | .where(id == predictions.c.id) 19 | .values(ticker=ticker, prediction=prediction_data) 20 | ) 21 | return await database.execute(query=query) 22 | -------------------------------------------------------------------------------- /fastapi-prophet/app/trained/GOOG.joblib: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rafapi/fastapi-prophet/66814e7f3456f10515d627b1ce80accb1c4f095d/fastapi-prophet/app/trained/GOOG.joblib -------------------------------------------------------------------------------- /fastapi-prophet/app/utils.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | 4 | def pred_to_dict(prediction_items): 5 | pred_dict = {} 6 | for k, v in prediction_items.items(): 7 | pred_dict[k] = json.loads(v) if k == "prediction" else v 8 | return pred_dict 9 | -------------------------------------------------------------------------------- /fastapi-prophet/coverage.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | /usr/src/app 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | -------------------------------------------------------------------------------- /fastapi-prophet/db/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM postgres:13-alpine 2 | 3 | ADD create.sql /docker-entrypoint-initdb.d 4 | -------------------------------------------------------------------------------- /fastapi-prophet/db/create.sql: -------------------------------------------------------------------------------- 1 | CREATE DATABASE prophet_dev; 2 | CREATE DATABASE prophet_test; 3 | -------------------------------------------------------------------------------- /fastapi-prophet/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | echo "Waiting for PostgreSQL..." 4 | 5 | while ! nc -z prophet-db 5432; do 6 | sleep 0.1 7 | done 8 | 9 | echo "PostgreSQL started" 10 | 11 | exec "$@" 12 | -------------------------------------------------------------------------------- /fastapi-prophet/pre-reqs.in: -------------------------------------------------------------------------------- 1 | wheel==0.37.0 2 | Cython>=0.22 3 | # cmdstanpy>=0.9.5 4 | psycopg2-binary==2.8.6 5 | numpy>=1.20.1 6 | # pandas==1.3.3 7 | matplotlib>=2.0.0 8 | LunarCalendar>=0.0.9 9 | convertdate>=2.1.2 10 | holidays>=0.10.2 11 | pystan==2.19.1.1 12 | tqdm>=4.36.1 13 | -------------------------------------------------------------------------------- /fastapi-prophet/requirements-dev.in: -------------------------------------------------------------------------------- 1 | aiosqlite==0.17.0 2 | databases[sqlite]==0.5.2 3 | pytest==6.2.0 4 | pytest-cov>=2.10.0 5 | flake8===3.8.4 6 | black==20.8b1 7 | isort==5.6.4 8 | pytest-xdist==2.2.0 9 | pytest-asyncio>=0.14.0 10 | -------------------------------------------------------------------------------- /fastapi-prophet/requirements.in: -------------------------------------------------------------------------------- 1 | databases[postgresql]==0.5.2 2 | fastapi>=0.65.2 3 | prophet==1.0.1 4 | joblib==1.0.0 5 | plotly==4.14.3 6 | yfinance==0.1.63 7 | uvicorn[standard]==0.15.0 8 | gunicorn==20.1.0 9 | -------------------------------------------------------------------------------- /fastapi-prophet/setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 119 3 | -------------------------------------------------------------------------------- /fastapi-prophet/start.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env sh 2 | 3 | # set -e 4 | 5 | # if [ -f /app/app/main.py ]; then 6 | # DEFAULT_MODULE_NAME=app.main 7 | # elif [ -f /app/main.py ]; then 8 | # DEFAULT_MODULE_NAME=main 9 | # fi 10 | # MODULE_NAME=${MODULE_NAME:-$DEFAULT_MODULE_NAME} 11 | # VARIABLE_NAME=${VARIABLE_NAME:-app} 12 | # export APP_MODULE=${APP_MODULE:-"$MODULE_NAME:$VARIABLE_NAME"} 13 | 14 | # if [ -f /app/gunicorn_conf.py ]; then 15 | # DEFAULT_GUNICORN_CONF=/app/gunicorn_conf.py 16 | # elif [ -f /app/app/gunicorn_conf.py ]; then 17 | # DEFAULT_GUNICORN_CONF=/app/app/gunicorn_conf.py 18 | # else 19 | # DEFAULT_GUNICORN_CONF=/gunicorn_conf.py 20 | # fi 21 | # export GUNICORN_CONF=${GUNICORN_CONF:-$DEFAULT_GUNICORN_CONF} 22 | # export WORKER_CLASS=${WORKER_CLASS:-"uvicorn.workers.UvicornWorker"} 23 | 24 | # # Start Gunicorn 25 | # exec gunicorn -k "$WORKER_CLASS" -c "$GUNICORN_CONF" "$APP_MODULE" 26 | 27 | exec gunicorn --bind 0.0.0.0:$PORT app.main:app -k uvicorn.workers.UvicornWorker 28 | -------------------------------------------------------------------------------- /fastapi-prophet/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rafapi/fastapi-prophet/66814e7f3456f10515d627b1ce80accb1c4f095d/fastapi-prophet/tests/__init__.py -------------------------------------------------------------------------------- /fastapi-prophet/tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | # import asyncio 4 | import pytest 5 | from fastapi.testclient import TestClient 6 | 7 | from app.config import get_settings 8 | from app.db import get_db, get_eng 9 | from app.main import create_application 10 | from app.models.sqlalchemy import metadata 11 | 12 | settings = get_settings() 13 | 14 | 15 | @pytest.fixture(scope="module") 16 | def test_app(): 17 | settings.testing = True 18 | settings.environment = "test" 19 | settings.database_url = os.getenv("DATABASE_TEST_URL") 20 | app = create_application() 21 | with TestClient(app) as test_client: 22 | yield test_client 23 | 24 | 25 | @pytest.fixture(scope="module") 26 | def db(): 27 | metadata.create_all(get_eng()) 28 | database = get_db() 29 | yield database 30 | -------------------------------------------------------------------------------- /fastapi-prophet/tests/test_ping.py: -------------------------------------------------------------------------------- 1 | def test_ping(test_app): 2 | response = test_app.get("/ping") 3 | assert response.status_code == 200 4 | assert response.json() == { 5 | "ping": "pong!", 6 | "environment": "test", 7 | "testing": True, 8 | } 9 | -------------------------------------------------------------------------------- /fastapi-prophet/tests/test_predictions.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | from datetime import datetime 4 | 5 | from app.api import crud 6 | from app.config import get_settings 7 | from app.utils import pred_to_dict 8 | 9 | 10 | def test_db_test_url(test_app): 11 | settings = get_settings() 12 | assert settings.database_url == os.environ.get("DATABASE_TEST_URL") 13 | 14 | 15 | def test_create_prediction(test_app, db): 16 | test_request_payload = {"ticker": "GOOG"} 17 | 18 | response = test_app.post("/predict/", json.dumps(test_request_payload), db) 19 | 20 | prediction_id = response.json()["id"] 21 | 22 | assert response.status_code == 201 23 | assert response.json() == {"id": prediction_id, "ticker": "GOOG"} 24 | 25 | 26 | def test_create_prediction_invalid_json(test_app): 27 | response = test_app.post("/predict/", data=json.dumps({2: "GOOG"})) 28 | assert response.status_code == 422 29 | 30 | 31 | def test_read_prediction(test_app, db, monkeypatch): 32 | test_data = { 33 | "id": 1, 34 | "ticker": "MSFT", 35 | "prediction": json.dumps( 36 | { 37 | "07/22/2020": 212.29389088938012, 38 | "07/23/2020": 212.75441373941516, 39 | "07/24/2020": 213.21493658945016, 40 | "07/25/2020": 213.6754594394852, 41 | "07/26/2020": 214.1359822895202, 42 | "07/27/2020": 214.59650513955518, 43 | "07/28/2020": 215.05702798959027, 44 | } 45 | ), 46 | "created_date": datetime.utcnow().isoformat(), 47 | } 48 | 49 | async def mock_get(id, db): 50 | return test_data 51 | 52 | monkeypatch.setattr(crud, "get", mock_get) 53 | 54 | response = test_app.get("/predict/1/") 55 | assert response.status_code == 200 56 | assert response.json() == pred_to_dict(test_data) 57 | 58 | 59 | # def test_read_all_predictions(test_app, db): 60 | # test_request_payload = {"ticker": "GOOG"} 61 | 62 | # response_post = test_app.post("/predict/", json.dumps(test_request_payload), db) 63 | 64 | # response_get = test_app.get("/predict/") 65 | # assert response_get.status_code == 200 66 | # response_list = response_get.json() 67 | 68 | # prediction_id = response_post.json()["id"] 69 | # assert len(list(filter(lambda d: d["id"] == prediction_id, response_list))) == 1 70 | 71 | 72 | def test_read_prediction_incorrect_id(test_app, db): 73 | response = test_app.get("/predict/999/") 74 | assert response.status_code == 404 75 | assert response.json()["detail"] == "Prediction not found" 76 | 77 | 78 | # def test_delete_prediction(test_app, db): 79 | # test_request_payload = {"ticker": "GOOG"} 80 | 81 | # post_response = test_app.post("/predict/", json.dumps(test_request_payload), db) 82 | # prediction_id = post_response.json()["id"] 83 | 84 | # del_response = test_app.delete(f"/predict/{prediction_id}/") 85 | 86 | # assert del_response.json()["id"] == prediction_id 87 | 88 | 89 | def test_delete_prediction_incorrect_id(test_app, db): 90 | response = test_app.delete("/predict/999/") 91 | assert response.status_code == 404 92 | assert response.json()["detail"] == "Prediction not found" 93 | 94 | response = test_app.delete("/predict/0/") 95 | assert response.status_code == 422 96 | assert response.json() == { 97 | "detail": [ 98 | { 99 | "loc": ["path", "id"], 100 | "msg": "ensure this value is greater than 0", 101 | "type": "value_error.number.not_gt", 102 | "ctx": {"limit_value": 0}, 103 | } 104 | ] 105 | } 106 | --------------------------------------------------------------------------------