Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Praxisbeispiel: Eine Wirtschaftssimulation

Heinrich-Heine-Universität Düsseldorf

Dieselbe Simulation wie in der ersten Ausbaustufe, einmal gründlich refaktorisiert. Das Spiel verhält sich fast gleich — was sich geändert hat, ist die Frage, woran man später noch weiterbauen kann.

Damit ist dieses Notebook zugleich das erste Brownfield-Refactoring im Skript, nur mit dem Sonderfall, dass der alte Code von uns selbst stammt: Man liest fremden Code, erkennt die Muster, die nicht mehr tragen, und ersetzt sie schrittweise. Dasselbe Vorgehen wenden Sie in den Übungen auf Wesen und BamBam an.

import sys
import os
from dataclasses import dataclass, field
from itertools import cycle
from enum import Enum, auto
from typing import Optional


class GameException(Exception):
    """Base class for all game-related errors."""
    pass

class NotEnoughMoneyException(GameException):
    pass

class NotEnoughStockException(GameException):
    pass

class ProductType(Enum):
    FOOD = auto()
    # Später auch andere Produkte

    def __str__(self):
        return self.name.title()


@dataclass
class Inventory:
    """Each player has one inventory"""

    money: float
    stock: dict[ProductType, int] = field(default_factory=dict)

    def execute_trade(self, product: ProductType, quantity: int, unit_price: float):
        """
        Unified method for Buying AND Selling.
        - Positive quantity = BUY (Money down, Stock up)
        - Negative quantity = SELL (Money up, Stock down)
        """
        cost = quantity * unit_price

        # 1. Validation Logic
        if quantity > 0:  # BUYING
            if self.money < cost:
                raise NotEnoughMoneyException(
                    f"Need {cost:.2f}€, have {self.money:.2f}€"
                )

        elif quantity < 0:  # SELLING
            # check current stock. absolute value needed because quantity is negative
            current_stock = self.stock.get(product, 0)
            if current_stock < abs(quantity):
                raise NotEnoughStockException(f"Not enough {product.name} to sell.")

        # 2. Execution Logic (Only runs if Validation passes)
        self.money -= cost
        current_qty = self.stock.get(product, 0)
        self.stock[product] = current_qty + quantity

    def __str__(self) -> str:
        """Creates a pretty, human-readable string for the player."""
        money_display = f"{self.money:.2f} €"
        items_list = [
            f"{product.name.title()}: {amount}"
            for product, amount in self.stock.items()
            if amount > 0
        ]
        stock_display = ", ".join(items_list) if items_list else "Empty"
        return f"[Money: {money_display} | Stock: {stock_display}]"


@dataclass
class Market:
    """For each product type, there is one market"""

    product: ProductType
    current_price: float
    volatility: float

    def adjust_price(self, direction: int) -> None:
        """
        Adjusts price based on supply/demand.
        direction: 1 for Buy (Demand up), -1 for Sell (Supply up).
        """
        change = direction * self.volatility
        new_price = self.current_price + change
        self.current_price = max(1.0, round(new_price, 2))

Warum diese Umbauten

Die Zelle oben enthält drei Refactorings, die selten einzeln vorkommen. Sie hängen zusammen, und jedes beantwortet eine Frage, die in practical-0 offen blieb.

Von freien Variablen zu @dataclass. Inventory und Market fassen das zusammen, was vorher als money, stock, price und volatility nebeneinanderlag. Der Gewinn ist nicht die kürzere Schreibweise, sondern dass es den Zustand jetzt als Objekt gibt: Man kann ihn erzeugen, kopieren, in einen Test hineingeben und mehrfach haben. Der Dekorator erzeugt dabei __init__, __repr__ und __eq__; field(default_factory=dict) ist die Antwort auf die klassische Falle, dass ein veränderliches Default-Argument sonst von allen Instanzen geteilt würde. Siehe Dataclasses.

Von Strings zu enum.Enum. Vorher war das Produkt implizit — es gab genau eins, “Food”, und es stand als Text in der Ausgabe. ProductType.FOOD macht daraus einen Wert, den man nicht vertippen kann: ProductType.FOD ist ein AttributeError beim Start, "fod" wäre ein stiller Fehler zur Laufzeit. Der Kommentar # Später auch andere Produkte ist der eigentliche Punkt: Das Hinzufügen eines zweiten Produkts kostet jetzt eine Zeile. Siehe Enums.

