Skip to content

Add Trading212 brokerage model - #9982

Open
nimakalantar wants to merge 1 commit into
QuantConnect:masterfrom
nimakalantar:feature-9981-trading212-brokerage-model
Open

nimakalantar wants to merge 1 commit into
QuantConnect:masterfrom
nimakalantar:feature-9981-trading212-brokerage-model

Conversation

@nimakalantar

@nimakalantar nimakalantar commented Oct 29, 2026 •

Copy link
Copy Markdown

Description

Adds Trading212BrokerageModel:

  • Supports US-listed equities with the market, limit, stop and stop-limit order types the public API accepts.
  • Rejects unsupported security types, unsupported order types, contingent orders, and any order that would cross zero holdings (Trading 212 is long only).
  • Defaults to AccountType.Cash and pins equity leverage to 1, so a Margin account type (including the SetBrokerageModel(BrokerageName.Trading212) path, which passes Margin) still gets no leverage. The default cash account keeps the T+1 DelayedSettlementModel.

This PR covers the order-rule model only. Fees and buying power are planned as follow-ups (see the comment below).

Related Issue

Closes #9981

Motivation and Context

Trading 212 was not represented in LEAN, so users had to run the broker's rules under an unrelated brokerage model. This model states the supported security and order types and the long-only constraint up front, so unsupported orders are rejected with a clear NotSupported message instead of failing later at the brokerage layer.

Requires Documentation Change

Yes. The documentation's list of supported brokerage models should include Trading 212.

How Has This Been Tested?

  • dotnet test Tests/QuantConnect.Tests.csproj --filter "FullyQualifiedName~Trading212BrokerageModelTests"
  • dotnet test Tests/QuantConnect.Tests.csproj --filter "FullyQualifiedName~Brokerages"

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • Refactor (non-breaking change which improves implementation)
  • Performance (non-breaking change which improves performance. Please add associated performance test and results)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Non-functional change (xml comments/documentation/etc)

Checklist:

  • My code follows the code style of this project.
  • I have read the CONTRIBUTING document.
  • I have added tests to cover my changes.
  • All new and existing tests passed.
  • My branch follows the naming convention bug-<issue#>-<description> or feature-<issue#>-<description>

@nimakalantar

nimakalantar commented Oct 29, 2026 •

Copy link
Copy Markdown
Author

Planned follow-up work (not part of this PR):

  1. Fee model — Trading 212 is commission free; the only dealing fee is the 0.15% FX fee on the converted notional of USD trades against a GBP account (charged on both buys and sells), plus the US regulatory fees on sells (SEC Section 31 at 0.206 bps of sale value and FINRA TAF at $0.000195/share). Following the standard convention, GetOrderFee would return the fee in the security's quote currency (USD for US equities).

It will be opened as a separate PR once this model lands.

@nimakalantar nimakalantar mentioned this pull request Oct 29, 2026
6 of 11 tasks
@arhancanli

Copy link
Copy Markdown

One thing to check in the description: "DefaultBrokerageModel already returns T+1 and leverage 1 for a cash equity account" is true only when AccountType.Cash is passed. In DefaultBrokerageModel.cs on master, GetLeverage returns 1 only under if (AccountType == AccountType.Cash); otherwise SecurityType.Equity gets 2. GetSettlementModel has the same gate, so a Margin account gets ImmediateSettlementModel instead of DelayedSettlementModel.

The new constructor is Trading212BrokerageModel(AccountType accountType = AccountType.Margin). So new Trading212BrokerageModel() (what the test fixture uses) gives 2x leverage and immediate settlement, which contradicts the "long only, no margin" remark in the class summary. BrokerageName.Trading212 goes through Create(..., accountType), so users who set SetBrokerageModel(BrokerageName.Trading212) without an account type are affected too.

Two options: default the parameter to AccountType.Cash, or override GetLeverage to return 1. A test asserting GetLeverage(equity) == 1 and a DelayedSettlementModel from the default constructor would pin it down.

