C$50 金融

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

”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

之后有一大堆路由,其中只有两个是完全实现的:loginlogout。先阅读 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),返回一家公司的股票报价,形式为一个包含三个键的 dictname,其值为一个 strprice,其值为一个 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.htmllogin.html)将放入其中。它还包括对 Flask 的消息闪现的支持,以便你可以将消息从一个路由传递给另一个路由供用户查看。

规格说明

注册

完成 注册 的实现,使其允许用户通过表单注册账户。

  • 要求用户输入用户名,实现为一个 nameusername 的文本字段。如果用户的输入为空或用户名已存在,则渲染一个道歉页面。
    • 注意,如果你尝试 INSERT 一个重复的用户名,cs50.SQL.execute 会引发一个 ValueError 异常,因为我们在 users.username 上创建了一个 UNIQUE INDEX。因此,请务必使用 tryexcept 来判断用户名是否已存在。
  • 要求用户输入密码,实现为一个 namepassword 的文本字段,然后再输入一次同样的密码,实现为一个 nameconfirmation 的文本字段。如果任一输入为空或密码不匹配,则渲染一个道歉页面。
  • 通过 POST 将用户输入提交到 /注册
  • INSERT 新用户到 users 中,存储用户密码的哈希值,而不是密码本身。使用 generate_password_hash 对用户密码进行哈希处理。你可能需要创建一个新模板(例如 注册.html),它和 login.html 非常类似。

一旦你正确实现了 注册,你就能够注册账户并登录(因为 loginlogout 已经能正常工作了)!而且你应该能够通过 phpLiteAdmin 或 sqlite3 看到你的行。

quote

完成 quote 的实现,使其允许用户查找股票的当前价格。

  • 要求用户输入股票代码,实现为一个 namesymbol 的文本字段。
  • 通过 POST 将用户输入提交到 /quote
  • 你可能需要创建两个新模板(例如 quote.htmlquoted.html)。当用户通过 GET 访问 /quote 时,渲染其中一个模板,该模板内应包含一个通过 POST 提交到 /quote 的 HTML 表单。在响应 POST 时,quote 可以渲染第二个模板,在其中嵌入 lookup 的一个或多个值。

buy

完成 buy 的实现,使其允许用户买入股票。

  • 要求用户输入股票代码,实现为一个 namesymbol 的文本字段。如果输入为空或代码不存在(根据 lookup 的返回值),则渲染一个道歉页面。
  • 要求用户输入股票数量,实现为一个 nameshares 的文本字段。如果输入不是正整数,则渲染一个道歉页面。
  • 通过 POST 将用户输入提交到 /buy
  • 完成后,将用户重定向到首页。
  • 你可能需要调用 lookup 来查找股票的当前价格。
  • 你可能需要 SELECT 用户当前在 users 中有多少现金。
  • finance.db 中添加一个或多个新表,用来跟踪购买记录。存储足够的信息,以便你知道谁在何时以什么价格买了什么。
    • 使用适当的 SQLite 类型。
    • 在应该唯一的任何字段上定义 UNIQUE 索引。
    • 在你将要搜索的任何字段上定义(非 UNIQUE)索引(如通过 SELECT 配合 WHERE 进行搜索)。
  • 如果用户无法以当前价格负担该数量的股票,则渲染一个道歉页面,而不完成购买。
  • 你不必担心竞争条件(或使用事务)。

一旦你正确实现了 buy,你应该能够通过 phpLiteAdmin 或 sqlite3 在你的新表中看到用户的购买记录。

index

完成 index 的实现,使其显示一个 HTML 表格,汇总当前登录用户所拥有的股票、持有的股票数量、每只股票的当前价格以及每项持仓的总价值(即股票数量乘以价格)。同时显示用户当前的现金余额和总资产(即股票总价值加上现金)。

  • 你可能需要执行多个 SELECT。根据你如何实现你的表,你可能会对 GROUP BYHAVINGSUM 和/或 WHERE 感兴趣。
  • 你可能需要为每只股票调用 lookup

sell

完成 sell 的实现,使其允许用户卖出股票(他或她拥有的股票)。

  • 要求用户输入股票代码,实现为一个 select 菜单,其 namesymbol。如果用户未能选择股票或(以某种方式,在提交后)用户不拥有该股票的任何份额,则渲染一个道歉页面。
  • 要求用户输入股票数量,实现为一个 nameshares 的文本字段。如果输入不是正整数或用户不拥有该股票那么多份额,则渲染一个道歉页面。
  • 通过 POST 将用户输入提交到 /sell
  • 完成后,将用户重定向到首页。
  • 你不必担心竞争条件(或使用事务)。

history

完成 history 的实现,使其显示一个 HTML 表格,汇总用户所有的历史交易,逐行列出每一次买入和每一次卖出。

  • 对于每一行,清楚地表明是买入还是卖出,并包括股票代码、(购买或出售)价格、买入或卖出的股票数量以及交易发生的日期和时间。
  • 你可能需要修改你为 buy 创建的表或用额外的表来补充它。尽量最小化冗余。

个性化功能

至少实现一个你选择的个性化功能:

  • 允许用户更改他们的密码。
  • 允许用户向他们的账户添加额外现金。
  • 允许用户直接通过 index 页面本身买入更多股票或卖出他们已经拥有的股票,而无需手动输入股票代码。
  • 实现其他同等范围的功能。

讲解视频

测试

请务必手动测试你的 Web 应用,例如:

  • 注册一个新用户,并验证他们的投资组合页面加载了正确的信息,
  • 使用有效的股票代码请求报价,
  • 多次购买一只股票,验证投资组合显示正确的总数,
  • 卖出全部或部分股票,再次验证投资组合,以及
  • 验证你的历史页面显示当前登录用户的所有交易记录。

还要测试一些意外使用情况,例如:

  • 在只需要数字的表单中输入字母字符串,
  • 在只需要正数的表单中输入零或负数,
  • 在只需要整数的表单中输入浮点值,
  • 尝试花费比用户拥有更多的现金,
  • 尝试卖出比用户拥有更多的股票,
  • 输入无效的股票代码,以及
  • 在 SQL 查询中包含像 '; 这样有潜在危险的字符。

你还可以通过点击每个页面页脚的 I ♥ VALIDATOR 按钮来检查你的 HTML 的有效性,该按钮会将你的 HTML POST 到 validator.w3.org

满意后,用 check50 测试你的代码,执行以下命令。

check50 cs50/problems/2026/x/finance

代码风格

style50 app.py

工作人员解答

欢迎你以不同的风格美化你自己的应用,但以下是工作人员解答的样子!

https://finance.cs50.net/

请随意注册一个账户并体验一番。不要使用你在其他网站上使用的密码。

查看工作人员的 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 是一个 DELETEUPDATE,那么 execute 返回由 str 删除或更新的行数。
  • 请记住,cs50.SQL 会在你的终端窗口中记录你通过 execute 执行的任何查询(这样你可以确认它们是否符合预期)。
  • 请务必在调用 CS50 的 execute 方法时使用问题标记绑定的参数(即 paramstylenamed),类似于 WHERE ?不要使用 f-string、format+(即连接),以免面临 SQL 注入攻击的风险。
  • 如果你(且只有当你)已经熟悉 SQL,欢迎使用 SQLAlchemy CoreFlask-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