C$50 金融
实现一个网站,用户可以在其中”买入”和”卖出”股票,如下所示。

背景
如果你还不完全理解买入和卖出股票(即公司股份)的含义,请前往此处阅读教程。
你将要实现 C$50 金融,一个可以通过它管理股票投资组合的 Web 应用。这个工具不仅能让你查看真实股票的价格和投资组合的价值,还能让你通过查询股票价格来买入(好吧,”买入”)和卖出(好吧,”卖出”)股票。
确实,有一些工具(其中一个叫 IEX)可以让你通过 API(应用程序编程接口)使用类似 https://api.iex.cloud/v1/data/core/quote/nflx?token=API_KEY 的 URL 来下载股票报价。请注意 Netflix 的股票代码(NFLX)是如何嵌入在这个 URL 中的;这就是 IEX 知道要返回谁的数据的方式。这个链接实际上不会返回任何数据,因为 IEX 要求你使用 API 密钥,但如果它有效,你会看到像下面这样的 JSON(JavaScript Object Notation)格式的响应:
{
"avgTotalVolume":6787785,
"calculationPrice":"tops",
"change":1.46,
"changePercent":0.00336,
"close":null,
"closeSource":"official",
"closeTime":null,
"companyName":"Netflix Inc.",
"currency":"USD",
"delayedPrice":null,
"delayedPriceTime":null,
"extendedChange":null,
"extendedChangePercent":null,
"extendedPrice":null,
"extendedPriceTime":null,
"high":null,
"highSource":"IEX real time price",
"highTime":1699626600947,
"iexAskPrice":460.87,
"iexAskSize":123,
"iexBidPrice":435,
"iexBidSize":100,
"iexClose":436.61,
"iexCloseTime":1699626704609,
"iexLastUpdated":1699626704609,
"iexMarketPercent":0.00864679844447232,
"iexOpen":437.37,
"iexOpenTime":1699626600859,
"iexRealtimePrice":436.61,
"iexRealtimeSize":5,
"iexVolume":965,
"lastTradeTime":1699626704609,
"latestPrice":436.61,
"latestSource":"IEX real time price",
"latestTime":"9:31:44 AM",
"latestUpdate":1699626704609,
"latestVolume":null,
"low":null,
"lowSource":"IEX real time price",
"lowTime":1699626634509,
"marketCap":192892118443,
"oddLotDelayedPrice":null,
"oddLotDelayedPriceTime":null,
"open":null,
"openTime":null,
"openSource":"official",
"peRatio":43.57,
"previousClose":435.15,
"previousVolume":2735507,
"primaryExchange":"NASDAQ",
"symbol":"NFLX",
"volume":null,
"week52High":485,
"week52Low":271.56,
"ytdChange":0.4790450244167119,
"isUSMarketOpen":true
}
请注意,在大括号之间,有一个由逗号分隔的键值对列表,每个键和值之间用冒号分隔。我们将对我们自己的股票数据库 API 做非常类似的事情。
现在让我们把注意力转向获取这个问题的分发代码!
入门
登录 cs50.dev,点击你的终端窗口,然后单独执行 cd。你应该会看到终端窗口的提示符类似于下面这样:
$
接下来执行
wget https://cdn.cs50.net/2026/x/psets/9/finance.zip
从而将一个名为 finance.zip 的 ZIP 文件下载到你的 codespace 中。
然后执行
unzip finance.zip
来创建一个名为 finance 的文件夹。你不再需要这个 ZIP 文件了,所以可以执行
rm finance.zip
并在提示符下回复 “y” 然后按 Enter 键来删除你下载的 ZIP 文件。
现在输入
cd finance
然后按 Enter 键进入(即打开)该目录。你的提示符现在应该类似于下面这样。
finance/ $
单独执行 ls,你应该会看到一些文件和文件夹:
app.py finance.db helpers.py requirements.txt static/ templates/
如果你遇到任何问题,请再次按照这些相同的步骤操作,看看能否确定哪里出错了!
运行
启动 Flask 的内置 Web 服务器(在 finance/ 目录内):
$ flask run
访问 flask 输出的 URL 来查看运行中的分发代码。不过你现在还无法登录或注册!
在 finance/ 目录内,运行 sqlite3 finance.db 来用 sqlite3 打开 finance.db。如果你在 SQLite 提示符中运行 .schema,会注意到 finance.db 自带一个名为 users 的表。查看它的结构(即模式)。请注意,默认情况下,新用户将获得 $10,000 的现金。但是如果你运行 SELECT * FROM users;,里面(暂时!)还没有任何用户(即行)可以浏览。
另一种查看 finance.db 的方法是使用一个名为 phpLiteAdmin 的程序。在你的 codespace 文件浏览器中点击 finance.db,然后点击文本”Please visit the following link to authorize GitHub Preview”下方显示的链接。你应该会看到关于数据库本身的信息,以及一个表 users,就像你在 sqlite3 提示符中用 .schema 看到的一样。
理解代码
app.py
打开 app.py。文件顶部有一堆导入语句,其中包括 CS50 的 SQL 模块和一些辅助函数。稍后会详细介绍它们。
在配置 Flask 之后,请注意这个文件禁用了响应的缓存(前提是你处于调试模式,在 code50 codespace 中默认如此),以免你对某个文件做了更改但浏览器没有注意到。接下来请注意它如何用自定义的”过滤器” usd 来配置 Jinja,这是一个(定义在 helpers.py 中的)函数,可以更容易地将值格式化为美元(USD)。然后它进一步配置 Flask 将会话(sessions)存储到本地文件系统(即磁盘),而不是存储在(数字签名的)cookie 中,后者是 Flask 的默认设置。接着文件配置 CS50 的 SQL 模块来使用 finance.db。
之后有一大堆路由,其中只有两个是完全实现的:login 和 logout。先阅读 login 的实现。请注意它如何使用 db.execute(来自 CS50 的库)来查询 finance.db。还要注意它如何使用 check_password_hash 来比较用户密码的哈希值。还要注意 login 如何通过将用户的 user_id(一个 INTEGER)存储在 session 中来”记住”用户已登录。这样,这个文件中的任何路由都可以检查哪个用户(如果有的话)已登录。最后,请注意一旦用户成功登录,login 会重定向到 ”/”,将用户带到他们的首页。与此同时,注意 logout 仅仅是清空 session,从而有效地将用户登出。
请注意大多数路由如何用 @login_required(一个也定义在 helpers.py 中的函数)来”装饰”。这个装饰器确保,如果用户尝试访问这些路由中的任何一个,他或她将首先被重定向到 login 以便登录。
还要注意大多数路由都支持 GET 和 POST。即便如此,它们中的大多数(目前!)只是返回一个”道歉”,因为它们还没有实现。
helpers.py
接下来看一下 helpers.py。啊,这里有 apology 的实现。请注意它最终如何渲染一个模板 apology.html。它还在自身内部定义了另一个函数 escape,用来替换道歉信息中的特殊字符。通过将 escape 定义在 apology 内部,我们将其作用域限定在后者之内;没有其他函数能够(或需要)调用它。
文件中接下来是 login_required。如果这个有点难懂,不用担心,但如果你曾经好奇一个函数如何返回另一个函数,这里就是一个示例!
之后是 lookup,一个函数,给定一个 symbol(例如 NFLX),返回一家公司的股票报价,形式为一个包含三个键的 dict:name,其值为一个 str;price,其值为一个 float;以及 symbol,其值为一个 str,即经过规范化(大写)的股票代码,无论该代码传入 lookup 时是如何大小写的。请注意这些价格不是”实时”价格,但会随时间变化,就像现实世界一样!
文件中最后是 usd,一个简短函数,简单地将一个 float 格式化为美元(例如,1234.56 格式化为 $1,234.56)。
requirements.txt
接下来快速看一下 requirements.txt。该文件只是列出了这个应用所依赖的包。
static/
也看一下 static/,里面是 styles.css。那里是一些初始的 CSS。欢迎你根据自己的需要来修改它。
templates/
现在看看 templates/。在 login.html 中,实质就是一个 HTML 表单,用 Bootstrap 美化了。而在 apology.html 中,是一个道歉的模板。回想一下,helpers.py 中的 apology 接受两个参数:message,它作为 bottom 的值传递给 render_template,以及可选的 code,它作为 top 的值传递给 render_template。请注意在 apology.html 中这些值最终是如何使用的!而这就是为什么 0:-)
最后是 layout.html。它比平时大了一点,但这主要是因为带有一个同样基于 Bootstrap 的、精美的、移动友好的”导航栏”(navigation bar)。请注意它如何定义了一个块 main,模板(包括 apology.html 和 login.html)将放入其中。它还包括对 Flask 的消息闪现的支持,以便你可以将消息从一个路由传递给另一个路由供用户查看。
规格说明
注册
完成 注册 的实现,使其允许用户通过表单注册账户。
- 要求用户输入用户名,实现为一个
name为username的文本字段。如果用户的输入为空或用户名已存在,则渲染一个道歉页面。- 注意,如果你尝试
INSERT一个重复的用户名,cs50.SQL.execute会引发一个ValueError异常,因为我们在users.username上创建了一个UNIQUE INDEX。因此,请务必使用try和except来判断用户名是否已存在。
- 注意,如果你尝试
- 要求用户输入密码,实现为一个
name为password的文本字段,然后再输入一次同样的密码,实现为一个name为confirmation的文本字段。如果任一输入为空或密码不匹配,则渲染一个道歉页面。 - 通过
POST将用户输入提交到/注册。 -
INSERT新用户到users中,存储用户密码的哈希值,而不是密码本身。使用generate_password_hash对用户密码进行哈希处理。你可能需要创建一个新模板(例如注册.html),它和login.html非常类似。
一旦你正确实现了 注册,你就能够注册账户并登录(因为 login 和 logout 已经能正常工作了)!而且你应该能够通过 phpLiteAdmin 或 sqlite3 看到你的行。
quote
完成 quote 的实现,使其允许用户查找股票的当前价格。
- 要求用户输入股票代码,实现为一个
name为symbol的文本字段。 - 通过
POST将用户输入提交到/quote。 - 你可能需要创建两个新模板(例如
quote.html和quoted.html)。当用户通过 GET 访问/quote时,渲染其中一个模板,该模板内应包含一个通过 POST 提交到/quote的 HTML 表单。在响应 POST 时,quote可以渲染第二个模板,在其中嵌入lookup的一个或多个值。
buy
完成 buy 的实现,使其允许用户买入股票。
- 要求用户输入股票代码,实现为一个
name为symbol的文本字段。如果输入为空或代码不存在(根据lookup的返回值),则渲染一个道歉页面。 - 要求用户输入股票数量,实现为一个
name为shares的文本字段。如果输入不是正整数,则渲染一个道歉页面。 - 通过
POST将用户输入提交到/buy。 - 完成后,将用户重定向到首页。
- 你可能需要调用
lookup来查找股票的当前价格。 - 你可能需要
SELECT用户当前在users中有多少现金。 - 向
finance.db中添加一个或多个新表,用来跟踪购买记录。存储足够的信息,以便你知道谁在何时以什么价格买了什么。- 使用适当的 SQLite 类型。
- 在应该唯一的任何字段上定义
UNIQUE索引。 - 在你将要搜索的任何字段上定义(非
UNIQUE)索引(如通过SELECT配合WHERE进行搜索)。
- 如果用户无法以当前价格负担该数量的股票,则渲染一个道歉页面,而不完成购买。
- 你不必担心竞争条件(或使用事务)。
一旦你正确实现了 buy,你应该能够通过 phpLiteAdmin 或 sqlite3 在你的新表中看到用户的购买记录。
index
完成 index 的实现,使其显示一个 HTML 表格,汇总当前登录用户所拥有的股票、持有的股票数量、每只股票的当前价格以及每项持仓的总价值(即股票数量乘以价格)。同时显示用户当前的现金余额和总资产(即股票总价值加上现金)。
sell
完成 sell 的实现,使其允许用户卖出股票(他或她拥有的股票)。
- 要求用户输入股票代码,实现为一个
select菜单,其name为symbol。如果用户未能选择股票或(以某种方式,在提交后)用户不拥有该股票的任何份额,则渲染一个道歉页面。 - 要求用户输入股票数量,实现为一个
name为shares的文本字段。如果输入不是正整数或用户不拥有该股票那么多份额,则渲染一个道歉页面。 - 通过
POST将用户输入提交到/sell。 - 完成后,将用户重定向到首页。
- 你不必担心竞争条件(或使用事务)。
history
完成 history 的实现,使其显示一个 HTML 表格,汇总用户所有的历史交易,逐行列出每一次买入和每一次卖出。
- 对于每一行,清楚地表明是买入还是卖出,并包括股票代码、(购买或出售)价格、买入或卖出的股票数量以及交易发生的日期和时间。
- 你可能需要修改你为
buy创建的表或用额外的表来补充它。尽量最小化冗余。
个性化功能
至少实现一个你选择的个性化功能:
- 允许用户更改他们的密码。
- 允许用户向他们的账户添加额外现金。
- 允许用户直接通过
index页面本身买入更多股票或卖出他们已经拥有的股票,而无需手动输入股票代码。 - 实现其他同等范围的功能。
讲解视频
测试
请务必手动测试你的 Web 应用,例如:
- 注册一个新用户,并验证他们的投资组合页面加载了正确的信息,
- 使用有效的股票代码请求报价,
- 多次购买一只股票,验证投资组合显示正确的总数,
- 卖出全部或部分股票,再次验证投资组合,以及
- 验证你的历史页面显示当前登录用户的所有交易记录。
还要测试一些意外使用情况,例如:
- 在只需要数字的表单中输入字母字符串,
- 在只需要正数的表单中输入零或负数,
- 在只需要整数的表单中输入浮点值,
- 尝试花费比用户拥有更多的现金,
- 尝试卖出比用户拥有更多的股票,
- 输入无效的股票代码,以及
- 在 SQL 查询中包含像
'和;这样有潜在危险的字符。
你还可以通过点击每个页面页脚的 I ♥ VALIDATOR 按钮来检查你的 HTML 的有效性,该按钮会将你的 HTML POST 到 validator.w3.org。
满意后,用 check50 测试你的代码,执行以下命令。
check50 cs50/problems/2026/x/finance
请注意,check50 会将你的整个程序作为一个整体进行测试。如果你在完成所有必需功能之前运行它,它可能会在那些实际上正确但依赖其他函数的函数上报告错误。
代码风格
style50 app.py
工作人员解答
欢迎你以不同的风格美化你自己的应用,但以下是工作人员解答的样子!
请随意注册一个账户并体验一番。不要使用你在其他网站上使用的密码。
查看工作人员的 HTML 和 CSS 是合理的。
提示
- 要将一个值格式化为美元值(美分显示到两位小数),你可以在 Jinja 模板中使用
usd过滤器(将值打印为{{ value | usd }}而不是{{ value }})。 -
在
cs50.SQL中有一个execute方法,其第一个参数应该是一个 SQL 的str。如果该str包含需要绑定值的问题标记参数,这些值可以作为额外的命名参数提供给execute。查看login的实现以获取这样一个例子。execute的返回值如下:- 如果
str是一个SELECT,那么execute返回一个包含零个或多个dict对象的list,其中键和值分别代表表的字段和单元格。 - 如果
str是一个INSERT,并且数据被插入的表中包含一个自增的PRIMARY KEY,那么execute返回新插入行的主键值。 - 如果
str是一个DELETE或UPDATE,那么execute返回由str删除或更新的行数。
- 如果
- 请记住,
cs50.SQL会在你的终端窗口中记录你通过execute执行的任何查询(这样你可以确认它们是否符合预期)。 - 请务必在调用 CS50 的
execute方法时使用问题标记绑定的参数(即 paramstyle 为named),类似于WHERE ?。不要使用 f-string、format或+(即连接),以免面临 SQL 注入攻击的风险。 - 如果你(且只有当你)已经熟悉 SQL,欢迎使用 SQLAlchemy Core 或 Flask-SQLAlchemy(即 SQLAlchemy ORM)来代替
cs50.SQL。 - 请注意,虽然你可以验证在 HTML 表单中输入的值,但精明的用户可能会绕过这些。请确保你也在服务器端验证值。同样地,如果你选择在解决方案中使用 JavaScript,请确保如果你的用户禁用了 JavaScript,你的应用程序仍然可以工作。
- 欢迎你向
static/中添加额外的静态文件。 - 如果在
helpers.py中添加额外函数,请务必不要打开新的数据库连接。如果辅助函数需要使用数据库,请将其作为参数传入,而不是重新实例化连接。 - 在实现模板时,你可能需要查阅 Jinja 的文档。
- 请别人试用(并尝试触发错误)你的网站是合理的。
- 欢迎你修改网站的美观风格,可以通过
- 你可能会发现 Flask 的文档和 Jinja 的文档很有帮助!
常见问题
ImportError: No module named ‘application’
默认情况下,flask 会在你的当前工作目录中查找名为 app.py 的文件(因为我们将环境变量 FLASK_APP 的值配置为 app.py)。如果看到此错误,很可能你是在错误的目录中运行 flask 的!
OSError: [Errno 98] Address already in use
如果在运行 flask 时看到此错误,很可能你(仍然)在另一个标签页中运行着 flask。请务必在再次启动 flask 之前,通过 ctrl-c 终止该进程。如果你没有这样的其他标签页,请执行 fuser -k 8080/tcp 来终止(仍在)监听 TCP 端口 8080 的任何进程。
如何提交
在终端中执行以下命令提交你的作业,并按提示完成操作。
submit50 cs50/problems/2026/x/finance
为什么我的提交通过了 check50,但在运行 submit50 后成绩册中显示”No results”?
在某些情况下,submit50 可能不会对作业进行评分,原因可能是 (1) 你的 app.py 文件格式不一致,和/或 (2) 有其他不必要的文件随问题集一起提交了。要解决这些问题,请在 finance 文件夹中运行 style50 app.py。解决发现的任何问题。接下来,检查你的 finance 文件夹的内容。删除无关的文件,例如 flask 会话或其他不属于你实现的问题集的文件。此外,再次运行 check50 以确保你的提交仍然能正常工作。最后,再次运行上面的 submit50 命令。你的结果将在几分钟内显示在你的成绩册中。
请注意,如果你的成绩册中 submissions 区域里的金融提交旁边有一个数字分数,那么上述流程不适用于你。很可能你还没有完全满足问题集的要求,应该依赖 check50 来寻找还有哪些工作需要完成的线索。