nimakalantar added a commit to nimakalantar/Lean that referenced this pull request Oct 29, 2026
Trading 212's equity accounts (Invest, Stocks ISA, SIPP) are cash
accounts with no margin or short selling, so the default account type
should be Cash. Override GetLeverage to return 1 so a Margin account
type (including the SetBrokerageModel(BrokerageName.Trading212) factory
path, which defaults to Margin) can no longer pick up
DefaultBrokerageModel's 2x equity leverage.

Addresses review feedback on QuantConnect#9982.
@nimakalantar

nimakalantar commented Oct 29, 2026 •

Copy link
Copy Markdown
Author

Thanks for taking a look! I think I've fixed this.

  • Trading212BrokerageModel now defaults to AccountType.Cash.
  • Added a GetLeverage override returning 1, so a Margin account type (including SetBrokerageModel(BrokerageName.Trading212), which passes Margin) no longer picks up DefaultBrokerageModel's 2x equity leverage.
  • Added tests pinning the default account type, 1x leverage for both account types, DelayedSettlementModel from the default constructor, and the factory path.

Context:

  • Trading 212's equity accounts (Invest, Stocks ISA, SIPP) are cash/long-only
  • the only leveraged product is the CFD account, which trades SecurityType.Cfd and is rejected by this model, so Cash is the right default here.

@arhancanli

Copy link
Copy Markdown

Read the new head (10c83c2). Cash default plus GetLeverage returning 1 closes the 2x gap I raised, and GetLeverageReturnsOneForEquity covers both account types.

One path I could not find covered, from reading BrokerageExtensions.OrderCrossesZero (not by running it): it returns true only when holdings are non-zero and the order passes through zero. From flat (holdings == 0) a sell of -1 is not a cross, so ValidateCrossZeroOrder lets it through. Under AccountType.Cash the cash buying power model should reject it, but SetBrokerageModel(BrokerageName.Trading212) passes Margin, and there leverage 1 does not by itself stop a fresh short from flat. CanSubmitOrderCrossZeroReturnsExpected only uses holdings of 1 and 2, so a case with holdings 0 and order quantity -1 under AccountType.Margin would show whether the model really is long-only there. If it passes, a one-line check in CanSubmitOrder (reject order.Quantity < 0 when holdings <= 0) would make the "long only" remark in the class docs true for both account types.

Did you see the Margin short-from-flat case behave differently when you ran the suite?

@nimakalantar nimakalantar mentioned this pull request Oct 29, 2026
6 of 11 tasks
nimakalantar added a commit to nimakalantar/Lean that referenced this pull request Oct 29, 2026
OrderCrossesZero only flags a position passing through zero, so a sell
from a flat or already-short position slipped past ValidateCrossZeroOrder
and was accepted by CanSubmitOrder. On a cash account the buying power
model later rejected it, but the SetBrokerageModel(BrokerageName.Trading212)
path passes Margin, where leverage 1 still allowed the fresh short.

Reject any order with a negative quantity when holdings are zero or short,
and add coverage for sell-from-flat, add-to-short and buy-from-flat under
both account types.

Addresses review feedback on QuantConnect#9982.
@nimakalantar

nimakalantar commented Oct 29, 2026 •

Copy link
Copy Markdown
Author

I ran it, it is as you suspected:

Failed CanSubmitOrderSellFromFlatReturnsFalse(Cash)      Expected: False But was: True
Failed CanSubmitOrderSellFromFlatReturnsFalse(Margin)    Expected: False But was: True
Failed CanSubmitOrderSellWhenAlreadyShortReturnsFalse(Cash)    Expected: False But was: True
Failed CanSubmitOrderSellWhenAlreadyShortReturnsFalse(Margin)  Expected: False But was: True

So OrderCrossesZero does not catch it, and while the cash buying power model rejects it later for Cash, the Margin path (which SetBrokerageModel(BrokerageName.Trading212) uses) let the fresh short through.

Fixed in 44d4014

@arhancanli

Copy link
Copy Markdown

Read 44d4014: the order.Quantity < 0 && security.Holdings.Quantity <= 0 guard sits before base.CanSubmitOrder, so it applies to both account types, and the three new tests (sell from flat, sell while short, buy from flat) cover the cases that were open. The failing output you pasted for the Cash rows also shows the old gap was wider than the Margin path I flagged.

