├── .gitignore ├── Dockerfile ├── LICENSE.md ├── Procfile ├── README.md ├── celery_worker.py ├── config.py ├── docker-compose.yml ├── manage.py ├── migrations ├── README ├── alembic.ini ├── env.py ├── script.py.mako └── versions │ └── c697bc26e963_.py ├── project ├── __init__.py ├── authy │ ├── __init__.py │ ├── forms.py │ └── routes.py ├── dashboard │ ├── __init__.py │ └── routes.py ├── dca │ ├── __init__.py │ ├── forms.py │ └── routes.py ├── main │ ├── __init__.py │ ├── forms.py │ └── routes.py ├── models.py ├── services │ ├── __init__.py │ ├── ccxtHelper.py │ ├── dcaService.py │ ├── mailService.py │ └── sentryService.py ├── static │ ├── css │ │ └── main.min.css │ ├── favicon │ │ ├── android-chrome-192x192.png │ │ ├── android-chrome-512x512.png │ │ ├── apple-touch-icon.png │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── favicon.ico │ │ └── site.webmanifest │ ├── img │ │ ├── background.png │ │ └── site-img.png │ ├── robots.txt │ └── sitemap.xml └── templates │ ├── authy │ ├── check.html │ ├── login.html │ ├── reset.html │ ├── reset_email.html │ ├── reset_verified.html │ └── signup.html │ ├── base.html │ ├── contact.html │ ├── contact_complete.html │ ├── donate.html │ ├── faq.html │ ├── index.html │ └── user │ ├── dashboard.html │ ├── dca.html │ └── profile.html └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | auth/ 2 | __pycache__/ 3 | *.db 4 | *.sqlite 5 | celerybeat-schedule 6 | *.log 7 | .env 8 | *.pid 9 | .flaskenv -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.8 2 | 3 | # set work directory 4 | WORKDIR /usr/src/app 5 | 6 | # set environment variables 7 | ENV PYTHONDONTWRITEBYTECODE 1 8 | ENV PYTHONUNBUFFERED 1 9 | 10 | # install dependencies 11 | COPY ./requirements.txt . 12 | RUN pip install -r requirements.txt 13 | 14 | # copy project 15 | COPY . . -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 DCAStack 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 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: flask db upgrade; gunicorn manage:app --log-level=debug 2 | worker: celery worker -A celery_worker.celery --beat --loglevel=info --concurrency=4 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Welcome to DCA Stack for Centralized Exchanges! 2 | ## An Automated Dollar Cost Averaging Bot For Your Crypto 3 | 4 | This github repo was made in the spirit of **Don't Trust, Verify**. I totally understand the hesitation of using web apps created by strangers on the internet. Especially when it comes to sensitive information like API keys and your crypto. So to help foster trust and gather early user feedback, I'm releasing the source code that directly builds the production environment. 5 | 6 | Docs: https://docs.dcastack.com/ 7 | 8 | ## How do I host this? 9 | 10 | First, download Docker here: https://www.docker.com/products/docker-desktop 11 | 12 | Second, let's see if we can get this webapp going. Open up a terminal, clone this repo and enter the folder. We are going to make a .env file in the current location you are in. Here is what that looks like: 13 | 14 | SECRET_KEY=YouShouldRandomlyGenerateThis 15 | MAIL_SERVER=YourMailServer 16 | MAIL_PORT=YourPort 17 | MAIL_USE_TLS=TrueorFalse 18 | MAIL_USE_SSL=TrueorFalse 19 | MAIL_USERNAME=YourMailUsername 20 | MAIL_PASSWORD=YourMailPassword 21 | REDIS_URL=redis://redis-server:6379/0 22 | SET_SANDBOX=False 23 | SET_DEBUG=False 24 | SENTRY_FLASK_KEY=Sign up here: https://sentry.io/ and get a client key (DSN) for flask project 25 | SENTRY_CELERY_KEY=Sign up here: https://sentry.io/ and get a client key (DSN) for celery project 26 | 27 | Third, we need a .flaskenv file, add this: 28 | 29 | FLASK_APP=project 30 | FLASK_ENV=development 31 | FLASK_DEBUG=1 32 | 33 | Fourth, let's do the following outside the REPO directory. This will generate the sqlite DB for working locally. 34 | 35 | python3 -m venv YOUR_ENV_NAME_HERE 36 | source YOUR_ENV_NAME_HERE/bin/activate 37 | cd REPO_NAME 38 | pip install -r requirements.txt 39 | python3 manage.py db init 40 | 41 | Finally, let's get docker up! 42 | 43 | docker-compose up -d --build 44 | 45 | And that's it. Open your browser to http://localhost:5000 to view the app or to http://localhost:5556 to view the Flower dashboard (this is for viewing celery tasks running). 46 | 47 | -------------------------------------------------------------------------------- /celery_worker.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | from project import celery, create_app, DEBUG_MODE, SENTRY_CELERY_KEY 4 | import sentry_sdk 5 | from sentry_sdk.integrations.celery import CeleryIntegration 6 | 7 | if not DEBUG_MODE: 8 | sentry_sdk.init( 9 | dsn=SENTRY_CELERY_KEY, 10 | integrations=[CeleryIntegration()] 11 | ) 12 | 13 | app = create_app() 14 | app.app_context().push() -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | import os 2 | from dotenv import load_dotenv 3 | 4 | basedir = os.path.abspath(os.path.dirname(__file__)) 5 | load_dotenv(os.path.join(basedir, '.env')) 6 | 7 | 8 | class Config(object): 9 | CELERY_BROKER_URL = os.environ.get('REDIS_URL') 10 | CELERY_BACKEND_URL = os.environ.get('REDIS_URL') 11 | SECRET_KEY = os.environ.get('SECRET_KEY') 12 | SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL', '').replace( 13 | 'postgres://', 'postgresql://') or \ 14 | 'sqlite:///db.sqlite' 15 | SQLALCHEMY_TRACK_MODIFICATIONS = False 16 | MAIL_SERVER = os.environ.get('MAIL_SERVER') 17 | MAIL_PORT = int(os.environ.get('MAIL_PORT')) 18 | MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS') is not None 19 | MAIL_USE_TLS = os.environ.get('MAIL_USE_SSL') is not None 20 | MAIL_USERNAME = os.environ.get('MAIL_USERNAME') 21 | MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD') 22 | ADMINS = ['orderbot@dcastack.com'] 23 | REDIS_URL = os.environ.get('REDIS_URL') 24 | SET_SANDBOX = True if os.environ.get('SET_SANDBOX') == "true" else False 25 | IS_DEBUG = True if os.environ.get('SET_DEBUG') == "true" else False 26 | SENTRY_FLASK_KEY = os.environ.get('SENTRY_FLASK_KEY') 27 | SENTRY_CELERY_KEY = os.environ.get('SENTRY_CELERY_KEY') 28 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | 5 | web: 6 | build: . 7 | image: web 8 | container_name: web 9 | ports: 10 | - 5000:5000 11 | command: python manage.py run -h 0.0.0.0 12 | volumes: 13 | - .:/usr/src/app 14 | environment: 15 | - CELERY_BROKER_URL=redis://redis:6379/0 16 | - CELERY_RESULT_BACKEND=redis://redis:6379/0 17 | depends_on: 18 | - redis 19 | 20 | worker: 21 | build: . 22 | command: celery worker -A celery_worker.celery -f celeryLogs.log --loglevel=info --concurrency=4 23 | volumes: 24 | - .:/usr/src/app 25 | environment: 26 | - CELERY_BROKER_URL=redis://redis:6379/0 27 | - CELERY_RESULT_BACKEND=redis://redis:6379/0 28 | - C_FORCE_ROOT="true" 29 | depends_on: 30 | - web 31 | - redis 32 | 33 | beat: 34 | build: . 35 | command: celery -A celery_worker.celery beat 36 | volumes: 37 | - .:/usr/src/app 38 | environment: 39 | - CELERY_BROKER_URL=redis://redis:6379/0 40 | - CELERY_RESULT_BACKEND=redis://redis:6379/0 41 | depends_on: 42 | - web 43 | - redis 44 | - worker 45 | 46 | dashboard: 47 | build: . 48 | command: flower -A celery_worker.celery --port=5555 --broker=redis://redis:6379/0 49 | ports: 50 | - 5556:5555 51 | environment: 52 | - CELERY_BROKER_URL=redis://redis:6379/0 53 | - CELERY_RESULT_BACKEND=redis://redis:6379/0 54 | depends_on: 55 | - web 56 | - redis 57 | - worker 58 | 59 | migrations: 60 | build: . 61 | image: web 62 | command: > 63 | sh -c "python manage.py db migrate && 64 | python manage.py db upgrade" 65 | depends_on: 66 | - web 67 | 68 | redis: 69 | image: redis:6-alpine -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | from flask.cli import FlaskGroup 2 | import logging 3 | from project import create_app 4 | 5 | 6 | app = create_app() 7 | cli = FlaskGroup(create_app=create_app) 8 | 9 | 10 | if __name__ == "__main__": 11 | cli() 12 | 13 | if __name__ != '__main__': 14 | gunicorn_logger = logging.getLogger('gunicorn.error') 15 | app.logger.handlers = gunicorn_logger.handlers 16 | app.logger.setLevel(gunicorn_logger.level) -------------------------------------------------------------------------------- /migrations/README: -------------------------------------------------------------------------------- 1 | Single-database configuration for Flask. 2 | -------------------------------------------------------------------------------- /migrations/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # template used to generate migration files 5 | # file_template = %%(rev)s_%%(slug)s 6 | 7 | # set to 'true' to run the environment during 8 | # the 'revision' command, regardless of autogenerate 9 | # revision_environment = false 10 | 11 | 12 | # Logging configuration 13 | [loggers] 14 | keys = root,sqlalchemy,alembic,flask_migrate 15 | 16 | [handlers] 17 | keys = console 18 | 19 | [formatters] 20 | keys = generic 21 | 22 | [logger_root] 23 | level = WARN 24 | handlers = console 25 | qualname = 26 | 27 | [logger_sqlalchemy] 28 | level = WARN 29 | handlers = 30 | qualname = sqlalchemy.engine 31 | 32 | [logger_alembic] 33 | level = INFO 34 | handlers = 35 | qualname = alembic 36 | 37 | [logger_flask_migrate] 38 | level = INFO 39 | handlers = 40 | qualname = flask_migrate 41 | 42 | [handler_console] 43 | class = StreamHandler 44 | args = (sys.stderr,) 45 | level = NOTSET 46 | formatter = generic 47 | 48 | [formatter_generic] 49 | format = %(levelname)-5.5s [%(name)s] %(message)s 50 | datefmt = %H:%M:%S 51 | -------------------------------------------------------------------------------- /migrations/env.py: -------------------------------------------------------------------------------- 1 | from __future__ import with_statement 2 | 3 | import logging 4 | from logging.config import fileConfig 5 | 6 | from flask import current_app 7 | 8 | from alembic import context 9 | 10 | # this is the Alembic Config object, which provides 11 | # access to the values within the .ini file in use. 12 | config = context.config 13 | 14 | # Interpret the config file for Python logging. 15 | # This line sets up loggers basically. 16 | fileConfig(config.config_file_name) 17 | logger = logging.getLogger('alembic.env') 18 | 19 | # add your model's MetaData object here 20 | # for 'autogenerate' support 21 | # from myapp import mymodel 22 | # target_metadata = mymodel.Base.metadata 23 | config.set_main_option( 24 | 'sqlalchemy.url', 25 | str(current_app.extensions['migrate'].db.get_engine().url).replace( 26 | '%', '%%')) 27 | target_metadata = current_app.extensions['migrate'].db.metadata 28 | 29 | # other values from the config, defined by the needs of env.py, 30 | # can be acquired: 31 | # my_important_option = config.get_main_option("my_important_option") 32 | # ... etc. 33 | 34 | 35 | def run_migrations_offline(): 36 | """Run migrations in 'offline' mode. 37 | 38 | This configures the context with just a URL 39 | and not an Engine, though an Engine is acceptable 40 | here as well. By skipping the Engine creation 41 | we don't even need a DBAPI to be available. 42 | 43 | Calls to context.execute() here emit the given string to the 44 | script output. 45 | 46 | """ 47 | url = config.get_main_option("sqlalchemy.url") 48 | context.configure( 49 | url=url, target_metadata=target_metadata, literal_binds=True 50 | ) 51 | 52 | with context.begin_transaction(): 53 | context.run_migrations() 54 | 55 | 56 | def run_migrations_online(): 57 | """Run migrations in 'online' mode. 58 | 59 | In this scenario we need to create an Engine 60 | and associate a connection with the context. 61 | 62 | """ 63 | 64 | # this callback is used to prevent an auto-migration from being generated 65 | # when there are no changes to the schema 66 | # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html 67 | def process_revision_directives(context, revision, directives): 68 | if getattr(config.cmd_opts, 'autogenerate', False): 69 | script = directives[0] 70 | if script.upgrade_ops.is_empty(): 71 | directives[:] = [] 72 | logger.info('No changes in schema detected.') 73 | 74 | connectable = current_app.extensions['migrate'].db.get_engine() 75 | 76 | with connectable.connect() as connection: 77 | context.configure( 78 | connection=connection, 79 | target_metadata=target_metadata, 80 | process_revision_directives=process_revision_directives, 81 | **current_app.extensions['migrate'].configure_args 82 | ) 83 | 84 | with context.begin_transaction(): 85 | context.run_migrations() 86 | 87 | 88 | if context.is_offline_mode(): 89 | run_migrations_offline() 90 | else: 91 | run_migrations_online() 92 | -------------------------------------------------------------------------------- /migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /migrations/versions/c697bc26e963_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: c697bc26e963 4 | Revises: 5 | Create Date: 2022-01-05 09:38:33.713047 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'c697bc26e963' 14 | down_revision = None 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('dca_schedule', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('user_id', sa.Integer(), nullable=True), 24 | sa.Column('dca_instance', sa.String(length=100), nullable=True), 25 | sa.Column('exchange_id', sa.String(length=100), nullable=True), 26 | sa.Column('api_key', sa.PickleType(), nullable=True), 27 | sa.Column('api_secret', sa.PickleType(), nullable=True), 28 | sa.Column('api_uid', sa.PickleType(), nullable=True), 29 | sa.Column('api_passphrase', sa.PickleType(), nullable=True), 30 | sa.Column('dca_frequency', sa.String(length=100), nullable=True), 31 | sa.Column('trading_pair', sa.String(length=100), nullable=True), 32 | sa.Column('dca_lastRun', sa.DateTime(), nullable=True), 33 | sa.Column('dca_firstRun', sa.DateTime(), nullable=True), 34 | sa.Column('dca_nextRun', sa.DateTime(), nullable=True), 35 | sa.Column('dca_budget', sa.Integer(), nullable=True), 36 | sa.Column('isActive', sa.Boolean(), nullable=True), 37 | sa.PrimaryKeyConstraint('id') 38 | ) 39 | op.create_table('user', 40 | sa.Column('id', sa.Integer(), nullable=False), 41 | sa.Column('email', sa.String(length=100), nullable=True), 42 | sa.Column('password', sa.String(length=100), nullable=True), 43 | sa.Column('name', sa.String(length=1000), nullable=True), 44 | sa.PrimaryKeyConstraint('id'), 45 | sa.UniqueConstraint('email') 46 | ) 47 | # ### end Alembic commands ### 48 | 49 | 50 | def downgrade(): 51 | # ### commands auto generated by Alembic - please adjust! ### 52 | op.drop_table('user') 53 | op.drop_table('dca_schedule') 54 | # ### end Alembic commands ### 55 | -------------------------------------------------------------------------------- /project/__init__.py: -------------------------------------------------------------------------------- 1 | from logging import DEBUG 2 | from flask import Flask 3 | from flask_sqlalchemy import SQLAlchemy 4 | from flask_login import LoginManager 5 | from flask_mail import Mail 6 | from flask_jwt_extended import JWTManager 7 | from celery import Celery 8 | from flask_migrate import Migrate 9 | from celery.schedules import crontab 10 | import logging 11 | from config import Config 12 | import secure 13 | from flask_moment import Moment 14 | from flask_executor import Executor 15 | from sentry_sdk.integrations.flask import FlaskIntegration 16 | import sentry_sdk 17 | from flask_wtf.csrf import CSRFProtect 18 | 19 | csrf = CSRFProtect() 20 | executor = Executor() 21 | secure_headers = secure.Secure() 22 | SANDBOX_MODE = Config.SET_SANDBOX 23 | DEBUG_MODE = Config.IS_DEBUG 24 | SECRET_KEY = Config.SECRET_KEY 25 | SENTRY_FLASK_KEY = Config.SENTRY_FLASK_KEY 26 | SENTRY_CELERY_KEY = Config.SENTRY_CELERY_KEY 27 | db = SQLAlchemy() 28 | mail = Mail() 29 | jwt = JWTManager() 30 | celery = Celery(__name__, broker=Config.CELERY_BROKER_URL) 31 | login_manager = LoginManager() 32 | migrate = Migrate(compare_type=True) 33 | moment = Moment() 34 | DEBUG = True 35 | BEAT_INTERVAL = 5 #5 minutes 36 | 37 | if not DEBUG_MODE: 38 | sentry_sdk.init( 39 | dsn=SENTRY_FLASK_KEY, 40 | integrations=[FlaskIntegration()], 41 | traces_sample_rate=0.8 42 | ) 43 | 44 | # if SANDBOX_MODE: 45 | # logging.basicConfig(filename='flaskLogs.log', level=logging.DEBUG, format=f'%(asctime)s %(levelname)s %(name)s %(threadName)s : %(message)s') 46 | 47 | def create_app(config_class=Config): 48 | app = Flask(__name__, static_folder='static', static_url_path='') 49 | app.config.from_object(config_class) 50 | app.config['EXECUTOR_TYPE'] = 'thread' 51 | app.config['EXECUTOR_MAX_WORKERS'] = 2 52 | 53 | @app.after_request 54 | def set_secure_headers(response): 55 | secure_headers.framework.flask(response) 56 | return response 57 | 58 | app.config.update( 59 | CELERY_BROKER_URL=app.config['CELERY_BROKER_URL'], 60 | CELERY_BACKEND_URL=app.config['CELERY_BACKEND_URL'], 61 | accept_content=['json', 'pickle'], 62 | beat_schedule={ 63 | 'periodic_task': { 64 | 'task': 'project.services.dcaService.run_dcaSchedule', 65 | 'schedule': crontab(minute='*/{}'.format(BEAT_INTERVAL)) 66 | } 67 | } 68 | ) 69 | 70 | app.config.update( 71 | SESSION_COOKIE_SECURE=True, 72 | SESSION_COOKIE_HTTPONLY=True, 73 | SESSION_COOKIE_SAMESITE='Lax', 74 | ) 75 | 76 | celery.conf.update(app.config) 77 | 78 | db.init_app(app) 79 | 80 | with app.app_context(): 81 | if db.engine.url.drivername == 'sqlite': 82 | migrate.init_app(app, db, render_as_batch=True) 83 | else: 84 | migrate.init_app(app, db) 85 | 86 | mail.init_app(app) 87 | jwt.init_app(app) 88 | moment.init_app(app) 89 | executor.init_app(app) 90 | csrf.init_app(app) 91 | 92 | login_manager.session_protection = "strong" 93 | login_manager.login_view = 'auth.login' 94 | login_manager.init_app(app) 95 | 96 | 97 | from .models import User 98 | 99 | @login_manager.user_loader 100 | def load_user(user_id): 101 | # since the user_id is just the primary key of our user table, use it in the query for the user 102 | return User.query.get(int(user_id)) 103 | 104 | # blueprint for auth routes in our app 105 | from project.authy import bp as auth_blueprint 106 | app.register_blueprint(auth_blueprint) 107 | 108 | # blueprint for general main parts of app 109 | from project.main import bp as main_blueprint 110 | app.register_blueprint(main_blueprint) 111 | 112 | # blueprint for dca parts of app 113 | from project.dca import bp as dca_blueprint 114 | app.register_blueprint(dca_blueprint) 115 | 116 | # blueprint for dashboard parts of app 117 | from project.dashboard import bp as dashboard_blueprint 118 | app.register_blueprint(dashboard_blueprint) 119 | 120 | 121 | app.logger.info("App Created, returning") 122 | return app 123 | 124 | -------------------------------------------------------------------------------- /project/authy/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | bp = Blueprint('auth', __name__) 4 | 5 | from project.authy import routes -------------------------------------------------------------------------------- /project/authy/forms.py: -------------------------------------------------------------------------------- 1 | from wtforms import SelectField, StringField, IntegerField, validators, EmailField, PasswordField, SubmitField 2 | from flask_wtf import FlaskForm 3 | 4 | class SignupForm(FlaskForm): 5 | 6 | emailField = EmailField('email', [validators.DataRequired("Please enter email!")]) 7 | nameField = StringField('name', [validators.DataRequired("Please enter name!")]) 8 | passwordField = PasswordField(label='password', validators=[ 9 | validators.Length(min=6, max=20,message="Please enter password!"), 10 | validators.DataRequired("Please enter a password!") 11 | ]) 12 | password_confirm = PasswordField(label='password_confirm', validators=[ 13 | validators.Length(min=6, max=20, message="Please confirm password!"), 14 | validators.EqualTo('passwordField'), 15 | validators.DataRequired("Please confirm password!") 16 | ]) 17 | 18 | class LoginForm(FlaskForm): 19 | 20 | emailField = EmailField('email', [validators.DataRequired("Please enter email!")]) 21 | passwordField = PasswordField(label='validators', validators=[ 22 | validators.Length(min=6, max=20,message="Please enter password!"), 23 | ]) -------------------------------------------------------------------------------- /project/authy/routes.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, render_template, redirect, url_for, request, flash 2 | from werkzeug.security import generate_password_hash, check_password_hash 3 | from flask_login import login_user, logout_user, login_required 4 | from project.models import User 5 | from project import db 6 | from project.services.mailService import send_reset_password 7 | from project.authy import bp 8 | from project.authy.forms import LoginForm, SignupForm 9 | from flask import current_app 10 | 11 | @bp.route('/reset', methods=['GET', 'POST']) 12 | def reset_password(): 13 | 14 | if request.method == 'GET': 15 | return render_template('authy/reset.html') 16 | 17 | if request.method == 'POST': 18 | 19 | email = request.form.get('email') 20 | user = User.verify_email(email) 21 | 22 | if user: 23 | send_reset_password(user) 24 | return render_template('authy/check.html') 25 | 26 | else: 27 | flash('Did you type that email in correctly?') 28 | return redirect(url_for('auth.reset_password')) 29 | 30 | @bp.route('/password_reset_verified/', methods=['GET', 'POST']) 31 | def reset_verified(token): 32 | 33 | user = User.verify_reset_token(token) 34 | if not user: 35 | print('no user found') 36 | return redirect(url_for('auth.login')) 37 | 38 | password = request.form.get('password') 39 | if password: 40 | user.set_password(password, commit=True) 41 | 42 | return redirect(url_for('auth.login')) 43 | 44 | return render_template('authy/reset_verified.html') 45 | 46 | @bp.route('/login', methods=['GET', 'POST']) 47 | def login(): 48 | 49 | loginForm = LoginForm() 50 | 51 | if request.method == 'GET': 52 | return render_template('authy/login.html', form=loginForm) 53 | 54 | if request.method == 'POST': 55 | 56 | email = loginForm.emailField.data 57 | password = loginForm.passwordField.data 58 | 59 | user = User.query.filter_by(email=email).first() 60 | 61 | # check if user actually exists 62 | # take the user supplied password, hash it, and compare it to the hashed password in database 63 | if not user or not check_password_hash(user.password, password): 64 | flash('Please check your login details or did you') 65 | return redirect(url_for('auth.login',form=loginForm)) # if user doesn't exist or password is wrong, reload the page 66 | 67 | # if the above check passes, then we know the user has the right credentials 68 | login_user(user) 69 | return redirect(url_for('dca.dcaSetup')) 70 | 71 | @bp.route('/signup', methods=['GET', 'POST']) 72 | def signup(): 73 | 74 | signupForm = SignupForm() 75 | 76 | if request.method == 'GET': 77 | return render_template('authy/signup.html',form=signupForm), 200 78 | 79 | if request.method == 'POST': 80 | 81 | 82 | try: 83 | 84 | email = signupForm.emailField.data 85 | password = signupForm.passwordField.data 86 | name = signupForm.nameField.data 87 | repeatPassword = signupForm.password_confirm.data 88 | user = User.query.filter_by(email=email).first() # if this returns a user, then the email already exists in database 89 | 90 | if user: # if a user is found, we want to redirect back to signup page so user can try again 91 | flash('Email address already exists! Please login instead :)') 92 | return render_template("authy/signup.html",form=signupForm) 93 | 94 | if repeatPassword != password: 95 | flash('Passwords do not match!') 96 | repopSignupForm = SignupForm() 97 | repopSignupForm.emailField.data = signupForm.emailField.data 98 | repopSignupForm.nameField.data = signupForm.nameField.data 99 | return render_template("authy/signup.html",form=repopSignupForm) 100 | 101 | # create new user with the form data. Hash the password so plaintext version isn't saved. 102 | new_user = User(email=email, name=name, password=generate_password_hash(password, method='sha256')) 103 | 104 | # add the new user to the database 105 | db.session.add(new_user) 106 | db.session.commit() 107 | 108 | login_user(new_user) 109 | return redirect(url_for('dca.dcaSetup')) 110 | 111 | except: 112 | current_app.logger.exception("Could not signup user {}!".format(email)) 113 | db.session.rollback() 114 | return redirect(url_for('main.index')) 115 | 116 | 117 | 118 | @bp.route('/logout') 119 | @login_required 120 | def logout(): 121 | logout_user() 122 | return redirect(url_for('main.index')) -------------------------------------------------------------------------------- /project/dashboard/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | bp = Blueprint('dashboard', __name__) 4 | 5 | from project.dashboard import routes -------------------------------------------------------------------------------- /project/dashboard/routes.py: -------------------------------------------------------------------------------- 1 | from flask import render_template, redirect, url_for, flash, session, request, jsonify 2 | from project import db 3 | from flask_login import login_required 4 | from project.models import dcaSchedule 5 | from project.services.ccxtHelper import create_exchangeConnection 6 | from flask import current_app 7 | from project.dashboard import bp 8 | from pycoingecko import CoinGeckoAPI 9 | import json 10 | from flask import current_app 11 | from sqlalchemy import text 12 | from project import executor 13 | import concurrent.futures 14 | import collections, functools, operator 15 | from project.services.sentryService import capture_err 16 | 17 | cg = CoinGeckoAPI() 18 | allCoinList = cg.get_coins_list() 19 | 20 | def get_exchange_balances(x): 21 | 22 | freeFunds = {} 23 | 24 | try: #catch malformed exchange formats 25 | exchange_class_set,bal = create_exchangeConnection(x.exchange_id, 26 | x.api_key,x.api_secret,x.api_passphrase,x.api_uid,needBal=True) 27 | for k,v in bal['total'].items(): 28 | k = k.split('.')[0] #kraken fix 29 | if k not in freeFunds: 30 | freeFunds[k] = 0 31 | freeFunds[k] += v if v else 0 32 | 33 | except Exception as e: 34 | capture_err(e, session=session) 35 | current_app.logger.warning("fetch_account_balance failed for exchange: {}".format(x.exchange_id)) 36 | 37 | return freeFunds 38 | 39 | 40 | 41 | @bp.route('/userinfo/account_balance') 42 | @login_required 43 | def fetch_account_balance(): 44 | 45 | returnQuery = [] 46 | totalValue = 0 47 | coins = {} 48 | freeFunds = {} 49 | allFreeFunds = [] 50 | 51 | try: 52 | user_id=session['_user_id'] 53 | 54 | #unique exchanges by user_id only 55 | entry = dcaSchedule.query.filter_by(user_id=user_id).all() 56 | seenExchange = set() 57 | filteredEntry = [] 58 | for x in entry: 59 | if x.exchange_id not in seenExchange: 60 | filteredEntry.append(x) 61 | seenExchange.add(x.exchange_id) 62 | 63 | #connect to all exchanges and fetch balances simultaneously 64 | future_to_url = {executor.submit(get_exchange_balances, row): row for row in filteredEntry if row.user_id == int(user_id)} 65 | 66 | for future in concurrent.futures.as_completed(future_to_url): 67 | url = future_to_url[future] 68 | try: 69 | data = future.result() 70 | except: 71 | current_app.logger.warning("fetch_account_balance failed for exchange details threading") 72 | else: 73 | allFreeFunds.append(data) 74 | 75 | if allFreeFunds: 76 | freeFunds = dict(functools.reduce(operator.add,map(collections.Counter, allFreeFunds))) 77 | 78 | freeFunds = {k.upper(): v for k, v in freeFunds.items()} 79 | freeFundKeys = set(freeFunds.keys()) 80 | 81 | checkFundIds = [] 82 | matchedIds = {} 83 | 84 | for coin in allCoinList: 85 | coin['symbol'] = coin['symbol'].upper() 86 | 87 | if coin['symbol'] != "USD": #skip fiat 88 | 89 | if coin['symbol'] in freeFundKeys: 90 | if freeFunds[coin['symbol']] > 0: 91 | matchedIds[coin['id']] = coin['symbol'] 92 | checkFundIds.append(coin['id']) 93 | 94 | inverse_dict = {} 95 | for k, v in matchedIds.items(): 96 | inverse_dict.setdefault(v, []).append(k) 97 | 98 | currPrice = cg.get_price(ids=checkFundIds, vs_currencies='usd',include_market_cap=True) 99 | 100 | #sometimes we get duplicate coins for each symbol, here we filter by highest mkt cap for accuracy 101 | for k,v in inverse_dict.items(): 102 | if len(v) > 1: 103 | maxcap = 0 104 | trueidx = 0 105 | for i,value in enumerate(v): 106 | if 'usd_market_cap' in currPrice[value]: 107 | if currPrice[value]['usd_market_cap'] > maxcap: 108 | maxcap = currPrice[value]['usd_market_cap'] 109 | trueidx = i 110 | inverse_dict[k] = v[trueidx] 111 | else: 112 | inverse_dict[k] = v[0] 113 | 114 | verifiedCoins = set(inverse_dict.values()) 115 | 116 | for coinId,coinVal in currPrice.items(): 117 | if coinId in verifiedCoins: #verified mktcap only 118 | 119 | if "usd" in coinVal: #check 120 | coinParsedVal = float(coinVal['usd']) 121 | 122 | if matchedIds[coinId] not in coins: 123 | coins[matchedIds[coinId]] = [0,0,0] #fiat, native, price 124 | 125 | coinValue = freeFunds[matchedIds[coinId]] * coinParsedVal 126 | coins[matchedIds[coinId]][0] += round(coinValue,2) 127 | coins[matchedIds[coinId]][1] += freeFunds[matchedIds[coinId]] 128 | coins[matchedIds[coinId]][2] += round(coinParsedVal,4) 129 | 130 | totalValue += coinValue 131 | 132 | #add fiat to query 133 | if "USD" in freeFunds: 134 | coins["USD"] = [0,0,1] 135 | coins["USD"][0] = round(freeFunds["USD"],2) 136 | coins["USD"][1] = round(freeFunds["USD"],2) 137 | totalValue += freeFunds["USD"] 138 | 139 | except Exception as e: 140 | capture_err(e, session=session) 141 | current_app.logger.exception("fetch_account_balance GeneralError") 142 | 143 | coins = dict(sorted(coins.items(), key=lambda item: item[1], reverse=True)) 144 | return jsonify({'payload':json.dumps({ 145 | 'coins':list(coins.keys()), 146 | 'values':[v[0] for v in list(coins.values())], 147 | 'native':[v[1] for v in list(coins.values())], 148 | 'price':[v[2] for v in list(coins.values())], 149 | 'raw':[[k, "$"+str(v[2]), v[1], "$"+str(v[0])] for k,v in coins.items()], 150 | 'total_balance':round(totalValue,2)})}) 151 | 152 | 153 | @bp.route('/dashboard', methods=['GET', 'POST']) 154 | @login_required 155 | def dashboard_main(): 156 | return render_template('user/dashboard.html') 157 | 158 | -------------------------------------------------------------------------------- /project/dca/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | bp = Blueprint('dca', __name__) 4 | 5 | from project.dca import routes -------------------------------------------------------------------------------- /project/dca/forms.py: -------------------------------------------------------------------------------- 1 | import ccxt 2 | from flask_wtf import FlaskForm 3 | from wtforms import SelectField, StringField, IntegerField, validators 4 | from project import DEBUG_MODE, SANDBOX_MODE 5 | 6 | class DCA_Form(FlaskForm): 7 | 8 | allExchanges = [] 9 | allExchanges.extend(ccxt.exchanges) 10 | 11 | sandbox_exchanges = ["coinbasepro","kucoin","gemini","binance","phemex","wavesexchange"] 12 | 13 | exchangeList = allExchanges 14 | if SANDBOX_MODE: 15 | exchangeList = sandbox_exchanges 16 | 17 | exchange_id = SelectField( 18 | 'exchange_id', choices=exchangeList, default='coinbasepro') 19 | 20 | dca_instance = StringField('DCA_INSTANCE', [validators.DataRequired()]) 21 | 22 | trading_pairs = StringField( 23 | 'autocomplete', [validators.DataRequired()], id='autocomplete') 24 | 25 | dayChoices = [str(x) + " Days" for x in range(1, 31)] 26 | minuteChoice = ["1 Min Blast"] 27 | 28 | allChoices = dayChoices 29 | if DEBUG_MODE: 30 | allChoices = minuteChoice+dayChoices 31 | 32 | dca_interval = SelectField('dca_interval', choices=allChoices) 33 | 34 | api_key = StringField('API_KEY', [validators.DataRequired()]) 35 | api_secret = StringField('API_SECRET', [validators.DataRequired()]) 36 | api_pass = StringField('API_PASS') 37 | api_uid = StringField('API_UID') 38 | dca_amount = IntegerField( 39 | 'dca_amount', [validators.NumberRange(min=1), validators.DataRequired()]) -------------------------------------------------------------------------------- /project/dca/routes.py: -------------------------------------------------------------------------------- 1 | from ccxt.base.errors import AuthenticationError, InsufficientFunds, InvalidOrder, NetworkError 2 | from flask import render_template, redirect, url_for, flash, session, request, jsonify 3 | from project import db 4 | from flask_login import login_required 5 | import ccxt 6 | from project.models import dcaSchedule 7 | from project.models import User 8 | import datetime 9 | from project.services.ccxtHelper import create_exchangeConnection, place_market_order 10 | from flask import current_app 11 | from project.services import dcaService 12 | from project.dca import bp 13 | from project.dca.forms import DCA_Form 14 | from project.services.dcaService import async_placeMarketOrder_updateDb 15 | import ast 16 | from project.models import dcaSchedule 17 | from project.services.sentryService import capture_err 18 | 19 | @bp.route('/exchangeinfo/trading_pairs/') 20 | @login_required 21 | def get_trading_pairs(exchange_id): 22 | exchange_class = getattr(ccxt, exchange_id) 23 | exchange_pairs = list(exchange_class().load_markets().keys()) 24 | 25 | exchange_arr = [] 26 | for stuff in exchange_pairs: 27 | exchangeObj = {} 28 | exchangeObj['exchange_id'] = exchange_id 29 | exchangeObj['trading_pair'] = stuff 30 | exchange_arr.append(exchangeObj) 31 | 32 | return jsonify({'trading_pair': exchange_arr}) 33 | 34 | 35 | @bp.route('/autocomplete', methods=['GET']) 36 | @login_required 37 | def autocomplete(): 38 | search = request.args.get('q') 39 | usingExchangeId = request.args.get('exchange_id') 40 | exchange_class = getattr(ccxt, usingExchangeId) 41 | searchThis = list(exchange_class().load_markets().keys()) 42 | matching = [s for s in searchThis if search.upper() in s] 43 | results = [mv for mv in matching] 44 | return jsonify(matching_results=results) 45 | 46 | 47 | @bp.route('/exchangeinfo/exchange_api/') 48 | @login_required 49 | def get_exchange_apiNeededCreds(exchange_id): 50 | exchange_class = getattr(ccxt, exchange_id) 51 | exchange_neededCreds = exchange_class().requiredCredentials 52 | 53 | return jsonify({'exchange_apiNeeded': exchange_neededCreds}) 54 | 55 | 56 | @bp.route('/dcaschedule/delete/', methods=['POST']) 57 | @login_required 58 | def delete_dcaSchedule(id): 59 | if request.method == 'POST': 60 | 61 | try: 62 | instanceObj = {} 63 | entry = dcaSchedule.query.filter_by(user_id=session['_user_id']).all() 64 | for x in entry: 65 | if x.id == int(id): 66 | db.session.delete(dcaSchedule.query.get(id)) 67 | db.session.commit() 68 | 69 | instanceObj["status"] = "Delete" 70 | instanceObj["name"] = x.dca_instance 71 | 72 | return redirect(url_for("dca.dcaSetup",instanceStatus=instanceObj)) 73 | 74 | except Exception as e: 75 | capture_err(e,session=session) 76 | current_app.logger.exception("Could not delete schedule {}!".format(id)) 77 | db.session.rollback() 78 | return redirect(url_for('dca.dcaSetup')) 79 | 80 | 81 | @bp.route('/dcaschedule/resume/', methods=['POST']) 82 | @login_required 83 | def resume_dcaSchedule(id): 84 | 85 | if request.method == 'POST': 86 | 87 | try: 88 | try: 89 | form = DCA_Form() 90 | entry = dcaSchedule.query.filter_by(user_id=session['_user_id']).all() 91 | instanceObj = {} 92 | for subQuery in entry: 93 | if subQuery.id == int(id): 94 | 95 | #run synchronosly cause we need results in realtime and bypass async checks 96 | subQuery.isActive = True 97 | subQuery.dca_nextRun = datetime.datetime.now() #move next run to current date so that it will update 98 | db.session.commit() 99 | instanceStatus = async_placeMarketOrder_updateDb.run(subQuery,User.get_user(subQuery.user_id),True) 100 | 101 | if not instanceStatus: 102 | instanceStatus = "Error" 103 | subQuery.isActive = False #should probs rollback instead 104 | db.session.commit() 105 | 106 | instanceObj["status"] = str(instanceStatus) 107 | instanceObj["name"] = subQuery.dca_instance 108 | 109 | except: 110 | instanceStatus = "Error" #commonize this 111 | instanceObj["status"] = str(instanceStatus) 112 | instanceObj["name"] = subQuery.dca_instance 113 | subQuery.isActive = False 114 | db.session.commit() 115 | 116 | return redirect(url_for("dca.dcaSetup",instanceStatus=instanceObj)) 117 | 118 | except Exception as e: 119 | capture_err(e,session=session) 120 | current_app.logger.exception("Could not start schedule {}!".format(id)) 121 | db.session.rollback() 122 | return redirect(url_for('dca.dcaSetup')) 123 | 124 | @bp.route('/dcaschedule/pause/', methods=['POST']) 125 | @login_required 126 | def pause_dcaSchedule(id): 127 | if request.method == 'POST': 128 | 129 | try: 130 | instanceObj = {} 131 | entry = dcaSchedule.query.filter_by(user_id=session['_user_id']).all() 132 | for x in entry: 133 | if x.id == int(id): 134 | x.isActive = False 135 | db.session.commit() 136 | instanceObj["status"] = "Pause" 137 | instanceObj["name"] = x.dca_instance 138 | 139 | return redirect(url_for("dca.dcaSetup",instanceStatus=instanceObj)) 140 | 141 | except Exception as e: 142 | capture_err(e,session=session) 143 | current_app.logger.exception("Could not pause schedule {}!".format(id)) 144 | db.session.rollback() 145 | return redirect(url_for('dca.dcaSetup')) 146 | 147 | 148 | def fetch_dcaSchedules(): 149 | dca_scheduleQuery = dcaSchedule.query.filter_by( 150 | user_id=session['_user_id']).all() 151 | for x in dca_scheduleQuery: 152 | if x.isActive == None or x.isActive == False: 153 | x.isActive = "Paused" 154 | else: 155 | x.isActive = "Running" 156 | 157 | return dca_scheduleQuery 158 | 159 | 160 | @bp.route('/dca', methods=['GET', 'POST']) 161 | @login_required 162 | def dcaSetup(): 163 | 164 | form = DCA_Form() 165 | 166 | if request.method == 'POST': 167 | 168 | dca_instance = form.dca_instance.data 169 | exchange_id = form.exchange_id.data 170 | trading_pairs = str(form.trading_pairs.data).upper() 171 | api_key = str(form.api_key.data).strip() #whitespace filtering 172 | api_secret = str(form.api_secret.data).strip() 173 | api_pass = str(form.api_pass.data).strip() if form.api_pass.data else None 174 | api_uid = str(form.api_uid.data).strip() if form.api_uid.data else None 175 | dca_amount = form.dca_amount.data 176 | dca_interval = form.dca_interval.data 177 | dca_interval_int = [int(s) 178 | for s in dca_interval.split() if s.isdigit()][0] 179 | 180 | # in case connection fails 181 | repopulateForm = DCA_Form() 182 | repopulateForm.dca_instance.data = form.dca_instance.data 183 | repopulateForm.exchange_id.data = form.exchange_id.data 184 | repopulateForm.trading_pairs.data = form.trading_pairs.data 185 | repopulateForm.api_key.data = form.api_key.data 186 | repopulateForm.api_secret = form.api_secret 187 | repopulateForm.api_pass.data = form.api_pass.data if form.api_pass.data else None 188 | repopulateForm.api_uid.data = form.api_uid.data if form.api_uid.data else None 189 | repopulateForm.dca_amount.data = form.dca_amount.data 190 | repopulateForm.dca_interval.data = form.dca_interval.data 191 | 192 | try: 193 | 194 | #create exchange connection 195 | exchange_class_set = create_exchangeConnection(exchange_id,api_key, api_secret,api_pass,api_uid,isEncrypted=False) 196 | 197 | #verify user input trading pair is in our list 198 | exchange_class = getattr(ccxt, exchange_id) 199 | if trading_pairs in list(exchange_class().load_markets().keys()): 200 | 201 | # first run DCA 202 | orderStatus = place_market_order(exchange_class_set, 203 | trading_pairs, dca_amount,User.get_user(session['_user_id'])) 204 | 205 | # if all success, add to DB 206 | if orderStatus: 207 | 208 | currentDate = datetime.datetime.now() 209 | if "Days" in dca_interval: 210 | current_app.logger.info("dca first run set nextRunTime on DAYS") 211 | nextRunTime = currentDate + datetime.timedelta(days=dca_interval_int) 212 | else: 213 | current_app.logger.info("dca first run set nextRunTime on MINUTE BLAST") 214 | nextRunTime = currentDate + datetime.timedelta(minutes=dca_interval_int) 215 | 216 | #encrypt as we add 217 | addStatus = dcaSchedule.create_schedule(user_id=session['_user_id'], exchange_id=exchange_id, 218 | api_key=api_key, api_secret=api_secret, api_uid=api_uid, api_pass=api_pass, 219 | dca_interval=dca_interval, trading_pairs=trading_pairs, 220 | currentDate=currentDate, nextRunTime=nextRunTime, 221 | dca_instance=dca_instance, dca_amount=dca_amount) 222 | 223 | if addStatus: 224 | instanceObj = {} 225 | instanceObj["status"] = "True" 226 | instanceObj["name"] = dca_instance 227 | else: 228 | raise Exception 229 | 230 | return render_template('user/dca.html', form=form, dca_scheduleQuery=fetch_dcaSchedules(), instanceStatus=instanceObj) 231 | 232 | 233 | flash('Could not place order, please try again or contact us!') 234 | return render_template('user/dca.html', instanceStatus=None,form=repopulateForm, dca_scheduleQuery=fetch_dcaSchedules()) 235 | 236 | repopulateForm.trading_pairs.data = '' 237 | flash('Please choose a trading pair in the list!') 238 | return render_template('user/dca.html', form=repopulateForm, instanceStatus=None,dca_scheduleQuery=fetch_dcaSchedules()) 239 | 240 | except AuthenticationError as e: 241 | current_app.logger.warning("create_exchangeConnection AuthenticationError") 242 | flash('Please recheck your API details.') 243 | 244 | return render_template('user/dca.html', form=repopulateForm,instanceStatus=None, dca_scheduleQuery=fetch_dcaSchedules()) 245 | 246 | except InsufficientFunds as e: 247 | current_app.logger.warning("create_exchangeConnection InsufficientFunds") 248 | flash('Please ensure you have enough funds to place an order.') 249 | 250 | return render_template('user/dca.html', form=repopulateForm, instanceStatus=None,dca_scheduleQuery=fetch_dcaSchedules()) 251 | 252 | except InvalidOrder as e: 253 | current_app.logger.warning("create_exchangeConnection InvalidOrder") 254 | flash('Please increase purchase amount or contact us for further assistance!') 255 | return render_template('user/dca.html', form=repopulateForm, instanceStatus=None,dca_scheduleQuery=fetch_dcaSchedules()) 256 | 257 | except NetworkError as e: 258 | current_app.logger.warning("create_exchangeConnection NetworkError") 259 | flash('Could not connect because exchange is unreachable. Please try again later.') 260 | return render_template('user/dca.html', form=repopulateForm, instanceStatus=None,dca_scheduleQuery=fetch_dcaSchedules()) 261 | 262 | except Exception as e: 263 | capture_err(e,session=session) 264 | current_app.logger.exception("create_exchangeConnection GeneralException") 265 | flash('Connection issue, please double check your API details or contact us!') 266 | return render_template('user/dca.html', form=repopulateForm, instanceStatus=None,dca_scheduleQuery=fetch_dcaSchedules()) 267 | 268 | instanceObj = ast.literal_eval(request.args.get('instanceStatus')) if request.args.get('instanceStatus') else None 269 | return render_template('user/dca.html', form=form, dca_scheduleQuery=fetch_dcaSchedules(), instanceStatus=instanceObj) 270 | -------------------------------------------------------------------------------- /project/main/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | bp = Blueprint('main', __name__) 4 | 5 | from project.main import routes -------------------------------------------------------------------------------- /project/main/forms.py: -------------------------------------------------------------------------------- 1 | 2 | from flask_wtf import FlaskForm 3 | from wtforms import StringField, validators, SubmitField, EmailField, SelectField, TextAreaField 4 | 5 | 6 | class ContactForm(FlaskForm): 7 | emailField = EmailField('email', [validators.DataRequired("Please enter email!")]) 8 | 9 | subjectFieldOptions = ["Bug Report", "Feature Request", "Connection Issues", "Exchange API Help","Other"] 10 | subjectField = SelectField('subject', choices=subjectFieldOptions, default=subjectFieldOptions[0]) 11 | 12 | messageField = TextAreaField('message', [validators.DataRequired("Please enter a message!")]) -------------------------------------------------------------------------------- /project/main/routes.py: -------------------------------------------------------------------------------- 1 | from flask import render_template 2 | from flask_login import login_required, current_user 3 | from flask import current_app, request 4 | from project.main import bp 5 | from project.main.forms import ContactForm 6 | from project.services.mailService import receive_contact_message 7 | 8 | @bp.route('/') 9 | def index(): 10 | return render_template('index.html') 11 | 12 | @bp.route('/donate') 13 | def donate(): 14 | return render_template('donate.html') 15 | 16 | 17 | @bp.route('/faq') 18 | def faq(): 19 | return render_template('faq.html') 20 | 21 | @bp.route('/contact', methods=['GET', 'POST']) 22 | def contact(): 23 | 24 | cform = ContactForm() 25 | 26 | if request.method == 'POST': 27 | userEmail = cform.emailField.data 28 | msgBody = cform.messageField.data 29 | msgSubject = cform.subjectField.data 30 | receive_contact_message(userEmail,msgSubject,msgBody) 31 | return render_template('contact_complete.html') 32 | 33 | return render_template('contact.html', form=cform) 34 | 35 | 36 | 37 | @bp.route('/profile') 38 | @login_required 39 | def profile(): 40 | return render_template('user/profile.html', name=current_user.name) -------------------------------------------------------------------------------- /project/models.py: -------------------------------------------------------------------------------- 1 | from flask_login import UserMixin 2 | from . import db 3 | import jwt 4 | from time import time 5 | from werkzeug.security import generate_password_hash, check_password_hash 6 | from project import SECRET_KEY 7 | from Crypto.Cipher import AES 8 | import scrypt, os, binascii 9 | from flask import current_app 10 | 11 | class User(UserMixin, db.Model): 12 | __tablename__ = 'user' 13 | 14 | # primary keys are required by SQLAlchemy 15 | id = db.Column(db.Integer, primary_key=True) 16 | email = db.Column(db.String(100), unique=True) 17 | password = db.Column(db.String(100)) 18 | name = db.Column(db.String(1000)) 19 | 20 | def __repr__(self): 21 | return 'User is {}'.format(self.email) 22 | 23 | @staticmethod 24 | def get_user(id): 25 | user = User.query.filter_by(id=id).first() 26 | return user 27 | 28 | def verify_password(self, password): 29 | return check_password_hash(self.password_hash, password) 30 | 31 | def set_password(self, password, commit=False): 32 | 33 | self.password = generate_password_hash(password, method='sha256') 34 | 35 | try: 36 | if commit: 37 | db.session.commit() 38 | except: 39 | current_app.logger.exception("Could not set_password!") 40 | db.session.rollback() 41 | 42 | def get_reset_token(self, expires=500): 43 | return jwt.encode({'reset_password': self.email, 'exp': time() + expires}, 44 | key=SECRET_KEY) 45 | 46 | @staticmethod 47 | def verify_email(email): 48 | 49 | user = User.query.filter_by(email=email).first() 50 | 51 | return user 52 | 53 | @staticmethod 54 | def verify_reset_token(token): 55 | try: 56 | email = jwt.decode(token, key=SECRET_KEY)['reset_password'] 57 | print(email) 58 | except Exception as e: 59 | print(e) 60 | return 61 | return User.query.filter_by(email=email).first() 62 | 63 | @staticmethod 64 | def create_user(username, password, email): 65 | 66 | try: 67 | 68 | user_exists = User.query.filter_by(username=username).first() 69 | if user_exists: 70 | return False 71 | 72 | user = User() 73 | 74 | user.username = username 75 | user.password = user.set_password(password) 76 | user.email = email 77 | 78 | db.session.add(user) 79 | db.session.commit() 80 | 81 | return user 82 | 83 | except: 84 | current_app.logger.exception("Could not create_user {}!".format(email)) 85 | db.session.rollback() 86 | return None 87 | 88 | 89 | class dcaSchedule(db.Model): 90 | __tablename__ = 'dca_schedule' 91 | 92 | # primary keys are required by SQLAlchemy 93 | id = db.Column(db.Integer, primary_key=True) 94 | user_id = db.Column(db.Integer) 95 | dca_instance = db.Column(db.String(100)) 96 | exchange_id = db.Column(db.String(100)) 97 | api_key = db.Column(db.PickleType) 98 | api_secret = db.Column(db.PickleType) 99 | api_uid = db.Column(db.PickleType) 100 | api_passphrase = db.Column(db.PickleType) 101 | dca_frequency = db.Column(db.String(100)) 102 | trading_pair = db.Column(db.String(100)) 103 | dca_lastRun = db.Column(db.DateTime) 104 | dca_firstRun = db.Column(db.DateTime) 105 | dca_nextRun = db.Column(db.DateTime) 106 | dca_budget = db.Column(db.Integer) 107 | isActive = db.Column(db.Boolean, default=True) 108 | 109 | def encrypt_API(msg, password=SECRET_KEY): 110 | try: 111 | if msg: 112 | 113 | msg = str.encode(msg) 114 | kdfSalt = os.urandom(16) 115 | secretKey = scrypt.hash(password, kdfSalt, N=16384, r=8, p=1, buflen=32) 116 | aesCipher = AES.new(secretKey, AES.MODE_GCM) 117 | ciphertext, authTag = aesCipher.encrypt_and_digest(msg) 118 | tuplified = (kdfSalt, ciphertext, aesCipher.nonce, authTag) 119 | return tuplified 120 | 121 | return None 122 | 123 | except Exception as e: 124 | current_app.logger.exception("Could not encrypt API information") 125 | raise Exception("Something went wrong with storing API information!") 126 | 127 | 128 | def decrypt_API(encryptedMsg, password=SECRET_KEY): 129 | try: 130 | if encryptedMsg: 131 | 132 | (kdfSalt, ciphertext, nonce, authTag) = encryptedMsg 133 | secretKey = scrypt.hash(password, kdfSalt, N=16384, r=8, p=1, buflen=32) 134 | aesCipher = AES.new(secretKey, AES.MODE_GCM, nonce) 135 | plaintext = aesCipher.decrypt_and_verify(ciphertext, authTag) 136 | return plaintext.decode("utf-8") 137 | 138 | return None 139 | 140 | except Exception as e: 141 | current_app.logger.exception("Could not decrypt API information!") 142 | raise Exception("Something went wrong with using API information!") 143 | 144 | @staticmethod 145 | def create_schedule(user_id, exchange_id, api_key,api_secret,api_uid,api_pass,dca_interval,trading_pairs,currentDate,nextRunTime,dca_instance,dca_amount): 146 | 147 | try: 148 | 149 | new_schedule = dcaSchedule(user_id=user_id, exchange_id=exchange_id, 150 | api_key=dcaSchedule.encrypt_API(api_key), 151 | api_secret=dcaSchedule.encrypt_API(api_secret), 152 | api_uid=dcaSchedule.encrypt_API(api_uid), 153 | api_passphrase=dcaSchedule.encrypt_API(api_pass), 154 | dca_frequency=dca_interval, trading_pair=trading_pairs, 155 | dca_lastRun=currentDate, dca_firstRun=currentDate, dca_nextRun=nextRunTime, 156 | dca_instance=dca_instance, dca_budget=dca_amount, isActive=True) 157 | 158 | db.session.add(new_schedule) 159 | db.session.commit() 160 | 161 | return True 162 | 163 | except Exception as e: 164 | db.session.rollback() 165 | current_app.logger.exception("Could not create_schedule!") 166 | return False -------------------------------------------------------------------------------- /project/services/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DCAStack/DCAStackCefi/751c864ae4ae0d3424fcb28c3c1b46bf6cebced9/project/services/__init__.py -------------------------------------------------------------------------------- /project/services/ccxtHelper.py: -------------------------------------------------------------------------------- 1 | import ccxt 2 | from flask import current_app 3 | from .mailService import send_order_notification 4 | from project import SANDBOX_MODE 5 | from project.models import dcaSchedule 6 | from project.services.sentryService import capture_err 7 | 8 | def create_exchangeConnection(exchange_id,api_key, api_secret,api_pass,api_uid,sandboxMode=True,needBal=False,isEncrypted=True): 9 | 10 | #retrieve exchange class 11 | exchange_class = getattr(ccxt, exchange_id) 12 | 13 | #see what credentials we need 14 | exchange_neededCreds = exchange_class().requiredCredentials 15 | 16 | 17 | try: 18 | 19 | # decrypt to use 20 | if isEncrypted: 21 | api_key = dcaSchedule.decrypt_API(api_key) 22 | api_secret = dcaSchedule.decrypt_API(api_secret) 23 | api_pass = dcaSchedule.decrypt_API(api_pass) 24 | api_uid = dcaSchedule.decrypt_API(api_uid) 25 | 26 | #cast these to string to work with 27 | api_key = str(api_key) 28 | api_secret = str(api_secret) 29 | api_pass = str(api_pass) 30 | api_uid = str(api_uid) 31 | 32 | #setup exchange class 33 | exchange_class_set = exchange_class( 34 | {'apiKey': api_key, 'secret': api_secret, 'password': api_pass if exchange_neededCreds['password'] else None, 'uid': api_uid if exchange_neededCreds['uid'] else None, 35 | 'enableRateLimit': True, 36 | 'options': { 37 | # 'createMarketBuyOrderRequiresPrice': False, 38 | }, 39 | }) 40 | 41 | 42 | checkConnection = exchange_class_set.checkRequiredCredentials() 43 | 44 | if not checkConnection: 45 | current_app.logger.warning("create_exchangeConnection could not connect to {}!".format(exchange_id)) 46 | raise ccxt.AuthenticationError 47 | 48 | current_app.logger.warning("Sandbox mode is {}".format(SANDBOX_MODE)) 49 | if sandboxMode: 50 | current_app.logger.warning 51 | exchange_class_set.set_sandbox_mode(SANDBOX_MODE) 52 | 53 | #true test is really fetching account balance 54 | bal = exchange_class_set.fetch_balance() 55 | 56 | if needBal: 57 | return exchange_class_set,bal 58 | 59 | return exchange_class_set 60 | 61 | except ccxt.AuthenticationError as e: 62 | current_app.logger.warning("create_exchangeConnection AuthenticationError could not connect to {}!".format(exchange_id)) 63 | raise ccxt.AuthenticationError("create_exchangeConnection AuthenticationError: {}".format(exchange_id)) 64 | 65 | except Exception as e: 66 | current_app.logger.warning("create_exchangeConnection General Exception to {}!".format(exchange_id)) 67 | raise Exception("create_exchangeConnection General Exception: {}".format(exchange_id)) 68 | 69 | 70 | 71 | #has to run in real time once user configures 72 | def place_market_order(exchange,trading_pair, dcaAmount,user,repeat=False): 73 | current_app.logger.info("place_order: {} {} {} {}".format(exchange,trading_pair, dcaAmount,user)) 74 | order = None 75 | try: 76 | exchange.load_markets() 77 | 78 | if exchange.has['createMarketOrder']: 79 | current_app.logger.info("Start Making market order pair: {} on exchange: {}".format(trading_pair, exchange)) 80 | 81 | if exchange.options.get('createMarketBuyOrderRequiresPrice', False): 82 | current_app.logger.info("Start Making market createMarketBuyOrderRequiresPrice") 83 | # cost = price * amount = how much USD you want to spend for buying 84 | amount = 1 #base amount 85 | price = dcaAmount #price 86 | current_app.logger.info("place createMarketBuyOrderRequiresPrice pair:{} amount:{} price:{}".format(trading_pair, amount, price)) 87 | order = exchange.createOrder(trading_pair, 'market', 'buy', amount, price) 88 | 89 | else: 90 | current_app.logger.info("Start Making market just amount") 91 | got = exchange.fetch_order_book(trading_pair) 92 | maxAskPrice = got['asks'][0][0] 93 | lastAskAmount = got['asks'][0][1] 94 | 95 | # formatted_amount = exchange.amount_to_precision(trading_pair, dcaAmount/maxAskPrice) 96 | # formatted_price = exchange.price_to_precision(trading_pair, maxAskPrice) 97 | formatted_amount = dcaAmount/maxAskPrice 98 | formatted_price = maxAskPrice 99 | 100 | current_app.logger.info("place market order just amount for pair:{} on exchange: {} fmt_amount: {} fmt_price: {} dca_amt: {} lastAskSize: {}".format(trading_pair, exchange, formatted_amount, formatted_price, dcaAmount, lastAskAmount)) 101 | amount = formatted_amount # how much CRYPTO you want to market-buy, change for your value here 102 | order = exchange.createOrder(trading_pair, 'market', 'buy', amount) 103 | 104 | else: 105 | current_app.logger.info("Emulated market order") 106 | got = exchange.fetch_order_book(trading_pair) 107 | maxAskPrice = got['asks'][0][0] 108 | lastAskAmount = got['asks'][0][1] 109 | 110 | formatted_amount = dcaAmount/maxAskPrice 111 | formatted_price = maxAskPrice 112 | 113 | current_app.logger.info("place emulated market order for pair:{} on exchange: {} fmt_amount: {} fmt_price: {} dca_amt: {} lastAskSize: {}".format(trading_pair, exchange, formatted_amount, formatted_price, dcaAmount, lastAskAmount)) 114 | order = exchange.create_limit_buy_order(trading_pair, formatted_amount, formatted_price) #amount, price 115 | 116 | current_app.logger.info("Making market order for pair:{} on exchange: {} order {}".format(trading_pair, exchange, order)) 117 | 118 | if order: 119 | try: #catch exchanges with extra params 120 | if exchange.has['fetchOrderTrades']: 121 | 122 | orderDetails = exchange.fetch_order_trades(order['id']) 123 | current_app.logger.info("fetchOrderTrades order details {}".format(orderDetails)) 124 | 125 | elif exchange.has['fetchOrder']: 126 | 127 | orderDetails = exchange.fetchOrder(order['id']) 128 | current_app.logger.info("fetchOrder order details {}".format(orderDetails)) 129 | 130 | else: 131 | current_app.logger.info("Sending simple order notif to user: {}".format(user.email)) 132 | send_order_notification(user, exchange, dcaAmount, trading_pair) 133 | return True 134 | 135 | if not orderDetails: 136 | raise Exception("Order never filled for pair:{} on exchange: {} for order: {}!".format(trading_pair, exchange, order)) 137 | orderPrice = 0 138 | orderAmount = 0 139 | 140 | if isinstance(orderDetails, (list)): #multiple orders 141 | orderLen = len(orderDetails) 142 | for execOrder in orderDetails: 143 | if execOrder['price'] and execOrder['amount'] and isinstance(execOrder['price'], (int,float)) and isinstance(execOrder['amount'], (int,float)): 144 | orderPrice += execOrder['price'] 145 | orderAmount += execOrder['amount'] 146 | else: 147 | current_app.logger.info("Sending simple multi order notif to user: {}".format(user.email)) 148 | send_order_notification(user, exchange, dcaAmount, trading_pair) 149 | return True 150 | 151 | orderPrice = orderPrice/orderLen 152 | 153 | else: #single order 154 | if orderDetails['price'] and orderDetails['amount'] and isinstance(orderDetails['price'], (int,float)) and isinstance(orderDetails['amount'], (int,float)): 155 | orderPrice += orderDetails['price'] 156 | orderAmount += orderDetails['amount'] 157 | else: 158 | current_app.logger.info("Sending simple single order notif to user: {}".format(user.email)) 159 | send_order_notification(user, exchange, dcaAmount, trading_pair) 160 | return True 161 | 162 | current_app.logger.info("Sending explicit order notif to user: {}".format(user.email)) 163 | send_order_notification(user, exchange, dcaAmount, trading_pair, price=orderPrice, cryptoAmount=orderAmount) 164 | 165 | except ccxt.errors.ArgumentsRequired: 166 | current_app.logger.info("ccxt.errors.ArgumentsRequired Sending simple order notif to user: {}".format(user.email)) 167 | send_order_notification(user, exchange, dcaAmount, trading_pair) 168 | 169 | return True 170 | 171 | current_app.logger.warning("Failed order placement for user: {} pair:{} on exchange: {}".format(user.email,trading_pair, exchange)) 172 | return False 173 | 174 | 175 | except ccxt.InsufficientFunds as e: 176 | current_app.logger.warning("place_market_order InsufficientFunds for pair:{} on exchange: {} for order: {}!".format(trading_pair, exchange, order)) 177 | raise ccxt.InsufficientFunds("not enough funds!") 178 | 179 | except ccxt.AuthenticationError as e: 180 | current_app.logger.warning("place_market_order AuthenticationError for pair:{} on exchange: {} for order: {}!".format(trading_pair, exchange, order)) 181 | raise ccxt.AuthenticationError("could not authenticate API details!") 182 | 183 | except ccxt.InvalidOrder as e: 184 | current_app.logger.warning("place_market_order InvalidOrder for pair:{} on exchange: {} for order: {}!".format(trading_pair, exchange, order)) 185 | raise ccxt.InvalidOrder("could not place order due to small order size!") 186 | 187 | except ccxt.NetworkError as e: 188 | current_app.logger.warning("place_market_order NetworkError for pair:{} on exchange: {} for order: {}!".format(trading_pair, exchange, order)) 189 | raise ccxt.NetworkError("could not connect to exchange!") 190 | 191 | except Exception as e: 192 | capture_err(e,userEmail=user.email) 193 | current_app.logger.exception("place_market_order GeneralException for pair:{} on exchange: {} for order: {}!".format(trading_pair, exchange, order)) 194 | raise Exception("something went wrong with the API! Please contact us :(") -------------------------------------------------------------------------------- /project/services/dcaService.py: -------------------------------------------------------------------------------- 1 | from project import celery, db 2 | from project.models import User 3 | from project.models import dcaSchedule 4 | import datetime 5 | from flask import current_app 6 | from project.services.ccxtHelper import place_market_order, create_exchangeConnection 7 | from project.services.mailService import send_order_notification 8 | import ccxt 9 | import requests 10 | from project.services.sentryService import capture_err 11 | 12 | @celery.task(serializer='pickle',autoretry_for=(Exception,), retry_kwargs={'max_retries': 3, 'countdown': 10}) 13 | def async_placeMarketOrder_updateDb(subQuery,user,bypassAsync=False): 14 | 15 | newSession = dcaSchedule.query.filter_by(id=subQuery.id).first() #need new context to make changes 16 | exchange_class_name = subQuery.exchange_id 17 | trading_pair = subQuery.trading_pair 18 | dcaAmount = subQuery.dca_budget 19 | 20 | try: 21 | 22 | exchange_class_set = create_exchangeConnection(subQuery.exchange_id,subQuery.api_key, subQuery.api_secret,subQuery.api_passphrase,subQuery.api_uid) 23 | 24 | current_app.logger.info("Placing order for instance: {} {} {} {}".format(subQuery.id, exchange_class_set, subQuery.trading_pair, subQuery.dca_budget)) 25 | 26 | if (datetime.datetime.now() > subQuery.dca_nextRun and subQuery.isActive == True) or bypassAsync: #check if already ran async 27 | 28 | orderStatus = place_market_order(exchange_class_set,trading_pair, dcaAmount,user) 29 | current_app.logger.info("Order status is: {}".format(orderStatus)) 30 | 31 | if orderStatus: 32 | #update last run, next run 33 | currentDate = datetime.datetime.now() 34 | nextRun = newSession.dca_nextRun #increment nextRun to prevent order drift 35 | 36 | dca_interval_int = [int(s)for s in subQuery.dca_frequency.split() if s.isdigit()][0] 37 | if "Days" in subQuery.dca_frequency: 38 | nextRunTime = nextRun + datetime.timedelta(days=dca_interval_int) 39 | else: 40 | nextRunTime = nextRun + datetime.timedelta(minutes=dca_interval_int) 41 | 42 | newSession.dca_nextRun = nextRunTime 43 | newSession.dca_lastRun = currentDate #always true execution time 44 | db.session.commit() 45 | 46 | current_app.logger.info("Succeeded instance: {}".format(subQuery.id)) 47 | 48 | return True 49 | 50 | else: 51 | current_app.logger.warning("Failed order placement for instance: {}".format(subQuery.id)) 52 | raise Exception("Order failed!") 53 | 54 | 55 | except ccxt.InsufficientFunds as e: 56 | current_app.logger.warning("run_dcaSchedule InsufficientFunds") 57 | 58 | newSession.isActive = False 59 | db.session.commit() 60 | current_app.logger.info("run_dcaSchedule set to false now!") 61 | 62 | send_order_notification(user, exchange_class_name, dcaAmount, trading_pair,errorMsg=e) 63 | 64 | except ccxt.AuthenticationError as e: 65 | current_app.logger.warning("run_dcaSchedule AuthenticationError") 66 | 67 | newSession.isActive = False 68 | db.session.commit() 69 | current_app.logger.info("run_dcaSchedule set to false now!") 70 | 71 | send_order_notification(user, exchange_class_name, dcaAmount, trading_pair,errorMsg=e) 72 | 73 | except Exception as e: 74 | capture_err(e, userEmail=user.email) 75 | current_app.logger.exception("async_place_market_order GeneralException") 76 | 77 | if async_placeMarketOrder_updateDb.request.retries == async_placeMarketOrder_updateDb.max_retries: 78 | newSession.isActive = False 79 | db.session.commit() 80 | current_app.logger.info("run_dcaSchedule set to false now!") 81 | send_order_notification(user, exchange_class_name, dcaAmount, trading_pair,errorMsg=e) 82 | 83 | raise Exception("Something went wrong with async_place_market_order!") 84 | 85 | @celery.task(serializer='pickle',autoretry_for=(Exception,), retry_kwargs={'max_retries': 3, 'countdown': 60}) 86 | def run_dcaSchedule(): 87 | current_app.logger.info("Starting dcaSchedule") 88 | 89 | try: 90 | 91 | #heartbeat for status tracking 92 | url = "https://betteruptime.com/api/v1/heartbeat/sCfdXErPZKA4ritfadPQt7pt" 93 | resp = requests.get(url) 94 | 95 | getAll = db.session.query(dcaSchedule).all() 96 | current_app.logger.info("Retrieved query of size: {}".format(len(getAll))) 97 | 98 | for subQuery in getAll: 99 | if datetime.datetime.now() > subQuery.dca_nextRun and subQuery.isActive == True: #check if it's ready 100 | 101 | current_app.logger.info("Running schedule for instance {}".format(subQuery.id)) 102 | 103 | #make this async so we can spawn multiple workers and do better order notifs 104 | async_placeMarketOrder_updateDb.delay(subQuery,User.get_user(subQuery.user_id)) 105 | 106 | except Exception as e: 107 | capture_err(e) 108 | current_app.logger.exception("dca scheduled failed!") 109 | db.session.rollback() #rollback in crash 110 | 111 | raise Exception("run_dcaSchedule Something went wrong !") 112 | 113 | finally: 114 | db.session.close() 115 | 116 | 117 | -------------------------------------------------------------------------------- /project/services/mailService.py: -------------------------------------------------------------------------------- 1 | from flask_mail import Message 2 | from flask import render_template 3 | from project import mail,celery, DEBUG_MODE 4 | from flask import current_app 5 | from project.services.sentryService import capture_err 6 | 7 | @celery.task(serializer='pickle',autoretry_for=(Exception,), retry_kwargs={'max_retries': 5, 'countdown': 60}) 8 | def send_async_email(msg): 9 | 10 | if not DEBUG_MODE: 11 | try: 12 | mail.send(msg) 13 | except Exception as e: 14 | capture_err(e) 15 | current_app.logger.exception("async mail error: {}".format(msg)) 16 | else: 17 | current_app.logger.info("Mocking email: {}".format(msg)) 18 | 19 | def receive_contact_message(userEmail, msgSubject, msgBody): 20 | 21 | msg = Message() 22 | msg.subject = msgSubject 23 | msg.sender = current_app.config['ADMINS'][0] 24 | msg.recipients = [current_app.config['ADMINS'][0]] 25 | msg.body = "Message received from {} about {}".format(userEmail, msgBody) 26 | 27 | current_app.logger.info("Sending contact form: {}".format(userEmail)) 28 | send_async_email.delay(msg) 29 | 30 | def send_reset_password(user): 31 | token = user.get_reset_token() 32 | 33 | msg = Message() 34 | msg.subject = "DCA Stack Password Reset" 35 | msg.sender = current_app.config['ADMINS'][0] 36 | msg.recipients = [user.email] 37 | msg.html = render_template('authy/reset_email.html', user=user, token=token) 38 | 39 | 40 | 41 | current_app.logger.info("Sending reset password: {}".format(user.email)) 42 | send_async_email.delay(msg) 43 | 44 | 45 | def send_order_notification(user, exchange, dcaAmount, crypto, price=None,cryptoAmount=None,errorMsg=None): 46 | msg = Message() 47 | msg.subject = "{} Order Placed".format(crypto) 48 | msg.sender = current_app.config['ADMINS'][0] 49 | msg.recipients = [user.email] 50 | 51 | 52 | msg.body = "We placed an order on {} for {} with your DCA budget of ${}!".format(exchange, crypto, dcaAmount) 53 | 54 | if errorMsg: 55 | msg.body = "Error placing order on {} for {} with your DCA budget of ${} due to {}. Instance will be paused until you click resume.".format(exchange,crypto, dcaAmount, errorMsg) 56 | 57 | if price and cryptoAmount: 58 | msg.body = "We placed an order on {} with your DCA budget of ${} at a price per ${} for {} {}!".format(exchange, dcaAmount, price, cryptoAmount, crypto) 59 | 60 | 61 | current_app.logger.info("send_order_notification: {}".format(user.email)) 62 | send_async_email.delay(msg) 63 | -------------------------------------------------------------------------------- /project/services/sentryService.py: -------------------------------------------------------------------------------- 1 | from sentry_sdk import capture_exception, set_user 2 | from project.models import User 3 | 4 | 5 | def capture_err(exception,session=None,userEmail=None): 6 | 7 | set_user({"email": None}) 8 | 9 | if session: 10 | if '_user_id' in session: 11 | retrUser = User.get_user(session['_user_id']) 12 | set_user({"email": retrUser.email}) 13 | 14 | if userEmail: 15 | set_user({"email": userEmail}) 16 | 17 | capture_exception(exception) 18 | 19 | -------------------------------------------------------------------------------- /project/static/css/main.min.css: -------------------------------------------------------------------------------- 1 | .table-wrapper .table{margin-bottom:0}.table-wrapper:not(:last-child){margin-bottom:1.5rem}@media screen and (max-width: 1023px){.table-wrapper{overflow-x:auto}}.b-table{transition:opacity 86ms ease-out}@media screen and (min-width: 769px), print{.b-table .table-mobile-sort{display:none}}.b-table .icon{transition:transform 150ms ease-out,opacity 86ms ease-out}.b-table .icon.is-desc{transform:rotate(180deg)}.b-table .icon.is-expanded{transform:rotate(90deg)}.b-table .table{width:100%;border:1px solid transparent;border-radius:4px;border-collapse:separate}.b-table .table th{font-weight:600}.b-table .table th .th-wrap{display:flex;align-items:center}.b-table .table th .th-wrap .icon{margin-left:.5rem;margin-right:0;font-size:1rem}.b-table .table th .th-wrap.is-numeric{flex-direction:row-reverse;text-align:right}.b-table .table th .th-wrap.is-numeric .icon{margin-left:0;margin-right:.5rem}.b-table .table th .th-wrap.is-centered{justify-content:center;text-align:center}.b-table .table th.is-current-sort{border-color:#7a7a7a;font-weight:700}.b-table .table th.is-sortable:hover{border-color:#7a7a7a}.b-table .table th.is-sortable,.b-table .table th.is-sortable .th-wrap{cursor:pointer}.b-table .table th .multi-sort-cancel-icon{margin-left:10px}.b-table .table th.is-sticky{position:-webkit-sticky;position:sticky;left:0;z-index:3 !important;background:#fff}.b-table .table tr.is-selected .checkbox input:checked+.check{background:#fff url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'%3E%3Cpath style='fill:%2300d1b2' d='M 0.04038059,0.6267767 0.14644661,0.52071068 0.42928932,0.80355339 0.3232233,0.90961941 z M 0.21715729,0.80355339 0.85355339,0.16715729 0.95961941,0.2732233 0.3232233,0.90961941 z'%3E%3C/path%3E%3C/svg%3E") no-repeat center center}.b-table .table tr.is-selected .checkbox input+.check{border-color:#fff}.b-table .table tr.is-empty:hover{background-color:transparent}.b-table .table .is-chevron-cell,.b-table .table .is-checkbox-cell{width:40px}.b-table .table .is-chevron-cell{vertical-align:middle}.b-table .table .is-checkbox-cell .checkbox{vertical-align:middle}.b-table .table .is-checkbox-cell .checkbox.b-checkbox{margin-right:0}.b-table .table .is-checkbox-cell .checkbox .check{transition:none}.b-table .table tr.detail{box-shadow:inset 0 1px 3px #dbdbdb;background:#fafafa}.b-table .table tr.detail .detail-container{padding:1rem}.b-table .table:focus{border-color:#3273dc;box-shadow:0 0 0 0.125em rgba(50,115,220,0.25)}.b-table .table.is-bordered th.is-current-sort,.b-table .table.is-bordered th.is-sortable:hover{border-color:#dbdbdb;background:#f5f5f5}.b-table .table td.is-sticky{position:-webkit-sticky;position:sticky;left:0;z-index:1;background:#fff}.b-table .table td.is-image-cell .image{margin:0 auto;width:1.5rem;height:1.5rem}.b-table .table td.is-progress-cell{min-width:5rem;vertical-align:middle}.b-table .table-wrapper.has-sticky-header{height:300px;overflow-y:auto}@media screen and (max-width: 768px){.b-table .table-wrapper.has-sticky-header.has-mobile-cards{height:initial !important;overflow-y:initial !important}}.b-table .table-wrapper.has-sticky-header tr:first-child th{position:-webkit-sticky;position:sticky;top:0;z-index:2;background:#fff}@media screen and (max-width: 768px){.b-table .table-wrapper.has-mobile-cards thead{display:none}.b-table .table-wrapper.has-mobile-cards tfoot th{border:0;display:inherit}.b-table .table-wrapper.has-mobile-cards tr{box-shadow:0 2px 3px rgba(10,10,10,0.1),0 0 0 1px rgba(10,10,10,0.1);max-width:100%;position:relative;display:block}.b-table .table-wrapper.has-mobile-cards tr td{border:0;display:inherit}.b-table .table-wrapper.has-mobile-cards tr td:last-child{border-bottom:0}.b-table .table-wrapper.has-mobile-cards tr:not(:last-child){margin-bottom:1rem}.b-table .table-wrapper.has-mobile-cards tr:not([class*="is-"]){background:inherit}.b-table .table-wrapper.has-mobile-cards tr:not([class*="is-"]):hover{background-color:inherit}.b-table .table-wrapper.has-mobile-cards tr.detail{margin-top:-1rem}.b-table .table-wrapper.has-mobile-cards tr:not(.detail):not(.is-empty):not(.table-footer) td{display:flex;width:auto;justify-content:space-between;text-align:right;border-bottom:1px solid #f5f5f5}.b-table .table-wrapper.has-mobile-cards tr:not(.detail):not(.is-empty):not(.table-footer) td:before{content:attr(data-label);font-weight:600;padding-right:.5rem;text-align:left}.b-table .table-wrapper.has-mobile-cards tr:not(.detail):not(.is-empty):not(.table-footer) td.is-image-cell .image{width:6rem;height:6rem;margin:0 auto .5rem}.b-table .table-wrapper.has-mobile-cards tr:not(.detail):not(.is-empty):not(.table-footer) td.is-progress-cell span,.b-table .table-wrapper.has-mobile-cards tr:not(.detail):not(.is-empty):not(.table-footer) td.is-progress-cell progress{display:flex;width:45%;align-items:center;align-self:center}.b-table .table-wrapper.has-mobile-cards tr:not(.detail):not(.is-empty):not(.table-footer) td.is-checkbox-cell,.b-table .table-wrapper.has-mobile-cards tr:not(.detail):not(.is-empty):not(.table-footer) td.is-image-cell{border-bottom:0 !important}.b-table .table-wrapper.has-mobile-cards tr:not(.detail):not(.is-empty):not(.table-footer) td.is-checkbox-cell:before,.b-table .table-wrapper.has-mobile-cards tr:not(.detail):not(.is-empty):not(.table-footer) td.is-actions-cell:before{display:none}.b-table .table-wrapper.has-mobile-cards tr:not(.detail):not(.is-empty):not(.table-footer) td.is-label-hidden:before,.b-table .table-wrapper.has-mobile-cards tr:not(.detail):not(.is-empty):not(.table-footer) td.is-image-cell:before{display:none}.b-table .table-wrapper.has-mobile-cards tr:not(.detail):not(.is-empty):not(.table-footer) td.is-label-hidden span{display:block;width:100%}.b-table .table-wrapper.has-mobile-cards tr:not(.detail):not(.is-empty):not(.table-footer) td.is-label-hidden.is-progress-col progress{width:100%}}.b-table .table-wrapper.is-card-list thead{display:none}.b-table .table-wrapper.is-card-list tfoot th{border:0;display:inherit}.b-table .table-wrapper.is-card-list tr{box-shadow:0 2px 3px rgba(10,10,10,0.1),0 0 0 1px rgba(10,10,10,0.1);max-width:100%;position:relative;display:block}.b-table .table-wrapper.is-card-list tr td{border:0;display:inherit}.b-table .table-wrapper.is-card-list tr td:last-child{border-bottom:0}.b-table .table-wrapper.is-card-list tr:not(:last-child){margin-bottom:1rem}.b-table .table-wrapper.is-card-list tr:not([class*="is-"]){background:inherit}.b-table .table-wrapper.is-card-list tr:not([class*="is-"]):hover{background-color:inherit}.b-table .table-wrapper.is-card-list tr.detail{margin-top:-1rem}.b-table .table-wrapper.is-card-list tr:not(.detail):not(.is-empty):not(.table-footer) td{display:flex;width:auto;justify-content:space-between;text-align:right;border-bottom:1px solid #f5f5f5}.b-table .table-wrapper.is-card-list tr:not(.detail):not(.is-empty):not(.table-footer) td:before{content:attr(data-label);font-weight:600;padding-right:.5rem;text-align:left}.b-table .table-wrapper.is-card-list tr:not(.detail):not(.is-empty):not(.table-footer) td.is-image-cell .image{width:6rem;height:6rem;margin:0 auto .5rem}.b-table .table-wrapper.is-card-list tr:not(.detail):not(.is-empty):not(.table-footer) td.is-progress-cell span,.b-table .table-wrapper.is-card-list tr:not(.detail):not(.is-empty):not(.table-footer) td.is-progress-cell progress{display:flex;width:45%;align-items:center;align-self:center}.b-table .table-wrapper.is-card-list tr:not(.detail):not(.is-empty):not(.table-footer) td.is-checkbox-cell,.b-table .table-wrapper.is-card-list tr:not(.detail):not(.is-empty):not(.table-footer) td.is-image-cell{border-bottom:0 !important}.b-table .table-wrapper.is-card-list tr:not(.detail):not(.is-empty):not(.table-footer) td.is-checkbox-cell:before,.b-table .table-wrapper.is-card-list tr:not(.detail):not(.is-empty):not(.table-footer) td.is-actions-cell:before{display:none}.b-table .table-wrapper.is-card-list tr:not(.detail):not(.is-empty):not(.table-footer) td.is-label-hidden:before,.b-table .table-wrapper.is-card-list tr:not(.detail):not(.is-empty):not(.table-footer) td.is-image-cell:before{display:none}.b-table .table-wrapper.is-card-list tr:not(.detail):not(.is-empty):not(.table-footer) td.is-label-hidden span{display:block;width:100%}.b-table .table-wrapper.is-card-list tr:not(.detail):not(.is-empty):not(.table-footer) td.is-label-hidden.is-progress-col progress{width:100%}.b-table.is-loading{position:relative;pointer-events:none;opacity:0.5}.b-table.is-loading:after{animation:spinAround 500ms infinite linear;border:2px solid #dbdbdb;border-radius:290486px;border-right-color:transparent;border-top-color:transparent;content:"";display:block;height:1em;position:relative;width:1em;position:absolute;top:4em;left:calc(50% - 2.5em);width:5em;height:5em;border-width:0.25em}.b-table.has-pagination .table-wrapper{margin-bottom:0}.b-table.has-pagination .table-wrapper+.notification{border-top-left-radius:0;border-top-right-radius:0} 2 | -------------------------------------------------------------------------------- /project/static/favicon/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DCAStack/DCAStackCefi/751c864ae4ae0d3424fcb28c3c1b46bf6cebced9/project/static/favicon/android-chrome-192x192.png -------------------------------------------------------------------------------- /project/static/favicon/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DCAStack/DCAStackCefi/751c864ae4ae0d3424fcb28c3c1b46bf6cebced9/project/static/favicon/android-chrome-512x512.png -------------------------------------------------------------------------------- /project/static/favicon/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DCAStack/DCAStackCefi/751c864ae4ae0d3424fcb28c3c1b46bf6cebced9/project/static/favicon/apple-touch-icon.png -------------------------------------------------------------------------------- /project/static/favicon/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DCAStack/DCAStackCefi/751c864ae4ae0d3424fcb28c3c1b46bf6cebced9/project/static/favicon/favicon-16x16.png -------------------------------------------------------------------------------- /project/static/favicon/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DCAStack/DCAStackCefi/751c864ae4ae0d3424fcb28c3c1b46bf6cebced9/project/static/favicon/favicon-32x32.png -------------------------------------------------------------------------------- /project/static/favicon/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DCAStack/DCAStackCefi/751c864ae4ae0d3424fcb28c3c1b46bf6cebced9/project/static/favicon/favicon.ico -------------------------------------------------------------------------------- /project/static/favicon/site.webmanifest: -------------------------------------------------------------------------------- 1 | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} -------------------------------------------------------------------------------- /project/static/img/background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DCAStack/DCAStackCefi/751c864ae4ae0d3424fcb28c3c1b46bf6cebced9/project/static/img/background.png -------------------------------------------------------------------------------- /project/static/img/site-img.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DCAStack/DCAStackCefi/751c864ae4ae0d3424fcb28c3c1b46bf6cebced9/project/static/img/site-img.png -------------------------------------------------------------------------------- /project/static/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: / 3 | 4 | Sitemap: https://www.dcastack.com/sitemap.xml -------------------------------------------------------------------------------- /project/static/sitemap.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | https://www.dcastack.com/ 5 | 6 | 7 | 8 | https://www.dcastack.com/donate 9 | 10 | 11 | 12 | https://www.dcastack.com/faq 13 | 14 | 15 | 16 | https://status.dcastack.com/ 17 | 18 | 19 | 20 | https://docs.dcastack.com/ 21 | 22 | 23 | 24 | https://www.dcastack.com/contact 25 | 26 | 27 | 28 | https://www.dcastack.com/signup 29 | 30 | 31 | 32 | https://www.dcastack.com/login 33 | 34 | -------------------------------------------------------------------------------- /project/templates/authy/check.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |

