跳到正文
DEV Community· Roland HUON·· 3 小時前AI 評分65

Claude Opus 5.5 編寫的交易應用程式匯率轉換器,缺少匯率時回傳 1.0

An AI wrote my trading app's currency converter. When it doesn't know a rate, it says 1.0

AI 導讀

作者發現,Claude Opus 5.5 為其交易儀錶板編寫的匯率轉換函式,在報價缺失時會回傳 1.0,令錯誤估值看似正常。以 ¥1,000,000 的日本股票持倉兑美元為例,正確估值約為 $6,700,錯誤結果卻是 $1,000,000;這個錯誤值還可能被快取一小時。修正版在缺少貨幣或報價時拋出錯誤,並讓儀錶板標示該持倉匯率不可用、總額不完整,避免錯誤數字被加總。

正文

On Monday I posted a function Claude Opus 5.5 wrote for my personal trading dashboard and asked whether you'd merge it. Here's the answer.

The function converts every position into my portfolio currency before the dashboard adds them up:

@cached(ttl=3600)
def fx_rate(from_ccy: str, to_ccy: str) -> float:
    if not from_ccy or not to_ccy or from_ccy == to_ccy:
        return 1.0
    # Les cotations londoniennes sont en pence (GBp)
    factor = 1.0
    if from_ccy == "GBp":
        from_ccy, factor = "GBP", 0.01
    if from_ccy == to_ccy:
        return factor
    q = quotes([f"{from_ccy}{to_ccy}=X"])
    rate = next(iter(q.values()), {}).get("price")
    return (rate or 1.0) * factor

It reads well. It knows that London stocks are quoted in pence. It caches rates so I don't hit the API limits. It short-circuits when there's nothing to convert.

It shouldn't be merged.

The problem is a fallback that looks like caution

When the quote provider fails or doesn't know the symbol, quotes() returns an empty dict. Follow it through:

q = {}
rate = next(iter({}.values()), {}).get("price")   # → None
return (None or 1.0) * 1.0                          # → 1.0

No exception. No log. The function answers with a perfectly plausible number.

Line 3 does the same thing in a different place: if a position has no currency, it's treated as if it were already in the target currency.

And then @cached(ttl=3600) makes it worse. If your decorator stores whatever comes back — and most do — that 1.0 stays there for an hour. A quote provider that hiccups for one second gives you sixty minutes of wrong valuations.

What that looks like in a portfolio

Say I hold ¥1,000,000 of a Japanese stock, and my dashboard reports in dollars.

correct:   1,000,000 JPY × 0.0067  ≈  $6,700
fallback:  1,000,000 JPY × 1.0     =  $1,000,000

The position appears about 150 times larger than it is, inside a total that still looks like a real number. On a page whose job is to help me decide what to buy and sell, that's the worst kind of failure: silent, confident, and wrong.

Nobody asked for this, and nobody forbade it

The whole project started from this prompt (my translation; I wrote it in French):

I want to build a platform to organise and plan my trading. I need to know about the news instantly, see what I can invest in, see the confidence index, how many people bought shares in it, the returns, etc. Plan what you need to set up for this.

It says nothing about what to do when data is missing. So the model filled that silence with the most comfortable choice for the code itself: never crash, always return something.

That's the part I keep coming back to. The missing requirement — in a trading tool, a wrong number is worse than no number — is obvious once you say it. I never said it. A better model won't fix that; it will follow my incomplete prompt even more faithfully.

The fix

Fail loudly, and let nothing wrong reach the cache. This is the version I'm moving to:

SUBUNITS = {"GBp": ("GBP", 0.01), "ZAc": ("ZAR", 0.01), "ILA": ("ILS", 0.01)}

@cached(ttl=3600)
def fx_rate(from_ccy: str, to_ccy: str) -> float:
    if not from_ccy or not to_ccy:
        raise ValueError("Missing currency")
    from_ccy, f_from = SUBUNITS.get(from_ccy, (from_ccy, 1.0))
    to_ccy, f_to = SUBUNITS.get(to_ccy, (to_ccy, 1.0))
    if from_ccy == to_ccy:
        return f_from / f_to
    q = quotes([f"{from_ccy}{to_ccy}=X"])
    rate = next(iter(q.values()), {}).get("price")
    if rate is None:
        raise LookupError(f"No quote for {from_ccy}/{to_ccy}")
    return rate * f_from / f_to

Caching decorators generally store return values, not exceptions, so a failed lookup is retried on the next call instead of being frozen for an hour. It's worth checking that yours behaves that way.

The caller changes too: when a rate is unavailable, the dashboard shows "rate unavailable" for that position and marks the total as incomplete. It never adds up a total that's quietly wrong.

A smaller one, with the same ending

Pence only worked in one direction. The original handled GBp as a source currency, but fx_rate("USD", "GBp") returned the USD→GBP rate, 100 times too small. Johannesburg (ZAc) and Tel Aviv (ILA) also quote in sub-units; for those, the symbol most likely doesn't exist, the quote comes back empty, and you're back to 1.0. The fix handles both directions and all three.

What looked wrong and wasn't

"The factor should be 100, not 0.01." It shouldn't. A penny is 0.01 pounds; converting from pence means multiplying by 0.01, then by the pound's rate.

"The second if from_ccy == to_ccy is dead code." It isn't. After GBp becomes GBP, it correctly handles fx_rate("GBp", "GBP"), which should return 0.01.

"Use Decimal instead of float." Reasonable for accounting. For a displayed exchange rate, it doesn't change the outcome.

"A one-hour cache means stale rates." That's what a TTL is for. The problem isn't stale rates, it's invented ones.

What I take from it

Every line here is defensible on its own. The bug is a default: a value the code reaches for when it doesn't know, chosen to keep things running rather than to keep them true.

Worth searching your own code for: or 1.0, or 0, .get(key, 0), an except that returns something plausible. Each one is a question about what your program should do when it doesn't know — and whether anyone ever answered it.

I asked Claude Code to search the rest of my project for the same pattern. That list will probably be next week's article.


I write one of these every week from code an agent wrote for my own projects. If you'd rather catch the next one before the answer: https://discord.com/invite/WsYyZk5sWj

來源:DEV Community · dev.to