One limit of the check, which may be fine to leave: it reads Holdings.Quantity at submit time, so two sells of the full position submitted in the same time slice, before the first fills, both pass (holdings still 1), and the second would turn into a short when the first fills. LEAN does not track open orders in CanSubmitOrder for other brokerage models either, so this may be out of scope, but is it worth a line in the class summary that "long only" is enforced against current holdings, not pending orders?

nimakalantar added a commit to nimakalantar/Lean that referenced this pull request Oct 29, 2026
CanSubmitOrder evaluates the long-only rule against the security's
holdings at submission time, so it does not account for other pending
unfilled orders. Note this in the class remarks, matching the behavior
of the other LEAN brokerage models.

Addresses review feedback on QuantConnect#9982.
@nimakalantar

nimakalantar commented Oct 29, 2026 •

Copy link
Copy Markdown
Author

Agreed on both points — the check is holdings-based, and open-order tracking is out of scope since the other LEAN brokerage models behave the same way. I added the clarification to the class remarks in a147730:

/// The long-only rule is evaluated against the security's holdings when the order is submitted,
/// matching the other LEAN brokerage models, so it does not account for other pending unfilled orders.

nimakalantar added a commit to nimakalantar/Lean that referenced this pull request Oct 29, 2026
BrokerageModel.Create forwards the account type it is given, so
SetBrokerageModel(BrokerageName.Trading212) without an explicit Cash
account models a margin account even though the constructor defaults to
Cash. Spell that out on the constructor and on GetLeverage, which returns
1 either way.

Addresses review feedback on QuantConnect#9982.
@nimakalantar
nimakalantar force-pushed the feature-9981-trading212-brokerage-model branch from 7bd7b10 to 1ea58e8 Compare October 29, 2026 15:43
@arhancanli

Copy link
Copy Markdown

Read a147730: the two remarks lines say exactly what the check does, and "does not account for other pending unfilled orders" is the part a user would otherwise find out in a live account. Docs only, nothing else changed in the diff.

One thing I could not tell from the PR: when a second full-position sell does slip through in the same time slice, does the Trading 212 API reject it on its side (insufficient quantity), or would it be accepted and net short? If it rejects, the remark could say the broker is the backstop for that case.

@nimakalantar

nimakalantar commented Oct 29, 2026 •

Copy link
Copy Markdown
Author

I checked the Trading 212 API docs and it rejects on their side:

  • The Public API is enabled only for Invest and Stocks ISA accounts, both long-only.
  • An over-sell fails with SellingEquityNotOwned; the place-order error codes also include InsufficientValueForStocksSell and MaxEquitySellQuantityExceeded.
  • The positions endpoint exposes both quantity (total owned) and quantityAvailableForTrading, and the available figure already excludes shares reserved for pending orders (and shares held in pies). So once the first sell reserves the shares, a second full-position sell exceeds the available quantity and is rejected rather than netting short.

So the broker is the backstop for the same-slice gap, and the remark could say so. Happy to add that sentence if you think it adds value.

@arhancanli

Copy link
Copy Markdown

Yes, I think one sentence is worth adding. The remark already says the check ignores pending orders, and a reader's next question is what stops a second full sell in a live account. Something like "the broker rejects an over-sell (SellingEquityNotOwned), so a same-slice duplicate is not accepted as a short" answers it, and the error code gives them something to search for.

Is the order placement for this model handled in a separate brokerage-implementation PR? If so, it may be worth checking there that the rejection reaches the algorithm as an OrderEvent with the broker's message rather than being swallowed.

nimakalantar added a commit to nimakalantar/Lean that referenced this pull request Oct 29, 2026
The long-only check ignores pending unfilled orders, so a same-slice
duplicate sell can slip past it. Trading 212 rejects the second order
with SellingEquityNotOwned, so the broker is the backstop and the
duplicate is not accepted as a short in a live account.

Addresses review feedback on QuantConnect#9982.
@nimakalantar

nimakalantar commented Oct 29, 2026 •

Copy link
Copy Markdown
Author