5 | Please check your email to reset your password :) 6 |

7 | {% endblock %} -------------------------------------------------------------------------------- /project/templates/authy/login.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |
5 |

Login

6 |
7 | {% with messages = get_flashed_messages() %} 8 | {% if messages %} 9 |
10 | {{ messages[0] }} Forget Password? 11 |
12 | {% endif %} 13 | {% endwith %} 14 |
15 | {{ form.csrf_token }} 16 | 17 |
18 |
19 | {{ form.emailField (class_='input is-large',placeholder_="Email") }} 20 |
21 |
22 | 23 |
24 |
25 | {{ form.passwordField (class_='input is-large',placeholder_="Password") }} 26 |
27 |
28 |
29 | Forget Password 30 | or are you 31 | Not Registered? 32 | 33 |
34 | 35 |
36 |
37 |
38 | {% endblock %} -------------------------------------------------------------------------------- /project/templates/authy/reset.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |
5 |

Reset Password

6 |
7 | {% with messages = get_flashed_messages() %} 8 | {% if messages %} 9 |
10 | {{ messages[0] }} 11 |
12 | {% endif %} 13 | {% endwith %} 14 |
15 | 16 | 17 | 18 |
19 |
20 | 21 |
22 |
23 | 24 |
25 |
26 |
27 | {% endblock %} -------------------------------------------------------------------------------- /project/templates/authy/reset_email.html: -------------------------------------------------------------------------------- 1 |