Von einem else-Zweig zu eigenen Exceptions. NotEnoughMoneyException und NotEnoughStockException erben beide von GameException. Damit kann der Aufrufer wählen, wie genau er hinsehen will: except NotEnoughMoneyException behandelt den einen Fall, except GameException alle Spielfehler gemeinsam, und ein Programmierfehler — etwa ein TypeError — rutscht nicht versehentlich mit durch, wie es bei einem except Exception passiert wäre. Genauso wichtig: execute_trade prüft erst alles und bucht dann. Wäre es umgekehrt, bliebe nach einer Exception ein halb gebuchter Zustand zurück. Siehe Exceptions.

class EconomyEngine:
    def __init__(self):
        # 1. Initialize State
        self.player = Inventory(money=100.0)
        self.food_market = Market(
            product=ProductType.FOOD, current_price=10.0, volatility=0.5
        )
        self.history: list[tuple] = []
        self.market_trend = cycle([0.2, 0.2, -0.1, -0.3])

    def interact_with_market(self, is_buy: bool, quantity: int) -> tuple[str, float]:
        """
        Executes the trade logic.
        Returns details of the transaction (Action Name, Total Price)
        or raises an exception.
        """
        market = self.food_market
        # Math: Buy is positive quantity, Sell is negative
        signed_qty = quantity if is_buy else -quantity

        # 1. Execute logic (Raises exceptions if invalid)
        self.player.execute_trade(market.product, signed_qty, market.current_price)

        # 2. Update History
        action_name = "BUY" if is_buy else "SELL"
        self.history.append((action_name, quantity, market.current_price))

        # 3. Update Market Price (Supply/Demand)
        # +1 direction for Buy, -1 for Sell
        direction = 1 if is_buy else -1
        market.adjust_price(direction)

        return action_name, market.current_price

    def tick(self):
        """Advances the simulation by one step (Background market forces)."""
        trend = next(self.market_trend)
        new_price = self.food_market.current_price + trend
        self.food_market.current_price = max(1.0, round(new_price, 2))
class TerminalUI:
    def __init__(self, engine: EconomyEngine):
        self.engine = engine

    def parse_command(self, raw_input: str) -> tuple[str, int]:
        """Parses 'b 5' into ('b', 5). Handles errors internally."""
        parts = raw_input.split()
        if not parts:
            raise ValueError("Empty command")

        command = parts[0]
        amount = int(parts[1]) if len(parts) > 1 else 1
        return command, amount

    def render(self):
        """Visualizes the Engine state."""
        # Accessing data from the Engine to display it
        m = self.engine.food_market

        print("-" * 40)
        print(f"MARKET: {m.product.name.title()} @ {m.current_price:.2f} €")
        print("-" * 40)
        print(self.engine.player)  # Uses Inventory.__str__
        print("-" * 40)
        # Formatting the raw history data for the user
        last_3 = self.engine.history[-3:]
        print(f"History: {last_3}")
        print("-" * 40)
        print("COMMANDS: 'b 1' (buy), 's 5' (sell), 'q' (quit)")

    def run(self):
        while True:
            self.render()
            user_input = input(">> ").strip().lower()

            if user_input == "q":
                print("Exiting...")
                break

            try:
                cmd, amount = self.parse_command(user_input)

                if cmd == "b":
                    action, price = self.engine.interact_with_market(
                        is_buy=True, quantity=amount
                    )
                    print(f"\n[SUCCESS] {action} {amount} units @ {price:.2f}\n")
                elif cmd == "s":
                    action, price = self.engine.interact_with_market(
                        is_buy=False, quantity=amount
                    )
                    print(f"\n[SUCCESS] {action} {amount} units @ {price:.2f}\n")
                else:
                    print("\n[!] Unknown command.\n")

                # Advance the game world
                self.engine.tick()

            except ValueError:
                print("\n[!] Invalid input format. Use 'b 5' or 's 1'.\n")
            except GameException as e:
                # Catching the logic errors from the Engine
                print(f"\n[!] TRANSACTION FAILED: {e}\n")

Der Schnitt zwischen Engine und UI