Added to the model remarks:

/// The broker rejects an over-sell with <c>SellingEquityNotOwned</c>, so a same-slice duplicate is
/// not accepted as a short in a live account.

On the implementation question: there isn't one. This PR is the order-rule model only — there is no Trading212Brokerage in the repo, so the model is used for backtesting and paper trading. A live implementation would be a separate PR, and when it lands it should surface the broker's rejection as an order event carrying the SellingEquityNotOwned message rather than swallowing it. I'll carry that note into that PR.

@arhancanli

Copy link
Copy Markdown

That wording reads right, and scoping this PR to the order-rule model with the brokerage as a separate PR is the cleaner split. One thing to decide before the live PR: when the broker rejects with SellingEquityNotOwned, should the model-side check and the live rejection produce the same OrderEvent message text, so a backtest and a live run fail identically for the same order? Is the paper-trading path going to go through the same model check?

Add an order-rule brokerage model for Trading 212 covering US-listed
equities (ETFs included) with the market, limit, stop and stop-limit
order types the public API accepts. It rejects unsupported security
types, unsupported order types, contingent orders (OCO, OTO, brackets)
and any order that would cross zero holdings.

Trading 212's equity accounts (Invest, Stocks ISA and SIPP) are cash,
long-only accounts with no margin. The model therefore defaults to
AccountType.Cash and returns leverage 1 for every account type,
including the SetBrokerageModel(BrokerageName.Trading212) path, which
forwards Margin through BrokerageModel.Create. It also rejects sells
that would open or add to a short position from flat or already-short
holdings, since OrderCrossesZero only catches positions passing through
zero. The long-only rule is evaluated against current holdings at
submission time, matching the other LEAN brokerage models, so it does
not account for other pending unfilled orders; Trading 212 itself
rejects an over-sell with SellingEquityNotOwned, so a same-slice
duplicate is not accepted as a short in a live account.

Registers BrokerageName.Trading212 in BrokerageModel.Create and
GetBrokerageName, and adds NUnit coverage for the supported and rejected
order combinations, the account-type and leverage behavior, the
short-selling guard and the factory round trip.

Closes QuantConnect#9981.
@nimakalantar
nimakalantar force-pushed the feature-9981-trading212-brokerage-model branch from 21d6480 to 7fcbfcb Compare October 30, 2026 09:55
@nimakalantar

Copy link
Copy Markdown
Author

On paper: the check runs, but selecting the model is still opt-in. BrokerageTransactionHandler.HandleSubmitOrder calls _algorithm.BrokerageModel.CanSubmitOrder before _brokerage.PlaceOrder for every environment, so once the algorithm calls SetBrokerageModel(BrokerageName.Trading212), backtest and paper reject through the same path. PaperBrokerageFactory.GetBrokerageModel returns DefaultBrokerageModel, so a run through PaperBrokerage needs that explicit call; a future Trading 212 brokerage factory can return the model by default for live.

On message parity, I wouldn't force the full OrderEvent text to match.

  • When the model rejects, backtest and live already fail at the same point. I just made over-sell, sell-from-flat and sell-while-short all use the same ShortSellingNotSupported reason — previously an over-sell got the generic cross-zero message. The full event text still differs because the transaction handler wraps model refusals as "BrokerageModel declared unable to submit order…" and broker failures as "Brokerage failed to place orders…".
  • When the broker rejects (same-slice duplicate, stale/external holdings), backtest doesn't reject at all: CanSubmitOrder only sees holdings, not open orders. Matching the text wouldn't make the outcomes match.

For the live PR I'd map SellingEquityNotOwned to the same Messages.Trading212BrokerageModel.ShortSellingNotSupported(order) reason and emit it on an Invalid order event, with a test that the live mapping and the model use the same reason. True same-slice parity would need an open-order-aware check; BrokerageModel.Create already receives an IOrderProvider it doesn't use, but I'd decide that with the live brokerage rather than expand this PR.

@nimakalantar

Copy link
Copy Markdown
Author

@arhancanli thanks for all your reviews so far, do you have any further comments before approving this?

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.

Add a Trading 212 brokerage model

2 participants