2 | To reset your password 3 | 4 | click here 5 | . 6 |

-------------------------------------------------------------------------------- /project/templates/authy/reset_verified.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block content %} 4 |
5 |

Enter New Password

6 |
7 | 8 | 9 |
10 |
11 | 12 |
13 |
14 | 16 | 17 |
18 |
19 | {% endblock %} -------------------------------------------------------------------------------- /project/templates/authy/signup.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | 4 | 5 | {% block content %} 6 |
7 |

Sign Up

8 |
9 | {% with messages = get_flashed_messages() %} 10 | {% if messages %} 11 |
12 | {{ messages[0] }} 13 |
14 | {% endif %} 15 | {% endwith %} 16 |
17 | {{ form.csrf_token }} 18 | 19 |
20 |
21 | {{ form.emailField (class_='input is-large',placeholder_="Email") }} 22 |
23 |
24 | 25 |
26 |
27 | {{ form.nameField (class_='input is-large',placeholder_="Name") }} 28 |
29 |
30 | 31 |
32 |
33 | {{ form.passwordField (class_='input is-large',placeholder_="Password") }} 34 | {% for error in form.passwordField.errors %} 35 | [{{ error }}] 36 | {% endfor %} 37 |
38 |
39 | 40 |
41 |
42 | {{ form.password_confirm (class_='input is-large',placeholder_="Repeat Password") }} 43 | {% for error in form.password_confirm.errors %} 44 | [{{ error }}] 45 | {% endfor %} 46 |
47 |
48 | 51 | 52 |
53 |
54 |
55 | {% endblock %} -------------------------------------------------------------------------------- /project/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 10 | DCA Stack | Automated Dollar Cost Averaging Bot for Crypto 11 | 12 | 13 | 14 | 15 | 16 | 17 | 19 | 21 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 34 | 35 | 36 | 37 | 44 | 45 | 46 | 122 | 123 | {{ moment.include_moment() }} 124 | 125 | 126 | 214 | 215 | 216 | 217 |
218 |
219 |
220 | {% block content %} 221 | {% endblock %} 222 |
223 | 224 |
225 | 226 |
227 | 228 | 229 | 230 | 231 | 258 | 259 | -------------------------------------------------------------------------------- /project/templates/contact.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | 4 | 5 | {% block content %} 6 |
7 |

