Try each task in your workspace first. Open a solution only when your code runs, or when you have been stuck for a while. Your solution can be different and still right: compare what it prints. The reading tasks and their answers are on the Questions page.
Writing tasks
Task 6 · two functions for the ledger
The ledger is a list of (ticker, shares, price) tuples. Write position_value(shares, price) that returns the value of one position, and total_value(positions) that loops over the tuples, unpacks each one, and returns the sum of position_value for all of them. Both need a one-line docstring. Print total_value(ledger).
Show the solution
ledger = [("AAPL", 10, 190.5), ("NESN", 4, 102.3), ("VOO", 2, 512.0)]
def position_value(shares, price):
"""Value of one position, in CHF."""
return shares * price
def total_value(positions):
"""Sum of the values of (ticker, shares, price) tuples."""
total = 0.0
for ticker, n, p in positions:
total += position_value(n, p)
return total
print("Task 6:", total_value(ledger))
It prints:
Task 6: 3338.2
One function per idea; the second one calls the first. The loop header unpacks each tuple into three names. return sits AFTER the loop, not inside it (Pavel's "Spot the lie").
Task 7 · a fee with defaults
Write fee(value, rate=0.001, minimum=5.0) that returns value × rate, but never less than minimum. Then print, on one line, fee(1000.0), fee(10000.0) and fee(10000.0, rate=0.002).
Show the solution
def fee(value, rate=0.001, minimum=5.0):
"""Broker fee: a share of the value, with a minimum charge."""
return max(value * rate, minimum)
print("Task 7:", fee(1000.0), fee(10000.0), fee(10000.0, rate=0.002))
It prints:
Task 7: 5.0 10.0 20.0
Defaults make parameters optional. 1000 × 0.001 = 1.0 is below the minimum, so 5.0; 10000 × 0.001 = 10.0; the third call overrides rate by keyword and leaves minimum at its default. max() with two arguments picks the larger one.
Task 8 · session 3's while, as a function
Write years_to_reach(start, target, rate=0.05) that returns how many whole years start needs to grow to at least target. Use while inside. Print years_to_reach(1000.0, 1500.0) and years_to_reach(1000.0, 1500.0, rate=0.03).
Show the solution
def years_to_reach(start, target, rate=0.05):
"""Whole years until start, growing at rate, reaches target."""
years = 0
while start < target:
start *= 1 + rate
years += 1
return years
print("Task 8:", years_to_reach(1000.0, 1500.0), years_to_reach(1000.0, 1500.0, rate=0.03))
It prints:
Task 8: 9 14
start is a local name: growing it inside the function does not touch the 1000.0 the caller passed (floats are immutable). Session 3's 14 years at 3 % comes back, now as one call.
Task 9 · teach Position a new method
Here is Pavel's Position class. Add a method buy(self, n) that increases the shares of THIS position by n. Then create aapl = Position("AAPL", 10, 190.5), call aapl.buy(5), and print aapl.shares and aapl.value().
Show the solution
class Position:
"""One position: a ticker, a number of shares, a price."""
def __init__(self, ticker, shares, price):
self.ticker = ticker
self.shares = shares
self.price = price
def value(self):
return self.shares * self.price
def buy(self, n):
"""Add n shares to this position."""
self.shares += n
aapl = Position("AAPL", 10, 190.5)
aapl.buy(5)
print("Task 9:", aapl.shares, aapl.value())
It prints:
Task 9: 15 2857.5
self is the object before the dot: in aapl.buy(5), self is aapl, so self.shares += 5 changes aapl's own attribute. buy changes the object and returns None, like list.append. value() then reads the new shares.
Task 10 · a module instead of a loop
import the standard-library module statistics. Print the mean (rounded to 2 decimals), the median and the standard deviation (rounded to 2) of the week's closes. Look up the function names with help(statistics) or by typing statistics. and waiting.
Show the solution
import statistics
print("Task 10:", round(statistics.mean(closes), 2), statistics.median(closes),
round(statistics.stdev(closes), 2))
It prints:
Task 10: 191.51 191.4 4.52
import statistics loads the module once; statistics.mean means "the function mean inside statistics". Finding the right function in a module you have never used is the everyday skill here, not the arithmetic.
★ Harder: classic puzzles
★ Task 11 · Moving average
Write moving_average(prices, k) that returns a list: the average of every run of k days in a row, each rounded to 2 decimals. Use a slice for each window. Print moving_average(closes, 3).
Show the solution
def moving_average(prices, k):
"""Average of each run of k consecutive prices."""
out = []
for i in range(len(prices) - k + 1):
out.append(round(sum(prices[i:i + k]) / k, 2))
return out
print("Task 11:", moving_average(closes, 3))
It prints:
Task 11: [190.5, 188.7, 190.1, 191.7, 194.67]
With 7 days and windows of 3 there are 7 - 3 + 1 = 5 windows. prices[i:i + k] is one window; the last one starts at index 4. Getting that + 1 right is the whole exercise.
★ Task 12 · Net positions from a list of trades
Trades arrive as (ticker, quantity) tuples; a negative quantity is a sale. Write net_positions(trades) that returns a dict: ticker → shares held after all trades. Print it for the trades below.
Show the solution
trades = [("AAPL", 10), ("NESN", 4), ("AAPL", -3), ("VOO", 2), ("NESN", -4)]
def net_positions(trades):
"""Shares held per ticker after a list of (ticker, quantity) trades."""
net = {}
for ticker, q in trades:
net[ticker] = net.get(ticker, 0) + q
return net
print("Task 12:", net_positions(trades))
It prints:
Task 12: {'AAPL': 7, 'NESN': 0, 'VOO': 2}
Session 3's counting pattern, but adding a quantity instead of 1. NESN stays in the dict with 0: whether a closed position should be kept or removed is a decision, and a good docstring says which.
★ Task 13 · Merge two sorted lists
Two traders logged their trade times (HHMM as whole numbers), each list already sorted. Write merge_sorted(a, b) that returns one sorted list WITHOUT sorted() or .sort(): walk through both lists with two positions and always take the smaller front item.
Show the solution
times_a = [930, 1015, 1340, 1555]
times_b = [945, 1015, 1200]
def merge_sorted(a, b):
"""Merge two sorted lists into one sorted list."""
i = j = 0
out = []
while i < len(a) and j < len(b):
if a[i] <= b[j]:
out.append(a[i])
i += 1
else:
out.append(b[j])
j += 1
return out + a[i:] + b[j:]
print("Task 13:", merge_sorted(times_a, times_b))
It prints:
Task 13: [930, 945, 1015, 1015, 1200, 1340, 1555]
Two positions, one per list; each step takes the smaller front item and moves that position on. When one list runs out, the rest of the other is already sorted: a slice past the end is empty, so a[i:] + b[j:] adds exactly the leftovers. This is the heart of merge sort.
★ Task 14 · Maximum drawdown
The drawdown on a day is how far the price is below the highest price seen so far, in percent. Write max_drawdown(prices) that returns the worst drawdown of the period (a negative number). Print it for closes as a percentage with 2 decimals.
Show the solution
def max_drawdown(prices):
"""Worst fall from a running peak, as a fraction (negative)."""
peak = prices[0]
worst = 0.0
for p in prices:
if p > peak:
peak = p
dd = (p - peak) / peak
if dd < worst:
worst = dd
return worst
print("Task 14:", f"{max_drawdown(closes) * 100:.2f}%")
It prints:
Task 14: -4.49%
Two running values: the peak so far, and the worst fall from it. The worst drawdown is 193.8 → 185.1, not the week's highest to lowest price: the lowest price has to come AFTER the peak. It is the risk number every fund report shows.
Extra practice
Practice 1 · Binary search
Write binary_search(xs, target) for a SORTED list: look at the middle item; if it is too small, keep only the right half, if too big, the left half; repeat until found. Return the position, or -1 if the target is not there. Test it on the prices below with 410.0 and with 100.0.
Show the solution
prices = [88.7, 102.3, 190.5, 410.0, 512.0]
def binary_search(xs, target):
"""Position of target in the sorted list xs, or -1."""
lo, hi = 0, len(xs) - 1
while lo <= hi:
mid = (lo + hi) // 2
if xs[mid] == target:
return mid
if xs[mid] < target:
lo = mid + 1
else:
hi = mid - 1
return -1
print(binary_search(prices, 410.0), binary_search(prices, 100.0))
It prints:
3 -1
Each step halves the part still to search: a million sorted items take about 20 steps instead of a million. The whole algorithm lives in the three index updates; lo <= hi (not <) is where most people go wrong.
Practice 2 · Write your own sort
Write bubble_sort(xs) that returns a NEW sorted list and leaves xs unchanged: walk through the list, swap every neighbour pair that is in the wrong order, and repeat until nothing is out of order. Print the result and the original list.
Show the solution
unsorted = [512.0, 102.3, 410.0, 88.7, 190.5]
def bubble_sort(xs):
"""A sorted copy of xs, by repeated neighbour swaps."""
xs = xs.copy()
for i in range(len(xs)):
for j in range(len(xs) - 1 - i):
if xs[j] > xs[j + 1]:
xs[j], xs[j + 1] = xs[j + 1], xs[j]
return xs
print(bubble_sort(unsorted), unsorted)
It prints:
[88.7, 102.3, 190.5, 410.0, 512.0] [512.0, 102.3, 410.0, 88.7, 190.5]
After each pass the largest remaining item has bubbled to the end, so the inner loop can stop 1 + i early. xs.copy() first: otherwise the function would change the caller's list (Task 3). sorted() is far faster; the point is to see what a sort has to do.
Practice 3 · Euclid: identical baskets
You want to split 8 AAPL and 12 NESN shares into identical baskets, as many baskets as possible, each with the same number of AAPL and of NESN. That number of baskets is the greatest common divisor. Write gcd(a, b) with Euclid's rule: replace (a, b) by (b, a % b) until b is 0; then a is the answer. Print gcd(8, 12), gcd(10, 4) and gcd(9, 4).
Show the solution
def gcd(a, b):
"""Greatest common divisor, by Euclid's algorithm."""
while b != 0:
a, b = b, a % b
return a
print(gcd(8, 12), gcd(10, 4), gcd(9, 4))
It prints:
4 2 1
Four baskets of 2 AAPL and 3 NESN. Euclid's algorithm is over 2000 years old and still the fastest way: each step the numbers shrink quickly. a, b = b, a % b updates both at once; two separate lines would use the new a by mistake.