// EcoFinLearn quiz questions: Python Foundations // Source: https://ecofinlearning.com/learn/python-foundations // (c) Sitraka Forler. Teachers may use these with a class, with a link back: https://ecofinlearning.com/terms#reuse // Import in Moodle: Question bank > Import > GIFT format. $CATEGORY: $course$/EcoFinLearn/Python Foundations/Getting Started // pf-m0-l1: Hello, Finance World! ::pf-m0-l1 Q1::[plain]What does print("10 + 5") display?{ =10 + 5#The quotes make it a string, so Python shows the characters literally instead of computing. Remove the quotes - print(10 + 5) - and you get 15. ~15#Python only does the maths when there are no quotes. Inside quotes, "10 + 5" is just six characters of text. ~"10 + 5" (with the quotes)#The quotes are instructions for Python ("this is text"), not part of the message - print shows only what is between them. ~An error#No error here - "10 + 5" is a perfectly valid string, and print happily displays it. } // pf-m0-l1: Hello, Finance World! ::pf-m0-l1 Q2::[plain]The variable total holds the number 1745.5. Which line displays that number?{ =print(total)#No quotes, so Python treats total as a variable name, looks up its value and prints 1745.5. ~print("total")#The quotes make it the literal five-letter word total - the variable is never looked up. ~print \= total#This assigns the number to the name print - nothing is displayed, and worse, the real print function is now clobbered. ~total(print)#This tries to call the number 1745.5 as if it were a function - Python raises a TypeError. } // pf-m0-l1: Hello, Finance World! ::pf-m0-l1 Q3::[plain]What is the result of 10 / 4 in Python?{ =2.5#The / operator does exact division and always returns a decimal number (a float) - just like the table in the lesson shows. ~2#That is floor division, written 10 // 4, which drops the remainder. Plain / keeps it\: 2.5. ~2.25#That would be 9 / 4. Check the arithmetic\: 10 divided by 4 is 2.5. ~An error#Dividing two numbers is fine - the only division that errors is dividing by zero. } // pf-m0-l2: Your First Return Calculation ::pf-m0-l2 Q1::[plain]AAPL goes from $150 to $165. What is the simple return?{ =10%#(165-150)/150 \= 15/150 \= 0.10 \= 10%. ~15%#$15 is the absolute gain, not the percentage return. Always divide by the entry price. ~1.10%#This would be the return if the price moved from $150 to $151.65. ~9.09%#That is 15/165 - the profit divided by the SELL price instead of the buy price. Returns are always measured against what you invested\: 15/150 \= 10%. } // pf-m0-l2: Your First Return Calculation ::pf-m0-l2 Q2::[plain]Why do quants prefer log returns over simple returns?{ =Log returns are time-additive - daily log returns sum to the monthly log return#This property (ln(P_T/P_0) \= sum of daily ln(P_t/P_\{t-1\})) makes log returns ideal for risk models and multi-period analysis. ~Log returns are always larger than simple returns#Log returns are actually slightly smaller than simple returns for positive moves (ln(1.1) ≈ 0.0953 < 0.10). ~Log returns are easier to compute in Python#Both are equally easy - np.log(p1/p0) vs (p1-p0)/p0. The reason is mathematical, not computational. ~Log returns are always positive#Log returns are negative when prices fall (ln of a number less than 1 is negative). } // pf-m0-l2: Your First Return Calculation ::pf-m0-l2 Q3::[plain]Your trade report prints "Return\: 45.0%" for the MSFT trade (bought at $300, sold at $345). What is the bug?{ =The code printed the dollar profit as if it were a percentage - it never divided by the buy price#profit is 45 dollars. A return must be normalised by what you invested\: 45 / 300 × 100 \= 15.0%. Skipping the division is the single most common beginner mistake with returns. ~The profit was divided by sell_price instead of buy_price#That mistake would print about 13.0% (45/345 × 100), not 45.0%. Returns are measured against what you invested - the buy price. ~The multiplication by 100 was forgotten#Forgetting the ×100 gives 45/300 \= 0.15, which would print as roughly 0.1% - not 45.0%. ~The \:.1f format code rounds incorrectly#\:.1f only controls how many decimal places are displayed; it cannot turn 15.0 into 45.0. The bug is in the formula, not the formatting. } $CATEGORY: $course$/EcoFinLearn/Python Foundations/Variables & Data Types // pf-m1-l1: Python Variables & Data Types ::pf-m1-l1 Q1::[plain]A junior developer stores the share quantity as quantity \= 200.0 (a float). The code runs fine. Why would a senior quant still reject the change?{ =Share counts are discrete - using int documents that half a share cannot exist and protects against silent fractional bugs#Types are documentation the computer enforces. An int says "this is a count". A float quantity lets a bug like quantity * 0.5 slip through and book half a share - an impossible trade on a whole-lot exchange. ~Floats cannot store the number 200#Floats store 200.0 exactly - small whole numbers are perfectly representable. The issue is meaning, not storage\: a count should be an int. ~Python would crash when multiplying a float quantity by a float price#float * float works fine. Nothing crashes - which is exactly why the wrong type is dangerous\: the error stays silent. ~Floats use more memory than ints, slowing the system down#Memory is not the concern at this scale. The real reason is semantic correctness\: whole shares are counted, not measured. } // pf-m1-l1: Python Variables & Data Types ::pf-m1-l1 Q2::[plain]What does the expression 0.1 + 0.2 \=\= 0.3 evaluate to in Python, and why?{ =False - 0.1 and 0.2 have no exact binary representation, so the sum is 0.30000000000000004#Binary floating point cannot represent most decimal fractions exactly, just as 1/3 has no exact decimal form. This is why P&L systems compare with tolerances or use Decimal. ~True - Python rounds automatically#Python does not round arithmetic results. The tiny representation error survives, and the equality test fails. ~It raises an error - you cannot compare floats#Comparing floats is legal and returns a bool. The problem is that the answer is surprising, not that it is forbidden. ~False - the \=\= operator only works on integers#The \=\= operator works on every type. The comparison fails because of binary representation error, not the operator. } // pf-m1-l1: Python Variables & Data Types ::pf-m1-l1 Q3::[plain]price is a float (372.15) and quantity is an int (200). What is the type of price * quantity, and is that appropriate for a notional?{ =float - Python promotes the int, and a dollar amount is naturally a decimal number#When float and int meet in arithmetic, Python promotes the result to float\: 372.15 * 200 \= 74430.0. A notional in dollars and cents is a measurement, so float is the right type here. ~int - multiplication always returns the type of the second operand#Type promotion does not follow operand order. Mixing float and int always yields float, whichever side each is on. ~str - the result is formatted with commas, so it must be text#The comma formatting happens later, in the f-string. The arithmetic result itself is a float; formatting only changes how it is displayed. ~Decimal - Python switches to exact arithmetic for money#Python has no idea the number represents money. You only get Decimal if you explicitly import and use the decimal module. } $CATEGORY: $course$/EcoFinLearn/Python Foundations/Control Flow // pf-m2-l1: Trade Logic with if/elif/else ::pf-m2-l1 Q1::[plain]A junior dev writes this take-profit rule, and pnl_pct is 0.12 (a +12% gain)\:\n\nif pnl_pct > 0\:\n action \= "HOLD_WINNER"\nelif pnl_pct > 0.10\:\n action \= "TAKE_PROFIT"\n\nWhat does action become?{ =HOLD_WINNER - the ladder stops at the first True condition, so the take-profit branch is never even checked#0.12 > 0 is True, so Python runs that branch and skips the rest. With the general condition written first, TAKE_PROFIT is unreachable - the fix is to test the more extreme threshold (> 0.10) FIRST. ~TAKE_PROFIT - 0.12 is greater than 0.10, and Python picks the most specific matching branch#Python does not look for the "best" match; it takes the FIRST True branch top-down. 0.12 > 0 fires immediately and the elif is skipped. ~Both branches run, so action ends up TAKE_PROFIT because it was assigned last#Exactly one branch of an if/elif ladder runs. Once a condition is True, all remaining elif/else branches are skipped entirely. ~It raises an error because the two conditions overlap#Overlapping conditions are perfectly legal Python - the ladder resolves them by order. That is exactly why order matters. } // pf-m2-l1: Trade Logic with if/elif/else ::pf-m2-l1 Q2::[plain]GS shows -0.31% on your screen. With the ladder "if pct_return > 0 → WIN, elif pct_return < 0 → LOSS, else → FLAT", which flag does GS get?{ =LOSS - the comparison sees the raw negative number; only a return of exactly zero reaches the else#-0.31 < 0 is True, so the elif branch fires. "Nearly flat" is not flat\: the strict > and < split the world into up, down, and exactly-zero. ~FLAT - a third of a percent rounds to roughly zero#The condition tests the raw float, not your feeling about it. Any negative number, however small, satisfies pct_return < 0. ~It depends on how the number is rounded when printed#Printing and comparing are separate operations. The f-string rounds for display only; the if/elif compares the full-precision value. ~FLAT - Python treats anything within ±0.5% as equal to zero#Python has no tolerance band unless you code one. If the desk wants a "flat band", you must write it explicitly, e.g. abs(pct_return) < 0.5. } // pf-m2-l1: Trade Logic with if/elif/else ::pf-m2-l1 Q3::[plain]Your risk rule is\: elif pnl_pct < -0.05\: action \= "STOP_LOSS". A position sits at exactly -0.05 (a 5% loss). What happens?{ =The stop-loss does NOT fire - strict < excludes the boundary; you would need <\= to trigger at exactly -5%#-0.05 < -0.05 is False (a number is never strictly less than itself), so the ladder falls through to the next branch. Boundary behaviour is a classic production bug\: choose < or <\= deliberately. ~The stop-loss fires - -0.05 counts as less than -0.05#Strict inequality excludes equality\: -0.05 < -0.05 evaluates to False. To include the boundary you need <\=. ~Python raises an error because the comparison is ambiguous at the boundary#Nothing is ambiguous - strict inequality has an exact answer. The comparison quietly returns False and the ladder moves on. ~It fires only if the position keeps falling within the same second#The code has no notion of time; it evaluates the condition once with the current value, and -0.05 < -0.05 is simply False. } // pf-m2-l2: For Loops: Walk the Book ::pf-m2-l2 Q1::[plain]You want the total value of a book\:\n\ntotal \= 0.0\nfor ticker, shares, price in book\:\n total +\= shares * price\n\nWhere must total be initialised?{ =Before the loop (as written) - so it survives across every pass#Exactly. total starts at 0 once, then each pass adds to it. Move total \= 0.0 inside the loop and it resets every iteration - you would keep only the last position's value. ~Inside the loop, so it is fresh each pass#That resets total to 0 on every iteration - you would end with just the final position's value, not the sum. The accumulator must persist across passes, so it lives before the loop. ~After the loop, once all positions are seen#By then the loop has finished and there is nothing left to add. total must exist before the loop so each pass can accumulate into it. ~It does not matter where#Placement is the whole game\: before the loop \= running total, inside \= reset each pass, after \= too late. } // pf-m2-l2: For Loops: Walk the Book ::pf-m2-l2 Q2::[plain]book \= [("AAPL", 10, 174.55), ("MSFT", 5, 372.15)]\n\nfor ticker, shares, price in book\:\n ...\n\nOn the first pass, what is shares?{ =10 - the second field of the first tuple#Right. Tuple unpacking maps position by position\: ticker\="AAPL", shares\=10, price\=174.55. The loop deals one tuple per pass and splits its fields into the three names, in order. ~"AAPL"#That is ticker - the FIRST field. Unpacking is positional\: the names line up with the tuple fields in order, so shares is the second field, 10. ~The whole tuple ("AAPL", 10, 174.55)#You would get the whole tuple from for row in book (one name). With three names - for ticker, shares, price - Python unpacks the tuple, so shares is just 10. ~2 - the number of tuples in the book#The loop variable holds a field of the CURRENT tuple, not a count. On pass one, shares is the second field of the first tuple\: 10. } $CATEGORY: $course$/EcoFinLearn/Python Foundations/Functions // pf-m3-l1: ROI & Sharpe Calculator ::pf-m3-l1 Q1::[plain]A strategy returns 15% annually with 20% volatility. Risk-free rate is 3%. What is the Sharpe ratio?{ =0.60#(0.15 - 0.03) / 0.20 \= 0.12 / 0.20 \= 0.60. A Sharpe above 0.5 is generally considered acceptable. ~0.75#Check your formula\: Sharpe \= (Return - Risk-Free) / Volatility. Did you forget to subtract the risk-free rate? ~1.00#1.0 would require return - risk_free \= volatility, i.e. 0.20 excess return with 0.20 vol. ~0.15#0.15 is just the raw return, not the risk-adjusted return. } // pf-m3-l1: ROI & Sharpe Calculator ::pf-m3-l1 Q2::[plain]What is the main advantage of using a function over copy-pasting the formula everywhere?{ =If the formula has a bug, you fix it in one place and all uses are fixed#This is the DRY principle (Don't Repeat Yourself). Functions also make code testable and readable. ~Functions run faster than inline code#There is a tiny function-call overhead. The advantage is code quality and maintainability, not speed. ~Python requires functions for all calculations#Python has no such requirement. Functions are a design choice, not a language requirement. ~Functions automatically handle errors#Functions do not add error handling automatically - you must add try/except or input validation yourself. } // pf-m3-l1: ROI & Sharpe Calculator ::pf-m3-l1 Q3::[plain]Your backtest reports a daily Sharpe ratio of 0.10. What is the annualised Sharpe, and why?{ =About 1.6 - multiply by √252, because mean returns scale with 252 days but volatility only scales with √252#0.10 × √252 ≈ 0.10 × 15.87 ≈ 1.59. The ratio scales by 252 ÷ √252 \= √252 - which is exactly why forgetting annualisation makes strong strategies look terrible. ~About 25.2 - multiply by 252, one for each trading day#That would only hold if volatility stayed constant as the horizon grows. It does not - vol grows with √time, so the ratio scales by √252, not 252. ~Still 0.10 - the Sharpe ratio does not depend on the measurement horizon#The Sharpe ratio is NOT horizon-independent\: mean and volatility scale differently with time. Always state and match the horizon - professionals quote annualised figures. ~About 0.006 - divide by √252 to convert to annual#Wrong direction\: going from daily to annual multiplies by √252. Dividing would convert an annualised figure back to daily. } // pf-m3-l2: NPV and IRR in Python: Is the Project Worth It? ::pf-m3-l2 Q1::[plain]A machine costs 100 today and returns 110 exactly one year from now. The hurdle rate is 10%. What are its NPV and IRR?{ =NPV \= 0 and IRR \= 10%\: the project exactly breaks even at the hurdle#-100 + 110 / 1.10 \= -100 + 100 \= 0. The IRR is the rate that makes NPV zero, which is that same 10%. A zero-NPV project earns exactly its cost of capital\: it neither creates nor destroys value. ~NPV \= 10 and IRR \= 10%#The 10 is the undiscounted profit (110 - 100). Discount the 110 back one year at 10% and it is worth exactly 100 today, so the NPV is 0. ~NPV \= 0 and IRR \= 0%#The NPV is right but the IRR is not\: at a 0% rate nothing is discounted, so NPV \= -100 + 110 \= +10, not zero. The rate that brings it to zero is 10%. ~NPV \= 9.09 and IRR \= 11%#9.09 is the profit of 10 divided by 1.10, but the purchase happens today and is never discounted\: -100 + 110 / 1.10 \= 0. And the IRR solves 110 / (1 + r) \= 100, which gives exactly 10%, not 11%. } // pf-m3-l2: NPV and IRR in Python: Is the Project Worth It? ::pf-m3-l2 Q2::[plain]In the starter, npv() added every cash flow at face value. Why did irr() then report 100.00%?{ =npv() returned +26,000 at every rate, so every midpoint looked profitable and the lower end climbed until the bracket closed on its 100% ceiling#Bisection needs NPV to change sign inside the bracket. With the rate ignored, npv() never goes negative, so every step sets lo \= mid and the answer converges on the upper bound. An IRR sitting exactly on its search ceiling is a red flag that the function, or the bracket, is wrong. ~The bracket was too narrow\: the real IRR of these cash flows lies above 100%, so the search stopped at the ceiling#Discounted properly, the NPV at 100% is -90,250, so the real IRR sits well inside the bracket, at 7.29%. Widening the bracket would not help the broken npv() either\: it would simply converge on the new ceiling. ~100% really is the IRR of the undiscounted cash flows#An IRR is the rate that makes the discounted cash flows sum to zero. Undiscounted, these cash flows sum to +26,000 whatever the rate, so no rate can zero them\: the 100.00% is simply the upper edge of the search bracket. ~The tolerance of 1e-7 was too loose to find the true rate#1e-7 is far finer than the two decimals printed. Tolerance controls precision, not direction\: the search went the wrong way because npv() ignored the rate. } // pf-m3-l2: NPV and IRR in Python: Is the Project Worth It? ::pf-m3-l2 Q3::[plain]Operations revises its savings upwards and the IRR rises to 12%, with the hurdle still at 10%. What must be true of the NPV at 10%?{ =It is positive\: for a conventional project, an IRR above the hurdle means the NPV at the hurdle is above zero#NPV falls as the rate rises and crosses zero at the IRR. At 10% you are still to the left of the 12% crossing, so NPV is positive and the project should be accepted. ~It is exactly zero, because the IRR changed#NPV is zero only AT the IRR, which is now 12%. At 10% the cash flows are discounted less hard, so NPV is above zero. ~It equals 2% of the cost, the gap between the IRR and the hurdle#NPV is a sum of discounted euros, not a percentage spread times the cost. The gap tells you the sign only\: IRR above the hurdle means NPV positive. ~It could be positive or negative\: NPV and IRR measure unrelated things#They are two readings of the same curve\: the IRR is where the NPV crosses zero. For conventional cash flows, an IRR above the hurdle always means a positive NPV at the hurdle. } $CATEGORY: $course$/EcoFinLearn/Python Foundations/Data Structures // pf-m4-l1: Portfolio as a Dictionary ::pf-m4-l1 Q1::[plain]Your loop reads for ticker, weight in portfolio.items(). A colleague "simplifies" it to for ticker, weight in portfolio. What happens?{ =Python iterates over the KEYS only, so it tries to unpack the string "AAPL" into two variables and crashes with a ValueError#A bare dict iterates its keys. "AAPL" is a 4-character string, which cannot unpack into exactly two names - ValueError. Use .items() whenever you need key and value together. ~Nothing changes - .items() is optional syntactic sugar#It is not optional. Without .items() the loop sees only the keys, and the two-variable unpacking fails. ~It loops over the values (the weights) only#Values-only iteration is portfolio.values(). A bare dict yields its keys, not its values. ~It loops over key–value pairs but in random order#Order is not the issue - since Python 3.7 dicts keep insertion order. The issue is that a bare dict yields keys only. } // pf-m4-l1: Portfolio as a Dictionary ::pf-m4-l1 Q2::[plain]You need the total invested weight of the book. Which expression is correct?{ =sum(portfolio.values())#.values() yields the weights 0.30, 0.25, ... and sum() adds them to 1.0 - the fully-invested check. ~sum(portfolio)#Iterating a bare dict yields its KEYS, so this tries to add the strings "AAPL" + "MSFT" via sum() and raises a TypeError. ~portfolio.sum()#Dicts have no .sum() method - that is pandas/NumPy territory. This raises an AttributeError. ~sum(portfolio.items())#.items() yields (ticker, weight) tuples; sum() cannot add tuples to numbers - TypeError. } // pf-m4-l1: Portfolio as a Dictionary ::pf-m4-l1 Q3::[plain]A junior analyst builds the book as \{"AAPL"\: 0.30, "MSFT"\: 0.25, "AAPL"\: 0.45\}. What does Python do?{ =No error - the later value silently wins, so AAPL ends at 0.45 and the book now sums to 0.70#Dict keys are unique; a repeated key overwrites the earlier entry without any warning. In finance that silently vaporises a booking - exactly why professional systems always run the sum-of-weights check. ~SyntaxError - duplicate keys are illegal in a dict literal#Python accepts the literal without complaint. Duplicate keys are legal syntax; the last assignment simply wins. ~Python keeps the first value, so AAPL stays at 0.30#It is the opposite\: the LAST occurrence of a key wins, so AAPL becomes 0.45. ~Python adds them together\: AAPL becomes 0.75#Dicts never merge values on duplicate keys - the later assignment replaces the earlier one entirely. } $CATEGORY: $course$/EcoFinLearn/Python Foundations/List Comprehensions // pf-m5-l1: List Comprehensions & Filters ::pf-m5-l1 Q1::[plain]Using the lesson's 6 stocks (where MSFT and AMZN trade below their SMA), what does [t for t, p, s in stocks if p > s] evaluate to?{ =A list of 4 ticker strings - AAPL, GOOGL, NVDA and JPM#The expression selects only t (the ticker), and the if clause drops the two stocks trading below their SMA - 6 rows in, 4 tickers out. SELECT ticker FROM stocks WHERE price > sma. ~A list of all 6 tickers - the if clause only reorders them#The if clause FILTERS, it never reorders. MSFT (415.30 < 418.90) and AMZN (198.60 < 201.30) fail the condition and are excluded entirely. ~A list of 4 full tuples like ("AAPL", 187.50, 179.20)#The leading expression decides the output shape - here it is just t, so you get bare ticker strings, not the original 3-element tuples. ~A SyntaxError - the if must come before the for#A filtering if is only legal AFTER the for clause. (An if BEFORE the for exists too, but it is a ternary expression like x if cond else y, which selects values rather than filtering rows.) } // pf-m5-l1: List Comprehensions & Filters ::pf-m5-l1 Q2::[plain]Spot the difference\: [p for t, p, s in stocks if p > s] versus [p if p > s else 0 for t, p, s in stocks]. What changes?{ =The first FILTERS (4 items survive); the second keeps all 6 items, replacing bearish prices with 0#if after the for is a WHERE clause - rows that fail vanish. if/else before the for is a ternary expression - every row stays, but its value is chosen conditionally. Screens use the first; imputation uses the second. ~Nothing - both produce the same 4-element list#The second form has no filter at all\: the ternary evaluates for every one of the 6 rows, so the result has 6 elements (two of them 0). ~The second is a SyntaxError - else is not allowed in comprehensions#x if cond else y is a perfectly legal ternary expression in the SELECT position. What is illegal is a bare trailing else in the filter position. ~The first is slower because filtering requires a second pass over the data#Both are single-pass. The difference is semantics - filtering rows out versus transforming every row - not performance. } // pf-m5-l1: List Comprehensions & Filters ::pf-m5-l1 Q3::[plain]In sorted(momentum_list, key\=lambda x\: x[1], reverse\=True), what exactly does the key argument do?{ =It tells sorted() to compare each tuple by its second element - the momentum score - instead of the whole tuple#Without a key, Python compares tuples element-by-element starting with the ticker string (alphabetical order). key\=lambda x\: x[1] redirects the comparison to the momentum number, and reverse\=True ranks strongest first. ~It keeps only the second element of each tuple in the result#key affects the ORDER, never the contents - the result still holds the full (ticker, momentum) tuples, just rearranged. ~It removes tuples whose momentum is negative#sorted() never filters. Screening out bearish stocks is the comprehension's job (the if clause), done before the sort. ~It sorts the list alphabetically by ticker as a tie-breaker#With key\=lambda x\: x[1], tickers are ignored completely during comparison - only the momentum values are compared. } $CATEGORY: $course$/EcoFinLearn/Python Foundations/Object-Oriented Finance // pf-m6-l1: OOP: Bond & Stock Classes ::pf-m6-l1 Q1::[plain]The Stock class never defines a describe() method, yet aapl.describe() works and prints "AAPL (USD)". Why?{ =Stock inherits from FinancialInstrument, so Python looks up describe() on the parent class automatically#That is inheritance at work\: when a method is not found on the object's own class, Python walks up the class hierarchy. Stock and Bond both reuse the parent's describe() - write once, use everywhere. ~Python auto-generates a describe() method for every class#Python auto-generates a few dunder methods like __init__ defaults and __repr__ fallbacks, but describe() is ordinary user code - it exists only because FinancialInstrument defines it. ~super().__init__() copies all the parent's methods into the Stock object#super().__init__() only runs the parent's initialiser to set ticker and currency. Methods are never copied - they are found by lookup through the class hierarchy at call time. ~It works only because Stock and FinancialInstrument are in the same file#File location is irrelevant. What matters is the declaration class Stock(FinancialInstrument) - that parenthesised parent is what creates the inheritance link. } // pf-m6-l1: OOP: Bond & Stock Classes ::pf-m6-l1 Q2::[plain]The US10Y bond pays a fixed 4.5% coupon. Market yields rise from 4.0% to 5.0%. What happens to the bond's price, and why?{ =It falls below face value - a fixed 4.5% coupon is less attractive when new bonds pay 5%, so the price drops until the effective yield matches the market#Malkiel's first theorem\: prices move inversely to yields. Your code shows it numerically - 1040.55 at 4.0% yield, 961.39 at 5.0%. Same bond, same cash flows, different discount rate. ~It rises above face value, because higher yields mean the bond earns more#Backwards. The coupon is fixed at issue - the bond does not earn more when market yields rise. Existing fixed cash flows are discounted more heavily, so the price falls. ~It stays at 1000, because the face value repaid at maturity never changes#The face value repayment is indeed fixed, but the price today is the PRESENT VALUE of all cash flows - and that value shrinks when the discount rate rises. Only the placeholder code believes prices never move. ~The coupon rate automatically adjusts up to 5% to stay competitive#A fixed-coupon bond's rate is set at issuance and never changes - that rigidity is exactly why the PRICE must do all the adjusting. } // pf-m6-l1: OOP: Bond & Stock Classes ::pf-m6-l1 Q3::[plain]You hold a \= Bond("US10Y", 1000, 0.045, 10) and b \= Bond("DE10Y", 1000, 0.025, 10). When you call b.price(0.03), what does self refer to inside the method?{ =The b object - self is always the specific instance the method was called on, so the calculation uses DE10Y's 2.5% coupon#Exactly. self is how one method body serves every object\: b.price(0.03) binds self to b, so self.coupon_rate is 0.025. Call a.price(0.03) and the same code uses 0.045 instead. ~The Bond class itself#self is an instance, not the class. If it referred to the class, self.coupon_rate could not differ between the two bonds - and it clearly does. ~The a object, because a was created first#Creation order is irrelevant. The binding happens at call time\: whatever object appears before the dot in b.price(...) becomes self. ~Nothing - you must pass self yourself, as in Bond.price(0.03)#With dot-call syntax Python passes the instance automatically. (Bond.price(b, 0.03) is the explicit equivalent - note b still has to be supplied.) } // pf-m6-l2: Bond Duration in Python: Macaulay and Modified ::pf-m6-l2 Q1::[plain]The lesson's 10-year coupon bond has a Macaulay duration of 8.31 years. Why is it shorter than its 10-year maturity?{ =The coupons arrive before year 10 and each carries some PV weight, which pulls the weighted average time earlier#Duration is a PV-weighted average of payment dates, all between year 1 and year 10. Nine coupons sit before year 10, so the average lands before it. Only a zero-coupon bond, with its single payment, has a duration equal to its maturity. ~The 4% yield is subtracted from the maturity somewhere in the formula#No such subtraction exists. The yield enters only through the discount factors 1 / (1 + y)ᵗ that set each cash flow's present value, and therefore its weight. ~The bond trades above par, and only premium bonds have a duration shorter than their maturity#A coupon bond priced at par or at a discount also has a duration below its maturity. It is the early coupons that shorten duration, whatever the price. ~It is a rounding effect\: with more decimals it would come out at exactly 10#A gap of 1.69 years is far too large for rounding. Printing 8.308 instead of 8.31 changes nothing\: the coupons genuinely move the balance point earlier. } // pf-m6-l2: Bond Duration in Python: Macaulay and Modified ::pf-m6-l2 Q2::[plain]For a +1 point rise in yield, the duration estimate is -83.13 but exact repricing gives -79.16. What explains the 3.96 difference?{ =Convexity\: the price-yield curve bends, so the straight-line duration estimate overstates the loss when yields rise#Modified duration is the slope of the price curve at 4.0%. The true curve lies above that straight line on both sides, so exact losses come out smaller and exact gains bigger than the estimate. The same curvature is why, in Malkiel's theorems, a fall in yield lifts the price more than an equal rise cuts it. ~A floating-point rounding error in the discount factors#Python floats carry about 15 significant digits. A gap of nearly 4 on a price around 1000 is a real, systematic effect, and it widens as the yield move gets bigger. ~The estimate should use Macaulay duration instead of modified duration#Plugging in Macaulay duration (8.31) gives -86.45, which is further from the exact -79.16, not closer. Dividing by (1 + y) is exactly what converts an average time into a price sensitivity. ~The coupon payments fall when market yields rise#A fixed-rate bond's coupons never change\: the same 45 a year is paid at 4% and at 5%. Only the discounting changes, and the gap comes from its curved shape. } // pf-m6-l2: Bond Duration in Python: Macaulay and Modified ::pf-m6-l2 Q3::[plain]The report prints a modified duration of 7.99 for the bond priced at 1040.55. What does that number tell a risk manager?{ =For a one percentage point rise in yield, the price falls by roughly 7.99%#Modified duration is a percentage sensitivity. 7.99% of 1040.55 is about 83, the size of the duration estimate in the report. Divide by 100 and you get the loss per basis point, the DV01. ~The bond repays its face value in 7.99 years#The face value comes back at maturity, in year 10. Duration measures rate risk; even Macaulay's version is an average of all payment dates, not a repayment date. ~The price falls by exactly 7.99 for every one point rise in yield#Modified duration is a percentage, not an amount\: multiply it by the price to get money. And the result is an approximation, as the convexity gap shows. ~Market yields are expected to rise by 7.99% over the next year#Duration makes no forecast about yields at all. It only says how sensitive the price is IF yields move. }