Contact Us

8 |
9 | {% with messages = get_flashed_messages() %} 10 | {% if messages %} 11 |
12 | {{ messages[0] }} 13 |
14 | {% endif %} 15 | {% endwith %} 16 |
17 | {{ form.csrf_token }} 18 | 19 |
20 |
21 | {{ form.emailField (class_='input is-large',placeholder_="Your email") }} 22 |
23 |
24 | 25 |
26 |
27 |
28 | {{ form.subjectField }} 29 |
30 |
31 |
32 | 33 |
34 |
35 | {{ form.messageField (class_='textarea',placeholder_="Your message...") }} 36 |
37 |
38 | 39 | 40 |
41 |
42 |
43 | {% endblock %} -------------------------------------------------------------------------------- /project/templates/contact_complete.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |

5 | Message received! We will get back to you as soon as we can :) 6 |

7 | 8 | 9 | 14 | {% endblock %} -------------------------------------------------------------------------------- /project/templates/donate.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | 4 | 5 | {% block content %} 6 |
7 |

Support Us :)

8 |

9 | We Run Purely Off Donations and Your Kindness 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 |
CryptoAddress
XMR49XFhWs1T63Piz6BkEtpGkGDxD4PBGxjYBQAJPGKSu8SKj2mmFCoCxy2WkomcbzCrajZhrPoBfFSuCj4V8UrBkLCGYDVyPe
BTCbc1q8w98sh2hcrh8amkvc5nh08thuma5dn2vazun9y
ETH0x3ddaE572C415D2dEdc1F22D8DB366153d964DF51
MATIC0x3ddaE572C415D2dEdc1F22D8DB366153d964DF51
AVAX0x3ddaE572C415D2dEdc1F22D8DB366153d964DF51
ADAaddr1q9f5rgj4vswl4xflgjp7avy9glyrglggdd9ln244k4x3h6r7cdutpzn825vp7vywjmdzd043yrmnjlyskzd76jrcu7vqv2kpdm
DOT148cU1rgXXvmp3jjMhFjzuHsz8iQ3A322ThcU9zKAnZjGPNk
LTCltc1q3pz87jhaf6p7xew69fa67n8p8k4yuc0c0pz4q5
75 |
76 |
77 |
78 |
79 | 80 | 81 |
82 | {% endblock %} -------------------------------------------------------------------------------- /project/templates/faq.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | 4 | 5 | {% block content %} 6 | 7 |
8 |

FAQ Me!

9 | 10 |
11 | 12 |
13 |
14 | 15 |
16 |
17 |

What is DCA?

18 | 19 | 20 | 21 |
22 | 27 |
28 | 29 |
30 |
31 |

How do I DCA?

32 | 33 | 34 | 35 |
36 | 41 |
42 | 43 |
44 |
45 |

What exchanges do you support?

46 | 47 | 48 | 49 |
50 | 55 |
56 | 57 |
58 |
59 |

Where do I find the API stuff?

60 | 61 | 62 | 63 |
64 | 69 |
70 | 71 |
72 |
73 |

What permissions should I enable on the API key?

74 | 75 | 76 | 77 |
78 | 83 |
84 | 85 |
86 |
87 |

Is this safe to use?

88 | 89 | 90 | 91 |
92 | 97 |
98 | 99 |
100 |
101 |

What are your terms and conditions?

102 | 103 | 104 | 105 |
106 | 111 |
112 | 113 |
114 |
115 |

What is your privacy policy?

116 | 117 | 118 | 119 |
120 | 125 |
126 | 127 | 128 |
129 | 130 | 131 |
132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 156 | 157 | {% endblock %} -------------------------------------------------------------------------------- /project/templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | 4 | {% block content %} 5 | 6 |
7 | 8 |
9 |

