Skip to content

feat(market-data): UK (LSE .L) equity market support - #1206

Open
cgycorey wants to merge 18 commits into
HKUDS:mainfrom
cgycorey:feat/uk-equity-support
Open

feat(market-data): UK (LSE .L) equity market support#1206
cgycorey wants to merge 18 commits into
HKUDS:mainfrom
cgycorey:feat/uk-equity-support

Conversation

@cgycorey

@cgycorey cgycorey commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Review fix: yfinance currency (91aa401)

Finding: the yfinance fallback inferred currency from the .L suffix and ÷100'd every LSE line — but .L is not uniformly GBp. Verified against Yahoo get_history_metadata()['currency'] (2026-08-29): VOD.L=GBp, HSBA.L=GBp, IUSA.L=GBp, SGLN.L=GBp, VUSA.L=GBP, VUSD.L=USD. A backtest via the yfinance path would corrupt every absolute quantity (initial-capital sizing, lot rounding, min-notional, cross-market composite weights) while returns survive.

Fix: the yfinance path now reads Yahoo's declared currency (Ticker.history_metadata['currency'], same field the direct-Yahoo path already passes to scale_pence_to_currency) and scales only on GBp/p. A missing currency fails closed — "do not scale", never "assume pence" from the suffix. GBp names (VOD.L) still ÷100.

Tests (in test_yfinance_uk_scale.py): GBP-quoted .L (VUSA.L) unscaled; USD-quoted .L (VUSD.L) unscaled; absent currency unscaled (fail-closed); GBp still scaled.

Branch rebased onto origin/main (e90b6c6c); 16+1 commits.

References

Scope

UK LSE (.L) equity market support per #1205. Full parity: routing, classification, settlement currency, price units, engine dispatch, statutory SDRT, plus the journal/shadow and financial-statements downstream consumers. (Irish ISE .IL explicitly out of scope.)

What this adds

