# Install what is not already in Colab. (~30 s)
!pip -q install yfinance tiktoken google-genaiWeek 1 — How we will work, and what is inside the machine
ESE · AI for Business and FinTech · 23 September 2026
This notebook is the live material for session 1. Run it top to bottom in Google Colab (Runtime ▸ Run all is fine the first time). Cells marked 🔍 CHECK contain something you must verify or critique before moving on — that is the main skill of this course.
How to use the AI assistant in this course: ask it to write code, run the code, read the error or the output, ask again, and verify against something you already know (a price you can look up, a number you can compute by hand, a date you remember).
Setup
import sys, platform, pandas as pd, numpy as np, matplotlib
print("Python", sys.version.split()[0], "| pandas", pd.__version__, "| numpy", np.__version__)
try:
import google.colab # noqa
IN_COLAB = True
except ImportError:
IN_COLAB = False
print("Running in Colab:", IN_COLAB)Make it yours: File ▸ Save a copy in Drive. From now on you work on your copy.
Once: in Google Drive, create a folder ese-ai-fintech, share it with the tutor, and save every notebook and homework there, one sub-folder per week.
API key (optional today, needed from week 4): create a free key at Google AI Studio, then in Colab open the 🔑 Secrets panel on the left, add GEMINI_API_KEY, and enable notebook access. Never paste a key into a cell.
Today — from one neuron to an LLM
One question from start to finish: is this card payment fraud? Each section adds one piece. The numbers are the same as on the slides.
1. Eight past payments
import math, numpy as np, pandas as pd, matplotlib.pyplot as plt
payments = pd.DataFrame({
"large": [0, 1, 0, 0, 1, 1, 0, 1], # over EUR 1,000?
"abroad": [0, 0, 1, 0, 1, 0, 1, 1], # paid abroad?
"device": [0, 0, 0, 1, 0, 1, 1, 1], # new device?
"fraud": [0, 0, 0, 0, 1, 1, 1, 1], # the hidden truth: at least two red flags
}, index=list("ABCDEFGH"))
payments2. A first guess, as one neuron
Bet first: weight 1 on large, 0 on the others, bias −1, block if the score is above 0. Which payments does it block?
w = {"large": 1.0, "abroad": 0.0, "device": 0.0}
bias = -1.0
payments["score"] = sum(w[f] * payments[f] for f in w) + bias
payments["block"] = (payments.score > 0).astype(int)
payments3. Learning from mistakes, by hand (the perceptron)
Wrong → move each weight a little towards the right answer. Right → leave it. The learning rate eta is how big each move is.
w = {"large": 1.0, "abroad": 0.0, "device": 0.0}
bias, eta = -1.0, 0.5
for rnd in range(1, 10):
mistakes = 0
for name, row in payments.iterrows():
score = sum(w[f] * row[f] for f in w) + bias
decision = 1 if score > 0 else 0
error = row.fraud - decision # +1 missed fraud, -1 false alarm, 0 right
if error:
for f in w:
w[f] = float(w[f] + eta * error * row[f]) # weight <- weight + eta * (fraud - decision) * answer
bias = float(bias + eta * error)
mistakes += 1
print(f"round {rnd} {name}: score {score:+.1f} -> {'block' if decision else 'pass'} WRONG "
f"-> {w} bias {bias:g}")
if mistakes == 0:
print(f"round {rnd}: no mistakes. Learned: {w} bias {bias:g}")
break4. Yes or no is not enough: the sigmoid
Same weights, but instead of a step we squash the score into a probability.
def sigmoid(z):
return 1 / (1 + np.exp(-z))
payments["score"] = sum(w[f] * payments[f] for f in w) + bias
payments["p_fraud"] = sigmoid(payments.score).round(2)
payments[["large", "abroad", "device", "fraud", "score", "p_fraud"]]🔍 CHECK. C and D sit exactly on the line. What probability do they get, and why is that more honest than “don’t block”?
5. How wrong, as one number: the loss
def loss(p, y):
return -math.log(p) if y == 1 else -math.log(1 - p)
payments["loss"] = [loss(sigmoid(s), y) for s, y in zip(payments.score, payments.fraud)]
print(payments[["fraud", "p_fraud", "loss"]].round(2))
print(f"average loss: {payments.loss.mean():.2f} <- the one number training pushes down")6. The formula for every weight
w <- w - eta * (p - y) * x. One step on payment E (large, abroad, fraud), with eta = 1.
x = {"large": 1, "abroad": 1, "device": 0, "bias": 1} # payment E (the bias always sees a 1)
w = {**{f: float(v) for f, v in w.items()}, "bias": float(bias)}
y, eta = 1, 1.0
p = sigmoid(sum(w[k] * x[k] for k in w))
blame = {k: (p - y) * x[k] for k in w} # what backpropagation computes
w_new = {k: w[k] - eta * blame[k] for k in w}
p_new = sigmoid(sum(w_new[k] * x[k] for k in w))
print("before ", {k: round(float(v), 2) for k, v in w.items()}, f" p(fraud) for E = {p:.2f}")
print("blame ", {k: round(float(v), 2) for k, v in blame.items()})
print("after ", {k: round(float(v), 2) for k, v in w_new.items()}, f" p(fraud) for E = {p_new:.2f}")7. The learning rate
Real data is messier. Add payment I: large, abroad — and genuine, a customer on holiday. Now no rule is perfect. Train with three learning rates.
X = np.vstack([payments[["large", "abroad", "device"]].values, [1, 1, 0]]) # A..H, then I
Y = np.append(payments.fraud.values, 0)
def train(eta, steps=30, w=(0.5, 1.0, 1.0), b=-1.0):
w, b, hist = np.array(w, float), b, []
for _ in range(steps):
p = np.clip(sigmoid(X @ w + b), 1e-12, 1 - 1e-12)
hist.append(-np.mean(Y * np.log(p) + (1 - Y) * np.log(1 - p))) # how wrong
w, b = w - eta * X.T @ (p - Y) / len(Y), b - eta * np.mean(p - Y) # nudge
return hist
for eta in (0.3, 3, 20):
plt.plot(train(eta), label=f"eta = {eta}", lw=2)
plt.xlabel("training steps"); plt.ylabel("loss"); plt.ylim(0, 5); plt.legend(); plt.show()Try: eta = 8. Does it settle, or keep jumping?
8. A pattern one neuron cannot draw: card testing
A fraudster tries the card with €10, then spends €2,000. Fraud sits at both ends of the amount. Two hidden neurons each answer one question; the output combines them.
amount = np.linspace(0, 3, 300) # thousands of euros
small = sigmoid(-10 * amount + 2) # hidden neuron 1: "small?"
large = sigmoid( 10 * amount - 19) # hidden neuron 2: "large?"
fraud = sigmoid(8 * small + 8 * large - 4) # output: "small OR large"
fig, ax = plt.subplots(3, 1, figsize=(9, 6), sharex=True)
for a, (name, curve) in zip(ax, [("small?", small), ("large?", large), ("fraud?", fraud)]):
a.plot(amount, curve, lw=2); a.set_ylabel(name); a.set_ylim(-0.05, 1.05)
ax[-1].set_xlabel("amount, thousands of euros"); plt.show()
print("parameters: 2 x 2 in the hidden layer + 3 in the output =", 2 * 2 + 3)Try: change -19 to -10. What does the network now call fraud?
9. What the model reads: tokens
The same machine, with one difference: the input is text, cut into pieces called tokens.
import tiktoken
enc = tiktoken.get_encoding("o200k_base")
for s in ["Il margine è insostenibile", "The margin is unsustainable", "Маржа неустойчива"]:
t = enc.encode(s)
print(f"{len(t):2d} tokens | {s:30s} | {[enc.decode([i]) for i in t]}")Before you leave: three lines, in your words
- …
- …
- …
Then File ▸ Save a copy in Drive, into your ese-ai-fintech folder.
For the next sessions
The sections below are used in sessions 2 and 3. You do not need them today.
Block A2 — One decision, three kinds of software
The slide split “block this transaction, or do not” into Software 1.0 (a rule you write), 2.0 (a model you fit) and 3.0 (an instruction you give). Here it is as code, on the same data, judged by the same euros.
The book of transactions below is synthetic on purpose: the point of this section is the comparison between three kinds of software, not the fraud model. Everything is reproducible from the seed. Real data arrives in Block C.
import numpy as np, pandas as pd
rng = np.random.default_rng(11)
N = 20_000
MU, SD = 4.6, 0.85
benign = ["GROCERY", "PHARMACY", "FUEL", "RESTAURANT", "TRANSIT", "BOOKSHOP"]
voucher = ["PREPAID VOUCHER RELOAD", "DIGITAL GIFT CERTIFICATE",
"STORED VALUE TOP-UP", "E-VOUCHER PURCHASE"]
amount = np.round(np.exp(rng.normal(MU, SD, N)), 2)
hour = rng.integers(0, 24, N)
abroad = (rng.random(N) < 0.09).astype(int)
new_device = (rng.random(N) < 0.13).astype(int)
odd_hour = ((hour < 5) | (hour > 22)).astype(int)
# Fraud mode 1 — the classic pattern, and all of it is in the numbers.
z = (np.log(amount) - MU) / SD
latent = 0.9*z + 1.5*abroad + 2.0*new_device + 1.3*odd_hour
fraud_numeric = rng.random(N) < 1/(1 + np.exp(-(latent - 5.6)))
# Fraud mode 2 — a pattern that exists only in the merchant descriptor.
is_voucher = rng.random(N) < 0.025
fraud_words = is_voucher & (rng.random(N) < 0.45)
fraud = fraud_numeric | fraud_words
note = np.where(is_voucher, rng.choice(voucher, N), rng.choice(benign, N))
tx = pd.DataFrame(dict(amount=amount, hour=hour, abroad=abroad,
new_device=new_device, note=note, fraud=fraud.astype(int)))
CUT = int(N * 0.6) # rows are in time order: fit on the first 60%,
TEST = slice(CUT, N) # and report on the rest. Week 3 is about why.
print(f"{N:,} transactions | fraud {fraud.mean():.2%} | test rows {N-CUT:,}")
print(f"of the fraud, {(fraud_words & ~fraud_numeric).sum()} cases are visible ONLY in the merchant text")
tx.head(3)One scoring function for all three, so nothing is compared on its own terms.
A blocked good customer costs €8 to contact. A missed fraud costs the amount of the transaction — that is what a chargeback is.
CONTACT = 8.0
def evaluate(blocked, name):
b = np.asarray(blocked)[TEST]; t = fraud[TEST]; a = amount[TEST]
cost = (b & ~t).sum() * CONTACT + a[~b & t].sum()
print(f"{name:<32} blocked {b.sum():>5,} caught {(b & t).sum():>4}/{t.sum():<5}"
f"good blocked {(b & ~t).sum():>5,} cost EUR {cost:>10,.0f}")
return costBet before you run it. The rule blocks a card payment made abroad for more than €150. Of all the fraud in the test period, what share do you think it catches? Write a number down now.
rule = ((tx.amount > 150) & (tx.abroad == 1)).values
cost_1 = evaluate(rule, "1.0 the rule")Now 2.0, held to exactly the same budget: it may block the same number of transactions as the rule, no more. Only the choice of which ones changes.
That constraint is the whole comparison. A model that blocks more will always catch more, and it will also annoy more customers.
from sklearn.linear_model import LogisticRegression
X = pd.DataFrame(dict(log_amount=np.log1p(tx.amount), abroad=tx.abroad,
new_device=tx.new_device, odd_hour=odd_hour))
model = LogisticRegression(max_iter=1000).fit(X[:CUT], fraud[:CUT])
p = model.predict_proba(X)[:, 1]
budget = int(rule[TEST].sum())
thr = np.sort(p[TEST])[-budget]
blocked_20 = p >= thr
cost_2 = evaluate(blocked_20, "2.0 the model, same budget")
print(f"\nsaved against the rule: EUR {cost_1-cost_2:,.0f} ({cost_2/cost_1-1:+.0%})")
print("\nwhat it learned to weight:")
print(pd.Series(model.coef_[0], index=X.columns).round(2).sort_values(ascending=False))🔍 CHECK. The model blocked exactly as many transactions as the rule and caught roughly twice the fraud. Look at the coefficients: which feature does the rule ignore entirely? Write the sentence you would say to the fraud team — in words, not coefficients.
Your sentence here:
And now the part neither of them can see
Some of the fraud is not in the numbers at all. It is in the merchant descriptor — four different ways of writing the same thing:
PREPAID VOUCHER RELOAD · DIGITAL GIFT CERTIFICATE · STORED VALUE TOP-UP · E-VOUCHER PURCHASE
Your first instinct is a keyword list. Try it.
keyword = tx.note.str.contains("VOUCHER").values
cost_kw = evaluate(blocked_20 | keyword, "3.0 faked with a keyword")
print("\ndescriptors the keyword catches:")
for d in sorted(set(tx.note[keyword & is_voucher])): print(" ", d)
print("descriptors it misses:")
for d in sorted(set(tx.note[is_voucher]) - set(tx.note[keyword & is_voucher])): print(" ", d)
# What a model that actually reads the descriptor would reach. We are standing in
# for it here so the comparison runs with no API key; the real call is below.
cost_3 = evaluate(blocked_20 | is_voucher, "3.0 something that reads words")
print(f"\nsaved against 2.0: EUR {cost_2-cost_3:,.0f} ({cost_3/cost_2-1:+.0%})")🔍 CHECK. The keyword version is Software 1.0 wearing a 3.0 costume, and it catches half the descriptors.
That is the test to carry out of this room: if a keyword list would have done the job, you did not need a model. Most of what is sold as AI fails exactly here — and the way to find out costs you ten minutes, which is what you just spent.
The real call — optional today, and the point of week 4
If you added GEMINI_API_KEY to Colab’s 🔑 Secrets panel, this asks a model to judge five descriptors it has never been given a list for. If you have not, read the prompt and move on.
PROMPT = (
"You review card transactions for a bank.\n"
'Merchant descriptor: "{note}"\n'
"Amount: EUR {amount:.2f}\n\n"
"Is this merchant category one commonly used to convert stolen card funds "
"into untraceable value? Answer with one word, YES or NO, then one short "
"sentence of why."
)
# the three largest voucher transactions and two ordinary ones, so the model
# is asked something a reviewer would actually have escalated
suspect = tx.loc[tx.index[is_voucher]].nlargest(3, "amount").index
ordinary = tx.loc[tx.index[~is_voucher]].nlargest(2, "amount").index
samples = tx.loc[[*suspect, *ordinary], ["note", "amount"]]
try:
import os
from google.colab import userdata
from google import genai
client = genai.Client(api_key=userdata.get("GEMINI_API_KEY"))
for _, r in samples.iterrows():
out = client.models.generate_content(
model="gemini-2.0-flash",
contents=PROMPT.format(note=r.note, amount=r.amount),
config={"temperature": 0})
print(f"{r.note:<26} -> {out.text.strip().splitlines()[0]}")
except Exception as e:
print(f"[no key / no network: {type(e).__name__}] Here is what would be sent:\n")
print(PROMPT.format(**samples.iloc[0].to_dict()))So which one do you build?
The honest answer is all three, in a pipeline — and the arithmetic below is why you do not simply put 3.0 in front of every transaction.
# Illustrative prices. CHECK TODAY'S PAGE before you quote any of this to anyone;
# this is the number that goes stale fastest in the whole notebook.
PRICE_IN, PRICE_OUT = 3.00, 15.00 # EUR per million tokens
TOK_IN, TOK_OUT = 700, 120 # per transaction reviewed
DAILY = 1_000_000
per_call = (TOK_IN*PRICE_IN + TOK_OUT*PRICE_OUT) / 1e6
flag_rate = blocked_20[TEST].mean()
print(f"cost of one 3.0 review EUR {per_call:,.4f}")
print(f"3.0 on every transaction EUR {per_call*DAILY:>10,.0f} / day"
f" = EUR {per_call*DAILY*365:>12,.0f} / year")
print(f"3.0 only on what 2.0 flagged EUR {per_call*DAILY*flag_rate:>10,.0f} / day"
f" = EUR {per_call*DAILY*flag_rate*365:>12,.0f} / year")
print(f"\n2.0 flags {flag_rate:.1%} of traffic, so the escalation costs {flag_rate:.1%} of the naive design.")The architecture that falls out of this, and you will meet it again in weeks 6 and 10:
| does | costs | |
|---|---|---|
| 1.0 | throws away the obvious, in microseconds | nothing |
| 2.0 | ranks what is left, and sets the budget | cents per million |
| 3.0 | reads and explains the few that get escalated | cents per call |
Vocabulary you now own: rule, fitted model, prompt; block budget; the cost of a false positive against the cost of a miss; and the question that ends most AI pitches — would a keyword list have done this?
Block B — LLMs at a working level
B1. Tokens: the model does not see words
An LLM reads and writes tokens — chunks of characters, roughly ¾ of an English word each. Everything is billed, limited and reasoned about in tokens. Numbers, code and non-English text tokenize badly: a Russian sentence costs 1.5× the tokens of its English translation on the GPT-4o tokenizer, and 2.75× on the older GPT-4 one — same sentence, same model family.
try:
import tiktoken
enc = tiktoken.get_encoding("o200k_base") # tokenizer family used by recent OpenAI models; others are similar
except Exception as e: # offline fallback: crude approximation, only to show the idea
import re
class _Enc:
def encode(self, s): return re.findall(r"\w+|[^\w\s]", s)
def decode(self, t): return t[0]
enc = _Enc(); print("tiktoken unavailable (%s) — using a crude word/punctuation splitter instead" % type(e).__name__)
samples = [
"The ECB left rates unchanged at 2.00% on Thursday.",
"Bitcoin fell 4.2% to $58,310 after the ETF outflow data.",
"ЕЦБ оставил ставки без изменений на уровне 2,00%.",
"df.groupby('ticker')['ret'].rolling(21).std()",
]
for s in samples:
toks = enc.encode(s)
print(f"{len(toks):3d} tokens | {len(s.split()):2d} words | {s}")
print(" ", [enc.decode([t]) for t in toks][:14], "...\n")B2. Context window: the model’s working memory
Everything the model knows about your problem must fit in the context window (today: 128k–1M tokens depending on the model). What is not in the context does not exist for the model — it will fill the gap with something plausible. This is the root of most “hallucinations”: not lying, but completing a pattern with missing information.
Practical consequences for business use: - long documents must be chunked and only the relevant parts fed in (that is what retrieval does — week 4); - the model has no memory between calls unless you send the history again; - cost ≈ (input tokens + output tokens) × price per token — so verbosity is money.
# Rough cost of sending a document to a model. Prices are illustrative — check the provider page.
price_per_1M_input = {"small model": 0.10, "mid model": 1.00, "frontier model": 5.00} # USD per 1M input tokens
doc_tokens = 90_000 # e.g. a 10-K annual report, ~60 pages of dense text
for name, p in price_per_1M_input.items():
print(f"{name:15s}: ${doc_tokens/1e6*p:.3f} per read; 1,000 reads/day -> ${doc_tokens/1e6*p*1000:,.0f}/day")B3. Sampling: the same prompt gives different answers
The model outputs a probability distribution over the next token and samples from it. temperature controls how much randomness is allowed. Temperature 0 is (almost) deterministic — use it for extraction and classification. Higher temperatures for brainstorming.
The cell below calls Gemini only if you set up the key. If not, skip it — the tutor will demonstrate.
import os
API_KEY = None
try:
from google.colab import userdata
API_KEY = userdata.get("GEMINI_API_KEY")
except Exception:
API_KEY = os.environ.get("GEMINI_API_KEY")
if API_KEY:
from google import genai
from google.genai import types
client = genai.Client(api_key=API_KEY)
prompt = "In one sentence: why did Bitcoin fall in the week of 5 August 2024?"
for temp in (0.0, 1.0, 1.0):
r = client.models.generate_content(model="gemini-2.5-flash", contents=prompt,
config=types.GenerateContentConfig(temperature=temp))
print(f"T={temp}: {r.text.strip()}\n")
else:
print("No API key found — skipping. Ask the tutor to run this cell on the projector.")🔍 CHECK. Whatever the model answered above: which claims can you verify in five minutes, and from which source? Which claims are plausible but unverifiable? Write two lines in the cell below. (This exact exercise — separate what you can check from what merely sounds right — is what you will do with every AI output in this course.)
Your notes here:
B4. Embeddings: meaning as geometry
An embedding turns a piece of text into a vector (a list of numbers) so that texts with similar meaning are close together. This is what makes search-by-meaning, clustering and retrieval possible. We do not need a neural model to understand the idea — the cell below uses a crude bag-of-words embedding so you can see the mechanics; real embeddings from a model are much better at meaning, but the geometry is the same.
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
docs = [
"The central bank raised interest rates to fight inflation.",
"Monetary policy tightened as the ECB hiked its deposit rate.",
"The football match ended in a draw after extra time.",
"Ethereum gas fees dropped after the network upgrade.",
"Transaction costs on the blockchain fell following the protocol update.",
]
X = TfidfVectorizer().fit_transform(docs)
sim = pd.DataFrame(cosine_similarity(X), index=range(len(docs)), columns=range(len(docs))).round(2)
print(sim)
print("\nDoc 0 vs doc 1 (same meaning, different words):", sim.loc[0, 1])
print("Doc 3 vs doc 4 (same meaning, different words):", sim.loc[3, 4])🔍 CHECK. Docs 0–1 and docs 3–4 say the same thing in different words, yet the crude embedding gives them low similarity. Why? What would a model-based embedding do differently? (Hint: “raised rates” and “hiked its deposit rate” share no words.) This is the gap that neural embeddings close — we will use them in week 4.
B5. Tool use and agents (concept only today)
A plain LLM can only produce text. Tool use lets the model emit a structured request (“call get_price('BTC-USD')”), your code executes it, and the result goes back into the context. An agent is a loop: model → tool → model → tool … until done. Everything you will build in weeks 4–5 is this loop; everything that goes wrong with agents (cost, loops, wrong tool, injected instructions) comes from the same loop.
Block C — First data notebook: three assets
# --- course helper: price loader with fallbacks (run this cell once per session) ---
import warnings, numpy as np, pandas as pd
warnings.filterwarnings("ignore")
def load_prices(tickers, start="2022-01-01", end=None, cache_dir="data"):
"""Daily close prices, one column per ticker.
1) try yfinance (live) 2) try a CSV snapshot in data/ 3) synthetic random walk (pipeline test only)."""
import os
os.makedirs(cache_dir, exist_ok=True)
key = "_".join(t.replace("^", "") for t in tickers)
snap = os.path.join(cache_dir, f"prices_{key}.csv")
try:
import yfinance as yf
raw = yf.download(tickers, start=start, end=end, auto_adjust=True, progress=False)
px = raw["Close"] if isinstance(raw.columns, pd.MultiIndex) else raw[["Close"]].rename(columns={"Close": tickers[0]})
px = px.dropna(how="all")
if len(px) < 50:
raise RuntimeError("empty download")
px.to_csv(snap)
print(f"[live] {px.shape[0]} rows from yfinance; snapshot saved to {snap}")
return px
except Exception as e:
print(f"[warn] yfinance failed ({type(e).__name__}); trying snapshot")
if os.path.exists(snap):
px = pd.read_csv(snap, index_col=0, parse_dates=True)
print(f"[snapshot] {px.shape[0]} rows from {snap}")
return px
print("[SYNTHETIC] no network and no snapshot: generating a random walk. Numbers below are NOT real.")
rng = np.random.default_rng(0)
idx = pd.bdate_range(start, end or pd.Timestamp.today().normalize())
px = pd.DataFrame({t: 100 * np.exp(np.cumsum(rng.normal(0.0004, 0.02 if "USD" in t else 0.011, len(idx))))
for t in tickers}, index=idx)
return pxtickers = ["BTC-USD", "AAPL", "^GSPC"]
px = load_prices(tickers, start="2022-01-01")
px.tail()# Daily simple returns
rets = px.pct_change().dropna()
print(rets.describe().round(4))import matplotlib.pyplot as plt
(px / px.iloc[0] * 100).plot(figsize=(10, 4), title="Growth of 100 (rebased)")
plt.ylabel("index"); plt.show()🔍 CHECK — the assistant’s volatility code
The cell below is the kind of code an assistant produces when asked “annualise the volatility of these assets”. It runs without errors. It contains two mistakes. Find them before reading the solution.
# --- as produced by the assistant (do not trust) ---
ann_vol = rets.std() * np.sqrt(252)
cum_ret = rets.sum()
summary = pd.DataFrame({"annualised_vol": ann_vol, "cumulative_return": cum_ret}).round(3)
summarySolution (open after you have tried)
- 252 is the number of trading days for equities. Bitcoin trades every day: 365 rows per year, so its annualised volatility is understated by √(252/365) ≈ 17%. Whenever a constant appears in assistant code, ask where it comes from.
- Summing simple returns is not the cumulative return. +50% then −50% sums to 0 but leaves you at 75. The correct figure is
(1 + rets).prod() - 1. Check: compare withpx.iloc[-1] / px.iloc[0] - 1.
# Corrected version
days_per_year = {t: (365 if t.endswith("-USD") else 252) for t in px.columns}
ann_vol = pd.Series({t: rets[t].std() * np.sqrt(days_per_year[t]) for t in px.columns})
cum_ret = (1 + rets).prod() - 1
check = px.iloc[-1] / px.iloc[0] - 1 # second way
pd.DataFrame({"annualised_vol": ann_vol, "cumulative_return": cum_ret, "check": check}).round(3)Block D — Prompting that matters at work
Try these in your chat assistant now, on the same question, and compare the outputs:
- Bare: “Explain the volatility of Bitcoin vs Apple.”
- Framed: “You are a quantitative analyst. Using daily data 2022–2026, compare the annualised volatility of BTC-USD and AAPL. State the day-count convention you use for each. Output a two-row table and one sentence of interpretation. If you are not sure of a number, say so instead of inventing it.”
- Structured for code: “Write Python (pandas) that computes annualised volatility for a DataFrame
retsof daily returns, using 365 days for columns ending in-USDand 252 otherwise. Return a Series. No explanation.”
What changed and why: role and context reduce generic filler; explicit conventions remove silent assumptions; asking for a fixed output shape makes the answer checkable; “say so instead of inventing” lowers (does not remove) hallucination.
Homework brief → see week-01/homework.md.