Welcome to DCA Stack

10 |

11 | An Automated Dollar Cost Averaging Bot For Your Crypto 12 |

13 |
14 | 15 |
16 |
17 |
18 | 19 | Learn More 20 | 21 | 22 | 23 | View Code 24 | 25 |
26 | 27 |
28 | 29 |
30 |
31 |
32 |

100+ Exchanges

33 |
34 |
35 |

1000+ Trading Pairs

36 |
37 |
38 |

Completely Free

39 |
40 |
41 |
42 |
43 | 44 | 45 |
46 | 47 | 48 | 49 | {% endblock %} -------------------------------------------------------------------------------- /project/templates/user/dashboard.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 | 5 | 6 |
7 |
8 |
9 |

10 |

Portfolio Value: $0

11 |

12 |
13 |
14 | 15 |
16 | 24 |
25 |
26 | 27 |
28 |
29 | 30 | 31 |
32 | 33 |
34 | 35 | 36 |
37 | 38 | 46 | 47 |
48 | 49 | 211 | {% endblock %} -------------------------------------------------------------------------------- /project/templates/user/dca.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 | 5 | 173 | 174 |
175 | 176 |

Running Schedules

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 | {% for dca_scheduleRows in dca_scheduleQuery %} 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 214 | 216 | 218 | 220 | 275 | 276 | {% endfor %} 277 | 278 | 279 | 280 |
StatusInstanceExchangeTrading PairDCA BudgetFrequencyMonthly BudgetStartedLast RunNext RunActions
{{ dca_scheduleRows.isActive }}{{ dca_scheduleRows.dca_instance }}{{ dca_scheduleRows.exchange_id }}{{ dca_scheduleRows.trading_pair }}${{ dca_scheduleRows.dca_budget }}Every {{ dca_scheduleRows.dca_frequency }}${{ (dca_scheduleRows.dca_budget|int) * 213 | (30/(dca_scheduleRows.dca_frequency.split()[0]|int)) }}{{ moment(dca_scheduleRows.dca_firstRun).format('MMM Do YYYY, 215 | h:mm a') }}{{ moment(dca_scheduleRows.dca_lastRun).format('MMM Do YYYY, 217 | h:mm a') }}{{ moment(dca_scheduleRows.dca_nextRun).format('MMM Do YYYY, 219 | h:mm a') }} 221 |
222 | 223 |
224 | 225 | {% if (dca_scheduleRows.isActive == "Running") %} 226 |