UK equities (LSE .L) currently fall through _SOURCE_PATTERNS to the tushare default, get classified a_share, and die on the China fallback chain with a misleading _unresolved (several seconds of network attempts, indistinguishable from a dead ticker). This makes UK a first-class market with full parity to Canada/US:

  1. Routing_SOURCE_PATTERNS: .Lyahoo; FALLBACK_CHAINS: uk_equity: [yahoo, yfinance, local]
  2. Classification_MARKET_PATTERNSuk_equity; _detect_submarketuk
  3. Settlementcode_currencyGBP (was UNKNOWN:uk_equity)
  4. Engine — composite/runner dispatch → GlobalEquityEngine(market="uk"); never silent CryptoEngine fallthrough (2x set literals in runner, + _MARKET_TO_SOURCE)
  5. SDRT cost model — 0.5% Stamp Duty Reserve Tax on purchases, penny rounding with exact-1/2p rounds-up (FA86/S99(13)), Decimal ROUND_HALF_UP; config key uk_stamp_tax; trade-side detection is is_open-aware (opens: direction>0=buy; closes: -direction — closing a long is a sale, covering a short is a purchase, matching base.py's position-side close path); validated against HMRC manual worked examples (13.4547->13.45, 13.455->13.46)
  6. Volume units — yahoo/yfinance loaders declare uk_equity: shares (was null/undeclared, breaking the fix(data): declare per-market volume units in market-data provenance #1065/[Bug] A-share volume unit inconsistency across fallback chain (lots vs shares, 100x) and missing unit metadata #1062 provenance contract)
  7. Price units — Yahoo serves LSE names in pence (GBp): VOD.L was ~117p, HSBA.L ~1500p; the engine priced a GBP 1.17 share at GBP 117 (100x notional error on positions and SDRT). Normalize OHLC /100 at the loader boundary (chart-meta currency == "GBp" on the Yahoo path, suffix rule on the yfinance fallback) so every consumer sees GBP values matching code_currency; provenance declares currency_conversion: "GBp->GBP (/100)"; loader cache version bumped v4->v5 (pre-normalization parquet never served again, [Bug] A-share volume unit inconsistency across fallback chain (lots vs shares, 100x) and missing unit metadata #1062 precedent)
  8. Journal/shadow consumers_infer_market_from_symbol: .L"uk" (was "other", silently degrading shadow-analysis price features to NaN); shadow _MARKET_KEY_MAP/labels map ukuk_equity, "UK equity"
  9. Financial statements — `.L` routed to Yahoo quoteSummary in `get_financial_statements` (income/balance/cashflow annual histories + financialData/defaultKeyStatistics indicators, `{raw,...}` flattened to numbers); was rejected with a suffix error. `get_fundamentals` stays US-only by contract (SEC XBRL PIT panels — Yahoo has no filed-date PIT history), failing loud for UK

Verified

  • Live: VOD.L close now GBP 1.1715 (was 117.15), HSBA.L GBP 15.184 (was 1514.6) — yahoo direct
  • Economics: 1,000 sh VOD.L -> GBP 1,171.5 notional, SDRT GBP 5.86 (was 585.75) — the 100x unit bug is gone end to end
  • 386 affected tests pass locally (UK suites: yahoo client/loader, yfinance scale, market-data provenance, support + backtest smoke, registry, journal, shadow, financial statements, stock profile; plus upstream settings/source-order suites); CI green on all 9 checks (Python 3.11 + 3.14 tests, package, hash locks, Windows lifecycles)

Known scope notes

  • Fee model: SDRT is the only exchange-mandated UK cost modeled; broker commissions are caller-config (same as US zero-commission default)
  • SDRT exemptions (UCITS ETFs, eligible AIM shares, gilts, new issues) are the caller's concern, not per-symbol detection
  • Scope note: Yahoo's Irish ISE surface is .IR/EUR (BIRG.IR serves EUR data); routing it requires a separate market class (EUR currency, unverified Irish tax rules) — intentionally out of scope. This PR covers LSE .L only

Follow-ups (not included)

  • Live market-open specs (MARKET_SPECS has no London session; missing market fails loud, never silent)
  • Symbol-search UK suffix hints

cgycorey added a commit to cgycorey/Vibe-Trading that referenced this pull request Aug 24, 2026
test_all_expected_markets_present asserted the exact FALLBACK_CHAINS key
set, so adding uk_equity (PR HKUDS#1206) failed CI on Python 3.11 and 3.14.
Add uk_equity to the expected set and a uk chain-composition test
mirroring the canada/vietnam ones.
@cgycorey
cgycorey marked this pull request as ready for review August 24, 2026 16:17
@cgycorey
cgycorey marked this pull request as draft August 24, 2026 16:17
cgycorey added a commit to cgycorey/Vibe-Trading that referenced this pull request Aug 24, 2026
Journals carrying VOD.L/HSBA.L/DCC.IL trades were inferred as 'other'
(no .L/.IL branch), degrading shadow analysis to NaN price features and
'Other' rule labels. Add .L/.IL -> 'uk' inference, map 'uk' to
uk_equity in the shadow extractor's registry key map, and label it
'UK equity' in rule translation (HKUDS#1206).
@cgycorey cgycorey changed the title [Draft] feat(market-data): UK/Irish equity market support (.L/.IL) [Draft] feat(market-data): UK (LSE .L) equity market support Aug 27, 2026
@cgycorey cgycorey changed the title [Draft] feat(market-data): UK (LSE .L) equity market support feat(market-data): UK (LSE .L) equity market support Aug 27, 2026
@cgycorey
cgycorey marked this pull request as ready for review August 27, 2026 19:14
cgycorey added a commit to cgycorey/Vibe-Trading that referenced this pull request Aug 27, 2026
…after reads succeed

Reviewer blocker (HKUDS#1206) — two holes in the preemptive halt sweep:

1. flatten read paths assumed call_tool returns a list; the MCP adapter
   returns {"status": "error", ...} envelopes instead of raising, so a
   failed open-orders/positions read crashed on string keys
   (AttributeError: 'str' object has no attribute 'get') AFTER the sweep
   latch was persisted — a restart then suppressed the kill action for
   that halt episode forever. _read_broker_state now rejects envelopes,
   exceptions, and non-list results as structured report["errors"] items
   (phase read_open_orders / read_positions); non-dict entries are
   skipped with an error instead of iterated.

2. _run_preemptive_sweep persisting FLATTEN_FIRED before running the
   sweep. The latch is now written only after broker-state reads
   succeeded (nothing happened that must not be repeated); raising
   sweeps and side-effect failures still latch (non-idempotent, §8.5).

3. sweep_latch episode precedence: the newest tripped sentinel (broker or
   global) now wins the episode lookup, so a stale per-broker latch no
   longer suppresses a sweep for a newer global HALT (halt_flag_set
   treats the global as authoritative); the record accumulates episodes
   so clearing one halt never re-fires an older, already-swept episode.

Tests: adapter-envelope regression for both read paths, non-list/invalid
read rejection, newer-global rearm + latch persistence, runner
restart-replay on read failure (reviewer repro). 390 affected tests pass.
Also removes remaining LSE/ISE wording that contradicted the .L-only
scope (registry, base, yahoo_client, statements, profile, skill doc) and
pre-existing unused imports flagged by ruff.
@warren618

Copy link
Copy Markdown
Collaborator

Thanks — the UK work itself reads well, and the parts I checked (SDRT on the purchase side, GBp→GBP normalisation at the loader boundary, .L routing through the fallback chain, statements / profile / journal) are the right shape.

I can't merge it as it stands, because it isn't only UK support. The branch is up to date with main at 80ffdda4, which already carries #1232 and #1233 — and it still changes src/live/runtime/ by +502/−54, a second implementation of the halt-sweep and flatten-latch work that landed on 2026-08-27:

  • _halt_episode goes from "the per-broker sentinel wins" to "the newest sentinel wins", and the latch record accumulates an episodes list instead of binding one.
  • mark_sweep_fired moves from before the sweep (runner.py:585 on main) to after it, gated on a new side_effects_attempted flag.

The second one changes the kill switch's no-retry rule. It may well be the right change, but it cannot be reviewed inside a market-data PR, and the diff that would carry it is titled "UK equity market support".

The underlying point is legitimate and I want the fix. I reproduced both gaps against current main:

  1. A broker read that returns an error envelope ({"status": "error", ...}) is iterated as a mapping, so the sweep dies with AttributeError: 'str' object has no attribute 'get'. fix(live): fail closed on broker error envelopes during the halt sweep #1232 taught the submit responses about envelopes; the reads never learned.
  2. Because mark_sweep_fired runs before flatten_and_cancel, a sweep whose reads both fail still latches — so the kill switch's action is silently skipped for that episode, and neither a later tick nor a restart retries it.

So: please rebase onto main and drop 10a438e6, bd20789c, 0ea29b36 and bd93142 from this branch, leaving the UK commits. Send those four as their own PR against main, stating those two behaviours as the thing being fixed with a test for each, and I'll review it promptly. Splitting also means the UK support isn't held behind a live-safety review.

@cgycorey
cgycorey force-pushed the feat/uk-equity-support branch 3 times, most recently from 0cc74df to 2efd822 Compare August 28, 2026 17:19
cgycorey added a commit to cgycorey/Vibe-Trading that referenced this pull request Aug 28, 2026
test_all_expected_markets_present asserted the exact FALLBACK_CHAINS key
set, so adding uk_equity (PR HKUDS#1206) failed CI on Python 3.11 and 3.14.
Add uk_equity to the expected set and a uk chain-composition test
mirroring the canada/vietnam ones.
cgycorey added a commit to cgycorey/Vibe-Trading that referenced this pull request Aug 28, 2026
Journals carrying VOD.L/HSBA.L/DCC.IL trades were inferred as 'other'
(no .L/.IL branch), degrading shadow analysis to NaN price features and
'Other' rule labels. Add .L/.IL -> 'uk' inference, map 'uk' to
uk_equity in the shadow extractor's registry key map, and label it
'UK equity' in rule translation (HKUDS#1206).
@warren618

Copy link
Copy Markdown
Collaborator

Thank you for doing the split — the four live commits are gone, the scope is LSE-only, and CI is 9/9. I also want to note the _LOADER_CACHE_VERSION bump to v5: pre-normalization pence entries becoming unreachable is exactly right and easy to forget.

I cannot merge it yet, because the yfinance path infers the currency from the ticker suffix:

if is_gbp_pence_symbol(symbol):          # ^[A-Z0-9&.\-]+\.L$
    normalized, _ = scale_pence_to_currency(normalized, "GBp")

.L is not uniformly GBp. Live Yahoo get_history_metadata()['currency'], checked 2026-08-29:

ticker currency
VOD.L GBp
HSBA.L GBp
IUSA.L GBp
SGLN.L GBp
VUSA.L GBP
VUSD.L USD

So a backtest on VUSA.L or VUSD.L through the yfinance fallback gets every OHLC price divided by 100. Returns survive, since the whole book is scaled together, but every absolute quantity does not — initial-capital sizing, lot rounding, min-notional checks, and any cross-market composite run that weights this sleeve against a correctly-scaled one.

The direct-Yahoo path in this same PR already does the right thing: it passes the chart meta's real currency into scale_pence_to_currency, so a GBP or USD line falls through untouched. yfinance exposes the same field. Please read the declared currency there too, and fail closed when it is absent rather than falling back to the suffix — a missing currency should mean "do not scale", not "assume pence".

Worth a test with a .L symbol whose source currency is GBP and one whose currency is USD, both asserting the prices come through unscaled. Everything else in the PR I am happy with.

UK equities (LSE .L, ISE .IL) previously fell through _SOURCE_PATTERNS
to the tushare default, were classified as a_share, and routed down the
China fallback chain — ending in _unresolved after several seconds of
network attempts, indistinguishable from a dead ticker.

Add UK as a first-class market with parity to Canada/US across the
routing surface:

- market_data.py: _SOURCE_PATTERNS maps .L/.IL -> yahoo
- _market_hooks.py: _MARKET_PATTERNS -> uk_equity, currency GBP,
  _detect_submarket -> uk
- registry.py: FALLBACK_CHAINS uk_equity: [yahoo, yfinance, local]
- runner.py: _MARKET_TO_SOURCE uk_equity -> yahoo; GlobalEquity set
  literals include uk_equity (prevents silent CryptoEngine routing)
- composite.py: dispatch uk_equity -> GlobalEquityEngine(market=uk)

Verified: VOD.L / SHEL.L / HSBA.L fetch 15 real LSE bars each under
source=auto (served by yfinance after Yahoo fallback); engine dispatch,
currency (GBP), and submarket (uk) all resolve. 109 tests pass.
Add comprehensive UK market coverage and close a volume-unit parity gap
found while testing:

- test_uk_equity_support.py: 17 tests across source detection, market
  classification, submarket, GBP currency, fallback chain, and backtest
  engine routing (GlobalEquityEngine market=uk, never CryptoEngine).
- test_uk_backtest_smoke.py: end-to-end engine run on in-memory LSE bars,
  mirroring the India/Korea/Vietnam smoke convention; verifies fills,
  whole-share sizing, and the zero-commission generic equity path.
- yahoo/yfinance loaders: declare volume_units["uk_equity"] = "shares"
  (was null/undeclared, so UK volume would read as unknown unit and break
  the provenance contract from HKUDS#1062/HKUDS#1065).

165 related tests pass; 21 new UK tests pass.
test_all_expected_markets_present asserted the exact FALLBACK_CHAINS key
set, so adding uk_equity (PR HKUDS#1206) failed CI on Python 3.11 and 3.14.
Add uk_equity to the expected set and a uk chain-composition test
mirroring the canada/vietnam ones.
UK Main Market equities carry Stamp Duty Reserve Tax: 0.5% of
consideration charged to the buyer, rounded to the nearest penny with an
exact half-penny rounding UP (FA86/S99(13)). The generic GlobalEquity
path charged zero, so a UK backtest ignored a real exchange-mandated
cost.

Add market='uk' support to GlobalEquityEngine:
- uk_stamp_tax config key (default 0.005); uk_slippage defaults to
  slippage_us
- calc_commission charges SDRT on purchase fills only (direction > 0,
  including buying to cover a short); sells pay nothing — unlike HK's
  bilateral stamp tax
- Decimal ROUND_HALF_UP for the penny rounding: float round() is
  banker's and miscounts exact half-pennies (1.005 -> 1.00), which the
  HMRC manual requires to round up (13.455 -> 13.46)
- _direction param renamed to direction (was reserved; UK uses it)

Exemptions (UCITS ETFs, eligible AIM shares, gilts, new issues) are
documented as the caller's concern — the engine applies the statutory
Main Market rate.

Verified against the HMRC manual's worked examples: 13.4547 -> 13.45,
13.455 -> 13.46. 172 related tests pass.
…loader

Yahoo serves .L (always) and .IL (GBp-quoted) equities in pence: VOD.L
was ~117p -> the engine priced a £1.17 share at £117, a 100x notional
error on positions and SDRT. Normalize OHLC ÷100 when the source quotes
GBp, at the loader boundary so every consumer (MCP tool, backtests,
indicators) sees GBP values matching code_currency/engine currency:

- yahoo_client.get_chart now returns (rows, currency) from chart meta
- yahoo_loader: gate .L/.IL (they were silently skipped -> yfinance
  always served UK), scale when meta says GBp, declare price_units
- yfinance_loader: scale .L/.IL via shared suffix rule (no meta there)
- base: scale_pence_to_currency + is_gbp_pence_symbol helpers; bump
  loader cache version v4->v5 so pre-normalization pence parquet files
  are never served again (HKUDS#1062 precedent)
- market_data provenance: currency_conversion reports 'GBp->GBP (÷100)'
  for UK from the serving loader's price_units; 'none' elsewhere

Verified live: VOD.L close now 1.1715 GBP (was 117.15), HSBA.L 15.18
GBP (was 1514.6); notional and SDRT follow. 245 affected tests pass.

Known follow-up: Yahoo-meta EUR-quoted .IL names (e.g. Irish corporates)
pass through unmodified on the yahoo path, but the yfinance fallback's
suffix rule would scale them; those symbols fetch no data today.
Journals carrying VOD.L/HSBA.L/DCC.IL trades were inferred as 'other'
(no .L/.IL branch), degrading shadow analysis to NaN price features and
'Other' rule labels. Add .L/.IL -> 'uk' inference, map 'uk' to
uk_equity in the shadow extractor's registry key map, and label it
'UK equity' in rule translation (HKUDS#1206).
…ahoo

UK had no fundamentals surface: .L/.IL symbols were rejected by the
statements tool (suffix error), while get_fundamentals stays US-only by
contract (SEC XBRL PIT panels; Yahoo has no filed-date PIT history, so
that boundary stays honest and loud). Add the feasible parity:

- _classify_market: .L/.IL -> uk
- _fetch_yahoo_statement: income/balance/cashflow from Yahoo quoteSummary
  statement histories (crumb-gated client already exists); financialData +
  defaultKeyStatistics for indicators; {raw, fmt, longFmt} flattened to
  plain numbers; newest-first; capped periods
- Tool description/params + suffix error mention .L/.IL
- 4 UK tests (routing, flattening, indicators, failure envelope); live
  verified VOD.L income 4 periods (netIncome -4.17B), balance 4, indicators 1

Yahoo histories are annual-only; a quarter request returns the annual
periods (documented degradation, matches Eastmoney's graceful fallback).
Adversarial review found the UK branch silently returned ANNUAL periods
for period='quarter' (an explicit 'del period'), whose docstring claimed
this 'matches Eastmoney' — false: Eastmoney fetches actual quarter
reports. Yahoo quoteSummary carries incomeStatementHistoryQuarterly /
balanceSheetHistoryQuarterly / cashflowStatementHistoryQuarterly for
LSE/ISE tickers, verified live for VOD.L (4 quarterly periods; Q3'25
revenue 10.45B). Map (statement, cadence) -> module + key so a quarter
request returns real quarters; quarterly endDate is {raw, fmt} and
flattens to int like every other value.
Adversarial round-trip review exposed a real bug: base.py's close
callsite passes the POSITION side (before.direction), so closing a long
(direction=1) was charged 0.5% SDRT on the SALE, and covering a short
(direction=-1) skipped the charge on an actual PURCHASE. The unit tests
only ever fed ideal trade-side values, so the round-trip path was never
exercised.

Trade side is direction on opens (1=buy-long, -1=short) and -direction
on closes (1=close-long is a sale, -1=cover-short is a buy). Charge SDRT
only when trade_is_buy, i.e. direction>0 if is_open else direction<0.

HK (bilateral, sign-independent), Canada (rate * notional) and US
(zero) are unaffected. Added close-path assertions to the smoke test.
get_stock_profile sends the ticker verbatim to Yahoo, so VOD.L already
fetched its full profile — but _market_for classified everything
non-HK as 'us', so a UK profile was reported as market='us'. Extend the
classifier (.L/.IL -> uk), update the tool description and the parameter
help to advertise the supported suffixes. Verified live: VOD.L profile
sections all render; label now 'uk'.
…ll ref

Review caught the two user-facing doc surfaces still claiming US/HK-only
while the code fixes were already in: the MCP wrapper's get_stock_profile
docstring (published to MCP clients) and the yfinance skill reference
(tool_get_stock_profile.md — loaded into agent context: description,
ticker help, market enum us|hk|uk, market-label rule, example list).
Both now advertise .L/.IL; 7 profile tests pass.
Upstream's regression test (ad79b62) hardcoded the pre-UK 12-market
set; this PR adds uk_equity as a 13th market, so the set must grow.
Also pins uk_equity's default order (yahoo) and env var name.
User decision: 'just uk lse'. Empirical evidence backs it — 20+ .IL
tickers (DCC, BIRG, AIBG, KRY, VOD, SHEL, HSBA, ULVR, BATS, DGE, RIO)
all 404 at Yahoo; the surviving Irish surface is .IR/EUR, which is a
separate market class (EUR currency, unverified Irish tax rules).
Remove .IL from routing, market detection, submarket, loader gating,
GBp pence detection, journal inference, statements/profile classify,
MCP + skill docs, and tests (which now assert .IL is NOT supported).
LSE .L remains the sole UK surface, live-verified.
Drops the residual LSE/ISE and UK/Irish wording in registry, base,
yahoo_client, financial-statements, stock-profile and the yfinance skill
reference to match the .L-only scope settled on HKUDS#1205.
… suffix

Reviewer finding: the yfinance fallback inferred the quote currency from
the .L ticker suffix and ÷100'd EVERY LSE line. But .L is not uniformly
GBp — VUSA.L prices in GBP, VUSD.L in USD (Yahoo history_metadata,
checked 2026-08-29): a backtest through the yfinance fallback would scale
every absolute quantity (initial-capital sizing, lot rounding, min-notional
checks, cross-market composite weights) while returns survive.

The direct-Yahoo path already passes the chart meta's real currency into
scale_pence_to_currency; the yfinance path now reads the same field via
Ticker.history_metadata['currency'] and scales ONLY on GBp/p. A missing
currency fails closed: no scale (never assume pence from the suffix).

Rebased onto origin/main (e90b6c6, merged HKUDS#1244 era). Tests: GBP-quoted
.L (VUSA.L) and USD-quoted .L (VUSD.L) pass through unscaled; absent
currency unscaled (fail-closed); GBp still ÷100s (VOD.L).
@cgycorey
cgycorey force-pushed the feat/uk-equity-support branch from 2efd822 to 91aa401 Compare August 29, 2026 12:25
…osed offline

The earlier yfinance-currency commit made _declared_currency a real
network call (yf.Ticker.history_metadata). The two pre-existing scaling
tests mocked only _download_history, so CI (no/slow yfinance access)
either failed or behaved nondeterministically — VOD.L could come back
unscaled (fail-closed) or the symbol could be dropped.

- Mock _declared_currency -> 'GBp' in test_fetch_scales_lse_pence_to_gbp
  and test_fetch_scales_other_lse_names: deterministic, offline-safe.
- New test_fetch_declared_currency_failure_is_fail_closed: currency
  lookup returns None (the production fail-closed path) -> prices pass
  through unscaled and no exception escapes.

8/8 tests pass offline (no yfinance network access).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants