feat(exchange): add Trading212 broker integration#1073
Conversation
Adds a Trading212 exchange implementation following the same layered recipe as Alpaca: zod-validated REST client (axios + axios-retry with per-endpoint rate-limit table), neutral mapper, and an Exchange subclass. Trading212 has no historical-bar endpoint and no public WebSocket, so streaming and candle methods throw with a clear message. Also documents the cross-broker architectural style in BROKER_TEMPLATE.md and links it from the package CLAUDE.md so future broker integrations have a written template to follow.
- Switch Trading212 auth from raw API key to `Basic base64(key:secret)`,
matching the current vendor scheme. Adds `apiSecret` parameter through
Trading212API/Trading212Exchange/getTrading212Client and updates the
factory + .env.defaults (`TRADING212_PAPER_API_SECRET`,
`TRADING212_LIVE_API_SECRET`).
- Use `DAY` instead of `GTC` for limit orders. Trading212 rejects GTC
for stock limit orders ("Invalid payload"); DAY works on both paper
and live.
- Loosen response schemas (`.nullish()`) for fields that are sometimes
absent on paper responses: most of OrderSchema, several InstrumentSchema
fields, almost everything in HistoryOrderSchema. Mapper and getFills
now defend against missing values.
- Add `demo/testAll.ts` end-to-end script exercising every public method.
- Default `TRADING212_USE_PAPER=true` in `.env.defaults` (matches `ALPACA_USE_PAPER=true`); previous `false` made `npm run trading212:buy` fire on live as soon as someone populated `TRADING212_LIVE_API_KEY`. - Match `HISTORY_ORDERS` retry delay with `startsWith` so paginated cursor URLs (`?cursor=…`) hit the calibrated 60s wait instead of busy-looping the 1s default under Trading212's 1 req / 60s ceiling. - `toExchangePendingOrder`: wrap size in `Math.abs` so SELL orders don't return a negative `size` (the SELL is already encoded in `options.side`); aligns with `toOpenOrder` / `toFilledOrder`. - `toOpenOrder` / `toFilledOrder`: fall back to `value` / `orderedValue` / `filledValue` when QUANTITY-strategy fields are null, and derive side from whichever signed source is present. Without this, VALUE-strategy orders placed via the Trading212 app (e.g. "sell EUR 100 of AAPL") get mis-classified as 0-size BUYs. - `getOpenOrders`: drop `STOP` / `STOP_LIMIT` orders. The neutral `ExchangeOrderType` only models MARKET and LIMIT, so mapping these would silently lose `stopPrice` and lie about the type. - `getFills`: pass the account `currencyCode` (from `getAccountInfo`) to the mapper. Trading212 debits FX/duty fees in the account currency, not the instrument currency, so labelling `feeAsset` with `pair.counter` corrupts P&L for cross-currency accounts.
…R_TEMPLATE Lift broker-agnostic lessons from the Trading212 review into the template so future integrations inherit them: cursor-URL matching for per-endpoint retry-delay tables, safe env defaults, and a new Mapper invariants subsection (unsigned size, multi-representation fallback, filter-don't-coerce, fee asset = account currency).
Introduces MarketDataSource as a separate abstraction from Exchange so brokers without candle endpoints don't have to throw on half their interface. Exchange now covers brokerage only (orders, fills, balances, order-stream watchers); candle methods (getCandles, getLatestCandle, watchCandles, unwatchCandles) live on MarketDataSource. - AlpacaExchange extends Exchange implements MarketDataSource — exposes both, with the implements clause guarding signature drift. - Trading212Exchange accepts an optional marketData?: MarketDataSource in its constructor and delegates candle calls to it. Stripping the vendor ticker suffix (AAPL_US_EQ -> AAPL) happens inside the broker so the data source sees clean symbols. Lifecycle of marketData is owned by the caller; broker disconnect() does not close it. - Adds TwelveDataMarketData using the official @twelvedata SDK (REST-only v1; polling-based watchCandles). - Adds polling-based watchOrders to Trading212Exchange (tails /equity/history/orders with a cursor; 60s default to match the rate limit) and a getHistoryOrdersPage single-page helper. - Demo cleanup: getDemoClient() factors env loading + assert.ok across all three Trading212 demo scripts; ExchangeMock return types collapse to inference where possible. - BROKER_TEMPLATE.md documents the split and the delegation pattern.
- Re-export `MarketDataSource` from the package barrel so consumers can import / extend it via `@typedtrader/exchange`. - `Trading212Exchange implements MarketDataSource` (it already has the delegating methods), so `getExchangeClient` now returns `Exchange & MarketDataSource` and existing in-repo callers (WatchMonitor, /price, /candle, watchAdd, etc.) keep compiling. - Replace `setInterval` polling loops with self-rescheduling `setTimeout` chains in `TwelveDataMarketData.watchCandles` and `Trading212Exchange.watchOrders` so a slow tick (network stall, rate-limit retry) cannot overlap with the next request. - Move SIGINT registration in `getTwelveDataMarketData` to after the instance is created so the handler doesn't reference an uninitialised binding.
AlpacaExchange already implements MarketDataSource and streams 1-minute bars over WebSocket, which is the better default than polling Twelve Data REST. Removing the placeholder integration avoids carrying an unused SDK dependency and a paywall-prone fallback path. - Delete src/exchange/twelvedata/ and the @twelvedata/twelvedata-node dependency. - Drop TWELVEDATA_API_KEY from .env.defaults and the twelvedata:test npm script. - Update BROKER_TEMPLATE.md so the reference pairing for "broker without market data" is Trading212 + Alpaca instead of Trading212 + Twelve Data.
…uire marketData on every broker The optional `marketData?: MarketDataSource` slot on Trading212Exchange was a polite lie — it let the candle methods exist as throws when no source was wired, which is exactly the dishonest interface the split was supposed to eliminate. Make it mandatory on every broker, and pull Alpaca's market-data layer into its own class so the contract holds symmetrically across integrations. - New `AlpacaMarketData extends MarketDataSource`: owns the WebSocket bar subscriptions (CandleBatcher, alpacaWebSocket subscriptions) and the historical-bar REST endpoints. Independently usable as a data source for any broker. - New `getAlpacaMarketData` composition root for standalone use. - Shared `alpacaSymbol.ts` helpers (`createAlpacaSymbol`, `isAlpacaCryptoSymbol`) since both broker and data class need them. - `AlpacaExchange` drops the in-class candle code; constructor now takes a mandatory `marketData: MarketDataSource` and delegates. `getAlpacaClient` constructs an `AlpacaMarketData` from the same credentials when none is passed in, so the caller-facing API for the all-in-one Alpaca case is unchanged. - `Trading212Exchange.marketData` is now non-optional; conditional throws are gone. `getTrading212Client` requires `marketData`. - `getExchangeClient` accepts `options?.marketData`. For Alpaca it's optional (default = AlpacaMarketData with same creds). For Trading212 it's required and throws a clear error if missing. - BROKER_TEMPLATE.md updated to document the mandatory-injection rule and the lifecycle ownership distinction.
The Exchange class is behaviourally a broker integration — it places orders, holds balances, lists fills. NASDAQ doesn't have listBalances(). The "exchange" name was inherited from crypto-CEX terminology where the matching engine and the brokerage are the same firm; for traditional equities they're separate. Rename the abstraction to match. - Class renames: Exchange → Broker, AlpacaExchange → AlpacaBroker, Trading212Exchange → Trading212Broker, ExchangeMock → BrokerMock, AlpacaExchangeMock → AlpacaBrokerMock. - Function rename: getExchangeClient → getBrokerClient. - File renames matching the class names (git mv preserves history). - Domain-type prefixes (ExchangeBalance, ExchangeFill, ExchangeCandle, ExchangeOrderSide, etc.) are intentionally left untouched — they're separate from the class abstraction and a wider rename can follow. - Folder layout (`src/exchange/`) and npm package (`@typedtrader/exchange`) keep their names — registry-level changes are out of scope for this refactor. - Updates messaging, trading-strategies, and trading-signals-docs consumers; full lerna test:types and test:units pass across all five workspace packages.
Now that the abstraction is called Broker, the Exchange* type prefixes on broker-neutral domain types read as residual cruft. Strip them. Renamed (with all consumers updated): - ExchangeBalance -> Balance - ExchangeFill -> Fill - ExchangeCandle -> Candle - ExchangeCandleBase -> CandleBase - ExchangeCandleSchema -> CandleSchema - ExchangeCandleBaseSchema -> CandleBaseSchema - ExchangeCandleImportRequest -> CandleImportRequest - ExchangeOrderSide -> OrderSide - ExchangeOrderType -> OrderType - ExchangeOrderPosition -> OrderPosition - ExchangeOrderOptions -> OrderOptions - ExchangeMarketOrderOptions -> MarketOrderOptions - ExchangeLimitOrderOptions -> LimitOrderOptions - ExchangeOrderBase -> OrderBase - ExchangePendingOrder -> PendingOrder - ExchangePendingMarketOrder -> PendingMarketOrder - ExchangePendingLimitOrder -> PendingLimitOrder - ExchangeFeeRate -> FeeRate - ExchangeTradingRules -> TradingRules To free the OrderSide / OrderType / OrderStatus / AssetClass names, Alpaca's wire-format enums (previously bare) are now Alpaca-prefixed, matching the existing Trading212-prefix convention: - OrderSide (Alpaca wire) -> AlpacaOrderSide - OrderType (Alpaca wire) -> AlpacaOrderType - OrderStatus (Alpaca wire) -> AlpacaOrderStatus - AssetClass (Alpaca wire) -> AlpacaAssetClass Full lerna test:types and test:units pass across all five workspace packages (1,022 tests).
Aligns the folder name with the abstraction it contains. The npm package name (@typedtrader/exchange) is intentionally left alone — that's a registry-level rename and out of scope here. - git mv preserves history for every file in the tree. - Internal import paths (./exchange/, ../exchange/, ../../exchange/) rewritten to ./broker/, etc. - src/index.ts barrel re-exports updated. - External consumers are unaffected: they import from '@typedtrader/exchange', not from internal paths.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 100 out of 114 changed files in this pull request and generated 8 comments.
Comments suppressed due to low confidence (1)
packages/exchange/src/broker/Broker.ts:211
- The static helper is still named
toExchangeOrderSide, but the public API has been renamed toBroker/OrderSide. Consider renaming this to something liketoOrderSide/inferOrderSideto avoid mixing the old “Exchange” terminology into the new broker domain model.
- packages/exchange/package.json: npm scripts pointed at the now-gone
src/exchange/... paths after the folder rename. Update to src/broker/.
- Trading212Broker.toMarketDataPair: split('_')[0] truncated tickers
containing underscores (e.g. BRK_B_US_EQ -> BRK). Strip only the last
two segments and join the rest with dots, so BRK_B_US_EQ -> BRK.B,
matching how US data providers render class shares.
- Trading212ExchangeMapper.toExchangePendingOrder: throw on a LIMIT
order with null limitPrice instead of stringifying "null"/"undefined"
into the neutral order.
- testAll.ts: candle methods no longer throw on Trading212Broker (they
delegate to the wired-in marketData), so expectThrow on them was
misleading. Replace with success-path section calls; drop the now-
unused expectThrow helper.
- getAlpacaClient: registered a new SIGINT listener on every call.
Move the handler to a module-level guard that closes every tracked
broker (and any market-data source it owns) once.
- BROKER_TEMPLATE.md: stale references to src/exchange/alpaca/ and
Exchange.ts updated to src/broker/alpaca/ and Broker.ts.
- TradingSession.test.ts: fix indentation on a misaligned it() block.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 100 out of 114 changed files in this pull request and generated 5 comments.
Comments suppressed due to low confidence (3)
packages/exchange/src/broker/Broker.ts:118
ExchangePendingOrderBasenaming looks left over from the pre-rename API (everything else is nowBroker/Pending*). Renaming this base interface (e.g.PendingOrderBase) would avoid confusion and keep the public surface consistent.
packages/exchange/src/broker/Broker.ts:168ExchangeAvailableBalanceis still named with the oldExchange*prefix even though the rest of the file exportsBroker/Balance/TradingRules. Consider renaming to something likeAvailableBalance(and aligning downstream imports) to keep the domain model consistent.
packages/exchange/src/broker/Broker.ts:204Broker.toExchangeOrderSide()appears to be an old name carried over from theExchangeAPI. Renaming it (e.g.Broker.toOrderSide()/toOrderSideFromBalances()) would better reflect the newBrokerterminology and avoid implying anExchangetype still exists.
- Trading212Broker.getTradingRules: base_min_size fell back to '0', letting computed sizes of zero pass TradingSession's min-size guard. Use the same non-zero default as base_increment. - Trading212Broker.watchOrders: only the first 50-row history page was scanned per tick, so any burst >50 fills would silently lose the older ones. Page through nextPagePath until we hit lastSeenId. - Trading212API.getHistoryOrdersPage: accept an optional nextPath so callers can continue paging without hand-rolling the pagination. - Trading212BrokerMapper.toOpenOrder: same nullish-limitPrice guard as toPendingOrder — schema allows null, was producing price: "null"/"undefined" on the neutral order. - Rename remaining Exchange-prefixed mapper artifacts to match the Broker terminology used everywhere else: AlpacaExchangeMapper -> AlpacaBrokerMapper, Trading212ExchangeMapper -> Trading212BrokerMapper, toExchangeCandle -> toCandle, toExchangePendingOrder -> toPendingOrder. - BROKER_TEMPLATE.md: update remaining "Exchange*" references to "Broker*" / domain-type names so new integrations don't copy stale APIs (XxxExchangeMapper -> XxxBrokerMapper, getExchangeClient -> getBrokerClient, ExchangeCandle -> Candle, etc.). Copilot also flagged "no Trading212Broker.test.ts yet" — left for a follow-up; the current behaviour is exercised by the live demo script.
…, fill}
Trading212's /equity/history/orders returns each item as a nested
{order, fill} pair (status/side on order; price/timestamp/walletImpact
on fill). Our HistoryOrderSchema treated those fields as top-level, so
all reads were silently undefined — getFills() and getFillByOrderId()
always returned 0 rows / undefined, even when fills had cleared.
This was masked because z.looseObject() lets the parse succeed with
unknown fields. Caught while validating real fill prices on a paper
strategy run.
- HistoryOrderSchema: model the nested {order, fill} shape, including
fill.walletImpact (currency, fxRate, netValue, realisedProfitLoss,
taxes[]).
- Trading212BrokerMapper.toFilledOrder: read price/quantity/timestamp
from fill, side from sign of fill.quantity, and fee from
fill.walletImpact.taxes (using Math.abs because Trading212 reports
fees as negative deltas).
- Trading212Broker.getFills + watchOrders: filter on
item.order.status / item.order.id and pass the nested item to the
mapper.
Live-verified end-to-end: getFillByOrderId now returns the real
$446.51 buy / $448.30 sell prices with the €0.57 FX-conversion fee.
Plus two small unrelated tidy-ups noticed along the way:
- MultiIndicatorConfluenceSchema extracted into its own file.
- MultiIndicatorConfluenceStrategy's getResult()! non-null assertions
replaced with getResultOrThrow() (the canonical accessor).
- New .claude/rules/no-non-null-assertion-on-getResult.md captures
the rule for future work.
… non-standard wrappers The previous RVOL was a session-aware cumulative-vs-historical-curve indicator (Bloomberg / ThinkOrSwim style) — sophisticated but not the formula most retail sources describe. The simpler VolumeRatio matched the Aron Groups (and most online) definition: current / SMA(prior N). BarRange was a custom wrapper that has no standard public name (ATR covers the same use case). Following established naming and implementations: - Delete the cumulative session-aware RVOL (had no canonical public formula to validate against). - Rename VolumeRatio -> RVOL (the standard public name for this formula). - Fix the formula to exclude the current input from the baseline: RVOL = current / mean(last `period` prior values), matching the Aron Groups worked example exactly (10-day avg 200K, today 300K => RVOL 1.5). Tests include the worked example as a fixture. - Delete BarRange (no standard public name; ATR is the established equivalent). - Drop the runRvolDemo script that depended on the session-aware API. @see https://arongroups.co/technical-analyze/relative-volume-indicator/
v8 coverage's parser (rolldown) was choking on TS syntax inside fetchCandles.ts (`as StringValue`, `values.from!`) when trying to instrument it for coverage. Those files are CLI scripts with top-level await + process.exit, not library code under test, so excluding them from coverage is the right fix anyway. Adds src/broker/alpaca/demo.ts, src/broker/alpaca/fetchCandles.ts, and src/broker/trading212/demo/** to the coverage exclude list.
… (CI fix) Trading-signals enforces 100% coverage. The new RVOL had two uncovered branches: - the throw on `replace()` before any input has been added, - the trim path that bounds the priorVolumes buffer to ~2× period. Add tests for both (the trim path also doubles as a check that the sliding-window math stays correct as the buffer wraps). Plus codebase style cleanups while the file is open: - drop the redundant `<number>` generic argument on IndicatorSeries (the default already covers it), - drop the explicit `: number | null` return type on update() (let inference do the work), - add blank lines around control statements, - quote the variable in the "period must be >= 1" error message.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 111 out of 124 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (1)
packages/exchange/src/broker/alpaca/AlpacaWebSocket.ts:94
process.exit(1)on WebSocket close bypasses any SIGTERM/SIGINT-based graceful shutdown handlers (log flushing, disconnects, etc.). If the intention is to let an orchestrator restart the worker after cleanup, preferprocess.kill(process.pid, 'SIGTERM')(or emit an error and let the application decide/reconnect) rather than a hard exit from inside a library component.
…53b432 Six findings across two Copilot review passes. Trading212 mapper / broker: - Trading212BrokerMapper.toPendingOrder + toOpenOrder no longer fall back to order.value when order.quantity is null. `value` is notional (currency), not base-asset quantity, so the fallback was corrupting the neutral `PendingOrder.size` (interpreted as base units) for VALUE-strategy orders. Both mappers now throw if quantity is null; callers are expected to filter. - Trading212Broker.getOpenOrders adds an `order.quantity != null` filter so VALUE-strategy orders (placed via the Trading212 app) are dropped before mapping, alongside the existing STOP/STOP_LIMIT filter. CandleBatcher rename: - toExchangeCandles -> toCandles to match the post-Broker terminology. Local consumer variables (`exchangeCandles`) renamed too, choosing names that actually describe what they hold: - ScalpStrategy / ScalpScannerReport: result of `toCandles()` is plain (un-batched) `Candle[]`, so renamed to `plainCandles`. - runBacktest: result of `toBatchedCandles()` is genuinely batched, so the original `batchedCandles` is correct. RVOL.replace() rewind: - Copilot caught that the snapshot saved `previousResult` from before the most recent add, but `setResult(_, replace=true)` deliberately leaves `previousResult` untouched — so after more than one update, the restored value was too far back. Fix: snapshot the *current* `result` instead; on restore, set `previousResult` to that value. Then setResult(_, true) overwrites only `result` and the rewind matches what a fresh `add(replacement)` would have produced. New test asserts that equivalence directly. runStrategy.ts: - Replace raw `'SELL'` literal with `OrderSide.SELL` to stay typed.
Adds a Trading212 section to the package README. Highlights the key limitation (no historical bars, no public WebSocket) and shows the broker-delegation pattern using Alpaca as the market-data source. Reframes the Design Decisions section from a list of "no X" constraints into a positive description of the extension model: - bring your own broker, - extend Broker → get TradingSession + BacktestExecutor for free, - extend MarketDataSource → live streaming; or plug in an existing source like Alpaca when your broker doesn't have a data API. Drive-by: Exchange → Broker terminology fixed in the existing line that introduces the design-decisions section.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 114 out of 127 changed files in this pull request and generated 3 comments.
Comments suppressed due to low confidence (1)
packages/exchange/src/broker/alpaca/AlpacaWebSocket.ts:94
stream.once('close', ...)callsprocess.exit(1)from inside the exchange library. This bypasses the host application's graceful shutdown path (SIGTERM/SIGINT handlers, log flushing, cleanup) and makes the package unsafe to embed (e.g. in the messaging server). Prefer emitting an error/event up to the caller, or keep the previous behavior of signaling the process (e.g. SIGTERM) so the app can run its centralized shutdown routine.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
…ldest); fix README Copilot review on f782d8e/892904a flagged two bugs and one PR-description mismatch (the latter already updated). - AlpacaMarketData.getLatestCandle returned `candles[0]` after fetching a multi-bar range, which is the *oldest* candle in chronological order, not the latest. Sort by openTimeInMillis and return the most recent one. Also rewords the inline comment to describe what actually happens (server-side aggregation through getCandles, alignment to the latest 1-min bar's timestamp), not the inaccurate "aggregate locally" claim. Adds an explicit empty-result guard so an unexpected empty response throws a clear error instead of returning undefined cast as Candle. - README Trading212 snippet had a missing closing quote (`apiKey: 'ALPACA_API_KEY,`), so anyone copy-pasting got a syntax error.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 114 out of 127 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (1)
packages/exchange/src/broker/alpaca/AlpacaWebSocket.ts:94
process.exit(1)inside the exchange library will hard-kill any host process on transient WebSocket drops, bypassing the app’s own shutdown / log-flush path and making this package unsafe to embed (e.g., in long-running servers). Prefer emitting an error / closing the connection and letting the application decide whether to terminate (or reintroduce the previous SIGTERM-based approach so a centralized handler can run).
…OL stale result Copilot review on 3a816f5 flagged two real correctness bugs. - Trading212Broker.disconnect() stopped order watchers but left every active candle subscription wired through `marketData`. Forwarders stayed registered and the broker kept emitting after the caller thought it was shut down. disconnect() now iterates the active candle topics and calls unwatchCandles() for each (which removes the forwarder, deletes it from the map, and unsubscribes from the marketData topic). The injected marketData source itself is intentionally not disconnected here — its lifecycle is owned by the caller. - RVOL.update() returned null when baseline === 0 but left this.result pointing at the previous (now-stale) value, so isStable kept lying and getResultOrThrow() returned a stale ratio that the caller had no way to detect. Clear this.result on baseline-zero so isStable and getResultOrThrow() honestly reflect "no valid result". Adds a regression test that drives the indicator into a previously-stable state, then forces baseline to zero, and asserts isStable flips back to false and getResultOrThrow() throws. Direct assignment to this.result (rather than going through setResult) is the documented exception for the "clear state" case — setResult only accepts concrete numbers, and the CLAUDE.md rule guards against breaking signal tracking for value updates, which is not what we're doing here. Comment in the code explains.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 114 out of 127 changed files in this pull request and generated 4 comments.
Comments suppressed due to low confidence (1)
packages/exchange/src/broker/alpaca/AlpacaWebSocket.ts:93
stream.once('close', ...)callingprocess.exit(1)from within the exchange/broker library will hard-terminate any consuming application and bypass its graceful shutdown handlers (SIGTERM/SIGINT, log flushing, etc.). Prefer emitting an error / marking the connection unhealthy and letting the host process decide (or triggering a signal like SIGTERM) rather than exiting directly from this dependency.
| const marketData = new AlpacaMarketData(options); | ||
|
|
||
| process.on('SIGINT', () => { | ||
| console.log('Received signal interrupt...'); | ||
| marketData.disconnect(); | ||
| process.exit(0); | ||
| }); |
| const signedQty = fill.quantity ?? order.quantity ?? order.filledQuantity ?? 0; | ||
| const side = signedQty < 0 ? OrderSide.SELL : OrderSide.BUY; | ||
| const fee = (fill.walletImpact?.taxes ?? []).reduce((sum, tax) => sum + Math.abs(tax.quantity ?? 0), 0); | ||
|
|
| return { | ||
| created_at: fill.filledAt ?? order.createdAt ?? '', | ||
| fee: `${fee}`, | ||
| feeAsset: accountCurrency, | ||
| order_id: `${order.id}`, |
| candles: Candle[]; | ||
| /** The mock exchange instance that handles order matching, balance tracking, and fee calculation. */ | ||
| exchange: ExchangeMock; | ||
| broker: BrokerMock; | ||
| /** The strategy instance to backtest. */ |
Summary
This PR adds Trading212 as a second broker integration and reshapes the package's abstractions around the lessons learned. Highlights:
Trading212Broker) underpackages/exchange/src/broker/trading212/, built the same way as Alpaca: zod-validated REST client (axios + axios-retry with a per-endpoint rate-limit table calibrated to Trading212's documented limits), a neutral mapper, and a broker class.Exchangeclass is renamed toBroker(orders, balances, fills, order-stream watchers), and a new abstractMarketDataSourceowns candle methods. Brokers without their own candles (Trading212) take a mandatorymarketData: MarketDataSourceand delegate.AlpacaMarketDatais extracted fromAlpacaBrokerso the same data source can be paired with any broker.watchOrderspolls/api/v0/equity/history/orderswith cursor pagination (60s default, matching the documented 1 req / 60s rate limit). Candle methods delegate to the injectedMarketDataSource.extendedHours: trueso submissions outside NASDAQ regular hours route through the overnight venue instead of being silently cancelled.FeeRate.CURRENCY_CONVERSION_FEEis populated for Trading212 cross-currency accounts, andBroker.estimateFee()returns{commission, currencyConversion, total, feeAsset}so strategies can subtract round-trip costs before entering.Exchangeprefix:Candle,Fill,PendingOrder,OrderSide,OrderType,FeeRate,TradingRules, etc. Alpaca's vendor enums get anAlpacaprefix to avoid name collisions with the neutral types.RVOLindicator added totrading-signals(current value / SMA of the priorperiodvalues), matching the standard public definition.BROKER_TEMPLATE.mdcodifies the recipe (REST tier, schema validation, mapper invariants, streaming tier, the neutral base, code organisation); the package README documents the Trading212 + Alpaca pairing with a runnable code example.Pair convention
pair.baseis the Trading212 vendor ticker (e.g."AAPL_US_EQ");pair.counteris the instrument'scurrencyCode(e.g."USD","EUR").getTradingRulesvalidates the pairing against/equity/metadata/instruments. When delegating to aMarketDataSource,Trading212Broker.toMarketDataPairstrips the_<COUNTRY>_<TYPE>suffix and joins remaining intra-symbol underscores with dots (BRK_B_US_EQ→BRK.B), matching how US data providers render class shares.Notes
getFillByOrderId, take-profit triggers off the actual broker fill price, currency-conversion fees are surfaced inestimateFee().HistoryOrderSchemawas rewritten after the live test exposed that the response is nested{order, fill}, not flat —getFills()was returning empty before that fix.trading-signals.