227 |

229 | 234 | {{ form.csrf_token }} 235 | 236 |
237 |

238 | {% else %} 239 | 240 |

241 |

243 | 248 | {{ form.csrf_token }} 249 | 250 |
251 | 252 |

253 | {% endif %} 254 | 255 |

256 |

258 | 263 | {{ form.csrf_token }} 264 | 265 |
266 |

267 | 268 | 269 |
270 |
271 | 272 | 273 | 274 |
281 |
282 |
283 |
284 |
285 |
286 | 287 | 544 | 545 | {% endblock %} 546 | -------------------------------------------------------------------------------- /project/templates/user/profile.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 | 5 |

6 | Welcome, {{ name }}! 7 |

8 | 9 |
10 |
11 | 12 | 13 |
14 |
15 | {% endblock %} -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | celery==4.4.7 2 | ccxt==1.* 3 | email-validator==1.1.2 4 | Flask==2.0.2 5 | Flask-JWT-Extended==3.24.1 6 | Flask-Login==0.5.0 7 | Flask-Mail==0.9.1 8 | Flask-Migrate==3.1.0 9 | Flask-Modals==0.4.1 10 | flask-sock==0.4.0 11 | Flask-SQLAlchemy==2.5.1 12 | Flask-WTF==0.14.3 13 | itsdangerous==2.0.1 14 | Jinja2==3.0.2 15 | jinja2-time==0.2.0 16 | PyJWT==1.7.1 17 | PyYAML==5.4 18 | SQLAlchemy==1.4.26 19 | Twisted==18.9.0 20 | Werkzeug==2.0.2 21 | wsproto==1.0.0 22 | WTForms==3.0.0 23 | yarl==1.7.2 24 | python-dotenv==0.19.2 25 | flower==0.9.7 26 | redis==3.5.3 27 | gunicorn==20.1.0 28 | psycopg2-binary==2.9.2 29 | pycoingecko==2.2.0 30 | secure==0.3.0 31 | Flask-Moment==1.0.2 32 | Flask-Executor==0.10.0 33 | sentry-sdk==1.5.1 34 | pycryptodome==3.12.0 35 | scrypt==0.8.19 36 | --------------------------------------------------------------------------------