EconomyEngine rechnet und druckt nicht. TerminalUI druckt und rechnet nicht. Diese Trennung ist die folgenreichste Entscheidung im ganzen Notebook, und sie zahlt sich dreifach aus:

  1. Mehrere Oberflächen. Dieselbe Engine kann hinter einer Terminal-Ausgabe, einer Textual-Oberfläche oder einer HTTP-API stehen — alles im Kapitel User Interfaces. Ohne den Schnitt müsste man die Spielregeln für jede Oberfläche neu schreiben.

  2. Testbarkeit. interact_with_market nimmt Zahlen entgegen und gibt ein Tupel zurück oder wirft. So etwas testet man in drei Zeilen. Die Schleife aus practical-0 dagegen ließe sich nur testen, indem man input ersetzt und stdout abfängt — machbar (siehe Testing), aber ungleich mühsamer für dasselbe Ergebnis.

  3. Fehler haben einen Ort. Die Engine wirft, die UI fängt und formuliert. Die Regel “was schiefgehen darf” und die Regel “wie es dem Menschen erklärt wird” stehen nicht mehr in derselben Zeile.

Ein Detail, das leicht übersehen wird: parse_command gehört zur UI, nicht zur Engine. Das Zerlegen von "b 5" in ("b", 5) ist Eingabeverarbeitung; die Engine kennt nur is_buy und quantity. Wer diese Grenze verschiebt, hat die Trennung dem Namen nach, aber nicht in der Sache.

Ein bewusster Unterschied im Preismodell

Die Simulation ist nicht in jeder Hinsicht identisch zu practical-0 — an einer Stelle wurde die Spielregel absichtlich geändert:

practical-0practical-1
Preisbewegung durch einen Handelprice += 0.5 * amount — proportional zur Mengechange = direction * volatility — feste ±0.5, unabhängig von der Menge

In practical-0 verschiebt der Kauf von 10 Einheiten den Preis um 5.00 €, in practical-1 um 0.50 €. Das ist kein Übertragungsfehler, sondern eine Vereinfachung: volatility wird damit zu einer Eigenschaft des Marktes statt zu einer Formel im Handelsablauf, und verschiedene Märkte können sich verschieden stark bewegen. Der Preis dafür ist, dass große Aufträge den Markt nicht mehr stärker bewegen als kleine — eine plausible erste Übungsaufgabe, das wieder einzubauen, diesmal an der richtigen Stelle.

if __name__ == "__main__":
    # In a Jupyter notebook, this blocks the cell until 'q' is pressed.
    logic = EconomyEngine()
    app = TerminalUI(logic)
    app.run()
----------------------------------------
MARKET: Food @ 10.00 €
----------------------------------------
[Money: 100.00 € | Stock: Empty]
----------------------------------------
History: []
----------------------------------------
COMMANDS: 'b 1' (buy), 's 5' (sell), 'q' (quit)
>>  b 1

[SUCCESS] BUY 1 units @ 10.50

----------------------------------------
MARKET: Food @ 10.70 €
----------------------------------------
[Money: 90.00 € | Stock: Food: 1]
----------------------------------------
History: [('BUY', 1, 10.0)]
----------------------------------------
COMMANDS: 'b 1' (buy), 's 5' (sell), 'q' (quit)
>>  b 1

[SUCCESS] BUY 1 units @ 11.20

----------------------------------------
MARKET: Food @ 11.40 €
----------------------------------------
[Money: 79.30 € | Stock: Food: 2]
----------------------------------------
History: [('BUY', 1, 10.0), ('BUY', 1, 10.7)]
----------------------------------------
COMMANDS: 'b 1' (buy), 's 5' (sell), 'q' (quit)
>>  s 2

[SUCCESS] SELL 2 units @ 10.90

----------------------------------------
MARKET: Food @ 10.80 €
----------------------------------------
[Money: 102.10 € | Stock: Empty]
----------------------------------------
History: [('BUY', 1, 10.0), ('BUY', 1, 10.7), ('SELL', 2, 11.4)]
----------------------------------------
COMMANDS: 'b 1' (buy), 's 5' (sell), 'q' (quit)
>>  q
Exiting...

Was hier welchem Kapitel entspricht

Konstrukt in diesem NotebookKapitel
@dataclass, field(default_factory=...)Objektorientierung: Dataclasses
enum.Enum, auto()Objektorientierung: Enums
Eigene Exception-Hierarchie, raiseExceptions
itertools.cycle für den MarkttrendGeneratoren und Iteratorprotokoll
Type Hints an allen SignaturenType Hints
__str__ statt Formatierung im AufruferDunder-Methoden
Engine/UI-TrennungUser Interfaces, Testing
Vor- und Nachbedingungen von execute_tradeDesign by Contract

Wie dieselbe Simulation aussieht, wenn eine KI sie in einem Zug erzeugt statt sie schrittweise zu refaktorisieren, zeigt Generierte Wirtschaftssimulation.