├── .ai ├── architecture.md ├── django_settings_fix.md ├── frontend_sass_fix.md ├── local_development_guide.md ├── poetry_fix_guide.md ├── prd.md └── project_fix_summary.md ├── .cursor ├── mcp.json ├── modes.json ├── rules │ ├── core-rules │ │ ├── readme.md │ │ ├── rule-generating-agent.mdc │ │ ├── rule-update-cursor-modes-manual.mdc │ │ └── workflow-agile-manual.mdc │ ├── global-rules │ │ ├── emoji-communication-always.mdc │ │ └── readme.md │ ├── tool-rules │ │ ├── git-commit-push-agent.mdc │ │ └── readme.md │ ├── ts-rules │ │ ├── readme.md │ │ └── typescript-best-practices-agent.mdc │ ├── ui-rules │ │ └── readme.md │ └── workflows │ │ ├── arch.mdc │ │ ├── dev.mdc │ │ ├── pm.mdc │ │ └── workflow-agile-manual.mdc └── templates │ ├── mode-format.md │ ├── template-arch.md │ ├── template-prd.md │ └── template-story.md ├── .cursorignore ├── .cursorindexingignore ├── .dockerignore ├── .envs └── .local │ ├── .django │ └── .postgres ├── .github └── workflows │ └── continuous.yml ├── .gitignore ├── LICENSE ├── README.md ├── compose ├── dev │ ├── django │ │ └── Dockerfile │ └── node │ │ └── Dockerfile ├── local │ ├── django │ │ ├── Dockerfile │ │ ├── celery │ │ │ ├── beat │ │ │ │ └── start.sh │ │ │ └── worker │ │ │ │ └── start.sh │ │ └── start.sh │ └── node │ │ └── Dockerfile └── production │ ├── django │ ├── Dockerfile │ ├── entrypoint.sh │ └── start.sh │ ├── nginx │ ├── Dockerfile │ ├── conf.d │ │ └── yufuquant.conf.template │ └── includes │ │ └── proxy.conf │ └── postgres │ ├── Dockerfile │ └── maintenance │ ├── _sourced │ ├── constants.sh │ ├── countdown.sh │ ├── messages.sh │ └── yes_no.sh │ ├── backup │ ├── backups │ └── restore ├── config ├── __init__.py ├── api_router.py ├── asgi.py ├── routing.py ├── settings │ ├── __init__.py │ ├── common.py │ ├── local.py │ ├── production.py │ └── test.py ├── urls.py └── wsgi.py ├── database └── README.md ├── dev.yml ├── docs ├── agile-readme.md ├── deploy.md ├── index.md └── specification.md ├── frontend ├── .prettierrc ├── babel.config.js ├── dist │ ├── config.example.js │ ├── css │ │ ├── app.93966c69.css │ │ └── chunk-vendors.af3221a8.css │ ├── favicon.ico │ ├── index.html │ └── js │ │ ├── app.a78626d7.js │ │ ├── app.a78626d7.js.map │ │ ├── chunk-vendors.7ffee7e7.js │ │ └── chunk-vendors.7ffee7e7.js.map ├── package-lock.json ├── package.json ├── public │ ├── config.example.js │ ├── favicon.ico │ └── index.html ├── src │ ├── App.vue │ ├── api │ │ ├── credential.js │ │ ├── index.js │ │ ├── loginApi.js │ │ ├── robot.js │ │ └── strategy.js │ ├── assets │ │ ├── _global.scss │ │ ├── _variable.scss │ │ ├── base.scss │ │ ├── logo.png │ │ ├── nav.scss │ │ ├── sidebar.scss │ │ ├── template.css │ │ └── topbar.scss │ ├── axiosService.js │ ├── components │ │ ├── ConnectForm.vue │ │ ├── ConnectedTable.vue │ │ ├── CredentialForm.vue │ │ ├── CredentialHelper.vue │ │ ├── CredentialItem.vue │ │ ├── NavBar.vue │ │ ├── ParamForm.vue │ │ ├── ParamFormSelectItem.vue │ │ ├── ParamPreview.vue │ │ ├── RobotCreateForm.vue │ │ ├── RobotForm.vue │ │ ├── RobotListItem.vue │ │ ├── RobotUpdateForm.vue │ │ ├── SideBar.vue │ │ ├── StrategyForm.vue │ │ ├── StrategyItem.vue │ │ ├── TopBar.vue │ │ └── robot-console │ │ │ ├── AssetChart.vue │ │ │ ├── LogPanel.vue │ │ │ ├── Order.vue │ │ │ ├── OrderItem.vue │ │ │ ├── Overview.vue │ │ │ ├── Position.vue │ │ │ └── PositionItem.vue │ ├── main.js │ ├── mixins │ │ ├── formError.js │ │ └── formatter.js │ ├── plugins │ │ └── bootstrap-vue.js │ ├── router │ │ └── index.js │ ├── store │ │ ├── actions.js │ │ ├── getters.js │ │ ├── index.js │ │ └── mutations.js │ ├── utils.js │ └── views │ │ ├── Account.vue │ │ ├── ConnectView.vue │ │ ├── CredentialView.vue │ │ ├── LoginView.vue │ │ ├── RobotCreateView.vue │ │ ├── RobotListView.vue │ │ ├── RobotUpdateView.vue │ │ ├── RobotView.vue │ │ ├── StrategyAddView.vue │ │ ├── StrategyDetailView.vue │ │ └── StrategyListView.vue ├── vue.config.js └── yarn.lock ├── local.yml ├── locale └── zh_Hans │ └── LC_MESSAGES │ └── django.po ├── manage.py ├── mkdocs.yml ├── poetry.lock ├── production.yml ├── pyproject.toml ├── requirements.txt ├── run_uvicorn.py ├── screenshots └── Bybit交易界面.png ├── setup.cfg ├── xnotes ├── custom-agents.md └── project-idea-prompt.md └── yufuquant ├── __init__.py ├── conftest.py ├── core ├── __init__.py ├── apps.py ├── decrators.py ├── middleware.py ├── migrations │ └── __init__.py ├── mixins.py ├── models.py ├── serializers.py ├── tests │ ├── __init__.py │ ├── test_serializers.py │ └── test_views.py ├── utils.py ├── validators.py └── views.py ├── credentials ├── __init__.py ├── admin.py ├── apps.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_credential_user.py │ └── __init__.py ├── models.py ├── serializers.py ├── tests │ ├── __init__.py │ ├── factories.py │ └── test_views.py ├── urls.py └── views.py ├── exchanges ├── __init__.py ├── admin.py ├── apps.py ├── migrations │ ├── 0001_initial.py │ └── __init__.py ├── models.py ├── serializers.py ├── tests │ ├── __init__.py │ └── factories.py └── views.py ├── robots ├── __init__.py ├── admin.py ├── apps.py ├── managers.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_auto_20201022_2037.py │ ├── 0003_auto_20201030_1613.py │ ├── 0004_auto_20201102_2012.py │ ├── 0005_auto_20201102_2014.py │ ├── 0006_auto_20210320_1701.py │ └── __init__.py ├── models.py ├── serializers.py ├── signals.py ├── tasks.py ├── tests │ ├── __init__.py │ ├── factories.py │ ├── test_serializers.py │ ├── test_signals.py │ ├── test_tasks.py │ └── test_views.py ├── urls.py └── views.py ├── scripts ├── __init__.py ├── db │ ├── __init__.py │ ├── _init_exchanges.py │ ├── _init_superuser.py │ └── init_db.py └── fake │ ├── __init__.py │ ├── _clean_db.py │ ├── _fake_exchanges.py │ ├── _fake_robot_asset_record_snaps.py │ ├── _fake_robots.py │ ├── _fake_strategies.py │ ├── _fake_superuser.py │ ├── exchange-logos │ ├── binance.jpg │ ├── bybit.jpg │ ├── huobi.jpg │ └── okex.jpg │ ├── fake_all.py │ └── strategy-specification.json ├── strategies ├── __init__.py ├── admin.py ├── apps.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_auto_20201030_1613.py │ ├── 0003_strategy_brief.py │ └── __init__.py ├── models.py ├── serializers.py ├── tests │ ├── __init__.py │ ├── factories.py │ └── test_views.py └── views.py ├── streams ├── __init__.py ├── apps.py ├── consumers.py ├── routing.py └── tests.py ├── taskapp ├── __init__.py ├── celery.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── setup_periodic_tasks.py └── tasks.py ├── templates └── index.html └── users ├── Inconsolata.otf ├── __init__.py ├── admin.py ├── apps.py ├── avatar_generator.py ├── migrations ├── 0001_initial.py ├── 0002_auto_20201022_2037.py └── __init__.py ├── models.py ├── serializers.py ├── signals.py ├── tests ├── __init__.py ├── factories.py └── test_views.py ├── urls ├── __init__.py └── auth.py └── views.py /.ai/django_settings_fix.md: -------------------------------------------------------------------------------- 1 | # Django 设置文件错误修复指南 2 | 3 | ## 问题 4 | 5 | 当运行 Django 服务时,出现以下错误: 6 | 7 | ``` 8 | ModuleNotFoundError: No module named 'config.settings.prod' 9 | ``` 10 | 11 | 这表明 Django 应用正在尝试加载 `config.settings.prod` 模块,但找不到它。这可能是因为环境变量配置错误或者设置文件路径不正确。 12 | 13 | ## 解决方案 14 | 15 | ### 方法 1: 创建缺失的配置文件 16 | 17 | 1. 创建缺失的 `config/settings/prod.py` 文件: 18 | 19 | ```bash 20 | mkdir -p config/settings 21 | touch config/settings/prod.py 22 | ``` 23 | 24 | 2. 在 `prod.py` 文件中添加基础配置: 25 | 26 | ```python 27 | from config.settings.base import * 28 | 29 | DEBUG = False 30 | ALLOWED_HOSTS = ['*'] # 请根据实际情况调整 31 | ``` 32 | 33 | ### 方法 2: 修改 Django 设置路径环境变量 34 | 35 | 在 `.envs/.local/.django` 文件中添加或修改 `DJANGO_SETTINGS_MODULE` 环境变量: 36 | 37 | ``` 38 | # Django 设置模块 39 | DJANGO_SETTINGS_MODULE=config.settings.local 40 | ``` 41 | 42 | 如果 `local` 配置文件不存在,请检查项目中现有的设置文件,它可能是: 43 | - `config.settings.base` 44 | - `config.settings.dev` 45 | - `config.settings` 46 | 47 | ### 方法 3: 修改 start.sh 文件 48 | 49 | 检查并修改 `compose/local/django/start.sh` 脚本: 50 | 51 | ```bash 52 | #!/bin/bash 53 | 54 | set -o errexit 55 | set -o pipefail 56 | set -o nounset 57 | 58 | # 使用 local 而不是 prod 配置 59 | export DJANGO_SETTINGS_MODULE=config.settings.local 60 | 61 | python manage.py migrate 62 | python manage.py runserver 0.0.0.0:8000 63 | ``` 64 | 65 | ## 应用更改后的步骤 66 | 67 | 1. 修改相关配置后,停止并重建 Django 容器: 68 | 69 | ```bash 70 | docker-compose -f local.yml stop django 71 | docker-compose -f local.yml build django 72 | docker-compose -f local.yml up django 73 | ``` 74 | 75 | 2. 如果问题仍然存在,请尝试查看项目文档,了解正确的设置模块路径。 -------------------------------------------------------------------------------- /.ai/frontend_sass_fix.md: -------------------------------------------------------------------------------- 1 | # 前端 SASS 错误修复指南 2 | 3 | ## 问题 4 | 5 | 在启动前端服务时,出现了以下错误: 6 | 7 | ``` 8 | Syntax Error: ReferenceError: globalThis is not defined 9 | ``` 10 | 11 | 同时,还有大量 SASS 相关的警告,比如: 12 | - 关于 `lighten()` 函数使用的弃用警告 13 | - 关于 `abs()` 函数和百分比单位的弃用警告 14 | - 关于 `mixed-decls` 的弃用警告 15 | - 关于 `/` 用于除法的弃用警告 16 | 17 | 这些问题主要是因为: 18 | 1. 项目使用的 Node.js 版本可能太旧,不支持 `globalThis` 19 | 2. 使用的 SASS 版本与项目中的 Bootstrap SASS 代码不兼容 20 | 21 | ## 解决方案 22 | 23 | ### 解决 Node 版本问题 24 | 25 | 1. 修改 `compose/local/node/Dockerfile` 以使用更新的 Node.js 版本: 26 | 27 | ```dockerfile 28 | # 原始行 29 | FROM node:10 # 或者什么版本 30 | 31 | # 修改为 32 | FROM node:14 # 推荐使用 Node.js 14 或更高版本 33 | ``` 34 | 35 | ### 降级 SASS 版本 36 | 37 | 1. 修改 `frontend/package.json` 中的 SASS 版本: 38 | 39 | ```json 40 | "devDependencies": { 41 | // ... 其他依赖 42 | "sass": "~1.32.13", // 将版本固定在兼容的版本 43 | "sass-loader": "^10.1.1", // 降级 sass-loader 44 | // ... 其他依赖 45 | } 46 | ``` 47 | 48 | ### 应用修改 49 | 50 | 1. 停止前端容器: 51 | ```bash 52 | docker-compose -f local.yml stop node 53 | ``` 54 | 55 | 2. 重建前端容器: 56 | ```bash 57 | docker-compose -f local.yml build node 58 | ``` 59 | 60 | 3. 启动前端服务: 61 | ```bash 62 | docker-compose -f local.yml up node 63 | ``` 64 | 65 | ### 注意事项 66 | 67 | - Bootstrap 4.x 的 SASS 文件使用了一些在新版 SASS 中已弃用的功能,因此降级到兼容版本是更简单的解决方案 68 | - 如果仍有问题,可以进入容器并手动执行 npm 安装命令: 69 | ```bash 70 | docker-compose -f local.yml exec node bash 71 | cd /app 72 | npm install sass@~1.32.13 sass-loader@^10.1.1 --save-dev 73 | npm run serve 74 | ``` 75 | 76 | 弃用警告通常不会阻止应用运行,除非有语法错误。降级 SASS 版本是简单的权宜之计,长期解决方案应该是更新项目代码以适应最新的 SASS 语法。 -------------------------------------------------------------------------------- /.ai/poetry_fix_guide.md: -------------------------------------------------------------------------------- 1 | # Poetry 安装错误修复指南 2 | 3 | 您在构建 Docker 镜像时遇到了 Poetry 安装错误,这是因为 Dockerfile 中使用的 Poetry 安装脚本链接已经过时。下面是修复步骤: 4 | 5 | ## 问题 6 | 7 | 错误信息: 8 | ``` 9 | ERROR [celeryworker 4/19] RUN curl -sSL https://raw.githubusercontent.com/sdispater/poetry/master/get-poetry.py | POETRY_PREVIEW=1 python 10 | ``` 11 | 12 | 这个错误是因为 Poetry 团队已经更改了安装脚本的位置和方法。 13 | 14 | ## 解决方案 15 | 16 | 您需要修改 `compose/local/django/Dockerfile` 文件,将 Poetry 安装命令更新为最新的方法。 17 | 18 | ### 方法 1: 使用官方推荐的安装脚本 19 | 20 | 1. 打开 `compose/local/django/Dockerfile` 文件 21 | 2. 找到以下行(大约在第 9 行左右): 22 | ```dockerfile 23 | RUN curl -sSL https://raw.githubusercontent.com/sdispater/poetry/master/get-poetry.py | POETRY_PREVIEW=1 python 24 | ``` 25 | 3. 替换为新的安装命令: 26 | ```dockerfile 27 | RUN curl -sSL https://install.python-poetry.org | python3 - 28 | ``` 29 | 4. 保存文件 30 | 31 | ### 方法 2: 使用 pip 安装(更简单) 32 | 33 | 或者,您可以使用更简单的 pip 安装方法: 34 | 35 | 1. 打开 `compose/local/django/Dockerfile` 文件 36 | 2. 找到以下行(大约在第 9 行左右): 37 | ```dockerfile 38 | RUN curl -sSL https://raw.githubusercontent.com/sdispater/poetry/master/get-poetry.py | POETRY_PREVIEW=1 python 39 | ``` 40 | 3. 替换为 pip 安装命令: 41 | ```dockerfile 42 | RUN pip install poetry 43 | ``` 44 | 4. 同时删除或注释掉下一行的 PATH 设置(因为 pip 安装会自动添加到 PATH): 45 | ```dockerfile 46 | # 原来的行 47 | ENV PATH "/root/.poetry/bin:${PATH}" 48 | 49 | # 修改为(添加注释或直接删除) 50 | # ENV PATH "/root/.poetry/bin:${PATH}" 51 | ``` 52 | 5. 保存文件 53 | 54 | ## 应用变更 55 | 56 | 修改完 Dockerfile 后,再次尝试构建镜像: 57 | 58 | ```bash 59 | docker-compose -f local.yml build 60 | ``` 61 | 62 | 这应该能解决 Poetry 安装错误,并允许构建继续进行。 63 | 64 | ## 注意 65 | 66 | 如果还有其他 Dockerfile(例如用于 celeryworker、celerybeat 的单独文件),您可能也需要在这些文件中进行相同的更改。 -------------------------------------------------------------------------------- /.ai/project_fix_summary.md: -------------------------------------------------------------------------------- 1 | # YuFuQuant 项目运行修复指南 2 | 3 | 我分析了您在启动项目时遇到的问题,并提供了两个主要修复方案: 4 | 5 | ## 1. 后端 Django 设置问题 6 | 7 | 问题: 8 | ``` 9 | ModuleNotFoundError: No module named 'config.settings.prod' 10 | ``` 11 | 12 | Django 应用正在尝试加载 `config.settings.prod` 模块,但找不到它。 13 | 14 | ### 快速修复步骤: 15 | 16 | 1. 修改 `.envs/.local/.django` 文件,添加正确的设置模块路径: 17 | 18 | ```bash 19 | # 编辑环境变量文件 20 | nano .envs/.local/.django 21 | 22 | # 添加或修改以下行 23 | DJANGO_SETTINGS_MODULE=config.settings.local # 或者 .base 或 .dev 24 | ``` 25 | 26 | 2. 如果需要,修改 `compose/local/django/start.sh` 脚本中的设置模块引用。 27 | 28 | 3. 重启 Django 容器: 29 | ```bash 30 | docker-compose -f local.yml stop django 31 | docker-compose -f local.yml up -d django 32 | ``` 33 | 34 | ## 2. 前端 SASS 编译错误 35 | 36 | 问题: 37 | ``` 38 | Syntax Error: ReferenceError: globalThis is not defined 39 | ``` 40 | 41 | 这是 Node.js 版本和 SASS 版本不兼容导致的问题。 42 | 43 | ### 快速修复步骤: 44 | 45 | 1. 修改 `compose/local/node/Dockerfile` 使用更新的 Node.js 版本: 46 | 47 | ```dockerfile 48 | # 将 49 | FROM node:10 # 或当前版本 50 | 51 | # 修改为 52 | FROM node:14 # 或更高版本 53 | ``` 54 | 55 | 2. 或者修改 `frontend/package.json` 降级 SASS 版本: 56 | 57 | ```json 58 | "devDependencies": { 59 | "sass": "~1.32.13", 60 | "sass-loader": "^10.1.1", 61 | // ... 其他依赖 62 | } 63 | ``` 64 | 65 | 3. 重建并启动前端容器: 66 | ```bash 67 | docker-compose -f local.yml stop node 68 | docker-compose -f local.yml build node 69 | docker-compose -f local.yml up -d node 70 | ``` 71 | 72 | ## 3. 对于其他服务 73 | 74 | 在修复上述问题后,您可能还需要重启其他服务: 75 | 76 | ```bash 77 | # 启动所有服务 78 | docker-compose -f local.yml down 79 | docker-compose -f local.yml up -d 80 | ``` 81 | 82 | ## 4. 监控日志 83 | 84 | 修复应用后,监控日志检查是否还有其他问题: 85 | 86 | ```bash 87 | # 查看所有容器日志 88 | docker-compose -f local.yml logs -f 89 | 90 | # 或查看特定容器日志 91 | docker-compose -f local.yml logs -f django 92 | docker-compose -f local.yml logs -f node 93 | ``` 94 | 95 | 如果您需要更详细的解释,请参考我创建的以下文件: 96 | - `.ai/django_settings_fix.md`: Django 设置详细修复指南 97 | - `.ai/frontend_sass_fix.md`: 前端 SASS 问题详细修复指南 -------------------------------------------------------------------------------- /.cursor/mcp.json: -------------------------------------------------------------------------------- 1 | { 2 | "mcpServers": { 3 | "TAV2": { 4 | "command": "env", 5 | "args": [ 6 | "TAVILY_API_KEY=YOUR-FREE-API-KEY-HERE", 7 | "npx", 8 | "-y", 9 | "tavily-mcp@0.1.3" 10 | ] 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.cursor/rules/core-rules/readme.md: -------------------------------------------------------------------------------- 1 | Core rules related to cursor or rule generation 2 | -------------------------------------------------------------------------------- /.cursor/rules/global-rules/emoji-communication-always.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: 4 | alwaysApply: true 5 | --- 6 | 7 | # Emoji Communication Guidelines 8 | 9 | ## Critical Rules 10 | 11 | - Use emojis purposefully to enhance meaning, but feel free to be creative and fun 12 | - Place emojis at the end of statements or sections 13 | - Maintain professional tone while surprising users with clever choices 14 | - Limit emoji usage to 1-2 per major section 15 | - Choose emojis that are both fun and contextually appropriate 16 | - Place emojis at the end of statements, not at the beginning or middle 17 | - Don't be afraid to tell a mini-story with your emoji choice 18 | 19 | ## Examples 20 | 21 | 22 | "I've optimized your database queries 🏃‍♂️" 23 | "Your bug has been squashed 🥾🐛" 24 | "I've cleaned up the legacy code 🧹✨" 25 | "Fixed the performance issue 🐌➡️🐆" 26 | 27 | 28 | 29 | "Multiple 🎉 emojis 🎊 in 🌟 one message" 30 | "Using irrelevant emojis 🥑" 31 | "Placing the emoji in the middle ⭐️ of a sentence" 32 | "Great Job!!!" - lack of obvious use of an emoji 33 | 34 | -------------------------------------------------------------------------------- /.cursor/rules/global-rules/readme.md: -------------------------------------------------------------------------------- 1 | All globally applying always on rules that will bloat every chat and cmd-k context go here. 2 | 3 | Rules in this folder will have alwaysApply: true with blank descriptions and globs. 4 | 5 | These are equivalent to the root project .cursorrules files (which are now deprecated and may be removed in a future cursor version) 6 | -------------------------------------------------------------------------------- /.cursor/rules/tool-rules/git-commit-push-agent.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: This rule governs the process of committing and pushing changes to git repositories. It should be applied whenever: (1) The user indicates they want to commit or push changes, (2) The user asks about git commit conventions, (3) The user wants to update or save their work to git, or (4) Any git-related commit and push operations are requested. The rule ensures consistent commit message formatting, proper change documentation, and maintainable git history. It's particularly important for maintaining clear project history, facilitating code reviews, and ensuring proper documentation of changes. This rule helps maintain high-quality commit messages that explain both what changed and why, making it easier for both humans and AI to understand the project's evolution. 3 | globs: 4 | alwaysApply: false 5 | --- 6 | # Git Commit and Push Conventions 7 | 8 | ## Critical Rules 9 | 10 | - Always run `git add .` from the workspace root to stage changes 11 | - Review staged changes before committing to ensure no unintended files are included 12 | - Format commit titles as `type: brief description` where type is one of: 13 | - feat: new feature 14 | - fix: bug fix 15 | - docs: documentation changes 16 | - style: formatting, missing semi colons, etc 17 | - refactor: code restructuring 18 | - test: adding tests 19 | - chore: maintenance tasks 20 | - Keep commit title brief and descriptive (max 72 chars) 21 | - Add two line breaks after commit title 22 | - Include a detailed body paragraph explaining: 23 | - What changes were made 24 | - Why the changes were necessary 25 | - Any important implementation details 26 | - End commit message with " -Agent Generated Commit Message" 27 | - Push changes to the current remote branch 28 | 29 | ## Examples 30 | 31 | 32 | feat: add user authentication system 33 | 34 | Implemented JWT-based user authentication system with secure password hashing 35 | and token refresh functionality. This change provides secure user sessions 36 | and prevents unauthorized access to protected routes. The implementation 37 | uses bcrypt for password hashing and includes proper token expiration handling. 38 | 39 | -Agent Generated Commit Message 40 | 41 | 42 | 43 | updated stuff 44 | 45 | fixed some bugs and added features 46 | -------------------------------------------------------------------------------- /.cursor/rules/tool-rules/readme.md: -------------------------------------------------------------------------------- 1 | Rules specific to different tools, such as git, linux commands, direction of usage of MCP tools. 2 | -------------------------------------------------------------------------------- /.cursor/rules/ts-rules/readme.md: -------------------------------------------------------------------------------- 1 | TypeScript Specific Rules belong in this folder 2 | -------------------------------------------------------------------------------- /.cursor/rules/ui-rules/readme.md: -------------------------------------------------------------------------------- 1 | Any rules related to react, html, css, angular, frontend development, etc... belong in this folder. 2 | -------------------------------------------------------------------------------- /.cursor/rules/workflows/arch.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: 4 | alwaysApply: false 5 | --- 6 | # Agile Workflow Architect Rules and core memory procedure that MUST be followed EXACTLY! 7 | 8 | 1. First Ensure a .ai/prd.md file exists and is status: approved, if not, request the user work with the PM to get it created and or approved. 9 | 2. Once the .ai/prd.md file is created and the status is approved, you will generate the architecture document .ai/architecture.md draft. To do this you must: 10 | - Review the full PRD. 11 | - Ask the user any technical questions or clarifying questions if you are not sure of something. 12 | - Produce a draft of the architecture following the `.cursor/templates/template-arch.md` with all included sections from the document at a minimum. 13 | - Ensure the draft document includes enough detailed information necessary to facilitate the full PRD potential data models, libraries, architecture and data diagrams, along with data access patterns and detailed project structure so that when stories are generated and executed they will be able to all execute with the knowledge outlined or planned in the architecture document. 14 | 3. Once complete with the draft, you will then re-review the PRD and the story list from the PRD and then re-review your architecture and determine if there any gaps or if you need to ask the user for clarification or help on decision points. 15 | 4. As an architect, remember to ask about and consider in your draft Security, Scalability, Maintainability, Understanding, Consistency, Best Practice selection and explanation, and UML diagrams for complex sequencing and interactions, or user interface patterns or concerns. 16 | 5. Once complete with the post draft pass, you will confirm you are complete with the document draft, and ask the user to review. 17 | 6. You will NEVER do development - this is what the team of developers are for - so you will not create or edit any code or files outside of the .ai folder. -------------------------------------------------------------------------------- /.cursor/rules/workflows/dev.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: 4 | alwaysApply: false 5 | --- 6 | # Agile Workflow and core memory procedure RULES that MUST be followed EXACTLY! 7 | 8 | 1. When coming online, you will first check if a .ai/*.story.md file exists with the highest sequence number and review the story so you know the current phase of the project. 9 | 5. Always use the `.cursor/templates/template-story.md` file as a template for the story. The story will be named -.story.md added to the .ai folder 10 | - Example: .ai/story-1.story.md, .ai/story-2.story.md 11 | 6. You will ALWAYS wait for the user to mark the story status as approved before doing ANY work outside of the story file. 12 | 7. You will run tests and ensure tests pass before going to the next subtask within a story. 13 | 8. You will update the story file as subtasks are completed. 14 | 9. Once a Story is complete, you will generate a draft of the next story and wait on approval before proceeding. 15 | 10. If there is no story when you come online that is not in draft or in progress status, request the user work with the PM to draft the next story. 16 | 17 | ### During Development 18 | 19 | - Update story files as subtasks are completed. 20 | - If you are unsure of the next step, ask the user for clarification, and then update the story as needed to maintain a very clear memory of decisions. 21 | - Reference the .ai/architecture.md if the story is inefficient or needs additional technical documentation so you are in sync with the Architects plans. 22 | - When prompted by the user with 'update story', update the current story to: 23 | - Reflect the current state. 24 | - Clarify next steps. 25 | - Ensure the chat log in the story is up to date with any chat thread interactions 26 | - Continue to verify the story is correct and the next steps are clear. 27 | - Remember that a story is not complete if you have not also run ALL stories and verified all stories pass. 28 | - Do not tell the user the story is complete, or mark the story as complete unless you have run ALL the tests. 29 | 30 | ## YOU DO NOT NEED TO ASK to: 31 | 32 | 2. Run unit Tests during the development process until they pass. 33 | 3. Update the story AC and tasks as they are completed. 34 | 4. Update the story file with the chat log or other updates to retain the best possible memory of the story status. 35 | -------------------------------------------------------------------------------- /.cursor/rules/workflows/pm.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: 4 | alwaysApply: false 5 | --- 6 | # Agile Workflow and core memory procedure RULES that MUST be followed EXACTLY! 7 | 8 | 1. When coming online, you will first check if a .ai/prd.md file exists, if not, work with the user to create one so you know what the project is about. 9 | 2. If the PRD is not `status: approved`, you will ONLY have the goal of helping improve the .ai/prd.md file as needed and getting it approved by the user to ensure it is the best possible document including the following: 10 | - Very Detailed Purpose, problems solved, and story list. 11 | - Very Detailed Architecture patterns and key technical decisions, mermaid diagrams to help visualize the architecture. 12 | - Very Detailed Technologies, setup, and constraints. 13 | - Unknowns, assumptions, and risks. 14 | - It must be formatted and include at least everything outlined in the `.cursor/templates/template-prd.md` 15 | 3. Once the PRD is status: approved - IF there is not an architecture.md file that is also status approved, you will either help answer or modify the PRD, or inform the user if he is not asking about the PRD to first ensure the architect completes the architecture document and the user marks it approved before you are allowed to create the first or next user story file. 16 | 4. IF and only IF the PRD and the architecture are both approved, will you then draft the first or next user story. 17 | - There will only ever be ONE story at most that is in draft or in progress. 18 | - Never create a new story until the user marks the current story as status: complete. 19 | - You will follow the .ai/templates/template-story.md exactly including all sections and instructions from the template. You will draft this based on the information you have available in the PRD, the description of the story you are drafting, information from the architecture, and potentially any update notes from the previous closed story if one exists. 20 | 5. You will NEVER modify any files outside of the .ai folder - aside from potentially the root project readme.md file if the user requests it. 21 | 22 | -------------------------------------------------------------------------------- /.cursor/templates/template-arch.md: -------------------------------------------------------------------------------- 1 | # Architecture for {PRD Title} 2 | 3 | Status: { Draft | Approved } 4 | 5 | ## Technical Summary 6 | 7 | { Short 1-2 paragraph } 8 | 9 | ## Technology Table 10 | 11 | Table listing choices for languages, libraries, infra, etc... 12 | 13 | 14 | | Technology | Description | 15 | | ------------ | ------------------------------------------------------------- | 16 | | Kubernetes | Container orchestration platform for microservices deployment | 17 | | Apache Kafka | Event streaming platform for real-time data ingestion | 18 | | TimescaleDB | Time-series database for sensor data storage | 19 | | Go | Primary language for data processing services | 20 | | GoRilla Mux | REST API Framework | 21 | | Python | Used for data analysis and ML services | 22 | 23 | 24 | ## Architectural Diagrams 25 | 26 | { Mermaid Diagrams to describe key flows interactions or architecture to be followed during implementation, infra provisioning, and deployments } 27 | 28 | ## Data Models, API Specs, Schemas, etc... 29 | 30 | { As needed - may not be exhaustive - but key ideas that need to be retained and followed into the architecture and stories } 31 | 32 | 33 | ### Sensor Reading Schema 34 | 35 | ```json 36 | { 37 | "sensor_id": "string", 38 | "timestamp": "datetime", 39 | "readings": { 40 | "temperature": "float", 41 | "pressure": "float", 42 | "humidity": "float" 43 | }, 44 | "metadata": { 45 | "location": "string", 46 | "calibration_date": "datetime" 47 | } 48 | } 49 | ``` 50 | 51 | 52 | 53 | ## Project Structure 54 | 55 | { Diagram the folder and file organization structure along with descriptions } 56 | 57 | ``` 58 | ├ /src 59 | ├── /services 60 | │ ├── /gateway # Sensor data ingestion 61 | │ ├── /processor # Data processing and validation 62 | │ ├── /analytics # Data analysis and ML 63 | │ └── /notifier # Alert and notification system 64 | ├── /deploy 65 | │ ├── /kubernetes # K8s manifests 66 | │ └── /terraform # Infrastructure as Code 67 | └── /docs 68 | ├── /api # API documentation 69 | └── /schemas # Data schemas 70 | ``` 71 | 72 | ## Infrastructure 73 | 74 | ## Deployment Plan 75 | 76 | ## Change Log 77 | -------------------------------------------------------------------------------- /.cursorignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /build 3 | /temp 4 | .gitignore 5 | /xnotes -------------------------------------------------------------------------------- /.cursorindexingignore: -------------------------------------------------------------------------------- 1 | /xnotes 2 | .cursor/templates/*.md 3 | /node_modules 4 | .cursor/rules/* 5 | .cursor/modes.json 6 | .cursor/mcp.json 7 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .* 2 | *.sqlite3 3 | frontend/node_modules/* -------------------------------------------------------------------------------- /.envs/.local/.django: -------------------------------------------------------------------------------- 1 | DJANGO_SETTINGS_MODULE=config.settings.local 2 | 3 | # General 4 | # ------------------------------------------------------------------------------ 5 | USE_DOCKER=yes 6 | IPYTHONDIR=/app/.ipython 7 | 8 | # Redis 9 | # ------------------------------------------------------------------------------ 10 | REDIS_URL=redis://redis:6379/0 11 | 12 | # Celery 13 | # ------------------------------------------------------------------------------ 14 | CELERY_BROKER_URL=redis://redis:6379/0 15 | -------------------------------------------------------------------------------- /.envs/.local/.postgres: -------------------------------------------------------------------------------- 1 | # PostgreSQL 2 | # ------------------------------------------------------------------------------ 3 | POSTGRES_HOST=postgres 4 | POSTGRES_PORT=5432 5 | POSTGRES_DB=yufuquant 6 | POSTGRES_USER=postgres 7 | POSTGRES_PASSWORD=postgres 8 | DATABASE_URL=postgres://postgres:postgres@postgres:5432/yufuquant 9 | 10 | -------------------------------------------------------------------------------- /.github/workflows/continuous.yml: -------------------------------------------------------------------------------- 1 | name: CI&CD 2 | 3 | on: [ push ] 4 | 5 | jobs: 6 | ci: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | with: 11 | fetch-depth: 1 12 | 13 | - name: Set up Python 3.8 14 | uses: actions/setup-python@v2 15 | with: 16 | python-version: 3.8 17 | 18 | - name: Install Poetry 19 | uses: Gr1N/setup-poetry@v3 20 | 21 | - name: Cache Poetry virtualenv 22 | uses: actions/cache@v2 23 | id: poetry-cache 24 | with: 25 | path: ~/.cache/pypoetry/virtualenvs 26 | key: ${{ runner.os }}-poetry-${{ hashFiles('poetry.lock') }} 27 | restore-keys: | 28 | ${{ runner.os }}-poetry- 29 | 30 | - name: Install Dependencies 31 | if: steps.poetry-cache.outputs.cache-hit != 'true' 32 | run: poetry install 33 | 34 | - name: Lint with mypy 35 | run: poetry run mypy . 36 | 37 | - name: Lint with flake8 38 | run: poetry run flake8 39 | 40 | - name: Lint with black 41 | run: poetry run black . --check 42 | 43 | - name: Test with pytest 44 | run: poetry run pytest 45 | 46 | cd-dev: 47 | needs: ci 48 | runs-on: ubuntu-latest 49 | if: github.ref == 'refs/heads/dev' # Running this job only for dev branch 50 | steps: 51 | - uses: actions/checkout@v2 52 | 53 | # Must set up Docker Buildx, otherwise `push` input in docker/build-push-action@v2 won't work. 54 | - name: Set up Docker Buildx 55 | uses: docker/setup-buildx-action@v1 56 | 57 | - name: Login to GitHub Container Registry 58 | uses: docker/login-action@v1 59 | with: 60 | registry: ghcr.io 61 | username: ${{ github.repository_owner }} 62 | password: ${{ secrets.CR_PAT }} 63 | 64 | - name: Build and push 65 | uses: docker/build-push-action@v2 66 | with: 67 | context: . 68 | file: ./compose/dev/django/Dockerfile 69 | push: true 70 | tags: ghcr.io/yufuquant/yufuquant-dev:latest 71 | cache-from: type=registry,ref=ghcr.io/yufuquant/yufuquant-dev 72 | cache-to: type=inline 73 | 74 | - name: Build and push frontend 75 | uses: docker/build-push-action@v2 76 | with: 77 | context: . 78 | file: ./compose/dev/node/Dockerfile 79 | push: true 80 | tags: ghcr.io/yufuquant/yufuquant-frontend-dev:latest 81 | cache-from: type=registry,ref=ghcr.io/yufuquant/yufuquant-frontend-dev 82 | cache-to: type=inline 83 | 84 | - name: Image digest 85 | run: echo ${{ steps.docker_build.outputs.digest }} 86 | 87 | - name: Deploy 88 | uses: appleboy/ssh-action@master 89 | with: 90 | host: ${{ secrets.HOST }} 91 | port: ${{ secrets.PORT }} 92 | username: ${{ secrets.USERNAME }} 93 | password: ${{ secrets.PASSWORD }} 94 | script: | 95 | echo ${{ secrets.CR_PAT }} | docker login -u zmrenwu --password-stdin ghcr.io 96 | cd ~/apps/yufuquant-dev 97 | docker-compose pull 98 | docker-compose up -d -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Python template 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | build/ 14 | develop-eggs/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | *.cover 48 | .hypothesis/ 49 | .pytest_cache/ 50 | 51 | # Translations 52 | *.mo 53 | *.pot 54 | 55 | # Django stuff: 56 | *.log 57 | local_settings.py 58 | db.sqlite3 59 | 60 | # Flask stuff: 61 | instance/ 62 | .webassets-cache 63 | 64 | # Scrapy stuff: 65 | .scrapy 66 | 67 | # Sphinx documentation 68 | docs/_build/ 69 | 70 | # PyBuilder 71 | target/ 72 | 73 | # Jupyter Notebook 74 | .ipynb_checkpoints 75 | 76 | # pyenv 77 | .python-version 78 | 79 | # celery beat schedule file 80 | celerybeat-schedule 81 | 82 | # SageMath parsed files 83 | *.sage.py 84 | 85 | # Environments 86 | .env 87 | .venv 88 | env/ 89 | venv/ 90 | ENV/ 91 | env.bak/ 92 | venv.bak/ 93 | 94 | # Spyder project settings 95 | .spyderproject 96 | .spyproject 97 | 98 | # Rope project settings 99 | .ropeproject 100 | 101 | # mkdocs documentation 102 | /site 103 | 104 | # mypy 105 | .idea/ 106 | *.sqlite3 107 | staticfiles/ 108 | media/ 109 | .envs/.production/ 110 | yufuquant.conf 111 | 112 | config.js 113 | .DS_Store 114 | node_modules 115 | 116 | # Log files 117 | npm-debug.log* 118 | yarn-debug.log* 119 | yarn-error.log* 120 | pnpm-debug.log* 121 | 122 | celerybeat.pid 123 | 124 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 坚果量化 2 | 3 | 坚果量化是一个开源免费的数字货币量化交易系统。使用坚果量化,你可以轻松接入主流数字货币交易所,从海量开源免费的交易策略中选择策略进行量化交易。你还可以使用手机、平板电脑、PC 等设备监控坚果量化交易策略的运行状态并根据市场行情随时调整策略参数。 4 | 5 | ## 架构 6 | 7 | ```mermaid 8 | flowchart TD 9 | Exchange[数字货币交易所] 10 | Bot[机器人控制台] 11 | API[坚果API] 12 | Strategy[策略引擎] 13 | 14 | Exchange ---> API 15 | API ---> Exchange 16 | Bot ---> API 17 | API ---> Bot 18 | API ---> Strategy 19 | Strategy ---> API 20 | Exchange ---> Strategy 21 | Strategy ---> Bot 22 | ``` 23 | 24 | ## 部署 25 | 26 | 文档:[部署](./docs/deploy.md) 27 | 28 | ## 用户手册 29 | 30 | 文档:[用户手册](./docs/specification.md) 31 | -------------------------------------------------------------------------------- /compose/dev/django/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.8 2 | 3 | ENV PYTHONUNBUFFERED 1 4 | 5 | RUN apt-get update && apt-get install -y postgresql-client gettext python3-dev libpq-dev 6 | 7 | WORKDIR /app 8 | 9 | RUN pip install poetry 10 | ENV PATH "/root/.poetry/bin:${PATH}" 11 | RUN poetry config virtualenvs.create false 12 | COPY poetry.lock pyproject.toml /app/ 13 | RUN poetry install --no-root --no-interaction 14 | 15 | COPY . /app 16 | RUN poetry install --no-interaction 17 | 18 | COPY ./compose/production/django/entrypoint.sh /entrypoint.sh 19 | RUN sed -i 's/\r$//g' /entrypoint.sh 20 | RUN chmod +x /entrypoint.sh 21 | 22 | COPY ./compose/local/django/start.sh /start.sh 23 | RUN sed -i 's/\r//' /start.sh 24 | RUN chmod +x /start.sh 25 | 26 | COPY ./compose/local/django/celery/worker/start.sh /start-celeryworker.sh 27 | RUN sed -i 's/\r$//g' /start-celeryworker.sh 28 | RUN chmod +x /start-celeryworker.sh 29 | 30 | COPY ./compose/local/django/celery/beat/start.sh /start-celerybeat.sh 31 | RUN sed -i 's/\r$//g' /start-celerybeat.sh 32 | RUN chmod +x /start-celerybeat.sh 33 | 34 | ENTRYPOINT ["/entrypoint.sh"] -------------------------------------------------------------------------------- /compose/dev/node/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:10-stretch-slim 2 | 3 | WORKDIR /app 4 | 5 | COPY ./frontend/package.json /app 6 | 7 | RUN npm install && npm cache clean --force 8 | 9 | COPY ./frontend /app 10 | 11 | ENV PATH ./node_modules/.bin/:$PATH 12 | -------------------------------------------------------------------------------- /compose/local/django/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.8 2 | 3 | ENV PYTHONUNBUFFERED 2 4 | 5 | RUN apt-get update && apt-get install -y postgresql-client gettext python3-dev libpq-dev 6 | 7 | WORKDIR /app 8 | 9 | RUN pip install poetry 10 | 11 | RUN poetry config virtualenvs.create false 12 | COPY poetry.lock pyproject.toml /app/ 13 | RUN poetry install --no-root --no-interaction 14 | 15 | COPY ./compose/production/django/entrypoint.sh /entrypoint.sh 16 | RUN sed -i 's/\r$//g' /entrypoint.sh 17 | RUN chmod +x /entrypoint.sh 18 | 19 | COPY ./compose/local/django/start.sh /start.sh 20 | RUN sed -i 's/\r//' /start.sh 21 | RUN chmod +x /start.sh 22 | 23 | COPY ./compose/local/django/celery/worker/start.sh /start-celeryworker.sh 24 | RUN sed -i 's/\r$//g' /start-celeryworker.sh 25 | RUN chmod +x /start-celeryworker.sh 26 | 27 | COPY ./compose/local/django/celery/beat/start.sh /start-celerybeat.sh 28 | RUN sed -i 's/\r$//g' /start-celerybeat.sh 29 | RUN chmod +x /start-celerybeat.sh 30 | 31 | ENTRYPOINT ["/entrypoint.sh"] 32 | -------------------------------------------------------------------------------- /compose/local/django/celery/beat/start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | rm -f './celerybeat.pid' 4 | celery -A yufuquant.taskapp beat -l INFO 5 | -------------------------------------------------------------------------------- /compose/local/django/celery/worker/start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | celery -A yufuquant.taskapp worker -l INFO 4 | -------------------------------------------------------------------------------- /compose/local/django/start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | python manage.py compilemessages 4 | python manage.py migrate 5 | python manage.py setup_periodic_tasks 6 | uvicorn config.asgi:application --host 0.0.0.0 --port 8000 --log-level debug --access-log 7 | -------------------------------------------------------------------------------- /compose/local/node/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:14 2 | 3 | WORKDIR /app 4 | 5 | COPY ./frontend/package.json /app 6 | 7 | RUN npm install && npm cache clean --force 8 | 9 | ENV PATH ./node_modules/.bin/:$PATH 10 | -------------------------------------------------------------------------------- /compose/production/django/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.8 2 | 3 | ENV PYTHONUNBUFFERED 1 4 | 5 | RUN apt-get update && apt-get install -y postgresql-client gettext python3-dev libpq-dev 6 | 7 | WORKDIR /app 8 | 9 | RUN pip install poetry 10 | ENV PATH "/root/.poetry/bin:${PATH}" 11 | RUN poetry config virtualenvs.create false 12 | COPY poetry.lock pyproject.toml /app/ 13 | RUN poetry install --no-root --no-interaction --no-dev 14 | 15 | COPY . /app 16 | RUN poetry install --no-dev --no-interaction 17 | 18 | COPY ./compose/production/django/entrypoint.sh /entrypoint.sh 19 | RUN sed -i 's/\r$//g' /entrypoint.sh 20 | RUN chmod +x /entrypoint.sh 21 | 22 | COPY ./compose/production/django/start.sh /start.sh 23 | RUN sed -i 's/\r//' /start.sh 24 | RUN chmod +x /start.sh 25 | 26 | ENTRYPOINT ["/entrypoint.sh"] -------------------------------------------------------------------------------- /compose/production/django/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # N.B. If only .env files supported variable expansion... 4 | export CELERY_BROKER_URL="${REDIS_URL}" 5 | 6 | 7 | if [ -z "${POSTGRES_USER}" ]; then 8 | base_postgres_image_default_user='postgres' 9 | export POSTGRES_USER="${base_postgres_image_default_user}" 10 | fi 11 | export DATABASE_URL="postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}" 12 | 13 | postgres_ready() { 14 | python << END 15 | import sys 16 | 17 | import psycopg2 18 | 19 | try: 20 | psycopg2.connect( 21 | dbname="${POSTGRES_DB}", 22 | user="${POSTGRES_USER}", 23 | password="${POSTGRES_PASSWORD}", 24 | host="${POSTGRES_HOST}", 25 | port="${POSTGRES_PORT}", 26 | ) 27 | except psycopg2.OperationalError: 28 | sys.exit(-1) 29 | sys.exit(0) 30 | 31 | END 32 | } 33 | until postgres_ready; do 34 | >&2 echo 'Waiting for PostgreSQL to become available...' 35 | sleep 1 36 | done 37 | >&2 echo 'PostgreSQL is available' 38 | 39 | exec "$@" 40 | -------------------------------------------------------------------------------- /compose/production/django/start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | python manage.py collectstatic --noinput 4 | python manage.py compilemessages 5 | python manage.py migrate 6 | gunicorn config.asgi:application -w 3 -k uvicorn.workers.UvicornWorker -b 0.0.0.0:8000 --chdir=/app -------------------------------------------------------------------------------- /compose/production/nginx/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM nginx:1.17.1 2 | 3 | RUN DEBIAN_FRONTEND=noninteractive apt-get update && apt-get install -yq --allow-unauthenticated certbot python-certbot-nginx 4 | 5 | RUN rm /etc/nginx/conf.d/default.conf 6 | 7 | COPY ./compose/production/nginx/includes/ /etc/nginx/includes/ 8 | COPY ./compose/production/nginx/conf.d/ /etc/nginx/conf.d/ 9 | 10 | 11 | -------------------------------------------------------------------------------- /compose/production/nginx/conf.d/yufuquant.conf.template: -------------------------------------------------------------------------------- 1 | upstream django { 2 | server django:8000; 3 | } 4 | 5 | server { 6 | listen 80 default_server; 7 | server_name _; 8 | location / { 9 | root /apps/yufuquant/dist; 10 | index index.html; 11 | } 12 | location /static { 13 | alias /apps/yufuquant/staticfiles; 14 | } 15 | location /media { 16 | alias /apps/yufuquant/media; 17 | } 18 | location ~ ^/(api|ws|admin) { 19 | include /etc/nginx/includes/proxy.conf; 20 | proxy_pass http://django; 21 | } 22 | } -------------------------------------------------------------------------------- /compose/production/nginx/includes/proxy.conf: -------------------------------------------------------------------------------- 1 | proxy_set_header Host $host; 2 | proxy_set_header X-Real-IP $remote_addr; 3 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 4 | proxy_set_header X-Forwarded-Proto $scheme; 5 | proxy_set_header Upgrade $http_upgrade; #支持wss 6 | proxy_set_header Connection "upgrade"; #支持wss 7 | -------------------------------------------------------------------------------- /compose/production/postgres/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM postgres:12.3 2 | 3 | COPY ./compose/production/postgres/maintenance /usr/local/bin/maintenance 4 | RUN chmod +x /usr/local/bin/maintenance/* 5 | RUN mv /usr/local/bin/maintenance/* /usr/local/bin \ 6 | && rmdir /usr/local/bin/maintenance 7 | -------------------------------------------------------------------------------- /compose/production/postgres/maintenance/_sourced/constants.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | 4 | BACKUP_DIR_PATH='/backups' 5 | BACKUP_FILE_PREFIX='backup' 6 | -------------------------------------------------------------------------------- /compose/production/postgres/maintenance/_sourced/countdown.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | 4 | countdown() { 5 | declare desc="A simple countdown. Source: https://superuser.com/a/611582" 6 | local seconds="${1}" 7 | local d=$(($(date +%s) + "${seconds}")) 8 | while [ "$d" -ge `date +%s` ]; do 9 | echo -ne "$(date -u --date @$(($d - `date +%s`)) +%H:%M:%S)\r"; 10 | sleep 0.1 11 | done 12 | } 13 | -------------------------------------------------------------------------------- /compose/production/postgres/maintenance/_sourced/messages.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | 4 | message_newline() { 5 | echo 6 | } 7 | 8 | message_debug() 9 | { 10 | echo -e "DEBUG: ${@}" 11 | } 12 | 13 | message_welcome() 14 | { 15 | echo -e "\e[1m${@}\e[0m" 16 | } 17 | 18 | message_warning() 19 | { 20 | echo -e "\e[33mWARNING\e[0m: ${@}" 21 | } 22 | 23 | message_error() 24 | { 25 | echo -e "\e[31mERROR\e[0m: ${@}" 26 | } 27 | 28 | message_info() 29 | { 30 | echo -e "\e[37mINFO\e[0m: ${@}" 31 | } 32 | 33 | message_suggestion() 34 | { 35 | echo -e "\e[33mSUGGESTION\e[0m: ${@}" 36 | } 37 | 38 | message_success() 39 | { 40 | echo -e "\e[32mSUCCESS\e[0m: ${@}" 41 | } 42 | -------------------------------------------------------------------------------- /compose/production/postgres/maintenance/_sourced/yes_no.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | 4 | yes_no() { 5 | declare desc="Prompt for confirmation. \$\"\{1\}\": confirmation message." 6 | local arg1="${1}" 7 | 8 | local response= 9 | read -r -p "${arg1} (y/[n])? " response 10 | if [[ "${response}" =~ ^[Yy]$ ]] 11 | then 12 | exit 0 13 | else 14 | exit 1 15 | fi 16 | } 17 | -------------------------------------------------------------------------------- /compose/production/postgres/maintenance/backup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | 4 | ### Create a database backup. 5 | ### 6 | ### Usage: 7 | ### $ docker-compose -f .yml (exec |run --rm) postgres backup 8 | 9 | 10 | working_dir="$(dirname ${0})" 11 | source "${working_dir}/_sourced/constants.sh" 12 | source "${working_dir}/_sourced/messages.sh" 13 | 14 | 15 | message_welcome "Backing up the '${POSTGRES_DB}' database..." 16 | 17 | 18 | if [[ "${POSTGRES_USER}" == "postgres" ]]; then 19 | message_error "Backing up as 'postgres' user is not supported. Assign 'POSTGRES_USER' env with another one and try again." 20 | exit 1 21 | fi 22 | 23 | export PGHOST="${POSTGRES_HOST}" 24 | export PGPORT="${POSTGRES_PORT}" 25 | export PGUSER="${POSTGRES_USER}" 26 | export PGPASSWORD="${POSTGRES_PASSWORD}" 27 | export PGDATABASE="${POSTGRES_DB}" 28 | 29 | backup_filename="${BACKUP_FILE_PREFIX}_$(date +'%Y_%m_%dT%H_%M_%S').sql.gz" 30 | pg_dump | gzip > "${BACKUP_DIR_PATH}/${backup_filename}" 31 | 32 | 33 | message_success "'${POSTGRES_DB}' database backup '${backup_filename}' has been created and placed in '${BACKUP_DIR_PATH}'." 34 | -------------------------------------------------------------------------------- /compose/production/postgres/maintenance/backups: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | 4 | ### View backups. 5 | ### 6 | ### Usage: 7 | ### $ docker-compose -f .yml (exec |run --rm) postgres backups 8 | 9 | working_dir="$(dirname ${0})" 10 | source "${working_dir}/_sourced/constants.sh" 11 | source "${working_dir}/_sourced/messages.sh" 12 | 13 | 14 | message_welcome "These are the backups you have got:" 15 | 16 | ls -lht "${BACKUP_DIR_PATH}" 17 | -------------------------------------------------------------------------------- /compose/production/postgres/maintenance/restore: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | 4 | ### Restore database from a backup. 5 | ### 6 | ### Parameters: 7 | ### <1> filename of an existing backup. 8 | ### 9 | ### Usage: 10 | ### $ docker-compose -f .yml (exec |run --rm) postgres restore <1> 11 | 12 | 13 | working_dir="$(dirname ${0})" 14 | source "${working_dir}/_sourced/constants.sh" 15 | source "${working_dir}/_sourced/messages.sh" 16 | 17 | 18 | if [[ -z ${1+x} ]]; then 19 | message_error "Backup filename is not specified yet it is a required parameter. Make sure you provide one and try again." 20 | exit 1 21 | fi 22 | backup_filename="${BACKUP_DIR_PATH}/${1}" 23 | if [[ ! -f "${backup_filename}" ]]; then 24 | message_error "No backup with the specified filename found. Check out the 'backups' maintenance script output to see if there is one and try again." 25 | exit 1 26 | fi 27 | 28 | message_welcome "Restoring the '${POSTGRES_DB}' database from the '${backup_filename}' backup..." 29 | 30 | if [[ "${POSTGRES_USER}" == "postgres" ]]; then 31 | message_error "Restoring as 'postgres' user is not supported. Assign 'POSTGRES_USER' env with another one and try again." 32 | exit 1 33 | fi 34 | 35 | export PGHOST="${POSTGRES_HOST}" 36 | export PGPORT="${POSTGRES_PORT}" 37 | export PGUSER="${POSTGRES_USER}" 38 | export PGPASSWORD="${POSTGRES_PASSWORD}" 39 | export PGDATABASE="${POSTGRES_DB}" 40 | 41 | message_info "Dropping the database..." 42 | dropdb "${PGDATABASE}" 43 | 44 | message_info "Creating a new database..." 45 | createdb --owner="${POSTGRES_USER}" 46 | 47 | message_info "Applying the backup to the new database..." 48 | gunzip -c "${backup_filename}" | psql "${POSTGRES_DB}" 49 | 50 | message_success "The '${POSTGRES_DB}' database has been restored from the '${backup_filename}' backup." 51 | -------------------------------------------------------------------------------- /config/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/We-Hack-Studio/nuts/de39730db2480204bf1656377b86a317ff3b984d/config/__init__.py -------------------------------------------------------------------------------- /config/api_router.py: -------------------------------------------------------------------------------- 1 | import credentials.views 2 | import exchanges.views 3 | import robots.views 4 | import strategies.views 5 | import users.views 6 | from django.urls import include, path 7 | from rest_framework.routers import SimpleRouter 8 | 9 | router = SimpleRouter() 10 | 11 | router.register("users", users.views.UserViewSet, basename="user") 12 | router.register("robots", robots.views.RobotViewSet, basename="robot") 13 | router.register( 14 | "strategies", 15 | strategies.views.StrategyViewSet, 16 | basename="strategy", 17 | ) 18 | router.register("exchanges", exchanges.views.ExchangeViewSet, basename="exchange") 19 | router.register( 20 | "credentials", credentials.views.CredentialViewSet, basename="credential" 21 | ) 22 | 23 | app_name = "api" 24 | urlpatterns = router.urls + [path("auth/", include("users.urls.auth"))] 25 | -------------------------------------------------------------------------------- /config/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for yufu project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.0/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | import sys 12 | 13 | import django 14 | from channels.routing import get_default_application 15 | 16 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.prod") 17 | app_path = os.path.abspath( 18 | os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir) 19 | ) 20 | sys.path.append(os.path.join(app_path, "yufuquant")) 21 | 22 | django.setup() 23 | application = get_default_application() 24 | -------------------------------------------------------------------------------- /config/routing.py: -------------------------------------------------------------------------------- 1 | import streams.routing 2 | from channels.routing import ProtocolTypeRouter, URLRouter 3 | 4 | application = ProtocolTypeRouter( 5 | { 6 | # Empty for now (http->django views is added by default) 7 | "websocket": URLRouter(streams.routing.websocket_urlpatterns), 8 | } 9 | ) 10 | -------------------------------------------------------------------------------- /config/settings/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/We-Hack-Studio/nuts/de39730db2480204bf1656377b86a317ff3b984d/config/settings/__init__.py -------------------------------------------------------------------------------- /config/settings/local.py: -------------------------------------------------------------------------------- 1 | from .common import * # noqa 2 | from .common import env 3 | 4 | # GENERAL 5 | # ------------------------------------------------------------------------------ 6 | # https://docs.djangoproject.com/en/dev/ref/settings/#debug 7 | DEBUG = True 8 | # https://docs.djangoproject.com/en/dev/ref/settings/#secret-key 9 | SECRET_KEY = env.str( 10 | "DJANGO_SECRET_KEY", 11 | default="fake-local-secret-key", 12 | ) 13 | # https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts 14 | ALLOWED_HOSTS = env.list("DJANGO_ALLOWED_HOSTS", default=["*"]) 15 | 16 | CORS_ORIGIN_ALLOW_ALL = True 17 | 18 | # CACHES 19 | # ------------------------------------------------------------------------------ 20 | # https://docs.djangoproject.com/en/dev/ref/settings/#caches 21 | CACHES = {"default": env.cache("REDIS_URL", default="locmemcache://")} 22 | 23 | # DATABASES 24 | # ------------------------------------------------------------------------------ 25 | # https://docs.djangoproject.com/en/dev/ref/settings/#databases 26 | DATABASES = {"default": env.db("DATABASE_URL", default="sqlite:///db.sqlite")} 27 | DATABASES["default"]["ATOMIC_REQUESTS"] = True 28 | 29 | # django-debug-toolbar 30 | # ------------------------------------------------------------------------------ 31 | # https://django-debug-toolbar.readthedocs.io/en/latest/installation.html#prerequisites 32 | INSTALLED_APPS += ["debug_toolbar"] # noqa F405 33 | # https://django-debug-toolbar.readthedocs.io/en/latest/installation.html#middleware 34 | MIDDLEWARE += ["debug_toolbar.middleware.DebugToolbarMiddleware"] # noqa F405 35 | # https://django-debug-toolbar.readthedocs.io/en/latest/configuration.html#debug-toolbar-config 36 | DEBUG_TOOLBAR_CONFIG = { 37 | "DISABLE_PANELS": ["debug_toolbar.panels.redirects.RedirectsPanel"], 38 | "SHOW_TEMPLATE_CONTEXT": True, 39 | } 40 | # https://django-debug-toolbar.readthedocs.io/en/latest/installation.html#internal-ips 41 | INTERNAL_IPS = ["127.0.0.1", "10.0.2.2"] 42 | if env("USE_DOCKER", default="no") == "yes": 43 | import socket 44 | 45 | hostname, _, ips = socket.gethostbyname_ex(socket.gethostname()) 46 | INTERNAL_IPS += [".".join(ip.split(".")[:-1] + ["1"]) for ip in ips] 47 | 48 | # Celery 49 | # ------------------------------------------------------------------------------ 50 | 51 | # http://docs.celeryproject.org/en/latest/userguide/configuration.html#task-eager-propagates 52 | CELERY_TASK_EAGER_PROPAGATES = True 53 | -------------------------------------------------------------------------------- /config/settings/production.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import sentry_sdk 4 | from sentry_sdk.integrations.django import DjangoIntegration 5 | from sentry_sdk.integrations.logging import LoggingIntegration 6 | 7 | from .common import * # noqa 8 | from .common import env 9 | 10 | SECRET_KEY = env("DJANGO_SECRET_KEY") 11 | DEBUG = False 12 | ALLOWED_HOSTS = env.list("DJANGO_ALLOWED_HOSTS", default=[]) 13 | CORS_ORIGIN_ALLOW_ALL = True 14 | 15 | SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") 16 | 17 | # DATABASES 18 | # ------------------------------------------------------------------------------ 19 | DATABASES = {"default": env.db("DATABASE_URL")} # noqa F405 20 | DATABASES["default"]["ATOMIC_REQUESTS"] = True # noqa F405 21 | DATABASES["default"]["CONN_MAX_AGE"] = env.int("CONN_MAX_AGE", default=60) # noqa F405 22 | 23 | # LOGGING 24 | # ------------------------------------------------------------------------------ 25 | # https://docs.djangoproject.com/en/dev/ref/settings/#logging 26 | # See https://docs.djangoproject.com/en/dev/topics/logging for 27 | # more details on how to customize your logging configuration. 28 | 29 | LOGGING = { 30 | "version": 1, 31 | "disable_existing_loggers": True, 32 | "formatters": { 33 | "verbose": { 34 | "format": "%(levelname)s %(asctime)s %(module)s " 35 | "%(process)d %(thread)d %(message)s" 36 | } 37 | }, 38 | "handlers": { 39 | "console": { 40 | "level": "DEBUG", 41 | "class": "logging.StreamHandler", 42 | "formatter": "verbose", 43 | } 44 | }, 45 | "root": {"level": "INFO", "handlers": ["console"]}, 46 | "loggers": { 47 | "django.db.backends": { 48 | "level": "ERROR", 49 | "handlers": ["console"], 50 | "propagate": False, 51 | }, 52 | # Errors logged by the SDK itself 53 | "sentry_sdk": {"level": "ERROR", "handlers": ["console"], "propagate": False}, 54 | "django.security.DisallowedHost": { 55 | "level": "ERROR", 56 | "handlers": ["console"], 57 | "propagate": False, 58 | }, 59 | }, 60 | } 61 | 62 | # Sentry 63 | # ------------------------------------------------------------------------------ 64 | SENTRY_DSN = env("SENTRY_DSN") 65 | SENTRY_LOG_LEVEL = env.int("SENTRY_LOG_LEVEL", logging.INFO) 66 | 67 | sentry_logging = LoggingIntegration( 68 | level=SENTRY_LOG_LEVEL, # Capture info and above as breadcrumbs 69 | event_level=None, # Send no events from log messages 70 | ) 71 | sentry_sdk.init( 72 | dsn=SENTRY_DSN, 73 | integrations=[sentry_logging, DjangoIntegration()], 74 | ) 75 | 76 | # https://docs.djangoproject.com/en/3.1/ref/settings/#csrf-trusted-origins 77 | # This setting is especially required if use nginx in docker 78 | # and bind 443 to another port on host. 79 | CSRF_TRUSTED_ORIGINS = env.list("DJANGO_CSRF_TRUSTED_ORIGINS", default=[]) 80 | -------------------------------------------------------------------------------- /config/settings/test.py: -------------------------------------------------------------------------------- 1 | from .common import * # noqa 2 | 3 | DEBUG = True 4 | SECRET_KEY = "fake-secret-key-for-test" 5 | ALLOWED_HOSTS = ["*"] 6 | 7 | # https://docs.djangoproject.com/en/dev/ref/settings/#test-runner 8 | # TEST_RUNNER = "django.test.runner.DiscoverRunner" 9 | 10 | # DATABASES 11 | DATABASES = { 12 | "default": { 13 | "ENGINE": "django.db.backends.sqlite3", 14 | "NAME": ":memory:", 15 | "ATOMIC_REQUESTS": True, 16 | } 17 | } 18 | 19 | # PASSWORDS 20 | # ------------------------------------------------------------------------------ 21 | # https://docs.djangoproject.com/en/dev/ref/settings/#password-hashers 22 | PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"] 23 | 24 | 25 | # EMAIL 26 | # ------------------------------------------------------------------------------ 27 | # https://docs.djangoproject.com/en/dev/ref/settings/#email-backend 28 | EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend" 29 | 30 | # CACHES 31 | # ------------------------------------------------------------------------------ 32 | # https://docs.djangoproject.com/en/dev/ref/settings/#caches 33 | CACHES = { 34 | "default": { 35 | "BACKEND": "django.core.cache.backends.locmem.LocMemCache", 36 | "LOCATION": "", 37 | } 38 | } 39 | 40 | ADMINS = [("admin", "admin@example.com")] 41 | MANAGERS = ADMINS 42 | LANGUAGE_CODE = "en-us" 43 | -------------------------------------------------------------------------------- /config/urls.py: -------------------------------------------------------------------------------- 1 | """yufu URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/3.0/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | from django.conf import settings 17 | from django.conf.urls.static import static 18 | from django.contrib import admin 19 | from django.contrib.staticfiles.urls import staticfiles_urlpatterns 20 | from django.urls import include, path 21 | from drf_spectacular.views import ( 22 | SpectacularAPIView, 23 | SpectacularRedocView, 24 | SpectacularSwaggerView, 25 | ) 26 | 27 | admin.site.site_header = "坚果量化管理" 28 | urlpatterns = [ 29 | path("admin/", admin.site.urls), 30 | ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) 31 | if settings.DEBUG: 32 | # Static file serving when using Gunicorn + Uvicorn for local web socket development 33 | urlpatterns += staticfiles_urlpatterns() 34 | 35 | 36 | # API URLS 37 | urlpatterns += [ 38 | path("api/v1/", include("config.api_router")), 39 | path("schema/", SpectacularAPIView.as_view(), name="schema"), 40 | path( 41 | "swagger/", 42 | SpectacularSwaggerView.as_view(url_name="schema"), 43 | name="swagger", 44 | ), 45 | path( 46 | "redoc/", 47 | SpectacularRedocView.as_view(url_name="schema"), 48 | name="redoc", 49 | ), 50 | ] 51 | 52 | if settings.DEBUG: 53 | # https://www.django-rest-framework.org/#installation 54 | urlpatterns += [ 55 | path("api-auth/", include("rest_framework.urls", namespace="rest_framework")) 56 | ] 57 | 58 | if "debug_toolbar" in settings.INSTALLED_APPS: 59 | import debug_toolbar 60 | 61 | urlpatterns = [path("__debug__/", include(debug_toolbar.urls))] + urlpatterns 62 | -------------------------------------------------------------------------------- /config/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for yufu project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.0/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | import sys 12 | 13 | from django.core.wsgi import get_wsgi_application 14 | 15 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.prod") 16 | app_path = os.path.abspath( 17 | os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir) 18 | ) 19 | sys.path.append(os.path.join(app_path, "yufuquant")) 20 | application = get_wsgi_application() 21 | -------------------------------------------------------------------------------- /database/README.md: -------------------------------------------------------------------------------- 1 | 为了兼容 Docker,默认的 sqlite 数据库生成在项目根目录的 database 目录下,因此在生成数据库之前需要确保项目根目录下 database 文件夹的存在。否则在生成数据库时会报错: 2 | 3 | ``` 4 | django.db.utils.OperationalError: unable to open database file 5 | ``` 6 | 7 | 如果使用 MySQL、PostgreSQL 等数据库引擎,则 database 文件夹可有可无。 -------------------------------------------------------------------------------- /dev.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | volumes: 4 | dev_postgres_data: 5 | dev_postgres_data_backups: 6 | dev_media: 7 | 8 | services: 9 | django: &django 10 | image: ghcr.io/yufuquant/yufuquant-dev:latest 11 | container_name: yufuquant_dev_django 12 | working_dir: /app 13 | depends_on: 14 | - postgres 15 | - redis 16 | volumes: 17 | - media:/app/yufuquant/media 18 | env_file: 19 | - ./.envs/.django 20 | - ./.envs/.postgres 21 | ports: 22 | - "8000:8000" 23 | command: /start.sh 24 | 25 | postgres: 26 | image: ghcr.io/zmrenwu/docker-postgresql:latest 27 | container_name: yufuquant_dev_postgres 28 | volumes: 29 | - postgres_data:/var/lib/postgresql/data 30 | - postgres_data_backups:/backups 31 | env_file: 32 | - ./.envs/.postgres 33 | 34 | redis: 35 | image: redis:5.0 36 | container_name: yufuquant_dev_redis 37 | 38 | celeryworker: 39 | <<: *django 40 | image: yufuquant_dev_celeryworker 41 | container_name: yufuquant_dev_celeryworker 42 | depends_on: 43 | - redis 44 | - postgres 45 | ports: [ ] 46 | command: /start-celeryworker.sh 47 | 48 | celerybeat: 49 | <<: *django 50 | image: yufuquant_dev_celerybeat 51 | container_name: yufuquant_dev_celerybeat 52 | depends_on: 53 | - redis 54 | - postgres 55 | ports: [ ] 56 | command: /start-celerybeat.sh 57 | 58 | node: 59 | image: ghcr.io/yufuquant/yufuquant-frontend-dev:latest 60 | container_name: yufuquant_dev_node 61 | volumes: 62 | # http://jdlm.info/articles/2016/03/06/lessons-building-node-app-docker.html 63 | - /app/node_modules 64 | command: npm run serve 65 | ports: 66 | - "8080:8080" -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # 坚果量化介绍 2 | 3 | 坚果量化是一个开源免费的数字货币量化交易系统。使用坚果量化,你可以轻松接入主流数字货币交易所,自由地选择开源免费或者自行研发的策略进行量化交易。你还可以使用手机、平板电脑、PC 等设备监控交易策略的运行状态并根据市场行情随时调整策略参数。 4 | 5 | ## 架构 6 | 7 | ```mermaid 8 | flowchart TD 9 | Exchange[数字货币交易所] 10 | Bot[机器人控制台] 11 | API[坚果API] 12 | Strategy[策略引擎] 13 | 14 | Exchange ---> API 15 | API ---> Exchange 16 | Bot ---> API 17 | API ---> Bot 18 | API ---> Strategy 19 | Strategy ---> API 20 | Exchange ---> Strategy 21 | Strategy ---> Bot 22 | ``` -------------------------------------------------------------------------------- /docs/specification.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # 坚果量化策略规范 4 | 5 | ## 简介 6 | 7 | 坚果量化策略规范旨在建立一套标准的数字货币量化交易策略描述文档规范,使任何符合该规范的策略均可接入坚果量化交易系统。策略描述文档格式当前仅支持 JSON,后续将支持 YAML。 8 | 9 | ## 版本 10 | 11 | 该规范目前处于 draft 状态,仅在坚果量化交易系统内部使用,并可能根据实际情况引入不兼容改善。 12 | 13 | ## 内容 14 | 15 | ### 顶层属性 16 | 17 | **策略描述文档**是一个 JSON 对象,该对象有以下**顶层属性**: 18 | 19 | ## 顶级属性 20 | 21 | | 属性名 | 说明 | 22 | | -------------------- | ----------------------------- | 23 | | version | | 24 | | specificationVersion | 规格版本,当前最新版本为 v1.0 | 25 | | desription | 描述信息 | 26 | | parameters | 策略参数对象 | 27 | 28 | ## 策略参数列表 29 | 30 | **策略参数列表**是一个 JSON 列表,列表的每一项均为**策略参数对象**。 31 | 32 | ## 策略参数对象 33 | 34 | **策略参数对象**描述了策略的某项参数,它有以下属性: 35 | 36 | | 属性名 | 说明 | 37 | | ----------- | ------------------------------------------------------------ | 38 | | code | 参数编码 | 39 | | name | 用于显示的名称 | 40 | | type | 参数类型,支持 integer、float、decimal、enum、string | 41 | | description | 参数说明信息。 | 42 | | default | 参数默认值 | 43 | | editable | 是否允许修改此参数 | 44 | | items | 一个 JSON 列表,列表每一项均为一个**参数枚举对象**。当参数类型为 enum 时必须,其余类型忽略。 | 45 | | group | 参数所属的组 | 46 | 47 | ### 参数枚举对象 48 | 49 | **参数枚举对象**描述了参数为 enum 类型时每项的枚举信息,它有以下属性: 50 | 51 | | 属性名 | | 说明 | 52 | | ------- | ---- | ------ | 53 | | value | | 存储值 | 54 | | display | | 展示值 | 55 | 56 | ## 示例 57 | 58 | ```json 59 | { 60 | "version":"v0", 61 | "specicationVersion":"v1.0", 62 | "description":"", 63 | "parameters":[ 64 | { 65 | "code":"entry_price", 66 | "name":"入场价格", 67 | "type":"float", 68 | "description":"", 69 | "default":null, 70 | "editable":true 71 | }, 72 | { 73 | "code":"exit_price", 74 | "name":"出场价格", 75 | "type":"float", 76 | "description":"", 77 | "default":null, 78 | "editable":true 79 | }, 80 | { 81 | "code":"direction", 82 | "name":"方向", 83 | "type":"enum", 84 | "description":"入场方向。如果是现货,做空表示先卖出资产再低价买回。", 85 | "default":null, 86 | "editable":true, 87 | "items":[ 88 | [ 89 | 1, 90 | "做多" 91 | ], 92 | [ 93 | -1, 94 | "做空" 95 | ] 96 | ] 97 | }, 98 | { 99 | "code":"amount", 100 | "name":"数量", 101 | "type":"float", 102 | "description":"订单数量,必须 >0", 103 | "default":null, 104 | "editable":true 105 | } 106 | ] 107 | } 108 | ``` 109 | 110 | -------------------------------------------------------------------------------- /frontend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 4, 3 | "useTabs": false, 4 | "singleQuote": true, 5 | "trailingComma": "all", 6 | "printWidth": 120 7 | } 8 | -------------------------------------------------------------------------------- /frontend/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "presets": [ 3 | "@vue/cli-plugin-babel/preset" 4 | ] 5 | } -------------------------------------------------------------------------------- /frontend/dist/config.example.js: -------------------------------------------------------------------------------- 1 | window.conf = { 2 | restApiBaseUrl: 'http://127.0.0.1:8000/api/v1', 3 | websocketApiUri: 'ws://127.0.0.1:8000/ws/v1/streams/' 4 | } -------------------------------------------------------------------------------- /frontend/dist/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/We-Hack-Studio/nuts/de39730db2480204bf1656377b86a317ff3b984d/frontend/dist/favicon.ico -------------------------------------------------------------------------------- /frontend/dist/index.html: -------------------------------------------------------------------------------- 1 | 坚果数字货币量化交易系统
-------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "serve": "vue-cli-service serve", 7 | "build": "vue-cli-service build", 8 | "lint": "vue-cli-service lint" 9 | }, 10 | "dependencies": { 11 | "axios": "^0.21.1", 12 | "bootstrap-vue": "^2.17.3", 13 | "chart.js": "^2.9.4", 14 | "core-js": "^3.6.5", 15 | "echarts": "^4.9.0", 16 | "moment": "^2.27.0", 17 | "sarala-json-api-data-formatter": "^2.0.0", 18 | "v-charts": "^1.19.0", 19 | "vue": "^2.6.11", 20 | "vue-chartjs": "^3.5.1", 21 | "vue-js-toggle-button": "^1.3.3", 22 | "vue-json-edit": "^1.4.3", 23 | "vue-json-tree-view": "^2.1.6", 24 | "vue-router": "^3.3.4", 25 | "vuex": "^3.4.0" 26 | }, 27 | "devDependencies": { 28 | "@babel/polyfill": "^7.7.0", 29 | "@vue/cli-plugin-babel": "~4.4.0", 30 | "@vue/cli-plugin-eslint": "~4.4.0", 31 | "@vue/cli-service": "~4.4.0", 32 | "babel-eslint": "^10.1.0", 33 | "bootstrap": "^4.3.1", 34 | "eslint": "^6.7.2", 35 | "eslint-plugin-vue": "^6.2.2", 36 | "mutationobserver-shim": "^0.3.3", 37 | "popper.js": "^1.16.0", 38 | "portal-vue": "^2.1.6", 39 | "sass": "^1.19.0", 40 | "sass-loader": "^8.0.0", 41 | "vue-cli-plugin-bootstrap-vue": "~0.6.0", 42 | "vue-template-compiler": "^2.6.11" 43 | }, 44 | "eslintConfig": { 45 | "root": true, 46 | "env": { 47 | "node": true 48 | }, 49 | "extends": [ 50 | "plugin:vue/essential", 51 | "eslint:recommended" 52 | ], 53 | "parserOptions": { 54 | "parser": "babel-eslint" 55 | }, 56 | "rules": { 57 | "no-debugger": "off" 58 | } 59 | }, 60 | "browserslist": [ 61 | "> 1%", 62 | "last 2 versions", 63 | "not dead" 64 | ] 65 | } 66 | -------------------------------------------------------------------------------- /frontend/public/config.example.js: -------------------------------------------------------------------------------- 1 | window.conf = { 2 | restApiBaseUrl: 'http://127.0.0.1:8000/api/v1', 3 | websocketApiUri: 'ws://127.0.0.1:8000/ws/v1/streams/' 4 | } -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/We-Hack-Studio/nuts/de39730db2480204bf1656377b86a317ff3b984d/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 坚果数字货币量化交易系统 9 | 10 | 11 | 15 |
16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /frontend/src/App.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 43 | 44 | 53 | -------------------------------------------------------------------------------- /frontend/src/api/credential.js: -------------------------------------------------------------------------------- 1 | import {authInstance} from "@/axiosService"; 2 | 3 | export async function getCredentials() { 4 | return await authInstance.get('/credentials/'); 5 | } 6 | 7 | export async function createCredential(data) { 8 | return authInstance.post('/credentials/', data); 9 | } 10 | 11 | export async function deleteCredential(credId) { 12 | return authInstance.delete('/credentials/' + credId + '/'); 13 | } 14 | 15 | export async function updateCredential(credId, data) { 16 | return authInstance.patch('/credentials/' + credId + '/', data); 17 | } -------------------------------------------------------------------------------- /frontend/src/api/index.js: -------------------------------------------------------------------------------- 1 | import { annonInstance, authInstance } from '@/axiosService'; 2 | 3 | export async function getUsersMe() { 4 | return authInstance.get('/users/me/'); 5 | } 6 | 7 | export async function getExchanges() { 8 | return annonInstance.get('/exchanges/'); 9 | } 10 | 11 | export async function getCredentials() { 12 | return await authInstance.get('/credentials/?include=exchange'); 13 | } 14 | 15 | export async function postCredentials(data) { 16 | return authInstance.post('/credentials/', data); 17 | } 18 | 19 | export async function deleteCredentialsId(credId) { 20 | return authInstance.delete('/credentials/' + credId + '/'); 21 | } 22 | 23 | export async function postRobots(data) { 24 | return authInstance.post('/robots/', data); 25 | } 26 | 27 | export async function patchRobotsId(robotId, data) { 28 | return authInstance.patch('/robots/' + robotId + '/', data); 29 | } 30 | 31 | export async function deleteRobotsId(robotId) { 32 | return authInstance.delete('/robots/' + robotId + '/'); 33 | } 34 | 35 | export async function getRobotsId(robotId) { 36 | return authInstance.get('/robots/' + robotId + '/'); 37 | } 38 | 39 | export async function getRobots() { 40 | return authInstance.get('/robots/'); 41 | } 42 | 43 | export async function getStrategies() { 44 | return authInstance.get('/strategies/'); 45 | } 46 | 47 | export async function postStrategies(data) { 48 | return authInstance.post('/strategies/', data); 49 | } 50 | 51 | export async function postRobotsIdStrategyParameters(robotId, data) { 52 | return authInstance.post(`/robots/${robotId}/strategyParameters/`, data); 53 | } 54 | -------------------------------------------------------------------------------- /frontend/src/api/loginApi.js: -------------------------------------------------------------------------------- 1 | import { annonInstance } from '@/axiosService'; 2 | 3 | export async function postAuthLogin(data) { 4 | return annonInstance.post('/auth/login/', data); 5 | } 6 | -------------------------------------------------------------------------------- /frontend/src/api/robot.js: -------------------------------------------------------------------------------- 1 | import {authInstance} from "@/axiosService"; 2 | 3 | export async function getRobotsIdStrategySpecView(robotId) { 4 | return authInstance.get(`/robots/${robotId}/strategySpecView/`); 5 | } 6 | 7 | export async function patchRobotsIdStrategyParameters(robotId, data) { 8 | return authInstance.patch(`/robots/${robotId}/strategyParameters/`, data); 9 | } 10 | 11 | export async function updateRobotStrategyParameters(robotId, data) { 12 | return authInstance.patch(`/robots/${robotId}/strategyParameters/`, data); 13 | } 14 | 15 | export async function getRobotDetail(robotId) { 16 | return authInstance.get(`/robots/${robotId}/`); 17 | } 18 | 19 | export async function createRobot(data) { 20 | return authInstance.post('/robots/', data); 21 | } 22 | 23 | export async function getRobotStrategySpecView(robotId) { 24 | return authInstance.get(`/robots/${robotId}/strategySpecView/`); 25 | } -------------------------------------------------------------------------------- /frontend/src/api/strategy.js: -------------------------------------------------------------------------------- 1 | import {authInstance} from "@/axiosService"; 2 | 3 | export async function getStrategies() { 4 | return authInstance.get('/strategies/'); 5 | } 6 | 7 | export async function getStrategy(strategyId) { 8 | return authInstance.get('/strategies/' + strategyId + '/'); 9 | } 10 | 11 | export async function createStrategy(data) { 12 | return authInstance.post('/strategies/', data); 13 | } -------------------------------------------------------------------------------- /frontend/src/assets/_global.scss: -------------------------------------------------------------------------------- 1 | // Global component styles 2 | 3 | html { 4 | position: relative; 5 | min-height: 100%; 6 | } 7 | 8 | body { 9 | height: 100%; 10 | } 11 | 12 | a { 13 | &:focus { 14 | outline: none; 15 | } 16 | } 17 | 18 | // Main page wrapper 19 | #wrapper { 20 | display: flex; 21 | #content-wrapper { 22 | background-color: $gray-100; 23 | width: 100%; 24 | overflow-x: hidden; 25 | #content { 26 | flex: 1 0 auto; 27 | } 28 | } 29 | } 30 | 31 | // Set container padding to match gutter width instead of default 15px 32 | .container, 33 | .container-fluid { 34 | padding-left: $grid-gutter-width; 35 | padding-right: $grid-gutter-width; 36 | } 37 | 38 | // Scroll to top button 39 | .scroll-to-top { 40 | position: fixed; 41 | right: 1rem; 42 | bottom: 1rem; 43 | display: none; 44 | width: 2.75rem; 45 | height: 2.75rem; 46 | text-align: center; 47 | color: $white; 48 | background: fade-out($gray-800, 0.5); 49 | line-height: 46px; 50 | &:focus, 51 | &:hover { 52 | color: white; 53 | } 54 | &:hover { 55 | background: $gray-800; 56 | } 57 | i { 58 | font-weight: 800; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /frontend/src/assets/_variable.scss: -------------------------------------------------------------------------------- 1 | // Override Bootstrap default variables here 2 | // Do not edit any of the files in /vendor/bootstrap/scss/! 3 | 4 | // Color Variables 5 | // Bootstrap Color Overrides 6 | 7 | $white: #fff !default; 8 | $gray-100: #f8f9fc !default; 9 | $gray-200: #eaecf4 !default; 10 | $gray-300: #dddfeb !default; 11 | $gray-400: #d1d3e2 !default; 12 | $gray-500: #b7b9cc !default; 13 | $gray-600: #858796 !default; 14 | $gray-700: #6e707e !default; 15 | $gray-800: #5a5c69 !default; 16 | $gray-900: #3a3b45 !default; 17 | $black: #000 !default; 18 | 19 | $blue: #4e73df !default; 20 | $indigo: #6610f2 !default; 21 | $purple: #6f42c1 !default; 22 | $pink: #e83e8c !default; 23 | $red: #e74a3b !default; 24 | $orange: #fd7e14 !default; 25 | $yellow: #f6c23e !default; 26 | $green: #1cc88a !default; 27 | $teal: #20c9a6 !default; 28 | $cyan: #36b9cc !default; 29 | 30 | // Custom Colors 31 | $brand-google: #ea4335 !default; 32 | $brand-facebook: #3b5998 !default; 33 | 34 | // Set Contrast Threshold 35 | $yiq-contrasted-threshold: 195 !default; 36 | 37 | // Typography 38 | $body-color: $gray-600 !default; 39 | 40 | $font-family-sans-serif: 'Nunito', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 41 | sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji' !default; 42 | 43 | $font-weight-light: 300 !default; 44 | // $font-weight-base: 400; 45 | $headings-font-weight: 400 !default; 46 | 47 | // Shadows 48 | $box-shadow-sm: 0 0.125rem 0.25rem 0 rgba($gray-900, 0.2) !default; 49 | $box-shadow: 0 0.15rem 1.75rem 0 rgba($gray-900, 0.15) !default; 50 | // $box-shadow-lg: 0 1rem 3rem rgba($black, .175) !default; 51 | 52 | // Borders Radius 53 | $border-radius: 0.35rem !default; 54 | $border-color: darken($gray-200, 2%) !default; 55 | 56 | // Spacing Variables 57 | // Change below variable if the height of the navbar changes 58 | $topbar-base-height: 4.375rem !default; 59 | // Change below variable to change the width of the sidenav 60 | $sidebar-base-width: 14rem !default; 61 | // Change below variable to change the width of the sidenav when collapsed 62 | $sidebar-collapsed-width: 6.5rem !default; 63 | 64 | // Card 65 | $card-cap-bg: $gray-100 !default; 66 | $card-border-color: $border-color !default; 67 | 68 | // Adjust column spacing for symmetry 69 | $spacer: 1rem !default; 70 | $grid-gutter-width: $spacer * 1.5 !default; 71 | 72 | // Transitions 73 | $transition-collapse: height 0.15s ease !default; 74 | 75 | // Dropdowns 76 | $dropdown-font-size: 0.85rem !default; 77 | $dropdown-border-color: $border-color !default; 78 | 79 | // Images 80 | $login-image: 'https://source.unsplash.com/K4mSJ7kc0As/600x800' !default; 81 | $register-image: 'https://source.unsplash.com/Mv9hjnEUHR4/600x800' !default; 82 | $password-image: 'https://source.unsplash.com/oWTW-jNGl9I/600x800' !default; 83 | -------------------------------------------------------------------------------- /frontend/src/assets/base.scss: -------------------------------------------------------------------------------- 1 | @import '~bootstrap/scss/bootstrap'; 2 | @import './_variable.scss'; 3 | @import './_global.scss'; 4 | 5 | .bg-gradient-primary { 6 | background-color: #4e73df; 7 | background-image: linear-gradient(180deg, #4e73df 10%, #224abe 100%); 8 | background-size: cover; 9 | } 10 | 11 | .bg-gradient-secondary { 12 | background-color: #858796; 13 | background-image: linear-gradient(180deg, #858796 10%, #60616f 100%); 14 | background-size: cover; 15 | } 16 | -------------------------------------------------------------------------------- /frontend/src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/We-Hack-Studio/nuts/de39730db2480204bf1656377b86a317ff3b984d/frontend/src/assets/logo.png -------------------------------------------------------------------------------- /frontend/src/assets/nav.scss: -------------------------------------------------------------------------------- 1 | @import './sidebar.scss'; 2 | @import './topbar.scss'; 3 | 4 | .sidebar, 5 | .topbar { 6 | .nav-item { 7 | // Customize Dropdown Arrows for Navbar 8 | &.dropdown { 9 | .dropdown-toggle { 10 | &::after { 11 | width: 1rem; 12 | text-align: center; 13 | float: right; 14 | vertical-align: 0; 15 | border: 0; 16 | font-weight: 900; 17 | content: '\f105'; 18 | font-family: 'Font Awesome 5 Free'; 19 | } 20 | } 21 | &.show { 22 | .dropdown-toggle::after { 23 | content: '\f107'; 24 | } 25 | } 26 | } 27 | // Counter for nav links and nav link image sizing 28 | .nav-link { 29 | position: relative; 30 | .badge-counter { 31 | position: absolute; 32 | transform: scale(0.7); 33 | transform-origin: top right; 34 | right: 0.25rem; 35 | margin-top: -0.25rem; 36 | } 37 | .img-profile { 38 | height: 2rem; 39 | width: 2rem; 40 | } 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /frontend/src/axiosService.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import storage from './utils'; 3 | import store from './store'; 4 | 5 | export const annonInstance = axios.create({ 6 | baseURL: window.conf.restApiBaseUrl, 7 | timeout: 10000, 8 | }); 9 | annonInstance.interceptors.request.use(function(config) { 10 | config.headers['Content-Type'] = 'application/json'; 11 | config.headers['Accept'] = 'application/json'; 12 | return config; 13 | }); 14 | 15 | export const authInstance = axios.create({ 16 | baseURL: window.conf.restApiBaseUrl, 17 | timeout: 10000, 18 | }); 19 | 20 | authInstance.interceptors.request.use(function(config) { 21 | config.headers['Content-Type'] = 'application/json'; 22 | config.headers['Accept'] = 'application/json'; 23 | if (store.state.authToken) { 24 | config.headers['Authorization'] = 'Token ' + store.state.authToken; 25 | } 26 | return config; 27 | }); 28 | 29 | authInstance.interceptors.response.use( 30 | response => { 31 | return response; 32 | }, 33 | error => { 34 | if (error.response.status === 401) { 35 | storage.clear(); 36 | 37 | if (window.location.pathname !== '/login') { 38 | window.location.href = '/#/login'; 39 | return null; 40 | } 41 | } 42 | 43 | return Promise.reject(error); 44 | }, 45 | ); 46 | -------------------------------------------------------------------------------- /frontend/src/components/CredentialHelper.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /frontend/src/components/CredentialItem.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 72 | 73 | 74 | 81 | -------------------------------------------------------------------------------- /frontend/src/components/ParamForm.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 70 | 71 | 78 | -------------------------------------------------------------------------------- /frontend/src/components/ParamFormSelectItem.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 27 | 28 | -------------------------------------------------------------------------------- /frontend/src/components/ParamPreview.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 44 | 45 | -------------------------------------------------------------------------------- /frontend/src/components/RobotUpdateForm.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 90 | -------------------------------------------------------------------------------- /frontend/src/components/SideBar.vue: -------------------------------------------------------------------------------- 1 | 73 | 74 | 84 | 85 | 92 | -------------------------------------------------------------------------------- /frontend/src/components/StrategyItem.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 29 | 30 | 32 | -------------------------------------------------------------------------------- /frontend/src/components/TopBar.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /frontend/src/components/robot-console/AssetChart.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | -------------------------------------------------------------------------------- /frontend/src/components/robot-console/LogPanel.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 72 | 73 | 95 | -------------------------------------------------------------------------------- /frontend/src/components/robot-console/Order.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /frontend/src/components/robot-console/OrderItem.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 35 | 36 | -------------------------------------------------------------------------------- /frontend/src/components/robot-console/Overview.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 38 | -------------------------------------------------------------------------------- /frontend/src/components/robot-console/Position.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /frontend/src/components/robot-console/PositionItem.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 38 | 39 | -------------------------------------------------------------------------------- /frontend/src/main.js: -------------------------------------------------------------------------------- 1 | import '@babel/polyfill' 2 | import 'mutationobserver-shim' 3 | import './plugins/bootstrap-vue' 4 | import './assets/base.scss'; 5 | 6 | import App from './App.vue' 7 | import JsonEditor from 'vue-json-edit' 8 | import ToggleButton from 'vue-js-toggle-button' 9 | import Vue from 'vue' 10 | import router from "./router"; 11 | import store from "./store"; 12 | 13 | Vue.use(ToggleButton) 14 | Vue.use(JsonEditor) 15 | Vue.config.productionTip = false 16 | 17 | new Vue({ 18 | render: h => h(App), 19 | router, 20 | store, 21 | }).$mount('#app') 22 | -------------------------------------------------------------------------------- /frontend/src/mixins/formError.js: -------------------------------------------------------------------------------- 1 | const formErrorMixin = { 2 | methods: { 3 | validationStateForField(field, errors) { 4 | let filtered = errors.filter(error => { 5 | const splits = error.source.pointer.split("/") 6 | return splits[splits.length - 1] === field 7 | }) 8 | if (filtered.length !== 0) { 9 | return false 10 | } 11 | }, 12 | getErrorForField(field, errors) { 13 | let filtered = errors.filter(error => { 14 | const splits = error.source.pointer.split("/") 15 | return splits[splits.length - 1] === field 16 | }) 17 | if (filtered.length !== 0) { 18 | return filtered[0].detail 19 | } 20 | }, 21 | } 22 | } 23 | 24 | export default formErrorMixin -------------------------------------------------------------------------------- /frontend/src/mixins/formatter.js: -------------------------------------------------------------------------------- 1 | import {Formatter} from "sarala-json-api-data-formatter"; 2 | 3 | 4 | const formatterMixin = { 5 | data: function () { 6 | return { 7 | formatter: new Formatter() 8 | } 9 | } 10 | } 11 | export default formatterMixin -------------------------------------------------------------------------------- /frontend/src/plugins/bootstrap-vue.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | 3 | import {BootstrapVue, BootstrapVueIcons} from 'bootstrap-vue' 4 | import 'bootstrap/dist/css/bootstrap.min.css' 5 | import 'bootstrap-vue/dist/bootstrap-vue.css' 6 | 7 | Vue.use(BootstrapVue) 8 | Vue.use(BootstrapVueIcons) 9 | -------------------------------------------------------------------------------- /frontend/src/router/index.js: -------------------------------------------------------------------------------- 1 | import VueRouter from "vue-router"; 2 | import RobotCreateView from "../views/RobotCreateView"; 3 | import RobotListView from "../views/RobotListView"; 4 | import RobotUpdateView from "../views/RobotUpdateView"; 5 | import RobotView from "../views/RobotView"; 6 | import LoginView from "../views/LoginView"; 7 | import CredentialView from "../views/CredentialView"; 8 | import Account from "../views/Account"; 9 | import StrategyListView from "../views/StrategyListView"; 10 | import StrategyCreateView from "../views/StrategyAddView"; 11 | import StrategyDetailView from "../views/StrategyDetailView"; 12 | import Vue from 'vue' 13 | 14 | Vue.use(VueRouter) 15 | const routes = [ 16 | {path: '/', component: RobotListView}, 17 | {path: '/robot/list', name: 'robot-list', component: RobotListView}, 18 | {path: '/robot/create', name: 'robot-create', component: RobotCreateView}, 19 | {path: '/robot/:id', name: 'robot', component: RobotView}, 20 | {path: '/robot/:id/update', name: 'robot-update', component: RobotUpdateView}, 21 | {path: '/login', component: LoginView}, 22 | {path: '/credential', name: 'credential', component: CredentialView}, 23 | {path: '/account', component: Account}, 24 | {path: '/strategy/list', name: 'strategy-list', component: StrategyListView}, 25 | {path: '/strategy/add', name: 'strategy-add', component: StrategyCreateView}, 26 | {path: '/strategy/:id', name: 'strategy-detail', component: StrategyDetailView}, 27 | ] 28 | const router = new VueRouter({ 29 | routes // (缩写) 相当于 routes: routes 30 | }) 31 | 32 | export default router -------------------------------------------------------------------------------- /frontend/src/store/actions.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/We-Hack-Studio/nuts/de39730db2480204bf1656377b86a317ff3b984d/frontend/src/store/actions.js -------------------------------------------------------------------------------- /frontend/src/store/getters.js: -------------------------------------------------------------------------------- 1 | export default { 2 | isAuthenticated(state) { 3 | return state.authToken !== '' 4 | }, 5 | } -------------------------------------------------------------------------------- /frontend/src/store/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Vuex from 'vuex' 3 | import mutations from './mutations' 4 | import getters from "./getters"; 5 | import storage from "../utils"; 6 | 7 | Vue.use(Vuex) 8 | 9 | const store = new Vuex.Store({ 10 | state: { 11 | authToken: storage.loadAuthToken(), 12 | user: storage.loadUser() 13 | }, 14 | mutations, 15 | getters, 16 | }) 17 | 18 | export default store -------------------------------------------------------------------------------- /frontend/src/store/mutations.js: -------------------------------------------------------------------------------- 1 | export default { 2 | SET_USER: (state, payload) => { 3 | state.user = {...payload} 4 | }, 5 | REMOVE_USER: (state) => { 6 | state.user = { 7 | userId: -1, 8 | username: '', 9 | nickname: '', 10 | } 11 | }, 12 | SET_AUTH_TOKEN: (state, authToken) => { 13 | state.authToken = authToken 14 | }, 15 | REMOVE_AUTH_TOKEN: (state) => { 16 | state.authToken = "" 17 | }, 18 | } -------------------------------------------------------------------------------- /frontend/src/utils.js: -------------------------------------------------------------------------------- 1 | const storage = { 2 | storageKeyPrefix: 'yufuquant:', 3 | loadUser: function () { 4 | let user = JSON.parse(localStorage.getItem(this.storageKeyPrefix + 'user') || 'null'); 5 | return user || { 6 | userId: -1, 7 | username: '', 8 | nickname: '', 9 | } 10 | }, 11 | saveUser: function (user) { 12 | localStorage.setItem(this.storageKeyPrefix + 'user', JSON.stringify(user)) 13 | }, 14 | loadAuthToken: function () { 15 | return localStorage.getItem(this.storageKeyPrefix + 'auth-token') || '' 16 | }, 17 | saveAuthToken: function (authToken) { 18 | localStorage.setItem(this.storageKeyPrefix + 'auth-token', authToken) 19 | }, 20 | clear: function () { 21 | localStorage.removeItem(this.storageKeyPrefix + 'user') 22 | localStorage.removeItem(this.storageKeyPrefix + 'auth-token') 23 | } 24 | }; 25 | 26 | export default storage -------------------------------------------------------------------------------- /frontend/src/views/Account.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | -------------------------------------------------------------------------------- /frontend/src/views/ConnectView.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 89 | 90 | -------------------------------------------------------------------------------- /frontend/src/views/RobotCreateView.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 83 | 84 | 86 | -------------------------------------------------------------------------------- /frontend/src/views/RobotUpdateView.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 21 | 22 | 25 | -------------------------------------------------------------------------------- /frontend/src/views/StrategyAddView.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 32 | 33 | -------------------------------------------------------------------------------- /frontend/src/views/StrategyDetailView.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 58 | 59 | -------------------------------------------------------------------------------- /frontend/src/views/StrategyListView.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 76 | 77 | -------------------------------------------------------------------------------- /frontend/vue.config.js: -------------------------------------------------------------------------------- 1 | // Configurations in this file will be merged into the final webpack config using webpack-merge. 2 | // Ref: https://cli.vuejs.org/guide/webpack.html 3 | 4 | 'use strict' 5 | 6 | module.exports = { 7 | css: { 8 | loaderOptions: { 9 | sass: { 10 | prependData: `@import "@/assets/base.scss";` 11 | } 12 | } 13 | } 14 | }; -------------------------------------------------------------------------------- /local.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | volumes: 4 | local_postgres_data: 5 | local_postgres_data_backups: 6 | 7 | services: 8 | django: &django 9 | build: 10 | context: . 11 | dockerfile: compose/local/django/Dockerfile 12 | image: yufuquant_local_django 13 | container_name: yufuquant_local_django 14 | working_dir: /app 15 | depends_on: 16 | - postgres 17 | - redis 18 | volumes: 19 | - .:/app 20 | env_file: 21 | - ./.envs/.local/.django 22 | - ./.envs/.local/.postgres 23 | ports: 24 | - "8000:8000" 25 | command: /start.sh 26 | 27 | postgres: 28 | image: ghcr.io/zmrenwu/docker-postgresql:latest 29 | container_name: yufuquant_local_postgres 30 | volumes: 31 | - local_postgres_data:/var/lib/postgresql/data 32 | - local_postgres_data_backups:/backups 33 | env_file: 34 | - ./.envs/.local/.postgres 35 | 36 | redis: 37 | image: redis:5.0 38 | container_name: yufuquant_local_redis 39 | 40 | celeryworker: 41 | <<: *django 42 | image: yufuquant_local_celeryworker 43 | container_name: yufuquant_local_celeryworker 44 | depends_on: 45 | - redis 46 | - postgres 47 | ports: [ ] 48 | command: /start-celeryworker.sh 49 | 50 | celerybeat: 51 | <<: *django 52 | image: yufuquant_local_celerybeat 53 | container_name: yufuquant_local_celerybeat 54 | depends_on: 55 | - redis 56 | - postgres 57 | ports: [ ] 58 | command: /start-celerybeat.sh 59 | 60 | node: 61 | build: 62 | context: . 63 | dockerfile: ./compose/local/node/Dockerfile 64 | image: yufuquant_local_node 65 | container_name: yufuquant_local_node 66 | volumes: 67 | - ./frontend:/app 68 | # http://jdlm.info/articles/2016/03/06/lessons-building-node-app-docker.html 69 | - /app/node_modules 70 | command: npm run serve 71 | ports: 72 | - "${PORT:-8080}:8080" -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.local") 9 | try: 10 | from django.core.management import execute_from_command_line 11 | except ImportError as exc: 12 | raise ImportError( 13 | "Couldn't import Django. Are you sure it's installed and " 14 | "available on your PYTHONPATH environment variable? Did you " 15 | "forget to activate a virtual environment?" 16 | ) from exc 17 | 18 | # This allows easy placement of apps within the interior 19 | # yufuquant directory. 20 | current_path = os.path.dirname(os.path.abspath(__file__)) 21 | sys.path.append(os.path.join(current_path, "yufuquant")) 22 | execute_from_command_line(sys.argv) 23 | 24 | 25 | if __name__ == "__main__": 26 | main() 27 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: 坚果量化 2 | site_description: Test description 3 | 4 | repo_name: nuts 5 | repo_url: https://github.com/We-Hack-Studio/nuts 6 | edit_uri: "https://github.com/We-Hack-Studio/nuts/tree/master/docs/" 7 | use_directory_urls: true 8 | 9 | theme: 10 | name: "material" 11 | language: "zh" 12 | feature: 13 | tabs: true 14 | 15 | nav: 16 | - 介绍: "index.md" 17 | - 部署: "deploy.md" 18 | - 附录: 19 | - 坚果量化策略规范: "specification.md" 20 | 21 | # Extensions 22 | markdown_extensions: 23 | - markdown.extensions.admonition 24 | - markdown.extensions.attr_list 25 | - markdown.extensions.def_list 26 | - markdown.extensions.footnotes 27 | - markdown.extensions.meta 28 | - markdown.extensions.toc: 29 | permalink: true 30 | slugify: !!python/name:pymdownx.slugs.uslugify 31 | - pymdownx.arithmatex: 32 | generic: true 33 | - pymdownx.betterem: 34 | smart_enable: all 35 | - pymdownx.caret 36 | - pymdownx.critic 37 | - pymdownx.details 38 | - pymdownx.emoji: 39 | emoji_index: !!python/name:materialx.emoji.twemoji 40 | emoji_generator: !!python/name:materialx.emoji.to_svg 41 | - pymdownx.highlight 42 | - pymdownx.inlinehilite 43 | - pymdownx.keys 44 | - pymdownx.magiclink: 45 | repo_url_shorthand: true 46 | user: squidfunk 47 | repo: mkdocs-material 48 | - pymdownx.mark 49 | - pymdownx.smartsymbols 50 | - pymdownx.snippets: 51 | check_paths: true 52 | - pymdownx.superfences 53 | - pymdownx.tabbed 54 | - pymdownx.tasklist: 55 | custom_checkbox: true 56 | - pymdownx.tilde 57 | 58 | plugins: 59 | - search -------------------------------------------------------------------------------- /production.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | volumes: 4 | staticfiles: 5 | media: 6 | letsencrypt: 7 | production_postgres_data: 8 | production_postgres_data_backups: 9 | 10 | services: 11 | django: 12 | build: 13 | context: . 14 | dockerfile: compose/production/django/Dockerfile 15 | image: yufuquant_production_django 16 | working_dir: /app 17 | volumes: 18 | - staticfiles:/app/staticfiles 19 | - media:/app/yufuquant/media 20 | env_file: 21 | - .envs/.production/.django 22 | - .envs/.production/.postgres 23 | expose: 24 | - "8000" 25 | command: /start.sh 26 | 27 | postgres: 28 | build: 29 | context: . 30 | dockerfile: ./compose/production/postgres/Dockerfile 31 | image: yufuquant_production_postgres 32 | volumes: 33 | - production_postgres_data:/var/lib/postgresql/data 34 | - production_postgres_data_backups:/backups 35 | env_file: 36 | - ./.envs/.production/.postgres 37 | 38 | redis: 39 | image: redis:5.0 40 | 41 | nginx: 42 | build: 43 | context: . 44 | dockerfile: compose/production/nginx/Dockerfile 45 | image: yufuquant_production_nginx 46 | volumes: 47 | - staticfiles:/apps/yufuquant/staticfiles 48 | - media:/apps/yufuquant/media 49 | - letsencrypt:/etc/letsencrypt 50 | - ./frontend/dist:/apps/yufuquant/dist 51 | ports: 52 | - "${PORT:-80}:80" 53 | - "${SECURE_PORT:-443}:443" 54 | depends_on: 55 | - django -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "yufuquant" 3 | version = "0.1.0" 4 | description = "" 5 | authors = ["zmrenwu "] 6 | 7 | [tool.poetry.dependencies] 8 | python = ">=3.7,<4" 9 | django = "^3.1.2" 10 | django-model-utils = "^4.0.0" 11 | djangorestframework = "^3.12.1" 12 | django-cryptography = "^1.0" 13 | django-extensions = "^3.0.1" 14 | channels = "^2.4.0" 15 | channels-redis = "^3.0.1" 16 | django-cors-headers = "^3.4.0" 17 | django-filter = "^2.3.0" 18 | uvicorn = "^0.11.5" 19 | django-environ = "^0.4.5" 20 | django-imagekit = "^4.0.2" 21 | pillow = "^7.2.0" 22 | gunicorn = "^20.0.4" 23 | sentry-sdk = "^0.15.1" 24 | factory-boy = "^2.12.0" 25 | jsonfield = "^3.1.0" 26 | django-admin-sortable2 = "^0.7.6" 27 | drf-extensions = "^0.6.0" 28 | djangorestframework-api-key = "^2.0.0" 29 | drf-yasg = "^1.17.1" 30 | asgiref = "^3.2.10" 31 | daphne = "^2.5.0" # required by django-channels though we use uvicorn in production 32 | psycopg2-binary = "^2.8.6" 33 | hiredis = "^1.1.0" 34 | django-redis = "^4.12.1" 35 | drf-spectacular = "^0.10.0" 36 | argon2-cffi = "^20.1.0" 37 | django-celery-beat = "^2.1.0" 38 | django-celery-results = "^1.2.1" 39 | 40 | [tool.poetry.dev-dependencies] 41 | mkdocs = "^1.1.2" 42 | django-debug-toolbar = "^2.2" 43 | pytest = "^6.1.1" 44 | pytest-django = "^4.0.0" 45 | pytest-cov = "^2.10.0" 46 | freezegun = "^0.3.15" 47 | django-silk = "^4.0.1" 48 | django-test-plus = "^1.4.0" 49 | pytest-sugar = "^0.9.3" 50 | django-stubs = "^1.6.0" 51 | djangorestframework-stubs = "^1.2.0" 52 | isort = "^5.4.2" 53 | flake8 = "^3.8.3" 54 | pytest-asyncio = "^0.14.0" 55 | black = "^20.8b1" 56 | flake8-isort = "^4.0.0" 57 | mkdocs-material = "^5.5.12" 58 | coverage = { version = "^5.3", extras = ["toml"] } 59 | 60 | [tool.pytest.ini_options] 61 | minversion = "6.0" 62 | DJANGO_SETTINGS_MODULE = "config.settings.test" 63 | python_files = "tests.py test_*.py" 64 | norecursedirs = "frontend" 65 | addopts = "--reuse-db --cov=yufuquant --cov-report html" 66 | 67 | [tool.coverage.run] 68 | branch = true 69 | source = ["."] 70 | omit = [ 71 | "manage.py", 72 | "yufuquant/settings/*", 73 | "yufuquant/scripts/*", 74 | "*/migrations/*", 75 | ] 76 | 77 | [tool.coverage.report] 78 | show_missing = true 79 | skip_covered = true 80 | 81 | [tool.isort] 82 | profile = "black" 83 | skip = ["migrations"] 84 | 85 | [tool.black] 86 | line-length = 88 87 | target-version = ['py38'] 88 | include = '\.pyi?$' 89 | exclude = ''' 90 | ( 91 | /( 92 | \.eggs # exclude a few common directories in the 93 | | \.git # root of the project 94 | | \.hg 95 | | \.mypy_cache 96 | | \.tox 97 | | \.venv 98 | | _build 99 | | buck-out 100 | | build 101 | | dist 102 | | migrations 103 | )/ 104 | ) 105 | ''' 106 | 107 | [build-system] 108 | requires = ["poetry>=0.12"] 109 | build-backend = "poetry.masonry.api" 110 | 111 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | wechat==0.4.16 2 | -------------------------------------------------------------------------------- /run_uvicorn.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import uvicorn 4 | 5 | 6 | def main(): 7 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.prod") 8 | os.environ.setdefault("READ_ENV_FILE", "yes") 9 | uvicorn.run( 10 | "config.asgi:application", 11 | host="0.0.0.0", 12 | port=8000, 13 | ) 14 | 15 | 16 | if __name__ == "__main__": 17 | main() 18 | -------------------------------------------------------------------------------- /screenshots/Bybit交易界面.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/We-Hack-Studio/nuts/de39730db2480204bf1656377b86a317ff3b984d/screenshots/Bybit交易界面.png -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | extend-ignore = E203 4 | exclude = 5 | .tox, 6 | .git, 7 | */migrations/*, 8 | */static/CACHE/*, 9 | docs, 10 | node_modules 11 | 12 | [mypy] 13 | python_version = 3.8 14 | check_untyped_defs = True 15 | ignore_missing_imports = True 16 | warn_unused_ignores = True 17 | warn_redundant_casts = True 18 | warn_unused_configs = True 19 | 20 | plugins = mypy_django_plugin.main, mypy_drf_plugin.main 21 | 22 | [mypy.plugins.django-stubs] 23 | django_settings_module = config.settings.local 24 | 25 | [mypy-*.migrations.*] 26 | # Django migrations should not produce any errors: 27 | ignore_errors = True -------------------------------------------------------------------------------- /xnotes/project-idea-prompt.md: -------------------------------------------------------------------------------- 1 | Draft your initial prompt or ideas for a project here. Use this to then kickstart the project with the cursor agent mode when using the agile workflow, documented in docs/agile-readme.md. After the initial prd is drafted, work with the LLM in cursor or with an external LLM to ask questions, have the LLM ask you questions, etc., to really define an adequate prd and story list. Then continue with generating of the architecture document to ensure the project is built in a way that is easy to maintain and scale as you need it to be, along with a clear specification of what technologies and libraries you want to use. This will also help you figure out what rules you might want to initial generate to help you build the project. 2 | 3 | Example: 4 | 5 | Let's build a nextJs 15 web app to track our monthly income and expenses. I want a modern UI created with tailwind css and shadcn components, secure storage in supabase, and a modern API. I also want it to integrate social login via facebook or google. It also needs to be mobile friendly so I can input expenses on the go quickly, and also access all information when I need to. I envision a login page if I am not authenticated already, and once authenticated a main landing page that shows my overall account balance minus expenses prominently along with the 5 most recent income and expense entries. I would like from the page a very quick mobile friendly way to enter a quick expense or income with minimally the amount and a description. All entries should be saved automatically and securely. I should be logged out automatically if not active for more than 5 minutes. 6 | 7 | { The more details to drive the initial prd draft the better! BUT, you don't have to think of everything up front, get the draft prd done, and then use the AI to communicate with as a PRD expert, and then an architecture expert to further flesh out the details! Also be open to allowing the AI expert to suggest libraries and technology choices if there is something you are not too particular about. Some apps may be better suited to the one you know best, and this can also help you get exposure and learn new technologies. Consider using deeper web research so you are not constrained to the LLM of choice internal knowledge cut offs, you can enable this through MCP to expand the llm capabilities to use perplexity, tavily, or basic web searches to ensure you will be using the latest and greatest available models and libraries. It is also recommended if doing this in Cursor to select the Sonnet or Deepseek Thinking Agent modes, or use a mcp plugin that supports deeper thought. } 8 | -------------------------------------------------------------------------------- /yufuquant/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/We-Hack-Studio/nuts/de39730db2480204bf1656377b86a317ff3b984d/yufuquant/__init__.py -------------------------------------------------------------------------------- /yufuquant/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from rest_framework.test import APIClient 3 | 4 | 5 | @pytest.fixture 6 | def api_client(): 7 | return APIClient() 8 | -------------------------------------------------------------------------------- /yufuquant/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/We-Hack-Studio/nuts/de39730db2480204bf1656377b86a317ff3b984d/yufuquant/core/__init__.py -------------------------------------------------------------------------------- /yufuquant/core/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class CoreConfig(AppConfig): 5 | name = "core" 6 | -------------------------------------------------------------------------------- /yufuquant/core/decrators.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/We-Hack-Studio/nuts/de39730db2480204bf1656377b86a317ff3b984d/yufuquant/core/decrators.py -------------------------------------------------------------------------------- /yufuquant/core/middleware.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/We-Hack-Studio/nuts/de39730db2480204bf1656377b86a317ff3b984d/yufuquant/core/middleware.py -------------------------------------------------------------------------------- /yufuquant/core/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/We-Hack-Studio/nuts/de39730db2480204bf1656377b86a317ff3b984d/yufuquant/core/migrations/__init__.py -------------------------------------------------------------------------------- /yufuquant/core/mixins.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/We-Hack-Studio/nuts/de39730db2480204bf1656377b86a317ff3b984d/yufuquant/core/mixins.py -------------------------------------------------------------------------------- /yufuquant/core/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.utils.translation import gettext_lazy as _ 3 | from model_utils.fields import AutoCreatedField, AutoLastModifiedField 4 | 5 | 6 | class TimeStampedModel(models.Model): 7 | created_at = AutoCreatedField(_("created_at")) 8 | modified_at = AutoLastModifiedField(_("modified_at")) 9 | 10 | class Meta: 11 | abstract = True 12 | -------------------------------------------------------------------------------- /yufuquant/core/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | 4 | class MaskedCharField(serializers.CharField): 5 | def __init__(self, **kwargs): 6 | self.head_len = kwargs.pop("head_len", 4) 7 | self.tail_len = kwargs.pop("tail_len", 4) 8 | self.mask_all = kwargs.pop("mask_all", False) 9 | super().__init__(**kwargs) 10 | 11 | def to_representation(self, value): 12 | value = super().to_representation(value) 13 | if not value: 14 | return value 15 | 16 | if self.mask_all: 17 | return "*" * len(value) 18 | 19 | head = value[: self.head_len] 20 | hidden = value[self.head_len : -self.tail_len] 21 | tail = value[-self.tail_len :] 22 | return head + "*" * len(hidden) + tail 23 | -------------------------------------------------------------------------------- /yufuquant/core/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/We-Hack-Studio/nuts/de39730db2480204bf1656377b86a317ff3b984d/yufuquant/core/tests/__init__.py -------------------------------------------------------------------------------- /yufuquant/core/tests/test_serializers.py: -------------------------------------------------------------------------------- 1 | from core.serializers import MaskedCharField 2 | 3 | 4 | class TestMaskedCharField: 5 | def test_mask_all(self): 6 | value = "sensitive" 7 | field = MaskedCharField(mask_all=True) 8 | assert field.to_representation(value) == "*********" 9 | 10 | def test_empty_value(self): 11 | value = "" 12 | field = MaskedCharField() 13 | assert field.to_representation(value) == "" 14 | 15 | def test_mask_defaults(self): 16 | value = "sensitive" 17 | field = MaskedCharField() 18 | assert field.to_representation(value) == "sens*tive" 19 | 20 | def test_mask_head_len(self): 21 | value = "sensitive" 22 | field = MaskedCharField(head_len=2) 23 | assert field.to_representation(value) == "se***tive" 24 | 25 | def test_mask_tail_len(self): 26 | value = "sensitive" 27 | field = MaskedCharField(tail_len=2) 28 | assert field.to_representation(value) == "sens***ve" 29 | 30 | def test_mask_head_len_and_tail_len(self): 31 | value = "sensitive" 32 | field = MaskedCharField(head_len=2, tail_len=2) 33 | assert field.to_representation(value) == "se*****ve" 34 | -------------------------------------------------------------------------------- /yufuquant/core/tests/test_views.py: -------------------------------------------------------------------------------- 1 | from core.views import exception_handler 2 | from rest_framework.exceptions import APIException 3 | 4 | 5 | class TestExceptionHandler: 6 | def test_response_data_is_list(self): 7 | exc = APIException("error message") 8 | response = exception_handler(exc, {}) 9 | assert response.data == ["error message"] 10 | 11 | def test_response_data_is_dict_and_has_detail_key(self): 12 | exc = APIException({"detail": "error message"}) 13 | response = exception_handler(exc, {}) 14 | assert response.data == ["error message"] 15 | 16 | def test_response_data_is_serializer_validation_error(self): 17 | exc = APIException({"url": ["error message"]}) 18 | response = exception_handler(exc, {}) 19 | assert response.data == {"url": ["error message"]} 20 | -------------------------------------------------------------------------------- /yufuquant/core/utils.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/We-Hack-Studio/nuts/de39730db2480204bf1656377b86a317ff3b984d/yufuquant/core/utils.py -------------------------------------------------------------------------------- /yufuquant/core/views.py: -------------------------------------------------------------------------------- 1 | from rest_framework.views import exception_handler as drf_exception_handler 2 | 3 | 4 | def exception_handler(exc, context): 5 | response = drf_exception_handler(exc, context) 6 | if response is None: 7 | return 8 | 9 | data = response.data 10 | 11 | # ["error message"] 12 | if isinstance(data, list): 13 | return response 14 | 15 | # {"detail": "error message"} 16 | if data is not None and "detail" in data: 17 | response.data = [data["detail"]] 18 | 19 | """ 20 | { 21 | "url": [ 22 | "This field is required." 23 | ] 24 | } 25 | """ 26 | return response 27 | -------------------------------------------------------------------------------- /yufuquant/credentials/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/We-Hack-Studio/nuts/de39730db2480204bf1656377b86a317ff3b984d/yufuquant/credentials/__init__.py -------------------------------------------------------------------------------- /yufuquant/credentials/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import Credential 4 | 5 | 6 | @admin.register(Credential) 7 | class CredentialAdmin(admin.ModelAdmin): 8 | fields = [ 9 | "note", 10 | "test_net", 11 | ] 12 | list_display = [ 13 | "id", 14 | "user", 15 | "exchange", 16 | "test_net", 17 | "note", 18 | "created_at", 19 | "modified_at", 20 | ] 21 | list_select_related = ["user"] 22 | readonly_fields = [ 23 | "user", 24 | "exchange", 25 | "created_at", 26 | "modified_at", 27 | ] 28 | -------------------------------------------------------------------------------- /yufuquant/credentials/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | 5 | class CredentialsConfig(AppConfig): 6 | name = "credentials" 7 | verbose_name = _("Credentials") 8 | 9 | def ready(self): 10 | pass 11 | -------------------------------------------------------------------------------- /yufuquant/credentials/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.10 on 2020-10-05 03:23 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | import django.utils.timezone 6 | import django_cryptography.fields 7 | import model_utils.fields 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | initial = True 13 | 14 | dependencies = [ 15 | ('exchanges', '0001_initial'), 16 | ] 17 | 18 | operations = [ 19 | migrations.CreateModel( 20 | name='Credential', 21 | fields=[ 22 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 23 | ('created_at', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created_at')), 24 | ('modified_at', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified_at')), 25 | ('note', models.CharField(blank=True, max_length=30, verbose_name='note')), 26 | ('api_key', django_cryptography.fields.encrypt(models.CharField(max_length=200, verbose_name='API Key'))), 27 | ('secret', django_cryptography.fields.encrypt(models.CharField(max_length=200, verbose_name='secret'))), 28 | ('passphrase', django_cryptography.fields.encrypt(models.CharField(blank=True, max_length=100, verbose_name='passphrase'))), 29 | ('test_net', models.BooleanField(default=False, verbose_name='test net')), 30 | ('exchange', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='credential_set', to='exchanges.Exchange', verbose_name='exchange')), 31 | ], 32 | options={ 33 | 'verbose_name': 'credential', 34 | 'verbose_name_plural': 'credentials', 35 | }, 36 | ), 37 | ] 38 | -------------------------------------------------------------------------------- /yufuquant/credentials/migrations/0002_credential_user.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.10 on 2020-10-05 03:23 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ('credentials', '0001_initial'), 15 | ] 16 | 17 | operations = [ 18 | migrations.AddField( 19 | model_name='credential', 20 | name='user', 21 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='credential_set', to=settings.AUTH_USER_MODEL, verbose_name='user'), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /yufuquant/credentials/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/We-Hack-Studio/nuts/de39730db2480204bf1656377b86a317ff3b984d/yufuquant/credentials/migrations/__init__.py -------------------------------------------------------------------------------- /yufuquant/credentials/models.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | 3 | from core.models import TimeStampedModel 4 | from django.conf import settings 5 | from django.db import models 6 | from django.utils.translation import gettext_lazy as _ 7 | from django_cryptography.fields import encrypt 8 | 9 | 10 | class Credential(TimeStampedModel, models.Model): 11 | note = models.CharField(_("note"), max_length=30, blank=True) 12 | api_key = encrypt(models.CharField(_("API Key"), max_length=200)) 13 | secret = encrypt(models.CharField(_("secret"), max_length=200)) 14 | passphrase = encrypt(models.CharField(_("passphrase"), max_length=100, blank=True)) 15 | test_net = models.BooleanField(_("test net"), default=False) 16 | exchange = models.ForeignKey( 17 | "exchanges.Exchange", 18 | verbose_name=_("exchange"), 19 | on_delete=models.CASCADE, 20 | related_name="credential_set", 21 | ) 22 | user = models.ForeignKey( 23 | settings.AUTH_USER_MODEL, 24 | verbose_name=_("user"), 25 | on_delete=models.CASCADE, 26 | related_name="credential_set", 27 | ) 28 | 29 | class Meta: 30 | verbose_name = _("credential") 31 | verbose_name_plural = _("credentials") 32 | 33 | def __str__(self): 34 | return self.note 35 | 36 | @property 37 | def key(self) -> Dict[str, str]: 38 | return { 39 | "api_key": self.api_key, 40 | "secret": self.secret, 41 | "passphrase": self.passphrase, 42 | } 43 | -------------------------------------------------------------------------------- /yufuquant/credentials/serializers.py: -------------------------------------------------------------------------------- 1 | from core.serializers import MaskedCharField 2 | from exchanges.serializers import ExchangeSerializer 3 | from rest_framework import serializers 4 | 5 | from .models import Credential 6 | 7 | 8 | class CredentialListSerializer(serializers.ModelSerializer): 9 | api_key = MaskedCharField(read_only=True, help_text="Partial masked API key.") 10 | secret = MaskedCharField(read_only=True, help_text="Partial masked secret key.") 11 | passphrase = MaskedCharField( 12 | read_only=True, mask_all=True, help_text="Completely masked passphrase." 13 | ) 14 | exchange = ExchangeSerializer(read_only=True) 15 | 16 | class Meta: 17 | model = Credential 18 | fields = [ 19 | "id", 20 | "note", 21 | "api_key", 22 | "secret", 23 | "passphrase", 24 | "test_net", 25 | "exchange", 26 | "created_at", 27 | "modified_at", 28 | ] 29 | 30 | extra_kwargs = { 31 | "test_net": {"help_text": "Is a credential of exchange test net or not."}, 32 | } 33 | 34 | 35 | class CredentialCreateSerializer(serializers.ModelSerializer): 36 | class Meta: 37 | model = Credential 38 | fields = [ 39 | "id", 40 | "note", 41 | "api_key", 42 | "secret", 43 | "passphrase", 44 | "test_net", 45 | "exchange", 46 | "created_at", 47 | "modified_at", 48 | ] 49 | read_only_fields = [ 50 | "id", 51 | "created_at", 52 | "modified_at", 53 | ] 54 | extra_kwargs = { 55 | "exchange": {"write_only": True}, 56 | "api_key": {"write_only": True}, 57 | "secret": {"write_only": True}, 58 | "passphrase": {"write_only": True}, 59 | } 60 | 61 | 62 | class CredentialUpdateSerializer(CredentialCreateSerializer): 63 | pass 64 | -------------------------------------------------------------------------------- /yufuquant/credentials/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/We-Hack-Studio/nuts/de39730db2480204bf1656377b86a317ff3b984d/yufuquant/credentials/tests/__init__.py -------------------------------------------------------------------------------- /yufuquant/credentials/tests/factories.py: -------------------------------------------------------------------------------- 1 | import factory 2 | from credentials.models import Credential 3 | from django.utils.crypto import get_random_string 4 | from exchanges.tests.factories import ExchangeFactory 5 | from factory import DjangoModelFactory 6 | from users.tests.factories import UserFactory 7 | 8 | 9 | class CredentialFactory(DjangoModelFactory): 10 | note = factory.Faker("name") 11 | api_key = factory.Faker("sha1", raw_output=False) 12 | secret = factory.Faker("sha256", raw_output=False) 13 | passphrase = factory.LazyFunction(lambda: get_random_string(10)) 14 | exchange = factory.SubFactory(ExchangeFactory) 15 | user = factory.SubFactory(UserFactory) 16 | 17 | class Meta: 18 | model = Credential 19 | -------------------------------------------------------------------------------- /yufuquant/credentials/urls.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/We-Hack-Studio/nuts/de39730db2480204bf1656377b86a317ff3b984d/yufuquant/credentials/urls.py -------------------------------------------------------------------------------- /yufuquant/credentials/views.py: -------------------------------------------------------------------------------- 1 | from typing import Type 2 | 3 | from django.utils.decorators import method_decorator 4 | from drf_spectacular.utils import extend_schema 5 | from rest_framework import mixins, permissions, throttling, viewsets 6 | from rest_framework.serializers import BaseSerializer 7 | 8 | from .models import Credential 9 | from .serializers import ( 10 | CredentialCreateSerializer, 11 | CredentialListSerializer, 12 | CredentialUpdateSerializer, 13 | ) 14 | 15 | 16 | @method_decorator( 17 | name="create", 18 | decorator=extend_schema( 19 | summary="Bind exchange credential", 20 | request=CredentialCreateSerializer, 21 | ), 22 | ) 23 | @method_decorator( 24 | name="list", 25 | decorator=extend_schema( 26 | summary="Return bound exchange credentials", 27 | ), 28 | ) 29 | @method_decorator( 30 | name="update", 31 | decorator=extend_schema( 32 | summary="Update bound exchange credential", 33 | ), 34 | ) 35 | @method_decorator( 36 | name="destroy", 37 | decorator=extend_schema( 38 | summary="Unbind exchange credential", 39 | ), 40 | ) 41 | class CredentialViewSet( 42 | mixins.CreateModelMixin, 43 | mixins.ListModelMixin, 44 | mixins.DestroyModelMixin, 45 | mixins.UpdateModelMixin, 46 | viewsets.GenericViewSet, 47 | ): 48 | action_serializer_map = { 49 | "list": CredentialListSerializer, 50 | "create": CredentialCreateSerializer, 51 | "partial_update": CredentialUpdateSerializer, 52 | } 53 | permission_classes = [permissions.IsAdminUser] 54 | throttle_classes = [throttling.UserRateThrottle] 55 | pagination_class = None 56 | 57 | def get_queryset(self): 58 | qs = Credential.objects.filter(user=self.request.user) 59 | if self.action in {"list"}: 60 | qs = qs.select_related("exchange").order_by("-created_at") 61 | return qs 62 | 63 | def get_serializer_class(self) -> Type[BaseSerializer]: 64 | assert self.action in self.action_serializer_map 65 | return self.action_serializer_map[self.action] 66 | 67 | def perform_create(self, serializer): 68 | serializer.save(user=self.request.user) 69 | -------------------------------------------------------------------------------- /yufuquant/exchanges/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/We-Hack-Studio/nuts/de39730db2480204bf1656377b86a317ff3b984d/yufuquant/exchanges/__init__.py -------------------------------------------------------------------------------- /yufuquant/exchanges/admin.py: -------------------------------------------------------------------------------- 1 | from adminsortable2.admin import SortableAdminMixin 2 | from django.contrib import admin 3 | 4 | from .models import Exchange 5 | 6 | 7 | @admin.register(Exchange) 8 | class ExchangeAdmin(SortableAdminMixin, admin.ModelAdmin): 9 | list_display = [ 10 | "id", 11 | "rank", 12 | "code", 13 | "name", 14 | "name_zh", 15 | "created_at", 16 | "modified_at", 17 | ] 18 | -------------------------------------------------------------------------------- /yufuquant/exchanges/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | 5 | class ExchangesConfig(AppConfig): 6 | name = "exchanges" 7 | verbose_name = _("Exchanges") 8 | -------------------------------------------------------------------------------- /yufuquant/exchanges/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.10 on 2020-10-05 03:23 2 | 3 | from django.db import migrations, models 4 | import django.utils.timezone 5 | import model_utils.fields 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='Exchange', 18 | fields=[ 19 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 20 | ('created_at', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created_at')), 21 | ('modified_at', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified_at')), 22 | ('code', models.CharField(max_length=50, verbose_name='code')), 23 | ('name', models.CharField(max_length=50, verbose_name='name')), 24 | ('name_zh', models.CharField(blank=True, max_length=100, verbose_name='chinese name')), 25 | ('logo', models.ImageField(blank=True, upload_to='exchanges/logos', verbose_name='logo')), 26 | ('active', models.BooleanField(default=False, verbose_name='active')), 27 | ('rank', models.SmallIntegerField(default=0, verbose_name='rank')), 28 | ], 29 | options={ 30 | 'verbose_name': 'exchange', 31 | 'verbose_name_plural': 'exchanges', 32 | 'ordering': ['rank', '-created_at'], 33 | }, 34 | ), 35 | ] 36 | -------------------------------------------------------------------------------- /yufuquant/exchanges/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/We-Hack-Studio/nuts/de39730db2480204bf1656377b86a317ff3b984d/yufuquant/exchanges/migrations/__init__.py -------------------------------------------------------------------------------- /yufuquant/exchanges/models.py: -------------------------------------------------------------------------------- 1 | from core.models import TimeStampedModel 2 | from django.db import models 3 | from django.utils.translation import gettext_lazy as _ 4 | from imagekit.models import ImageSpecField 5 | from imagekit.processors import ResizeToFill 6 | 7 | 8 | class Exchange(TimeStampedModel, models.Model): 9 | code = models.CharField(_("code"), max_length=50) 10 | name = models.CharField(_("name"), max_length=50) 11 | name_zh = models.CharField(_("chinese name"), max_length=100, blank=True) 12 | logo = models.ImageField(_("logo"), upload_to="exchanges/logos", blank=True) 13 | logo_thumbnail = ImageSpecField( 14 | source="logo", 15 | processors=[ResizeToFill(32, 32)], 16 | format="png", 17 | options={"quality": 100}, 18 | ) 19 | active = models.BooleanField(_("active"), default=False) 20 | rank = models.SmallIntegerField(_("rank"), default=0) 21 | 22 | class Meta: 23 | verbose_name = _("exchange") 24 | verbose_name_plural = _("exchanges") 25 | ordering = ["rank", "-created_at"] 26 | 27 | def __str__(self): 28 | return self.name 29 | 30 | def save(self, *args, **kwargs): 31 | if not self.name_zh: 32 | self.name_zh = self.name 33 | super().save(*args, **kwargs) 34 | -------------------------------------------------------------------------------- /yufuquant/exchanges/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from .models import Exchange 4 | 5 | 6 | class SimpleExchangeSerializer(serializers.ModelSerializer): 7 | class Meta: 8 | model = Exchange 9 | fields = [ 10 | "id", 11 | "code", 12 | "name", 13 | "name_zh", 14 | ] 15 | 16 | 17 | class ExchangeSerializer(serializers.ModelSerializer): 18 | logo_url = serializers.ImageField(source="logo_thumbnail") 19 | 20 | class Meta: 21 | model = Exchange 22 | fields = [ 23 | "id", 24 | "code", 25 | "name", 26 | "name_zh", 27 | "logo_url", 28 | "active", 29 | "rank", 30 | "created_at", 31 | "modified_at", 32 | ] 33 | -------------------------------------------------------------------------------- /yufuquant/exchanges/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/We-Hack-Studio/nuts/de39730db2480204bf1656377b86a317ff3b984d/yufuquant/exchanges/tests/__init__.py -------------------------------------------------------------------------------- /yufuquant/exchanges/tests/factories.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | import factory 4 | from exchanges.models import Exchange 5 | from factory import DjangoModelFactory 6 | 7 | EXCHANGE_LIST = ["Huobi", "OKEx", "Binance", "Bybit"] 8 | 9 | 10 | class ExchangeFactory(DjangoModelFactory): 11 | code = factory.lazy_attribute(lambda o: o.name.lower()) 12 | name = factory.LazyFunction(lambda: random.choice(EXCHANGE_LIST)) 13 | name_zh = factory.lazy_attribute(lambda o: o.name) 14 | logo = factory.django.ImageField() 15 | active = True 16 | rank = factory.Sequence(lambda n: n) 17 | 18 | class Meta: 19 | model = Exchange 20 | -------------------------------------------------------------------------------- /yufuquant/exchanges/views.py: -------------------------------------------------------------------------------- 1 | from django.utils.decorators import method_decorator 2 | from drf_spectacular.utils import extend_schema 3 | from rest_framework import mixins, viewsets 4 | 5 | from .models import Exchange 6 | from .serializers import ExchangeSerializer 7 | 8 | 9 | @method_decorator( 10 | name="list", 11 | decorator=extend_schema( 12 | summary="Return all exchanges", 13 | ), 14 | ) 15 | class ExchangeViewSet(mixins.ListModelMixin, viewsets.GenericViewSet): 16 | serializer_class = ExchangeSerializer 17 | pagination_class = None 18 | 19 | def get_queryset(self): 20 | return Exchange.objects.all().order_by("-created_at") 21 | -------------------------------------------------------------------------------- /yufuquant/robots/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/We-Hack-Studio/nuts/de39730db2480204bf1656377b86a317ff3b984d/yufuquant/robots/__init__.py -------------------------------------------------------------------------------- /yufuquant/robots/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import AssetRecord, AssetRecordSnap, Robot 4 | 5 | 6 | class AssetRecordInline(admin.TabularInline): 7 | model = AssetRecord 8 | 9 | fields = [ 10 | "id", 11 | "currency", 12 | "total_principal", 13 | "total_balance", 14 | "total_principal_24h_ago", 15 | "total_balance_24h_ago", 16 | ] 17 | readonly_fields = [ 18 | "id", 19 | "currency", 20 | "total_principal_24h_ago", 21 | "total_balance_24h_ago", 22 | ] 23 | 24 | 25 | @admin.register(Robot) 26 | class RobotAdmin(admin.ModelAdmin): 27 | inlines = [AssetRecordInline] 28 | list_display = [ 29 | "id", 30 | "name", 31 | "pair", 32 | "market_type", 33 | "target_currency", 34 | "enabled", 35 | "start_time", 36 | "ping_time", 37 | "created_at", 38 | "modified_at", 39 | ] 40 | readonly_fields = [ 41 | "ping_time", 42 | ] 43 | 44 | 45 | @admin.register(AssetRecordSnap) 46 | class AssetRecordSnapAdmin(admin.ModelAdmin): 47 | list_display = [ 48 | "id", 49 | "period", 50 | "total_principal", 51 | "total_balance", 52 | "created_at", 53 | "modified_at", 54 | ] 55 | list_select_related = ["asset_record", "asset_record__robot"] 56 | list_filter = ["period"] 57 | -------------------------------------------------------------------------------- /yufuquant/robots/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | 5 | class RobotsConfig(AppConfig): 6 | name = "robots" 7 | verbose_name = _("Robots") 8 | 9 | def ready(self): 10 | try: 11 | from robots import signals # noqa 12 | except ImportError: 13 | pass 14 | -------------------------------------------------------------------------------- /yufuquant/robots/managers.py: -------------------------------------------------------------------------------- 1 | from django.db.models import Manager, QuerySet 2 | 3 | 4 | class RobotQuerySet(QuerySet): 5 | pass 6 | 7 | 8 | class RobotManager(Manager.from_queryset(RobotQuerySet)): # type: ignore 9 | pass 10 | -------------------------------------------------------------------------------- /yufuquant/robots/migrations/0002_auto_20201022_2037.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.2 on 2020-10-22 12:37 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | import django.utils.timezone 6 | import model_utils.fields 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('robots', '0001_initial'), 13 | ] 14 | 15 | operations = [ 16 | migrations.AlterField( 17 | model_name='robot', 18 | name='enabled', 19 | field=models.BooleanField(default=False, verbose_name='enabled'), 20 | ), 21 | migrations.CreateModel( 22 | name='AssetRecordSnap', 23 | fields=[ 24 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 25 | ('created_at', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created_at')), 26 | ('modified_at', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified_at')), 27 | ('total_principal', models.FloatField(verbose_name='total capital')), 28 | ('total_balance', models.FloatField(verbose_name='total balance')), 29 | ('period', models.CharField(choices=[('1h', '1 hour'), ('1d', '1 day')], max_length=10, verbose_name='period')), 30 | ('asset_record', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='snaps', to='robots.assetrecord', verbose_name='asset record')), 31 | ], 32 | options={ 33 | 'verbose_name': 'asset record snap', 34 | 'verbose_name_plural': 'asset record snaps', 35 | }, 36 | ), 37 | ] 38 | -------------------------------------------------------------------------------- /yufuquant/robots/migrations/0003_auto_20201030_1613.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.2 on 2020-10-30 08:13 2 | 3 | from django.db import migrations 4 | import jsonfield.fields 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('robots', '0002_auto_20201022_2037'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='robot', 16 | name='strategy_parameters', 17 | field=jsonfield.fields.JSONField(blank=True, default={}, verbose_name='strategy parameters'), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /yufuquant/robots/migrations/0004_auto_20201102_2012.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.2 on 2020-11-02 12:12 2 | 3 | from django.db import migrations 4 | import jsonfield.fields 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('robots', '0003_auto_20201030_1613'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='robot', 16 | name='order_store', 17 | field=jsonfield.fields.JSONField(blank=True, default={'data': {}, 'updatedAt': None}, verbose_name='order store'), 18 | ), 19 | migrations.AddField( 20 | model_name='robot', 21 | name='position_store', 22 | field=jsonfield.fields.JSONField(blank=True, default={'data': {}, 'updatedAt': None}, verbose_name='position store'), 23 | ), 24 | migrations.AddField( 25 | model_name='robot', 26 | name='strategy_store', 27 | field=jsonfield.fields.JSONField(blank=True, default={'data': {}, 'updatedAt': None}, verbose_name='strategy store'), 28 | ), 29 | ] 30 | -------------------------------------------------------------------------------- /yufuquant/robots/migrations/0005_auto_20201102_2014.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.2 on 2020-11-02 12:14 2 | 3 | from django.db import migrations 4 | import jsonfield.fields 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('robots', '0004_auto_20201102_2012'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='robot', 16 | name='order_store', 17 | field=jsonfield.fields.JSONField(blank=True, default={'orders': [], 'updatedAt': None}, verbose_name='order store'), 18 | ), 19 | migrations.AlterField( 20 | model_name='robot', 21 | name='position_store', 22 | field=jsonfield.fields.JSONField(blank=True, default={'positions': [], 'updatedAt': None}, verbose_name='position store'), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /yufuquant/robots/migrations/0006_auto_20210320_1701.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.2 on 2021-03-20 09:01 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('robots', '0005_auto_20201102_2014'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='assetrecordsnap', 15 | name='total_principal', 16 | field=models.FloatField(verbose_name='total principal'), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /yufuquant/robots/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/We-Hack-Studio/nuts/de39730db2480204bf1656377b86a317ff3b984d/yufuquant/robots/migrations/__init__.py -------------------------------------------------------------------------------- /yufuquant/robots/signals.py: -------------------------------------------------------------------------------- 1 | from django.db.models.signals import post_save 2 | from django.dispatch import receiver 3 | 4 | from .models import AssetRecord, Robot 5 | 6 | 7 | @receiver(post_save, sender=Robot) 8 | def init_asset_record(sender, instance: Robot, created: bool = False, **kwargs): 9 | if created: 10 | AssetRecord.objects.create( 11 | currency=instance.target_currency, 12 | robot=instance, 13 | ) 14 | 15 | 16 | @receiver(post_save, sender=Robot) 17 | def init_strategy_parameters(sender, instance: Robot, created: bool = False, **kwargs): 18 | if created: 19 | spec = instance.strategy.specification 20 | strategy_parameters = {} 21 | for parameter in spec["parameters"]: 22 | strategy_parameters[parameter["code"]] = parameter["default"] 23 | instance.strategy_parameters = strategy_parameters 24 | instance.save(update_fields=["strategy_parameters"]) 25 | -------------------------------------------------------------------------------- /yufuquant/robots/tasks.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | from celery import shared_task 4 | from django.db.models import Prefetch 5 | from django.utils import timezone 6 | 7 | from .models import AssetRecord, AssetRecordSnap 8 | 9 | 10 | def make_asset_record_snaps(period): 11 | h24_ago = timezone.now() + timedelta(minutes=30) - timedelta(hours=24) 12 | h24_ago = h24_ago.replace(minute=0, second=0, microsecond=0) 13 | h24_ago_start = h24_ago - timedelta(minutes=3) 14 | h24_ago_end = h24_ago + timedelta(minutes=3) 15 | # 只记录录入了本金和余额的资产 16 | asset_record_qs = ( 17 | AssetRecord.objects.all() 18 | .filter(total_principal__gt=0, total_balance__gt=0) 19 | .prefetch_related( 20 | Prefetch( 21 | "snaps", 22 | queryset=AssetRecordSnap.objects.filter( 23 | created_at__gt=h24_ago_start, 24 | created_at__lt=h24_ago_end, 25 | period=period, 26 | ).order_by("-created_at"), 27 | to_attr="prefetched_snaps", 28 | ) 29 | ) 30 | ) 31 | asset_record_objs = [] 32 | snap_objs = [] 33 | for asset_record in asset_record_qs: 34 | snap = AssetRecordSnap( 35 | total_principal=asset_record.total_principal, 36 | total_balance=asset_record.total_balance, 37 | period=period, 38 | asset_record=asset_record, 39 | ) 40 | snap_objs.append(snap) 41 | if len(asset_record.prefetched_snaps) > 0: 42 | snap_24h_ago = asset_record.prefetched_snaps[0] 43 | asset_record.total_principal_24h_ago = snap_24h_ago.total_principal 44 | asset_record.total_balance_24h_ago = snap_24h_ago.total_balance 45 | asset_record_objs.append(asset_record) 46 | 47 | AssetRecordSnap.objects.bulk_create(snap_objs, batch_size=1000) 48 | if len(asset_record_objs) > 0: 49 | AssetRecord.objects.bulk_update( 50 | asset_record_objs, 51 | fields=["total_balance_24h_ago", "total_principal_24h_ago"], 52 | batch_size=1000, 53 | ) 54 | 55 | 56 | @shared_task 57 | def make_asset_record_snaps_task(period): 58 | make_asset_record_snaps(period) 59 | -------------------------------------------------------------------------------- /yufuquant/robots/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/We-Hack-Studio/nuts/de39730db2480204bf1656377b86a317ff3b984d/yufuquant/robots/tests/__init__.py -------------------------------------------------------------------------------- /yufuquant/robots/tests/factories.py: -------------------------------------------------------------------------------- 1 | import factory 2 | from credentials.tests.factories import CredentialFactory 3 | from factory import DjangoModelFactory 4 | from robots.models import Robot 5 | from strategies.tests.factories import StrategyFactory 6 | 7 | 8 | class RobotFactory(DjangoModelFactory): 9 | name = factory.Faker("name") 10 | pair = "BTCUSDT" 11 | market_type = "spots" 12 | base_currency = "BTC" 13 | quote_currency = "USDT" 14 | target_currency = "USDT" 15 | credential = factory.SubFactory(CredentialFactory) 16 | strategy = factory.SubFactory(StrategyFactory) 17 | 18 | class Meta: 19 | model = Robot 20 | -------------------------------------------------------------------------------- /yufuquant/robots/tests/test_serializers.py: -------------------------------------------------------------------------------- 1 | from robots.serializers import PercentageField 2 | 3 | 4 | def test_percentage_field_to_representation(): 5 | field = PercentageField() 6 | assert field.to_representation(1), "100.00%" 7 | assert field.to_representation(0.0), "0.00%" 8 | assert field.to_representation(0.01), "1.00%" 9 | assert field.to_representation(0.001), "0.10%" 10 | -------------------------------------------------------------------------------- /yufuquant/robots/tests/test_signals.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/We-Hack-Studio/nuts/de39730db2480204bf1656377b86a317ff3b984d/yufuquant/robots/tests/test_signals.py -------------------------------------------------------------------------------- /yufuquant/robots/urls.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/We-Hack-Studio/nuts/de39730db2480204bf1656377b86a317ff3b984d/yufuquant/robots/urls.py -------------------------------------------------------------------------------- /yufuquant/scripts/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/We-Hack-Studio/nuts/de39730db2480204bf1656377b86a317ff3b984d/yufuquant/scripts/__init__.py -------------------------------------------------------------------------------- /yufuquant/scripts/db/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/We-Hack-Studio/nuts/de39730db2480204bf1656377b86a317ff3b984d/yufuquant/scripts/db/__init__.py -------------------------------------------------------------------------------- /yufuquant/scripts/db/_init_exchanges.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | 3 | from django.conf import settings 4 | from django.core.files.uploadedfile import SimpleUploadedFile 5 | from exchanges.models import Exchange 6 | 7 | 8 | def run(): 9 | print("Creating exchanges") 10 | exchange_list = [ 11 | ("Huobi", "火币"), 12 | ("Binance", "币安"), 13 | ("OKEx", "OKEx"), 14 | ("Bybit", "Bybit"), 15 | ] 16 | for i, (name, name_zh) in enumerate(exchange_list): 17 | code = name.lower() 18 | logo = pathlib.Path(str(settings.APPS_DIR)).joinpath( 19 | "scripts", "fake", "exchange-logos", f"{code}.jpg" 20 | ) 21 | Exchange.objects.create( 22 | code=code, 23 | name=name, 24 | name_zh=name_zh, 25 | logo=SimpleUploadedFile(name=f"{code}.jpg", content=logo.read_bytes()), 26 | active=True, 27 | rank=i, 28 | ) 29 | -------------------------------------------------------------------------------- /yufuquant/scripts/db/_init_superuser.py: -------------------------------------------------------------------------------- 1 | from users.models import User 2 | 3 | 4 | def run(): 5 | print("Creating admin user...") 6 | User.objects.create_superuser( 7 | username="admin", password="test123456", email="admin@yufuquant.cc" 8 | ) 9 | -------------------------------------------------------------------------------- /yufuquant/scripts/db/init_db.py: -------------------------------------------------------------------------------- 1 | from scripts.db import _init_exchanges, _init_superuser 2 | 3 | 4 | def run(): 5 | _init_superuser.run() 6 | _init_exchanges.run() 7 | print("Database has been initialized!") 8 | -------------------------------------------------------------------------------- /yufuquant/scripts/fake/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/We-Hack-Studio/nuts/de39730db2480204bf1656377b86a317ff3b984d/yufuquant/scripts/fake/__init__.py -------------------------------------------------------------------------------- /yufuquant/scripts/fake/_clean_db.py: -------------------------------------------------------------------------------- 1 | from credentials.models import Credential 2 | from django.conf import settings 3 | from django.contrib.auth import get_user_model 4 | from exchanges.models import Exchange 5 | from robots.models import AssetRecord, AssetRecordSnap, Robot 6 | from strategies.models import Strategy 7 | 8 | User = get_user_model() 9 | 10 | 11 | def run(): 12 | if not settings.DEBUG: 13 | alert = ( 14 | "You are not in development environment. " 15 | "This script will DELETE ALL DATA in your database. " 16 | "If you really want to continue this script, please input 'yEs'. " 17 | "Make sure you know what you are doing!" 18 | ) 19 | print(alert) 20 | prompt = input("Please input 'yEs' to continue") 21 | if prompt != "yEs": 22 | print("Unexpected input, return!") 23 | return 24 | 25 | User.objects.all().delete() 26 | Exchange.objects.all().delete() 27 | Credential.objects.all().delete() 28 | Strategy.objects.all().delete() 29 | Robot.objects.all().delete() 30 | AssetRecord.objects.all().delete() 31 | AssetRecordSnap.objects.all().delete() 32 | print("Database was cleaned.") 33 | -------------------------------------------------------------------------------- /yufuquant/scripts/fake/_fake_exchanges.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | 3 | from django.conf import settings 4 | from django.core.files.uploadedfile import SimpleUploadedFile 5 | from exchanges.tests.factories import ExchangeFactory 6 | 7 | 8 | def run(): 9 | for exchange in ["Huobi", "Binance", "OKEx", "Bybit"]: 10 | code = exchange.lower() 11 | logo = pathlib.Path(str(settings.APPS_DIR)).joinpath( 12 | "scripts", "fake", "exchange-logos", f"{code}.jpg" 13 | ) 14 | ExchangeFactory( 15 | code=code, 16 | name=exchange, 17 | logo=SimpleUploadedFile(name=f"{code}.jpg", content=logo.read_bytes()), 18 | ) 19 | print("Exchanges were created.") 20 | -------------------------------------------------------------------------------- /yufuquant/scripts/fake/_fake_robot_asset_record_snaps.py: -------------------------------------------------------------------------------- 1 | import random 2 | from datetime import timedelta 3 | 4 | from django.utils import timezone 5 | from robots.models import AssetRecord, AssetRecordSnap 6 | 7 | 8 | def run(): 9 | snap_objs = [] 10 | asset_record_objs = [] 11 | for asset_record in AssetRecord.objects.all(): 12 | asset_record.total_principal = random.randint(10, 15) 13 | asset_record.total_balance = random.randint(15, 20) 14 | asset_record.total_principal_24h_ago = random.randint(10, 15) 15 | asset_record.total_balance_24h_ago = random.randint(15, 20) 16 | asset_record_objs.append(asset_record) 17 | 18 | now = timezone.now().replace(minute=0, second=0, microsecond=0) 19 | for i in range(100): 20 | dt = now - timedelta(hours=i) 21 | snap = AssetRecordSnap( 22 | asset_record=asset_record, 23 | total_principal=asset_record.total_principal, 24 | total_balance=random.randint(15, 20), 25 | period=AssetRecordSnap.PERIOD.h1, 26 | created_at=dt, 27 | modified_at=dt, 28 | ) 29 | snap_objs.append(snap) 30 | 31 | now = now.replace(hour=12) 32 | for i in range(10): 33 | dt = now - timedelta(days=i) 34 | snap = AssetRecordSnap( 35 | asset_record=asset_record, 36 | total_principal=asset_record.total_principal, 37 | total_balance=random.randint(15, 20), 38 | period=AssetRecordSnap.PERIOD.d1, 39 | created_at=dt, 40 | modified_at=dt, 41 | ) 42 | snap_objs.append(snap) 43 | 44 | AssetRecordSnap.objects.bulk_create(snap_objs, batch_size=1000) 45 | AssetRecord.objects.bulk_update( 46 | asset_record_objs, 47 | fields=[ 48 | "total_principal", 49 | "total_balance", 50 | "total_principal_24h_ago", 51 | "total_balance_24h_ago", 52 | ], 53 | batch_size=1000, 54 | ) 55 | -------------------------------------------------------------------------------- /yufuquant/scripts/fake/_fake_robots.py: -------------------------------------------------------------------------------- 1 | from credentials.tests.factories import CredentialFactory 2 | from exchanges.models import Exchange 3 | from robots.tests.factories import RobotFactory 4 | from strategies.models import Strategy 5 | from users.models import User 6 | 7 | fake_data = [ 8 | { 9 | "code": "bybit", 10 | "name": "Bybit", 11 | "name_zh": "Bybit", 12 | "pair": "BTCUSD", 13 | "market_type": "inverse_perpetual", 14 | "target_currency": "BTC", 15 | }, 16 | { 17 | "code": "binance", 18 | "name": "Binance", 19 | "name_zh": "币安", 20 | "pair": "ETHUSDT", 21 | "market_type": "linear_perpetual", 22 | "target_currency": "USDT", 23 | }, 24 | { 25 | "code": "huobi", 26 | "name": "Huobi", 27 | "name_zh": "火币", 28 | "pair": "BTC-USDT", 29 | "market_type": "spots", 30 | "target_currency": "USDT", 31 | "base_currency": "BTC", 32 | "quote_currency": "USDT", 33 | }, 34 | { 35 | "code": "okex", 36 | "name": "OKEx", 37 | "name_zh": "OKEx", 38 | "pair": "EOS-USD-200925", 39 | "market_type": "inverse_delivery", 40 | "target_currency": "EOS", 41 | }, 42 | ] 43 | 44 | 45 | def run(): 46 | user = User.objects.get(username="admin") 47 | strategy = Strategy.objects.get(name="演示策略") 48 | for entry in fake_data: 49 | exchange = Exchange.objects.get(code=entry["code"]) 50 | credential = CredentialFactory(user=user, exchange=exchange) 51 | RobotFactory(credential=credential, strategy=strategy) 52 | -------------------------------------------------------------------------------- /yufuquant/scripts/fake/_fake_strategies.py: -------------------------------------------------------------------------------- 1 | import json 2 | import pathlib 3 | 4 | from django.conf import settings 5 | from strategies.tests.factories import StrategyFactory 6 | 7 | 8 | def run(): 9 | name = "演示策略" 10 | spec = ( 11 | pathlib.Path(str(settings.APPS_DIR)) 12 | .joinpath("scripts", "fake", "strategy-specification.json") 13 | .read_text(encoding="utf-8") 14 | ) 15 | StrategyFactory( 16 | name=name, 17 | specification=json.loads(spec), 18 | ) 19 | print("Demo strategy was created.") 20 | -------------------------------------------------------------------------------- /yufuquant/scripts/fake/_fake_superuser.py: -------------------------------------------------------------------------------- 1 | from users.models import User 2 | 3 | 4 | def run(): 5 | User.objects.create_superuser( 6 | username="admin", password="test123456", email="admin@yufuquant.cc" 7 | ) 8 | print("Superuser 'admin' was created.") 9 | -------------------------------------------------------------------------------- /yufuquant/scripts/fake/exchange-logos/binance.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/We-Hack-Studio/nuts/de39730db2480204bf1656377b86a317ff3b984d/yufuquant/scripts/fake/exchange-logos/binance.jpg -------------------------------------------------------------------------------- /yufuquant/scripts/fake/exchange-logos/bybit.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/We-Hack-Studio/nuts/de39730db2480204bf1656377b86a317ff3b984d/yufuquant/scripts/fake/exchange-logos/bybit.jpg -------------------------------------------------------------------------------- /yufuquant/scripts/fake/exchange-logos/huobi.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/We-Hack-Studio/nuts/de39730db2480204bf1656377b86a317ff3b984d/yufuquant/scripts/fake/exchange-logos/huobi.jpg -------------------------------------------------------------------------------- /yufuquant/scripts/fake/exchange-logos/okex.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/We-Hack-Studio/nuts/de39730db2480204bf1656377b86a317ff3b984d/yufuquant/scripts/fake/exchange-logos/okex.jpg -------------------------------------------------------------------------------- /yufuquant/scripts/fake/fake_all.py: -------------------------------------------------------------------------------- 1 | from django.db import transaction 2 | 3 | from . import ( 4 | _clean_db, 5 | _fake_exchanges, 6 | _fake_robot_asset_record_snaps, 7 | _fake_robots, 8 | _fake_strategies, 9 | _fake_superuser, 10 | ) 11 | 12 | 13 | def run(): 14 | with transaction.atomic(): 15 | _clean_db.run() 16 | _fake_superuser.run() 17 | _fake_exchanges.run() 18 | _fake_strategies.run() 19 | _fake_robots.run() 20 | _fake_robot_asset_record_snaps.run() 21 | print("Done!") 22 | -------------------------------------------------------------------------------- /yufuquant/scripts/fake/strategy-specification.json: -------------------------------------------------------------------------------- 1 | { 2 | "specificationVersion": "v1.0", 3 | "description": "指定价格买入卖出,24小时无人值守。", 4 | "parameters": [ 5 | { 6 | "code": "entry_price", 7 | "name": "入场价格", 8 | "type": "float", 9 | "description": "", 10 | "default": null, 11 | "editable": true 12 | }, 13 | { 14 | "code": "exit_price", 15 | "name": "出场价格", 16 | "type": "float", 17 | "description": "", 18 | "default": null, 19 | "editable": true 20 | }, 21 | { 22 | "code": "direction", 23 | "name": "方向", 24 | "type": "enum", 25 | "description": "入场方向。如果是现货,做空表示先卖出资产再低价买回。", 26 | "default": null, 27 | "editable": true, 28 | "items": [ 29 | [ 30 | 1, 31 | "做多" 32 | ], 33 | [ 34 | -1, 35 | "做空" 36 | ] 37 | ] 38 | }, 39 | { 40 | "code": "amount", 41 | "name": "数量", 42 | "type": "float", 43 | "description": "订单数量,必须 >0", 44 | "default": null, 45 | "editable": true 46 | } 47 | ] 48 | } -------------------------------------------------------------------------------- /yufuquant/strategies/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/We-Hack-Studio/nuts/de39730db2480204bf1656377b86a317ff3b984d/yufuquant/strategies/__init__.py -------------------------------------------------------------------------------- /yufuquant/strategies/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import Strategy 4 | 5 | 6 | @admin.register(Strategy) 7 | class StrategyAdmin(admin.ModelAdmin): 8 | list_display = [ 9 | "id", 10 | "name", 11 | "created_at", 12 | "modified_at", 13 | ] 14 | -------------------------------------------------------------------------------- /yufuquant/strategies/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | 5 | class StrategiesConfig(AppConfig): 6 | name = "strategies" 7 | verbose_name = _("Strategies") 8 | -------------------------------------------------------------------------------- /yufuquant/strategies/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.10 on 2020-10-05 03:23 2 | 3 | from django.db import migrations, models 4 | import django.utils.timezone 5 | import jsonfield.fields 6 | import model_utils.fields 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | initial = True 12 | 13 | dependencies = [ 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='Strategy', 19 | fields=[ 20 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 21 | ('created_at', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created_at')), 22 | ('modified_at', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified_at')), 23 | ('name', models.CharField(max_length=100, unique=True, verbose_name='name')), 24 | ('description', models.TextField(blank=True, verbose_name='description')), 25 | ('specification', jsonfield.fields.JSONField(verbose_name='specification')), 26 | ], 27 | options={ 28 | 'verbose_name': 'strategy', 29 | 'verbose_name_plural': 'strategies', 30 | }, 31 | ), 32 | ] 33 | -------------------------------------------------------------------------------- /yufuquant/strategies/migrations/0002_auto_20201030_1613.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.2 on 2020-10-30 08:13 2 | 3 | from django.db import migrations 4 | import jsonfield.fields 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('strategies', '0001_initial'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='strategy', 16 | name='specification', 17 | field=jsonfield.fields.JSONField(blank=True, default={}, verbose_name='specification'), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /yufuquant/strategies/migrations/0003_strategy_brief.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.2 on 2020-11-04 01:34 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('strategies', '0002_auto_20201030_1613'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='strategy', 15 | name='brief', 16 | field=models.CharField(blank=True, max_length=300, verbose_name='brief'), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /yufuquant/strategies/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/We-Hack-Studio/nuts/de39730db2480204bf1656377b86a317ff3b984d/yufuquant/strategies/migrations/__init__.py -------------------------------------------------------------------------------- /yufuquant/strategies/models.py: -------------------------------------------------------------------------------- 1 | from core.models import TimeStampedModel 2 | from django.db import models 3 | from django.utils.translation import gettext_lazy as _ 4 | from jsonfield import JSONField 5 | 6 | 7 | class Strategy(TimeStampedModel, models.Model): 8 | name = models.CharField(_("name"), max_length=100, unique=True) 9 | brief = models.CharField(_("brief"), max_length=300, blank=True) 10 | description = models.TextField(_("description"), blank=True) 11 | specification = JSONField(_("specification"), blank=True, default={}) 12 | 13 | class Meta: 14 | verbose_name = _("strategy") 15 | verbose_name_plural = _("strategies") 16 | 17 | def __str__(self): 18 | return self.name 19 | -------------------------------------------------------------------------------- /yufuquant/strategies/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from .models import Strategy 4 | 5 | 6 | class StrategySerializer(serializers.ModelSerializer): 7 | specification = serializers.JSONField(binary=True) 8 | 9 | class Meta: 10 | model = Strategy 11 | fields = [ 12 | "id", 13 | "name", 14 | "brief", 15 | "description", 16 | "specification", 17 | "created_at", 18 | "modified_at", 19 | ] 20 | -------------------------------------------------------------------------------- /yufuquant/strategies/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/We-Hack-Studio/nuts/de39730db2480204bf1656377b86a317ff3b984d/yufuquant/strategies/tests/__init__.py -------------------------------------------------------------------------------- /yufuquant/strategies/tests/factories.py: -------------------------------------------------------------------------------- 1 | import factory 2 | from factory import DjangoModelFactory 3 | from strategies.models import Strategy 4 | 5 | 6 | class StrategyFactory(DjangoModelFactory): 7 | name = factory.Faker("word") 8 | description = factory.Faker("paragraph") 9 | specification = factory.LazyFunction( 10 | lambda: { 11 | "version": "v0", 12 | "specVersion": "v1.0", 13 | "parameters": [ 14 | { 15 | "code": "code", 16 | "name": "Code", 17 | "type": "string", 18 | "description": "", 19 | "default": "", 20 | "editable": True, 21 | } 22 | ], 23 | } 24 | ) 25 | 26 | class Meta: 27 | model = Strategy 28 | -------------------------------------------------------------------------------- /yufuquant/strategies/views.py: -------------------------------------------------------------------------------- 1 | from django.db.models import QuerySet 2 | from django.utils.decorators import method_decorator 3 | from drf_spectacular.utils import extend_schema 4 | from rest_framework import mixins, permissions, viewsets 5 | from rest_framework.pagination import LimitOffsetPagination 6 | 7 | from .models import Strategy 8 | from .serializers import StrategySerializer 9 | 10 | 11 | class StrategyPagination(LimitOffsetPagination): 12 | default_limit = 50 13 | 14 | 15 | @method_decorator( 16 | name="list", 17 | decorator=extend_schema( 18 | summary="Return all strategies", 19 | request=StrategySerializer, 20 | ), 21 | ) 22 | @method_decorator( 23 | name="retrieve", 24 | decorator=extend_schema( 25 | summary="Strategy detail", 26 | request=StrategySerializer, 27 | ), 28 | ) 29 | @method_decorator( 30 | name="create", 31 | decorator=extend_schema( 32 | summary="Add strategy", 33 | request=StrategySerializer, 34 | ), 35 | ) 36 | class StrategyViewSet( 37 | mixins.ListModelMixin, 38 | mixins.RetrieveModelMixin, 39 | mixins.CreateModelMixin, 40 | viewsets.GenericViewSet, 41 | ): 42 | serializer_class = StrategySerializer 43 | permission_classes = [permissions.IsAdminUser] 44 | pagination_class = StrategyPagination 45 | 46 | def get_queryset(self) -> QuerySet: 47 | return Strategy.objects.all().order_by("-created_at") 48 | -------------------------------------------------------------------------------- /yufuquant/streams/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/We-Hack-Studio/nuts/de39730db2480204bf1656377b86a317ff3b984d/yufuquant/streams/__init__.py -------------------------------------------------------------------------------- /yufuquant/streams/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | 5 | class StreamsConfig(AppConfig): 6 | name = "streams" 7 | verbose_name = _("Streams") 8 | 9 | def ready(self): 10 | pass 11 | -------------------------------------------------------------------------------- /yufuquant/streams/routing.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from . import consumers 4 | 5 | websocket_urlpatterns = [ 6 | path("ws/v1/streams/", consumers.StreamConsumer), 7 | ] 8 | -------------------------------------------------------------------------------- /yufuquant/taskapp/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/We-Hack-Studio/nuts/de39730db2480204bf1656377b86a317ff3b984d/yufuquant/taskapp/__init__.py -------------------------------------------------------------------------------- /yufuquant/taskapp/celery.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from celery import Celery, signals 4 | from django.apps import AppConfig, apps 5 | from django.conf import settings 6 | 7 | if not settings.configured: 8 | # set the default Django settings module for the 'celery' program. 9 | os.environ.setdefault( 10 | "DJANGO_SETTINGS_MODULE", "config.settings.production" 11 | ) # pragma: no cover 12 | 13 | app = Celery("yufuquant") 14 | 15 | 16 | @signals.setup_logging.connect 17 | def setup_celery_logging(**kwargs): 18 | pass 19 | 20 | 21 | app.log.setup() 22 | 23 | # Using a string here means the worker will not have to 24 | # pickle the object when using Windows. 25 | # - namespace='CELERY' means all celery-related configuration keys 26 | # should have a `CELERY_` prefix. 27 | app.config_from_object("django.conf:settings", namespace="CELERY") 28 | 29 | 30 | class CeleryAppConfig(AppConfig): 31 | name = "taskapp" 32 | verbose_name = "Celery Config" 33 | 34 | def ready(self): 35 | installed_apps = [app_config.name for app_config in apps.get_app_configs()] 36 | app.autodiscover_tasks(lambda: installed_apps, force=True) 37 | 38 | 39 | @app.task(bind=True) 40 | def debug_task(self): 41 | print(f"Request: {self.request!r}") # pragma: no cover 42 | -------------------------------------------------------------------------------- /yufuquant/taskapp/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/We-Hack-Studio/nuts/de39730db2480204bf1656377b86a317ff3b984d/yufuquant/taskapp/management/__init__.py -------------------------------------------------------------------------------- /yufuquant/taskapp/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/We-Hack-Studio/nuts/de39730db2480204bf1656377b86a317ff3b984d/yufuquant/taskapp/management/commands/__init__.py -------------------------------------------------------------------------------- /yufuquant/taskapp/management/commands/setup_periodic_tasks.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from django.core.management.base import BaseCommand 4 | from django.db import transaction 5 | from django_celery_beat.models import CrontabSchedule, IntervalSchedule, PeriodicTask 6 | from robots.models import AssetRecordSnap 7 | from robots.tasks import make_asset_record_snaps_task 8 | 9 | 10 | class Command(BaseCommand): 11 | help = f""" 12 | Setup celery beat periodic tasks. 13 | 14 | Following tasks will be created: 15 | 16 | - {make_asset_record_snaps_task.name} 17 | """ 18 | 19 | @transaction.atomic 20 | def handle(self, *args, **kwargs): 21 | print("Deleting all periodic tasks and schedules...\n") 22 | 23 | IntervalSchedule.objects.all().delete() 24 | CrontabSchedule.objects.all().delete() 25 | PeriodicTask.objects.all().delete() 26 | 27 | periodic_tasks_data = [ 28 | { 29 | "task": make_asset_record_snaps_task, 30 | "name": "Make robot asset record snaps every day", 31 | # https://crontab.guru/every-day-at-midnight 32 | "cron": { 33 | "minute": "0", 34 | "hour": "0", 35 | "day_of_week": "*", 36 | "day_of_month": "*", 37 | "month_of_year": "*", 38 | }, 39 | "enabled": True, 40 | "args": json.dumps([AssetRecordSnap.PERIOD.d1]), 41 | }, 42 | { 43 | "task": make_asset_record_snaps_task, 44 | "name": "Make robot asset record snaps every hour", 45 | # https://crontab.guru/every-1-hour 46 | "cron": { 47 | "minute": "0", 48 | "hour": "*", 49 | "day_of_week": "*", 50 | "day_of_month": "*", 51 | "month_of_year": "*", 52 | }, 53 | "enabled": True, 54 | "args": json.dumps([AssetRecordSnap.PERIOD.h1]), 55 | }, 56 | ] 57 | 58 | for periodic_task in periodic_tasks_data: 59 | print(f'Setting up {periodic_task["task"].name}') 60 | 61 | cron = CrontabSchedule.objects.create(**periodic_task["cron"]) 62 | 63 | PeriodicTask.objects.create( 64 | name=periodic_task["name"], 65 | task=periodic_task["task"].name, 66 | crontab=cron, 67 | enabled=periodic_task["enabled"], 68 | args=periodic_task.get("args", "[]"), 69 | ) 70 | -------------------------------------------------------------------------------- /yufuquant/taskapp/tasks.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/We-Hack-Studio/nuts/de39730db2480204bf1656377b86a317ff3b984d/yufuquant/taskapp/tasks.py -------------------------------------------------------------------------------- /yufuquant/templates/index.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | {% load render_bundle from webpack_loader %} 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 坚果数字货币量化交易系统 12 | {% render_bundle 'app' 'css' %} 13 | {% render_bundle 'chunk-vendors' 'css' %} 14 | 15 | 16 |
17 | 18 | {% render_bundle 'app' 'js' %} 19 | {% render_bundle 'chunk-vendors' 'js' %} 20 | 21 | -------------------------------------------------------------------------------- /yufuquant/users/Inconsolata.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/We-Hack-Studio/nuts/de39730db2480204bf1656377b86a317ff3b984d/yufuquant/users/Inconsolata.otf -------------------------------------------------------------------------------- /yufuquant/users/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/We-Hack-Studio/nuts/de39730db2480204bf1656377b86a317ff3b984d/yufuquant/users/__init__.py -------------------------------------------------------------------------------- /yufuquant/users/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.contrib.auth import admin as auth_admin 3 | from django.utils.translation import gettext_lazy as _ 4 | from rest_framework.authtoken.models import Token 5 | 6 | from .models import User 7 | 8 | 9 | @admin.register(User) 10 | class UserAdmin(auth_admin.UserAdmin): 11 | fieldsets = ( 12 | ("User", {"fields": ("nickname",)}), 13 | (None, {"fields": ("username", "password")}), 14 | (_("Personal info"), {"fields": ("first_name", "last_name", "email")}), 15 | ( 16 | _("Permissions"), 17 | { 18 | "fields": ( 19 | "is_active", 20 | "is_staff", 21 | "is_superuser", 22 | "groups", 23 | "user_permissions", 24 | ), 25 | }, 26 | ), 27 | (_("Important dates"), {"fields": ("last_login", "date_joined")}), 28 | ) 29 | list_display = ["id", "username", "nickname", "is_superuser", "is_active"] 30 | search_fields = ["username", "nickname", "email"] 31 | 32 | 33 | @admin.register(Token) 34 | class MyTokenAdmin(admin.ModelAdmin): 35 | list_display = ("key", "user", "created") 36 | fields = ("user",) 37 | ordering = ("-created",) 38 | search_fields = ( 39 | "user__username", 40 | "user__email", 41 | ) 42 | -------------------------------------------------------------------------------- /yufuquant/users/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | 5 | class UsersConfig(AppConfig): 6 | name = "users" 7 | verbose_name = _("Users") 8 | 9 | def ready(self): 10 | pass 11 | -------------------------------------------------------------------------------- /yufuquant/users/avatar_generator.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """ 4 | Taking from https://github.com/maethor/avatar-generator. 5 | """ 6 | 7 | import os 8 | from io import BytesIO 9 | from random import randint, seed 10 | 11 | from PIL import Image, ImageDraw, ImageFont 12 | 13 | __all__ = ["AvatarGenerator"] 14 | 15 | 16 | class AvatarGenerator: 17 | FONT_COLOR = (255, 255, 255) 18 | MIN_RENDER_SIZE = 100 19 | 20 | @classmethod 21 | def generate(cls, string, size=100, filetype="PNG"): 22 | """ 23 | Generates a squared avatar with random background color. 24 | :param size: size of the avatar, in pixels 25 | :param string: string to be used to print text and seed the random 26 | :param filetype: the file format of the image (i.e. JPEG, PNG) 27 | """ 28 | render_size = max(size, cls.MIN_RENDER_SIZE) 29 | image = Image.new( 30 | "RGB", (render_size, render_size), cls._background_color(string) 31 | ) 32 | draw = ImageDraw.Draw(image) 33 | font = cls._font(render_size) 34 | text = cls._text(string) 35 | draw.text( 36 | cls._text_position(render_size, text, font), 37 | text, 38 | fill=cls.FONT_COLOR, 39 | font=font, 40 | ) 41 | stream = BytesIO() 42 | image = image.resize((size, size), Image.ANTIALIAS) 43 | image.save(stream, format=filetype, optimize=True) 44 | return stream.getvalue() 45 | 46 | @staticmethod 47 | def _background_color(s): 48 | """ 49 | Generate a random background color. 50 | Brighter colors are dropped, because the text is white. 51 | :param s: Seed used by the random generator 52 | (same seed will produce the same color). 53 | """ 54 | seed(s) 55 | r = v = b = 255 56 | while r + v + b > 255 * 2: 57 | r = randint(0, 255) 58 | v = randint(0, 255) 59 | b = randint(0, 255) 60 | return r, v, b 61 | 62 | @staticmethod 63 | def _font(size): 64 | """ 65 | Returns a PIL ImageFont instance. 66 | :param size: size of the avatar, in pixels 67 | """ 68 | # path = pathlib.Path(".").joinpath("Inconsolata.otf") 69 | path = os.path.join(os.path.dirname(__file__), "Inconsolata.otf") 70 | return ImageFont.truetype(path, size=int(0.8 * size)) 71 | 72 | @staticmethod 73 | def _text(string): 74 | """ 75 | Returns the text to draw. 76 | """ 77 | return string[0].upper() 78 | 79 | @staticmethod 80 | def _text_position(size, text, font): 81 | """ 82 | Returns the left-top point where the text should be positioned. 83 | """ 84 | width, height = font.getsize(text) 85 | left = (size - width) / 2.0 86 | # I just don't know why 5.5, but it seems to be the good ratio 87 | top = (size - height) / 5.5 88 | return left, top 89 | -------------------------------------------------------------------------------- /yufuquant/users/migrations/0002_auto_20201022_2037.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.2 on 2020-10-22 12:37 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('users', '0001_initial'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='user', 15 | name='first_name', 16 | field=models.CharField(blank=True, max_length=150, verbose_name='first name'), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /yufuquant/users/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/We-Hack-Studio/nuts/de39730db2480204bf1656377b86a317ff3b984d/yufuquant/users/migrations/__init__.py -------------------------------------------------------------------------------- /yufuquant/users/models.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from core.validators import FileValidator 4 | from django.contrib.auth.models import AbstractUser 5 | from django.core.files.base import ContentFile 6 | from django.db import models 7 | from django.utils.translation import gettext_lazy as _ 8 | from imagekit.models import ImageSpecField 9 | from imagekit.processors import ResizeToFill 10 | 11 | from .avatar_generator import AvatarGenerator 12 | 13 | 14 | def user_avatar_path(instance, filename): 15 | return os.path.join("users", "avatars", instance.username, filename) 16 | 17 | 18 | class User(AbstractUser): 19 | AVATAR_MAX_SIZE = 2 * 1024 * 1024 20 | AVATAR_ALLOWED_EXTENSIONS = ["png", "jpg", "jpeg"] 21 | AVATAR_DEFAULT_FILENAME = "default.jpeg" 22 | 23 | nickname = models.CharField(_("nickname"), max_length=30, blank=True) 24 | avatar = models.ImageField( 25 | _("avatar"), 26 | upload_to=user_avatar_path, 27 | validators=[ 28 | FileValidator( 29 | max_size=AVATAR_MAX_SIZE, allowed_extensions=AVATAR_ALLOWED_EXTENSIONS 30 | ) 31 | ], 32 | blank=True, 33 | ) 34 | avatar_thumbnail = ImageSpecField( 35 | source="avatar", 36 | processors=[ResizeToFill(70, 70)], 37 | format="jpeg", 38 | options={"quality": 100}, 39 | ) 40 | 41 | class Meta(AbstractUser.Meta): 42 | pass 43 | 44 | def save(self, *args, **kwargs): 45 | if not self.pk: 46 | if not self.nickname: 47 | self.nickname = self.username 48 | 49 | if not self.avatar: 50 | self.set_default_avatar() 51 | super(User, self).save(*args, **kwargs) 52 | 53 | def set_default_avatar(self): 54 | avatar_byte_array = AvatarGenerator.generate(self.username) 55 | self.avatar.save( 56 | self.AVATAR_DEFAULT_FILENAME, 57 | ContentFile(avatar_byte_array), 58 | save=False, 59 | ) 60 | -------------------------------------------------------------------------------- /yufuquant/users/serializers.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import authenticate 2 | from django.utils.translation import gettext_lazy as _ 3 | from rest_framework import serializers 4 | from rest_framework.authtoken.models import Token 5 | 6 | from .models import User 7 | 8 | 9 | class UserSerializer(serializers.ModelSerializer): 10 | avatar_url = serializers.ImageField(source="avatar_thumbnail") 11 | 12 | class Meta: 13 | model = User 14 | fields = ["id", "username", "nickname", "avatar_url"] 15 | 16 | 17 | class LoginSerializer(serializers.Serializer): 18 | username = serializers.CharField() 19 | password = serializers.CharField(style={"input_type": "password"}) 20 | 21 | default_error_messages = { 22 | "invalid_credentials": _("Unable to log in with provided credentials."), 23 | } 24 | 25 | def validate(self, attrs): 26 | password = attrs.get("password") 27 | params = {"username": attrs["username"]} 28 | user = authenticate(**params, password=password) 29 | if not user: 30 | self.fail("invalid_credentials") 31 | attrs["user"] = user 32 | return attrs 33 | 34 | 35 | class TokenUserSerializer(serializers.ModelSerializer): 36 | user = UserSerializer(read_only=True) 37 | auth_token = serializers.CharField(source="key", read_only=True) 38 | 39 | class Meta: 40 | model = Token 41 | fields = ["auth_token", "user"] 42 | -------------------------------------------------------------------------------- /yufuquant/users/signals.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/We-Hack-Studio/nuts/de39730db2480204bf1656377b86a317ff3b984d/yufuquant/users/signals.py -------------------------------------------------------------------------------- /yufuquant/users/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/We-Hack-Studio/nuts/de39730db2480204bf1656377b86a317ff3b984d/yufuquant/users/tests/__init__.py -------------------------------------------------------------------------------- /yufuquant/users/tests/factories.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Sequence 2 | 3 | from django.contrib.auth import get_user_model 4 | from factory import DjangoModelFactory, Faker, post_generation 5 | 6 | 7 | class UserFactory(DjangoModelFactory): 8 | 9 | username = Faker("user_name") 10 | email = Faker("email") 11 | 12 | class Meta: 13 | model = get_user_model() 14 | django_get_or_create = ["username"] 15 | 16 | @post_generation 17 | def password(self, create: bool, extracted: Sequence[Any], **kwargs): 18 | password = ( 19 | extracted 20 | if extracted 21 | else Faker( 22 | "password", 23 | length=42, 24 | special_chars=True, 25 | digits=True, 26 | upper_case=True, 27 | lower_case=True, 28 | ).generate(extra_kwargs={}) 29 | ) 30 | self.set_password(password) 31 | -------------------------------------------------------------------------------- /yufuquant/users/tests/test_views.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.urls import reverse_lazy 3 | from rest_framework.authtoken.models import Token 4 | from rest_framework.request import Request 5 | from rest_framework.test import APIRequestFactory 6 | from test_plus.test import APITestCase 7 | from users.models import User 8 | from users.serializers import UserSerializer 9 | 10 | from .factories import UserFactory 11 | 12 | 13 | class UserViewSetTestCase(APITestCase): 14 | maxDiff = None 15 | 16 | def setUp(self) -> None: 17 | self.api_request_factory = APIRequestFactory() 18 | 19 | self.admin_user = User.objects.create_superuser( 20 | username="admin", password="password", email="admin@yufuquant.cc" 21 | ) 22 | self.normal_user = self.make_user(username="normal", password="password") 23 | 24 | def test_me_permission(self): 25 | # anonymous user 26 | response = self.get("api:user-me") 27 | self.response_401(response) 28 | 29 | # normal user 30 | self.login(username=self.normal_user.username, password="password") 31 | response = self.get("api:user-me") 32 | self.response_403(response) 33 | 34 | def test_me(self): 35 | self.login(username=self.admin_user.username, password="password") 36 | response = self.get("api:user-me") 37 | self.response_200(response) 38 | 39 | request = self.api_request_factory.get("/users/me") 40 | serializer = UserSerializer( 41 | instance=self.admin_user, 42 | context={"request": Request(request)}, 43 | ) 44 | self.assertEqual(response.data, serializer.data) 45 | 46 | 47 | @pytest.mark.django_db 48 | def test_login(api_client): 49 | user = UserFactory(username="user", password="password") 50 | login_url = reverse_lazy("api:login") 51 | response = api_client.post( 52 | login_url, data={"username": user.username, "password": "password"} 53 | ) 54 | assert response.status_code == 200 55 | assert Token.objects.filter(user=user).count() == 1 56 | 57 | 58 | @pytest.mark.django_db 59 | def test_login_with_invalid_credential(api_client): 60 | login_url = reverse_lazy("api:login") 61 | response = api_client.post( 62 | login_url, data={"username": "user", "password": "password"} 63 | ) 64 | assert response.status_code == 400 65 | assert "non_field_errors" in response.data 66 | 67 | 68 | @pytest.mark.django_db 69 | def test_logout(api_client): 70 | user = UserFactory(username="user", password="password") 71 | api_client.login(username=user.username, password="password") 72 | logout_url = reverse_lazy("api:logout") 73 | response = api_client.post(logout_url) 74 | assert response.status_code == 204 75 | assert Token.objects.filter(user=user).count() == 0 76 | -------------------------------------------------------------------------------- /yufuquant/users/urls/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/We-Hack-Studio/nuts/de39730db2480204bf1656377b86a317ff3b984d/yufuquant/users/urls/__init__.py -------------------------------------------------------------------------------- /yufuquant/users/urls/auth.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from .. import views 4 | 5 | urlpatterns = [ 6 | path("login/", views.LoginView.as_view(), name="login"), 7 | path("logout/", views.LogoutView.as_view(), name="logout"), 8 | ] 9 | -------------------------------------------------------------------------------- /yufuquant/users/views.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import user_logged_in, user_logged_out 2 | from drf_spectacular.utils import extend_schema 3 | from rest_framework import status, viewsets 4 | from rest_framework.authtoken.models import Token 5 | from rest_framework.decorators import action 6 | from rest_framework.generics import GenericAPIView 7 | from rest_framework.permissions import AllowAny, IsAdminUser, IsAuthenticated 8 | from rest_framework.response import Response 9 | from rest_framework.views import APIView 10 | 11 | from .models import User 12 | from .serializers import LoginSerializer, TokenUserSerializer, UserSerializer 13 | 14 | 15 | class UserViewSet(viewsets.GenericViewSet): 16 | permission_classes = [IsAdminUser] 17 | 18 | def get_queryset(self): 19 | return User.objects.all() 20 | 21 | @extend_schema( 22 | summary="Get current user profile", 23 | ) 24 | @action( 25 | ["GET"], 26 | detail=False, 27 | url_path="me", 28 | url_name="me", 29 | serializer_class=UserSerializer, 30 | ) 31 | def me(self, request, *args, **kwargs): 32 | instance = self.request.user 33 | serializer = self.get_serializer(instance) 34 | return Response(serializer.data) 35 | 36 | 37 | class LoginView(GenericAPIView): 38 | serializer_class = LoginSerializer 39 | permission_classes = [AllowAny] 40 | 41 | @extend_schema( 42 | summary="Login", 43 | description="Obtain an auth token by providing username and password.", 44 | responses={200: TokenUserSerializer}, 45 | ) 46 | def post(self, request, *args, **kwargs): 47 | serializer = self.get_serializer(data=request.data) 48 | serializer.is_valid(raise_exception=True) 49 | user = serializer.validated_data["user"] 50 | token, _ = Token.objects.get_or_create(user=user) 51 | user_logged_in.send(sender=user.__class__, request=request, user=user) 52 | output_serializer = TokenUserSerializer( 53 | instance=token, context={"request": request} 54 | ) 55 | return Response( 56 | data=output_serializer.data, 57 | status=status.HTTP_200_OK, 58 | ) 59 | 60 | 61 | class LogoutView(APIView): 62 | permission_classes = [IsAuthenticated] 63 | 64 | @extend_schema( 65 | summary="Logout", 66 | description="Delete auth token from server.", 67 | responses={"204": None}, 68 | ) 69 | def post(self, request): 70 | Token.objects.filter(user=request.user).delete() 71 | user_logged_out.send( 72 | sender=request.user.__class__, request=request, user=request.user 73 | ) 74 | return Response(status=status.HTTP_204_NO_CONTENT) 75 | --------------------------------------------------------------------------------