On a student Spark the installer's data step printed hundreds of red yfinance "Quote not found for symbol" lines and built a universe of 652 instead of ~745 stocks. Cause: the iShares holdings download (German site) answers 404 since October 2026, and setup fell back to Wikipedia's STOXX 600 table, whose Reuters-style codes give Yahoo symbols that do not exist (BNPP.PA for BNP.PA, NOV-N.SW for NOVN.SW, VESTAS.CO for VWS.CO). - sources: the iShares file is read from the Swiss site (same German CSV, same parser), the German address stays as a spare; when iShares fails, setup uses the snapshot shipped in quantcore/data/static and not the Wikipedia guesses (now a last resort when no snapshot ships); only a primary source refreshes the snapshot - yahoo: yfinance's per-symbol ERROR lines are silenced during the metadata step and summarised in one line - install.sh: builds the data with `qc setup --offline`, so every student gets the universe of the shipped lists (the course data set's); new --rebuild-data moves the existing data aside and builds it anew - qc.sh: the image build attempt logs to ~/quantcore/build.log; a Spark that allows no build shows one plain line, then the no-build setup - tests for the source order, the address fallback and the summary Tested on a stripped-down machine (no git/curl/wget/python3, broken APPTAINER_TMPDIR): install, then install.sh --rebuild-data built 746 stocks (382 US, 364 Europe) from the 1117 shipped candidates in under 10 minutes with no yfinance error line; self-test passes; qc upgrade rebuilt and restarted. 142 tests pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
153 lines
8.0 KiB
Python
153 lines
8.0 KiB
Python
import numpy as np
|
|
import pandas as pd
|
|
import pytest
|
|
|
|
from quantcore.data.download_service import splice_adjusted
|
|
from quantcore.data.fx import FXConverter
|
|
from quantcore.data.yahoo import apply_price_scale, normalise_info
|
|
from quantcore.universe.sources import parse_ishares_holdings
|
|
from quantcore.universe.symbols import normalise_share_class_name, to_yahoo_symbol
|
|
|
|
|
|
def test_fx_conversion_and_log_return_identity():
|
|
idx = pd.bdate_range("2024-01-01", periods=5)
|
|
fx = pd.DataFrame({"USD": [0.85, 0.86, 0.84, 0.85, 0.87], "CHF": 1.0}, index=idx)
|
|
conv = FXConverter(fx, base="CHF")
|
|
px = pd.DataFrame({"A": [10.0, 11.0, 10.5, 10.8, 11.2], "B": [100.0, 101.0, 99.0, 98.0, 100.0]}, index=idx)
|
|
cur = pd.Series({"A": "USD", "B": "CHF"})
|
|
chf = conv.convert_prices(px, cur)
|
|
np.testing.assert_allclose(chf["A"], px["A"] * fx["USD"])
|
|
np.testing.assert_allclose(chf["B"], px["B"])
|
|
# CHF log-return = local log-return + FX log-return
|
|
r_chf = np.log(chf).diff().iloc[1:]
|
|
r_loc = np.log(px).diff().iloc[1:]
|
|
r_fx = conv.fx_log_returns(cur, idx).iloc[1:]
|
|
np.testing.assert_allclose(r_chf.values, (r_loc + r_fx).values)
|
|
with pytest.raises(KeyError):
|
|
conv.convert_prices(px, pd.Series({"A": "JPY", "B": "CHF"}))
|
|
|
|
|
|
def test_gbp_pence_normalisation():
|
|
info = normalise_info("HSBA.L", {"currency": "GBp", "marketCap": 1e11, "regularMarketPrice": 1533.8,
|
|
"firstTradeDateMilliseconds": 584089200000, "quoteType": "EQUITY"})
|
|
assert info["currency"] == "GBP" and info["price_scale"] == 100.0
|
|
assert info["last_price"] == pytest.approx(15.338)
|
|
assert info["first_trade_date"] == "1988-07-05"
|
|
px = pd.DataFrame({"HSBA.L": [1500.0, 1600.0], "NESN.SW": [80.0, 81.0]})
|
|
scaled = apply_price_scale(px, pd.Series({"HSBA.L": 100.0}))
|
|
assert scaled["HSBA.L"].iloc[0] == 15.0 and scaled["NESN.SW"].iloc[1] == 81.0
|
|
|
|
|
|
def test_splice_rescales_history_on_dividend():
|
|
idx = pd.bdate_range("2024-01-01", periods=10)
|
|
stored = pd.DataFrame({"A": np.linspace(100, 109, 10), "B": np.linspace(50, 59, 10)}, index=idx)
|
|
fresh_idx = pd.bdate_range("2024-01-08", periods=7) # overlaps the last 5 stored days, adds 2 new
|
|
fresh = pd.DataFrame({"A": np.concatenate([stored["A"].iloc[5:].values * 0.98, [110 * 0.98, 111 * 0.98]]),
|
|
"B": np.concatenate([stored["B"].iloc[5:].values, [60.0, 61.0]])}, index=fresh_idx)
|
|
out, rescaled = splice_adjusted(stored, fresh)
|
|
assert rescaled == ["A"]
|
|
assert len(out) == 12
|
|
np.testing.assert_allclose(out["A"].iloc[:5], stored["A"].iloc[:5] * 0.98) # history rescaled
|
|
np.testing.assert_allclose(out["A"].iloc[5:], fresh["A"]) # fresh window authoritative
|
|
np.testing.assert_allclose(out["B"], np.concatenate([stored["B"].values, [60.0, 61.0]]))
|
|
|
|
|
|
def test_symbol_mapping_rules():
|
|
assert to_yahoo_symbol("NOVO B", "Omx Nordic Exchange Copenhagen A/S") == "NOVO-B.CO"
|
|
assert to_yahoo_symbol("BT.A", "London Stock Exchange") == "BT-A.L"
|
|
assert to_yahoo_symbol("RR.", "London Stock Exchange") == "RR.L"
|
|
assert to_yahoo_symbol("NESN", "SIX Swiss Exchange") == "NESN.SW"
|
|
assert to_yahoo_symbol("BRK.B", country="United States") == "BRK-B"
|
|
assert to_yahoo_symbol("NDA FI", "Nasdaq Omx Helsinki Ltd.") == "NDA-FI.HE"
|
|
with pytest.raises(ValueError):
|
|
to_yahoo_symbol("X", "Mars Exchange")
|
|
assert normalise_share_class_name("Alphabet Inc. Class A") == normalise_share_class_name("Alphabet Inc. Class C")
|
|
assert normalise_share_class_name("Investor AB ser. B") == normalise_share_class_name("Investor Class A")
|
|
|
|
|
|
def test_parse_ishares_holdings():
|
|
text = ('Fondsposition per,"18.Sept.2026"\n \nEmittententicker,Name,Sektor,Anlageklasse,Marktwert,'
|
|
'Gewichtung (%),Nominalwert,Nominale,Kurs,Standort,Börse,Marktwährung\n'
|
|
'"ASML","ASML HOLDING","IT","Aktien","438.829.188,00","4,23","438.829.188,00","303.478,00","1.446,00",'
|
|
'"Niederlande","Euronext Amsterdam","EUR"\n'
|
|
'"NOVO B","NOVO NORDISK CLASS B","Gesundheitsversorgung","Aktien","1,00","1,00","1,00","1,00","1,00",'
|
|
'"Dänemark","Omx Nordic Exchange Copenhagen A/S","DKK"\n'
|
|
'"XEUR","EUR CASH","Barmittel und/oder Derivate","Geldmarkt","1,00","0,10","1,00","1,00","1,00",'
|
|
'"Deutschland","-","EUR"\n')
|
|
df = parse_ishares_holdings(text)
|
|
assert list(df["symbol"]) == ["ASML.AS", "NOVO-B.CO"]
|
|
assert df.iloc[0]["gics_sector"] == "Information Technology" and df.iloc[0]["country"] == "Netherlands"
|
|
assert df.iloc[0]["index_weight"] == pytest.approx(4.23)
|
|
assert df.iloc[0]["source_date"] == "2026-09-18"
|
|
|
|
|
|
# -- candidate sources: which list a setup builds its universe from ---------------------------------
|
|
def _frame(symbols, source):
|
|
from quantcore.universe.sources import CANDIDATE_COLUMNS
|
|
|
|
rows = [{c: "" for c in CANDIDATE_COLUMNS} | {"symbol": s, "country": "Switzerland", "region": "Europe",
|
|
"source": source, "source_date": "2026-10-06"} for s in symbols]
|
|
return pd.DataFrame(rows, columns=CANDIDATE_COLUMNS)
|
|
|
|
|
|
def test_europe_falls_back_to_the_shipped_list_not_to_wikipedia(monkeypatch):
|
|
"""iShares down (it answered 404 in October 2026): the shipped snapshot, with exact symbols, is used.
|
|
Wikipedia's table yields guesses such as BNPP.PA or NOV-N.SW that Yahoo does not know."""
|
|
from quantcore.universe import sources as S
|
|
|
|
calls = []
|
|
monkeypatch.setattr(S, "fetch_stoxx600_ishares", lambda timeout=30.0: (_ for _ in ()).throw(RuntimeError("404")))
|
|
monkeypatch.setattr(S, "fetch_stoxx600_wikipedia", lambda timeout=30.0: calls.append("wiki") or _frame(["X"] * 200, "w"))
|
|
monkeypatch.setattr(S, "save_static_candidates", lambda *a, **k: calls.append("save"))
|
|
df = S.fetch_candidates("Europe")
|
|
assert calls == [] and len(df) > 500 and set(df["source"]) == {"ishares_stoxx600"}
|
|
assert {"BNP.PA", "NOVN.SW", "VWS.CO", "UCG.MI"} <= set(df["symbol"])
|
|
|
|
|
|
def test_offline_setup_uses_the_shipped_lists_without_the_web(monkeypatch):
|
|
from quantcore.universe import sources as S
|
|
|
|
def no_web(*a, **k):
|
|
raise AssertionError("no web access with allow_web=False")
|
|
|
|
monkeypatch.setattr(S, "_get", no_web)
|
|
assert len(S.fetch_candidates("US", allow_web=False)) > 400
|
|
assert len(S.fetch_candidates("Europe", allow_web=False)) > 500
|
|
|
|
|
|
def test_ishares_tries_the_next_address(monkeypatch):
|
|
from quantcore.universe import sources as S
|
|
|
|
tried = []
|
|
|
|
def fake_get(url, timeout=30.0):
|
|
tried.append(url)
|
|
if len(tried) == 1:
|
|
raise RuntimeError("404 Client Error")
|
|
return b"csv"
|
|
|
|
monkeypatch.setattr(S, "_get", fake_get)
|
|
monkeypatch.setattr(S, "parse_ishares_holdings", lambda raw: _frame([f"S{i}.SW" for i in range(150)], "ishares"))
|
|
df = S.fetch_stoxx600_ishares()
|
|
assert tried == list(S.ISHARES_STOXX600_URLS[:2]) and len(df) == 150
|
|
|
|
|
|
def test_unknown_symbols_are_summarised_not_logged_one_by_one(monkeypatch, caplog):
|
|
import logging
|
|
|
|
from quantcore.config import Settings
|
|
from quantcore.data.yahoo import YahooClient
|
|
|
|
client = YahooClient(Settings(yahoo_info_pause=0.0))
|
|
known = {"quoteType": "EQUITY", "currency": "CHF", "longName": "Known AG", "exchange": "EBS"}
|
|
monkeypatch.setattr(YahooClient, "_info_fields",
|
|
lambda self, s: logging.getLogger("yfinance").error("HTTP Error 404 %s", s) or
|
|
({} if s.startswith("GONE") else known))
|
|
with caplog.at_level(logging.INFO):
|
|
df = client.ticker_infos(["KNOWN.SW", "GONE1.PA", "GONE2.SW"])
|
|
assert list(df.index) == ["KNOWN.SW", "GONE1.PA", "GONE2.SW"] and df.loc["KNOWN.SW", "currency"] == "CHF"
|
|
assert not [r for r in caplog.records if r.name == "yfinance"] # no per-symbol ERROR lines
|
|
summary = [r.getMessage() for r in caplog.records if "no quote for 2 of 3" in r.getMessage()]
|
|
assert summary and "GONE1.PA, GONE2.SW" in summary[0]
|
|
assert logging.getLogger("yfinance").level == logging.NOTSET # restored afterwards
|