简介:这是一份基于Django框架的股票交易管理系统完整项目资源,面向有一定Python基础、希望实战Web开发的开发者,也适合计算机专业学生用于课程设计或毕业设计。压缩包共1611个文件,约21.84MB,其中包含31个Python源码文件、20个SQL数据库脚本、879个JavaScript脚本,以及167个HTML页面、162个CSS样式表等前端资源,图片与字体素材服务于界面渲染,另有文档辅助阅读。整套资源完整覆盖了Django的MTV架构、ORM数据库操作、用户认证与权限管理、表单处理和AJAX异步更新等核心知识点,能直观看到模型、视图、模板与路由之间的协作方式。项目自带可运行的页面与数据脚本,能帮助快速搭建一个从用户注册登录到股票查询、交易记录展示的完整系统,同时支持按需扩展,适合作为课程设计、毕业设计或Python Web开发练手项目。当前已有187人学习浏览,目录层次清晰,便于对照源码逐模块理解一个真实项目的后端逻辑与前端呈现流程。
1. 基于 Django 的股票交易管理系统,先想清楚它要管什么
一个基于 Django 的股票交易管理系统,最容易做错的地方不是 K 线图,也不是用户注册,而是账目一致性。这种系统本质上是一个模拟盘闭环:用户有账户、能下单、能成交、有持仓,晚上还要算得出账,任何一笔资金错位都会让整套演示变成“玩具”。它适合两类人:一类是拿 Django 做毕设或课程项目,想看看核心逻辑如何组织;另一类是初级后端,想搞明白事务、行级锁和模型设计如何落在一个真实业务里。下面按我从零搭这个系统时的顺序展开,先把数据库立住,再写交易逻辑,最后处理行情展示和上线前验证。
2. Django 项目脚手架与数据模型设计,先把“账户-持仓-订单”三张表立住
2.1 用 venv 和 django-admin 初始化一个带 MySQL 的项目
常见做法是先建独立虚拟环境,再创建 Django 项目和应用。股票交易系统虽然可以先用 SQLite 起步,但部署到宝塔这类面板时多半会用 MySQL,所以我在第一步就把数据库连上,避免后边迁移出幺蛾子。
mkdir stock_trading && cd stock_trading python3 -m venv venv source venv/bin/activate pip install django mysqlclient pandas redis django-admin startproject config . python manage.py startapp tradingvenv隔离项目依赖,避免和系统 Python 包冲突;startproject config .把项目配置放在当前目录,manage.py直接落在根目录;startapp trading创建业务应用。执行完后,修改配置连接 MySQL:
# config/settings.py DATABASES = { 'default': { 'ENGINE': 'django.db.backends.mysql', 'NAME': 'stock_trading', 'USER': 'root', 'PASSWORD': 'your_password', 'HOST': '127.0.0.1', 'PORT': '3306', 'OPTIONS': { 'charset': 'utf8mb4', 'init_command': "SET sql_mode='STRICT_TRANS_TABLES'", }, } } INSTALLED_APPS = [ 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', 'trading', ]mysqlclient在部分环境需要编译依赖,宝塔里通常先安装 MySQL 扩展再pip install mysqlclient。charset必须用utf8mb4,否则股票名称里的生僻字可能存不进去;STRICT_TRANS_TABLES会让字段过长直接报错而不是静默截断,交易系统里宁可报错也不能悄悄丢数据。
2.2 股票与账户模型的三个关键约束
交易系统最核心的几张表是:股票、账户、持仓、订单。模型设计时先别急着写代码,把金额字段和数量字段的类型定死,比任何校验器都重要。
# trading/models.py from django.db import models from django.contrib.auth.models import User from django.core.validators import MinValueValidator class Stock(models.Model): code = models.CharField('股票代码', max_length=10, unique=True) name = models.CharField('股票名称', max_length=32) last_price = models.DecimalField('最新价', max_digits=12, decimal_places=2, default=0) updated_at = models.DateTimeField(auto_now=True) def __str__(self): return f'{self.code} {self.name}' class Account(models.Model): user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='account') cash = models.DecimalField('可用资金', max_digits=16, decimal_places=2, default=1000000) base_cash = models.DecimalField('初始资金', max_digits=16, decimal_places=2, default=1000000) created_at = models.DateTimeField(auto_now_add=True) class Holding(models.Model): account = models.ForeignKey(Account, on_delete=models.CASCADE, related_name='holdings') stock = models.ForeignKey(Stock, on_delete=models.PROTECT) quantity = models.IntegerField('持仓数量', default=0, validators=[MinValueValidator(0)]) available_quantity = models.IntegerField('可卖数量', default=0, validators=[MinValueValidator(0)]) class Meta: unique_together = ('account', 'stock')三个关键约束我一般会盯死:第一,金额用DecimalField,绝不用FloatField,浮点数的 0.1 加 0.2 会让对账脚本永远跑不通;第二,available_quantity和quantity分开,是为了以后做 T+1 或冻结卖出时不用改表结构;第三,unique_together保证同一账户同一股票只能有一条持仓记录,否则并发买入时会拆出多行,后面查询就会乱。
python manage.py makemigrations trading python manage.py migrate如果之后改了模型字段,记住每次迁移都先makemigrations再migrate,不要手改数据库表结构,否则 Django 的迁移历史会和你实际表结构对不上。
2.3 在 admin 后台把模型暴露出来并做基础美化
管理员后台是这类管理系统最容易出彩的地方。先用 Django 自带的 admin 把模型注册进去,再叠加一个主题美化,已经很能打。
pip install django-simpleui # config/settings.py 的 INSTALLED_APPS 中,把 'simpleui' 放在 'django.contrib.admin' 之前# trading/admin.py from django.contrib import admin from .models import Stock, Account, Holding, Order @admin.register(Stock) class StockAdmin(admin.ModelAdmin): list_display = ('code', 'name', 'last_price', 'updated_at') search_fields = ('code', 'name') list_editable = ('last_price',) @admin.register(Account) class AccountAdmin(admin.ModelAdmin): list_display = ('user', 'cash', 'base_cash') readonly_fields = ('user',) @admin.register(Holding) class HoldingAdmin(admin.ModelAdmin): list_display = ('account', 'stock', 'quantity', 'available_quantity') list_select_related = ('account', 'stock')search_fields直接给管理后台加搜索框,list_editable可以让最新价在列表页直接改,这对管理员手动修正行情非常方便。list_select_related能减少列表页对用户和股票的重复查询。如果你的项目走的是前后端分离路线,admin 只给运营用,用户端再用 Django REST Framework 或 Django + Vue 提供接口,不冲突。
3. 订单撮合与资金结算,用 Django 事务和行级锁保证账实相符
3.1 订单表设计:先定义订单状态和成交逻辑
交易逻辑是整个系统的命门。我先定义一个订单模型,它同时保留委托信息和成交信息,方便以后查历史流水。
# trading/models.py class Order(models.Model): STATUS = ( ('PENDING', '待成交'), ('SUCCESS', '已成交'), ('CANCELED', '已撤销'), ('FAILED', '失败'), ) SIDE = ( ('BUY', '买入'), ('SELL', '卖出'), ) account = models.ForeignKey(Account, on_delete=models.CASCADE, related_name='orders') stock = models.ForeignKey(Stock, on_delete=models.PROTECT) side = models.CharField('方向', max_length=8, choices=SIDE) price = models.DecimalField('委托价格', max_digits=12, decimal_places=2) quantity = models.IntegerField('委托数量') filled_price = models.DecimalField('成交均价', max_digits=12, decimal_places=2, null=True, blank=True) filled_quantity = models.IntegerField('成交数量', default=0) status = models.CharField('状态', max_length=16, choices=STATUS, default='PENDING') created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True)订单状态流转尽量简单,初始为PENDING,模拟盘里如果按下单即成交,就直接更新为SUCCESS;用户主动撤单且订单尚未成交,才能改成CANCELED。状态机的核心约束用表格说清楚:
| 状态 | 可执行操作 | 触发条件 |
|---|---|---|
| PENDING | 撤单 | 用户点击撤销,且未成交 |
| PENDING | 成交 | 撮合成功,资金/持仓结算 |
| SUCCESS | 不可撤单 | 已成交,只有流水记录 |
| CANCELED | 不可再次撤销 | 撤单时已经冻结释放 |
| FAILED | 不可操作 | 校验失败,比如资金不足 |
filled_price和filled_quantity保留实际成交价格与数量,是为了以后做部分成交。模拟盘可以先只支持一次性全部成交,但字段结构要留好。
3.2 买入成交:select_for_update 与原子事务的配合
买入操作要在同一个数据库事务里完成三件事:扣钱、加持仓、写订单。如果不加锁,两个请求同时读到同一余额,就会造成超买。
from decimal import Decimal from django.db import transaction from django.db.models import F from django.core.exceptions import ValidationError @transaction.atomic def create_buy_order(account_id, stock_id, price, quantity): account = Account.objects.select_for_update().get(pk=account_id) needed = price * quantity if account.cash < needed: raise ValidationError('可用资金不足') order = Order.objects.create( account_id=account_id, stock_id=stock_id, side='BUY', price=price, quantity=quantity, filled_price=price, filled_quantity=quantity, status='SUCCESS', ) account.cash = F('cash') - needed account.save(update_fields=['cash']) account.refresh_from_db() holding = Holding.objects.select_for_update().filter( account_id=account_id, stock_id=stock_id ).first() if holding is None: holding = Holding.objects.create( account_id=account_id, stock_id=stock_id, quantity=0, available_quantity=0, ) holding.quantity = F('quantity') + quantity holding.available_quantity = F('available_quantity') + quantity holding.save(update_fields=['quantity', 'available_quantity']) holding.refresh_from_db() return order这里有几个参数和动作必须说明。select_for_update()是在数据库层面锁住账户这一行,必须放在@transaction.atomic事务块里才生效,否则锁会随非原子操作自动提交。F('cash') - needed是在数据库端做减法,比先读后写在内存里减更抗并发。refresh_from_db()是因为F()表达式赋值后,Python 内存里的account.cash还是旧值,后续如果要记录日志或返回前端,必须先刷新。
买入时如果要支持挂单,还需要在账户上增加“冻结资金”字段,下单时先冻结,成交后再扣可用资金。上面这个版本是“下单即成交”的演示逻辑,已经足够说明事务和锁的配合方式。
3.3 卖出成交:先冻结持仓再释放,避免负数持仓
卖出和买入是对称操作,但坑更多。最容易出现的情况是用户反复点击卖出,把同一笔持仓卖两次。
@transaction.atomic def create_sell_order(account_id, stock_id, price, quantity): account = Account.objects.select_for_update().get(pk=account_id) try: holding = Holding.objects.select_for_update().get( account_id=account_id, stock_id=stock_id ) except Holding.DoesNotExist: raise ValidationError('没有该股票持仓') if holding.available_quantity < quantity: raise ValidationError('可卖数量不足') order = Order.objects.create( account_id=account_id, stock_id=stock_id, side='SELL', price=price, quantity=quantity, filled_price=price, filled_quantity=quantity, status='SUCCESS', ) holding.available_quantity = F('available_quantity') - quantity holding.quantity = F('quantity') - quantity holding.save(update_fields=['available_quantity', 'quantity']) holding.refresh_from_db() account.cash = F('cash') + price * quantity account.save(update_fields=['cash']) account.refresh_from_db() return order对持仓行同样加select_for_update(),锁住之后,同一时刻只有第一个请求能先扣减数量,第二个请求检查available_quantity时就会发现不足。注意卖出后持仓数量可能变成 0,此时我倾向于保留这条Holding记录,而不是删掉,因为历史报表还要统计曾经持有过什么;如果非要清理,建议在日终任务里统一处理。
3.4 用 reverse 和 resolve 写订单查询接口并验证路由
后台交易逻辑完成后,要给前端或管理端提供查询接口。常见的做法是 Django 项目里同时保留常规视图和接口路由,方便前后端分离时联调。
# trading/views.py from django.http import JsonResponse from django.urls import reverse from django.views.decorators.http import require_GET from .models import Order @require_GET def order_list(request, account_id): orders = Order.objects.filter(account_id=account_id).select_related('stock')[:50] data = [{ 'id': o.id, 'stock': o.stock.code, 'side': o.side, 'quantity': o.quantity, 'price': str(o.price), 'status': o.status, 'detail_url': request.build_absolute_uri(reverse('order-detail', args=[o.id])), } for o in orders] return JsonResponse({'orders': data})# trading/urls.py from django.urls import path from .views import order_list urlpatterns = [ path('api/accounts/<int:account_id>/orders/', order_list, name='order-list'), ]参数说明:account_id是路径参数,Django 会自动做整数转换;reverse('order-detail', args=[o.id])根据路由名称反解析出 URL,前端拿到 detail_url 就能继续查单笔明细。如果你要写单元测试校验路由,可以用resolve(reverse('order-list', args=[1])),测试里先反解析再正向解析,能确认 URL 和视图函数没有脱节。
4. 行情数据与 K 线展示,缓存和异步更新别拖垮业务库
4.1 用 pandas 把 CSV 行情一次性导入 MySQL
行情数据是这个系统里数据量最大的部分。手动一条条插入不现实,常见做法是准备好 CSV 文件,用 Django 的管理命令批量导入。
pip install pandas mkdir -p trading/management/commands touch trading/management/commands/__init__.py touch trading/management/commands/import_quotes.py# trading/management/commands/import_quotes.py import pandas as pd from django.core.management.base import BaseCommand from trading.models import Stock class Command(BaseCommand): help = '从 CSV 导入股票基础信息' def add_arguments(self, parser): parser.add_argument('csv_file') def handle(self, *args, **options): df = pd.read_csv(options['csv_file']) stocks = [] for row in df.itertuples(): stocks.append(Stock( code=str(row.code), name=row.name, last_price=row.last_price, )) Stock.objects.bulk_create(stocks, ignore_conflicts=True) self.stdout.write(self.style.SUCCESS(f'imported {len(stocks)} stocks'))bulk_create一次插入多行,比循环save()快很多;ignore_conflicts=True表示主键或唯一键冲突时跳过,重复执行导入命令不会报错。注意 CSV 表头必须和你代码里的字段名一致,如果是从交易所数据源拿到的文件,通常还要先处理中文表头。
4.2 用 django.core.cache 缓存高频查询的 K 线数据
Django 自带的缓存框架足够应付 K 线查询场景。先配置 Redis 缓存,再封装一个读取函数。
pip install django-redis# config/settings.py CACHES = { 'default': { 'BACKEND': 'django_redis.cache.RedisCache', 'LOCATION': 'redis://127.0.0.1:6379/1', 'OPTIONS': { 'CLIENT_CLASS': 'django_redis.client.DefaultClient', }, } }# trading/services.py from django.core.cache import cache from trading.models import Stock def kline_data(stock_code, days=120): cache_key = f'kline:{stock_code}:{days}' data = cache.get(cache_key) if data is not None: return data stock = Stock.objects.filter(code=stock_code).first() if not stock: return [] quotes = stock.quotes.order_by('-trade_date')[:days] data = [{ 'date': str(q.trade_date), 'open': float(q.open_price), 'high': float(q.high_price), 'low': float(q.low_price), 'close': float(q.close_price), 'volume': int(q.volume), } for q in reversed(quotes)] cache.set(cache_key, data, timeout=600) return data缓存键里带上股票代码和天数两个参数,是最容易被忽略的细节。如果不带days,用户切换 60 日和 120 日时会互相覆盖。缓存内容存 Python 的字典列表,而不是 ORM 对象,避免了请求结束后访问懒加载报错。timeout=600表示 10 分钟过期,行情更新频率不高时,这个时间足够。
| 参数 | 用途 | 建议 |
|---|---|---|
| timeout | 缓存过期时间 | 行情 300-900 秒 |
| cache_key | 区分不同参数 | 必须包含 code、days |
| data 类型 | 存储内容 | 只用基础类型,不用 ORM 对象 |
4.3 用 StreamingHttpResponse 导出交易流水,别等查询完才吐给用户
管理员经常要导出一整年的订单流水。用普通HttpResponse时,Django 会先拼完整张表格再发给浏览器,数据量大时请求会长时间无响应。改成 Streaming 后,先给响应头,再一行一行输出。
# trading/views.py import csv from django.http import StreamingHttpResponse from trading.models import Order class CsvEcho: def write(self, value): return value def export_orders(request): pseudo_buffer = CsvEcho() writer = csv.writer(pseudo_buffer) def rows(): yield writer.writerow(['订单号', '账户', '股票', '方向', '数量', '价格', '状态']) orders = Order.objects.select_related('account__user', 'stock').iterator(chunk_size=2000) for o in orders: yield writer.writerow([ o.id, o.account.user.username, o.stock.code, o.side, o.quantity, o.price, o.status, ]) response = StreamingHttpResponse(rows(), content_type='text/csv') response['Content-Disposition'] = 'attachment; filename="orders.csv"' return responseStreamingHttpResponse的content_type告诉浏览器这是 CSV 文件;Content-Disposition里的attachment表示下载而不是直接打开,filename指定下载后的文件名。iterator(chunk_size=2000)让数据库查询按每 2000 条分块返回,而不是把所有订单先加载到内存。小文件没必要用流式,但股票交易系统跑上一年后订单量很容易破十万,这个出口要提前留好。
5. 上线前用管理命令做缓存预热和日终对账,别等用户发现资产不对
5.1 先跑通宝塔部署 Django 的常用配置
如果你用宝塔面板部署,常见做法是在站点目录里创建虚拟环境,用 gunicorn 跑 Django 应用。命令大致如下:
cd /www/wwwroot/stock_trading python3 -m venv venv source venv/bin/activate pip install -r requirements.txt python manage.py collectstatic --noinput gunicorn config.wsgi:application --bind 127.0.0.1:8000 --workers 2 --threads 4collectstatic把 admin 和 simpleui 的静态文件集中到STATIC_ROOT,交给 Nginx 或者whitenoise服务;--workers 2不要乱调高,因为交易逻辑里用了select_for_update(),worker 再多也是在同一把数据库锁上排队,线程数可以稍微高一点处理 I/O。
5.2 写一条 manage.py 命令做日终资产对账
上线后第一件事不是看 K 线,而是对账。写一条 Django 管理命令,每天收盘后跑一遍,扫出资产异常的账户。
# trading/management/commands/daily_reconcile.py from decimal import Decimal from django.core.management.base import BaseCommand from trading.models import Account class Command(BaseCommand): help = '日终对账:校验现金 + 持仓市值是否小于初始资金' def handle(self, *args, **options): for account in Account.objects.select_related('user').iterator(): cash = account.cash market_value = Decimal('0') for holding in account.holdings.select_related('stock'): market_value += holding.quantity * holding.stock.last_price total = cash + market_value if total < account.base_cash - Decimal('0.01'): self.stderr.write( f'{account.user.username} 资产异常: {total}' ) else: self.stdout.write( f'{account.user.username} 对账通过: {total}' )对账脚本的核心不是算余额,而是用“现金+持仓市值”和“初始资金”做守恒校验。Decimal('0.01')是为了吸收计算时四舍五入带来的少量误差,但如果你发现误差经常超过 0.01,那一定不是浮点问题,而是某个事务没有写成原子操作。
缓存预热也可以合并到这条命令里:命令执行时顺手读取当日热点股票的 K 线,让 Redis 提前缓存好,开盘后用户访问就不会打到 MySQL 上。把这个命令挂到宝塔的计划任务里,每天收盘后执行,系统就算真正有了“日终结算”的雏形。
本文还有配套的精品资源,点击获取