diff --git a/docs/arch/package-boundaries.org b/docs/arch/package-boundaries.org index a1349bb..8d7712f 100644 --- a/docs/arch/package-boundaries.org +++ b/docs/arch/package-boundaries.org @@ -25,6 +25,7 @@ compatibility facade for removed import paths. | internal/marketdata/internal/provider/* | marketdata/internal/provider/* | No | Provider adapters owned only by the internal data subsystem | | internal/order | order (split) | No | Canonical intents/proposals/requests/orders/fills/positions, lifecycle; depends on public order vocabulary and internal IDs | | internal/account | account | No | Runtime accounting snapshots | +| internal/account/margin | account/margin | No | Pure gross-notional and initial-margin arithmetic shared by risk and the simulator (ADR-066) | | internal/portfolio | portfolio | No | Cross-account projections | | internal/backtest | backtest | No | Deterministic runner and scheduler | | internal/broker | broker | No | Broker ports | diff --git a/internal/account/listingkey.go b/internal/account/listingkey.go new file mode 100644 index 0000000..09ef529 --- /dev/null +++ b/internal/account/listingkey.go @@ -0,0 +1,22 @@ +package account + +import "github.com/rustyeddy/trader/instrument" + +// ListingKey identifies one listing within an account: an economic +// instrument together with the provider and venue that list it. +// +// It is the identity at which an account holds one net position +// (runtimeorder.Position is one position per account/listing pair), and +// at which a listing is valued. Two listings of the same instrument +// from different providers or venues are different keys: they can +// carry different marks, multipliers, and settlement terms. +type ListingKey struct { + InstrumentID instrument.ID + Provider string + Venue string +} + +// KeyOf returns l's ListingKey. +func KeyOf(l instrument.Listing) ListingKey { + return ListingKey{InstrumentID: l.InstrumentID(), Provider: l.Provider(), Venue: l.Venue()} +} diff --git a/internal/account/margin/doc.go b/internal/account/margin/doc.go new file mode 100644 index 0000000..0b8c63b --- /dev/null +++ b/internal/account/margin/doc.go @@ -0,0 +1,57 @@ +// Package margin is the one shared, pure calculation of an account's +// gross notional exposure and required initial margin (ADR-066, issue +// #411). Both the account-level initial-margin risk rule (#413) and the +// simulated broker's fill-time check (#415) use it, so Trader has +// exactly one definition of gross exposure and required margin. +// +// # What it computes +// +// Gross notional is the sum of the absolute notional of every open +// position — longs and shorts both add, and nothing is netted across +// instruments: +// +// gross = Σ |quantityᵢ| × priceᵢ × multiplierᵢ +// +// Required margin is a sum of per-position required-margin amounts, +// each computed by a Policy: +// +// required = Σ policy.RequiredMargin(positionᵢ) +// +// v1 has one Policy, Ratio, which requires |notional| × ratio for every +// position, so v1's required margin is exactly gross × ratio. The +// per-position Policy is the extension point ADR-066 reserves for +// per-listing or per-instrument margin (a percentage such as OANDA's +// marginRate, or a fixed amount per futures contract). No such policy +// exists yet. +// +// # Valuation basis +// +// Every position is valued at exactly one price — never a blend of a +// current price and a position's historical AvgPrice (#183). Account +// values each open position at its current mark, supplied by the +// caller in Marks. Assess values the changed listing at the Change's +// own price for both the current and the prospective state, so +// comparing the two is a pure comparison of that listing's quantity; +// every other position is valued at its mark in both. +// +// Positions and marks are identified by account.ListingKey (instrument, +// provider, venue) — the level at which an account holds one net +// position — not by instrument alone. Two listings of the same +// instrument are separate positions with separate marks. +// +// Assessment.Increases, the de-risking classification, compares gross +// notional rather than required margin, because required margin is +// rounded and could hide a small real increase. +// +// Deciding whether a proposal is de-risking, and whether a price is +// needed at all, is the caller's job (ADR-066): a de-risking proposal +// can be admitted from quantities alone, without calling this package. +// +// # Constraints +// +// All arithmetic uses Trader's exact num types (ADR-004); there is no +// floating point. v1 supports one account currency: every listing must +// settle in the currency the caller passes, and any mismatch is +// ErrCurrencyMismatch rather than a conversion. The package performs +// no I/O, holds no state, and is safe for concurrent use. +package margin diff --git a/internal/account/margin/errors.go b/internal/account/margin/errors.go new file mode 100644 index 0000000..46f735f --- /dev/null +++ b/internal/account/margin/errors.go @@ -0,0 +1,23 @@ +package margin + +import "errors" + +var ( + // ErrInvalidPolicy reports a margin Policy that cannot compute a + // requirement: a nil Policy, or a Ratio that is not positive. + ErrInvalidPolicy = errors.New("margin: invalid policy") + + // ErrMissingMark reports an open position whose instrument has no + // valuation price in the supplied Marks. A missing mark is never + // treated as zero, and never replaced by the position's AvgPrice. + ErrMissingMark = errors.New("margin: missing mark") + + // ErrCurrencyMismatch reports a listing whose settlement currency + // differs from the account currency. v1 performs no FX conversion. + ErrCurrencyMismatch = errors.New("margin: currency mismatch") + + // ErrInvalidInput reports structurally unusable input, such as an + // unconstructed listing or more than one open position in the + // instrument a Change describes. + ErrInvalidInput = errors.New("margin: invalid input") +) diff --git a/internal/account/margin/margin.go b/internal/account/margin/margin.go new file mode 100644 index 0000000..c88b3d0 --- /dev/null +++ b/internal/account/margin/margin.go @@ -0,0 +1,298 @@ +package margin + +import ( + "fmt" + + "github.com/rustyeddy/trader/instrument" + "github.com/rustyeddy/trader/internal/account" + runtimeorder "github.com/rustyeddy/trader/internal/order" + "github.com/rustyeddy/trader/num" +) + +// Valued is a position's size in one listing, valued at one price. +type Valued struct { + // Listing supplies the contract multiplier and settlement currency. + Listing instrument.Listing + // Quantity is the position's magnitude. Direction does not matter + // for gross exposure, so there is no side. + Quantity num.Quantity + // Price is the single valuation price for the whole Quantity. + Price num.Price +} + +// Notional returns |Quantity| × Price × multiplier in currency. It +// reports ErrCurrencyMismatch if v's listing does not settle in +// currency. +func Notional(v Valued, currency num.Currency) (num.Money, error) { + if err := checkListing(v.Listing, currency); err != nil { + return num.Money{}, err + } + perUnit, err := v.Price.MulRate(v.Listing.Spec().Multiplier()) + if err != nil { + return num.Money{}, fmt.Errorf("margin: value per unit of %s: %w", v.Listing.InstrumentID(), err) + } + notional, err := perUnit.MulQuantity(v.Quantity, currency) + if err != nil { + return num.Money{}, fmt.Errorf("margin: notional of %s: %w", v.Listing.InstrumentID(), err) + } + return notional, nil +} + +// Policy computes the initial margin one valued position requires. +// +// It is the per-position extension point ADR-066 reserves for +// listing- or instrument-specific margin; v1's only implementation is +// Ratio. +// +// Validate reports whether the policy can compute requirements at all. +// Account and Assess call it once, up front, so an invalid policy is +// rejected even when no position ever reaches RequiredMargin (a flat +// account, or a closing change). +type Policy interface { + Validate() error + RequiredMargin(v Valued, currency num.Currency) (num.Money, error) +} + +// Ratio is the v1 Policy: every position requires |notional| × ratio +// (initial_margin_ratio). 1.0 is unlevered, 0.5 permits 2× gross +// exposure, 0.25 permits 4×. +type Ratio struct { + ratio num.Rate +} + +// NewRatio returns a Ratio policy. ratio must be positive. +func NewRatio(ratio num.Rate) (Ratio, error) { + if ratio.Sign() <= 0 { + return Ratio{}, fmt.Errorf("%w: initial margin ratio must be positive, got %s", ErrInvalidPolicy, ratio) + } + return Ratio{ratio: ratio}, nil +} + +// Value returns the configured ratio. +func (r Ratio) Value() num.Rate { return r.ratio } + +// Validate implements Policy. The zero Ratio, not built by NewRatio, +// reports ErrInvalidPolicy. +func (r Ratio) Validate() error { + if r.ratio.Sign() <= 0 { + return fmt.Errorf("%w: ratio must be constructed with NewRatio", ErrInvalidPolicy) + } + return nil +} + +// RequiredMargin implements Policy. +func (r Ratio) RequiredMargin(v Valued, currency num.Currency) (num.Money, error) { + if err := r.Validate(); err != nil { + return num.Money{}, err + } + notional, err := Notional(v, currency) + if err != nil { + return num.Money{}, err + } + required, err := notional.MulRate(r.ratio) + if err != nil { + return num.Money{}, fmt.Errorf("margin: required margin of %s: %w", v.Listing.InstrumentID(), err) + } + return required, nil +} + +// Marks maps each listing to its current valuation price. Marks are +// keyed by listing, not instrument: two listings of the same +// instrument (different provider or venue) are different positions +// and can carry different marks. +type Marks map[account.ListingKey]num.Price + +// Requirement is an account's gross notional exposure and the initial +// margin it requires, both in the account currency. +type Requirement struct { + Gross num.Money + Required num.Money +} + +// Within reports whether the required margin does not exceed equity — +// the ADR-066 admission invariant. Exact equality is within the limit. +func (r Requirement) Within(equity num.Money) (bool, error) { + cmp, err := r.Required.Cmp(equity) + if err != nil { + return false, fmt.Errorf("margin: comparing required margin to equity: %w", err) + } + return cmp <= 0, nil +} + +// Account returns the gross notional and required margin of positions, +// each open position valued at its listing's mark. Flat positions +// contribute nothing and need no mark. An open position with no mark +// reports ErrMissingMark. +func Account(positions []runtimeorder.Position, marks Marks, policy Policy, currency num.Currency) (Requirement, error) { + acc, err := newAccumulator(policy, currency) + if err != nil { + return Requirement{}, err + } + for _, p := range positions { + if err := acc.addMarked(p, marks); err != nil { + return Requirement{}, err + } + } + return acc.req, nil +} + +// Change describes one listing's position after a proposal or fill. +type Change struct { + // Listing identifies the changed position. Positions are matched by + // account.ListingKey, the same identity at which an account holds + // one net position. + Listing instrument.Listing + // Resulting is the position's magnitude after the change, as + // computed with resulting-position semantics by the caller: a + // 100-long position reversed by a 200-unit sell has a Resulting of + // 100, not 200 or 300. Zero means the change closes the position. + Resulting num.Quantity + // Price values the changed listing in both the current and the + // prospective state: the reference price at admission, or the + // actual fill price at fill time. + Price num.Price +} + +// Assessment compares an account's requirement before and after one +// Change. +type Assessment struct { + Current Requirement + Prospective Requirement +} + +// Increases reports whether the change raises gross notional. A change +// that doesn't is de-risking in ADR-066's sense. +// +// It compares gross notional, not required margin: required margin is +// rounded (Money × Rate), so a small real increase in gross exposure can +// round to the same required margin and must still count as an +// increase. +func (a Assessment) Increases() (bool, error) { + cmp, err := a.Prospective.Gross.Cmp(a.Current.Gross) + if err != nil { + return false, fmt.Errorf("margin: comparing prospective to current gross notional: %w", err) + } + return cmp > 0, nil +} + +// Assess returns the account's requirement before and after change. +// +// The changed listing is valued at change.Price in both states, so the +// two differ only in its quantity. Every other open position — including +// another listing of the same instrument — is valued at its mark in both +// states and needs one; the changed listing does not. More than one open +// position in the changed listing reports ErrInvalidInput. +func Assess(positions []runtimeorder.Position, marks Marks, change Change, policy Policy, currency num.Currency) (Assessment, error) { + if change.Listing.InstrumentID().IsZero() { + return Assessment{}, fmt.Errorf("%w: change listing must be constructed", ErrInvalidInput) + } + current, err := newAccumulator(policy, currency) + if err != nil { + return Assessment{}, err + } + prospective, err := newAccumulator(policy, currency) + if err != nil { + return Assessment{}, err + } + + changedKey := account.KeyOf(change.Listing) + matched := false + for _, p := range positions { + if p.Quantity.IsZero() { + continue + } + if account.KeyOf(p.Listing) == changedKey { + if matched { + return Assessment{}, fmt.Errorf("%w: more than one open position in listing %s/%s/%s", + ErrInvalidInput, changedKey.InstrumentID, changedKey.Provider, changedKey.Venue) + } + matched = true + if err := current.add(Valued{Listing: p.Listing, Quantity: p.Quantity, Price: change.Price}); err != nil { + return Assessment{}, err + } + continue + } + if err := current.addMarked(p, marks); err != nil { + return Assessment{}, err + } + if err := prospective.addMarked(p, marks); err != nil { + return Assessment{}, err + } + } + if err := prospective.add(Valued{Listing: change.Listing, Quantity: change.Resulting, Price: change.Price}); err != nil { + return Assessment{}, err + } + return Assessment{Current: current.req, Prospective: prospective.req}, nil +} + +// accumulator sums gross notional and required margin in one currency. +type accumulator struct { + policy Policy + currency num.Currency + req Requirement +} + +func newAccumulator(policy Policy, currency num.Currency) (*accumulator, error) { + if policy == nil { + return nil, fmt.Errorf("%w: policy must be set", ErrInvalidPolicy) + } + if err := policy.Validate(); err != nil { + return nil, err + } + zero, err := num.ParseMoney("0", currency) + if err != nil { + return nil, fmt.Errorf("%w: account currency: %v", ErrInvalidInput, err) + } + return &accumulator{policy: policy, currency: currency, req: Requirement{Gross: zero, Required: zero}}, nil +} + +// addMarked adds p valued at its listing's mark. Flat positions are +// skipped. +func (a *accumulator) addMarked(p runtimeorder.Position, marks Marks) error { + if p.Quantity.IsZero() { + return nil + } + key := account.KeyOf(p.Listing) + mark, ok := marks[key] + if !ok { + return fmt.Errorf("%w: no mark for open position in listing %s/%s/%s", + ErrMissingMark, key.InstrumentID, key.Provider, key.Venue) + } + return a.add(Valued{Listing: p.Listing, Quantity: p.Quantity, Price: mark}) +} + +func (a *accumulator) add(v Valued) error { + if v.Quantity.IsZero() { + // A closing change contributes nothing, but a mismatched + // listing is still unusable input. + return checkListing(v.Listing, a.currency) + } + notional, err := Notional(v, a.currency) + if err != nil { + return err + } + required, err := a.policy.RequiredMargin(v, a.currency) + if err != nil { + return err + } + if a.req.Gross, err = a.req.Gross.Add(notional); err != nil { + return fmt.Errorf("margin: summing gross notional: %w", err) + } + if a.req.Required, err = a.req.Required.Add(required); err != nil { + return fmt.Errorf("margin: summing required margin: %w", err) + } + return nil +} + +// checkListing reports whether l is constructed and settles in +// currency. +func checkListing(l instrument.Listing, currency num.Currency) error { + if l.InstrumentID().IsZero() { + return fmt.Errorf("%w: listing must be constructed", ErrInvalidInput) + } + if settle := l.Spec().SettlementCurrency(); !settle.Equal(currency) { + return fmt.Errorf("%w: listing %s settles in %s, account currency is %s", + ErrCurrencyMismatch, l.InstrumentID(), settle, currency) + } + return nil +} diff --git a/internal/account/margin/margin_test.go b/internal/account/margin/margin_test.go new file mode 100644 index 0000000..0376cf3 --- /dev/null +++ b/internal/account/margin/margin_test.go @@ -0,0 +1,473 @@ +package margin + +import ( + "testing" + "time" + + "github.com/rustyeddy/trader/instrument" + "github.com/rustyeddy/trader/internal/account" + "github.com/rustyeddy/trader/internal/clock" + "github.com/rustyeddy/trader/internal/id" + runtimeorder "github.com/rustyeddy/trader/internal/order" + "github.com/rustyeddy/trader/num" + "github.com/rustyeddy/trader/order" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var usd = num.MustParseCurrency("USD") + +func money(s string) num.Money { return num.MustParseMoney(s, usd) } +func price(s string) num.Price { return num.MustParsePrice(s) } +func qty(s string) num.Quantity { return num.MustParseQuantity(s) } +func rate(s string) num.Rate { return num.MustParseRate(s) } +func pricePtr(s string) *num.Price { p := price(s); return &p } + +// listing builds a listing for inst settling in settle with the given +// contract multiplier. +func listing(t *testing.T, inst instrument.Instrument, symbol, multiplier string, settle num.Currency) instrument.Listing { + t.Helper() + return listingAt(t, inst, symbol, "", multiplier, settle) +} + +// listingAt is listing on a specific venue. +func listingAt(t *testing.T, inst instrument.Instrument, symbol, venue, multiplier string, settle num.Currency) instrument.Listing { + t.Helper() + spec, err := instrument.NewSpec(price("0.01"), qty("1"), rate(multiplier), settle) + require.NoError(t, err) + l, err := instrument.NewListing(instrument.ListingParams{ + Instrument: inst, + Provider: "sim", + Symbol: symbol, + Venue: venue, + Spec: spec, + Tradable: true, + }) + require.NoError(t, err) + return l +} + +func equity(t *testing.T, ticker string) instrument.Listing { + t.Helper() + inst, err := instrument.NewEquity("ARCX", ticker) + require.NoError(t, err) + return listing(t, inst, ticker, "1", usd) +} + +func future(t *testing.T) instrument.Listing { + t.Helper() + inst, err := instrument.NewFuture("ES", time.Date(2026, time.December, 18, 0, 0, 0, 0, time.UTC)) + require.NoError(t, err) + return listing(t, inst, "ESZ6", "50", usd) +} + +func jpySettled(t *testing.T) instrument.Listing { + t.Helper() + inst, err := instrument.NewCurrencyPair(usd, num.MustParseCurrency("JPY")) + require.NoError(t, err) + return listing(t, inst, "USD_JPY", "1", num.MustParseCurrency("JPY")) +} + +func accountID(t *testing.T) id.AccountID { + t.Helper() + gen := id.NewGenerator(clock.NewSimulated(time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC)), id.NewDeterministic(1, 2)) + aid, err := id.GenerateAccountID(gen) + require.NoError(t, err) + return aid +} + +func position(t *testing.T, l instrument.Listing, side order.PositionSide, quantity, avg string) runtimeorder.Position { + t.Helper() + p := runtimeorder.Position{AccountID: accountID(t), Listing: l, Side: side, Quantity: qty(quantity)} + if side != order.Flat { + p.AvgPrice = pricePtr(avg) + } + p, err := runtimeorder.NewPosition(p) + require.NoError(t, err) + return p +} + +func ratio(t *testing.T, s string) Ratio { + t.Helper() + r, err := NewRatio(rate(s)) + require.NoError(t, err) + return r +} + +func assertMoney(t *testing.T, want string, got num.Money) { + t.Helper() + assert.True(t, money(want).Equal(got), "want %s USD, got %s", want, got) +} + +func TestNewRatio(t *testing.T) { + r, err := NewRatio(rate("0.5")) + require.NoError(t, err) + assert.True(t, rate("0.5").Equal(r.Value())) + + for _, bad := range []string{"0", "-1"} { + _, err := NewRatio(rate(bad)) + assert.ErrorIs(t, err, ErrInvalidPolicy, bad) + } +} + +func TestRatio_ZeroValueIsInvalid(t *testing.T) { + _, err := Ratio{}.RequiredMargin(Valued{Listing: equity(t, "SPY"), Quantity: qty("1"), Price: price("1")}, usd) + assert.ErrorIs(t, err, ErrInvalidPolicy) +} + +func TestNotional(t *testing.T) { + t.Run("multiplier one", func(t *testing.T) { + n, err := Notional(Valued{Listing: equity(t, "SPY"), Quantity: qty("100"), Price: price("463.08")}, usd) + require.NoError(t, err) + assertMoney(t, "46308", n) + }) + t.Run("multiplier not one", func(t *testing.T) { + // 2 ES contracts × 5000.25 × 50 = 500025. + n, err := Notional(Valued{Listing: future(t), Quantity: qty("2"), Price: price("5000.25")}, usd) + require.NoError(t, err) + assertMoney(t, "500025", n) + }) + t.Run("currency mismatch", func(t *testing.T) { + _, err := Notional(Valued{Listing: jpySettled(t), Quantity: qty("1"), Price: price("150")}, usd) + assert.ErrorIs(t, err, ErrCurrencyMismatch) + }) + t.Run("unconstructed listing", func(t *testing.T) { + _, err := Notional(Valued{Quantity: qty("1"), Price: price("1")}, usd) + assert.ErrorIs(t, err, ErrInvalidInput) + }) +} + +func TestRatio_RequiredMargin(t *testing.T) { + v := Valued{Listing: equity(t, "SPY"), Quantity: qty("100"), Price: price("100")} + for _, tc := range []struct{ ratio, want string }{ + {"1", "10000"}, + {"0.5", "5000"}, + {"0.25", "2500"}, + } { + got, err := ratio(t, tc.ratio).RequiredMargin(v, usd) + require.NoError(t, err) + assertMoney(t, tc.want, got) + } + + _, err := ratio(t, "1").RequiredMargin(Valued{Listing: jpySettled(t), Quantity: qty("1"), Price: price("1")}, usd) + assert.ErrorIs(t, err, ErrCurrencyMismatch) +} + +func TestAccount(t *testing.T) { + spy, qqq := equity(t, "SPY"), equity(t, "QQQ") + marks := Marks{account.KeyOf(spy): price("100"), account.KeyOf(qqq): price("200")} + + t.Run("flat account", func(t *testing.T) { + req, err := Account(nil, nil, ratio(t, "1"), usd) + require.NoError(t, err) + assertMoney(t, "0", req.Gross) + assertMoney(t, "0", req.Required) + }) + t.Run("flat position needs no mark", func(t *testing.T) { + req, err := Account([]runtimeorder.Position{position(t, spy, order.Flat, "0", "")}, nil, ratio(t, "1"), usd) + require.NoError(t, err) + assertMoney(t, "0", req.Gross) + }) + t.Run("single long", func(t *testing.T) { + req, err := Account([]runtimeorder.Position{position(t, spy, order.Long, "100", "90")}, marks, ratio(t, "1"), usd) + require.NoError(t, err) + // Valued at the mark (100), never AvgPrice (90). + assertMoney(t, "10000", req.Gross) + assertMoney(t, "10000", req.Required) + }) + t.Run("single short", func(t *testing.T) { + req, err := Account([]runtimeorder.Position{position(t, spy, order.Short, "100", "110")}, marks, ratio(t, "1"), usd) + require.NoError(t, err) + assertMoney(t, "10000", req.Gross) + }) + t.Run("long and short are gross, not net", func(t *testing.T) { + positions := []runtimeorder.Position{ + position(t, spy, order.Long, "100", "100"), // +10000 + position(t, qqq, order.Short, "50", "200"), // -10000 net, +10000 gross + } + req, err := Account(positions, marks, ratio(t, "1"), usd) + require.NoError(t, err) + assertMoney(t, "20000", req.Gross) + assertMoney(t, "20000", req.Required) + }) + t.Run("multi-instrument at ratio 0.5", func(t *testing.T) { + positions := []runtimeorder.Position{ + position(t, spy, order.Long, "100", "100"), + position(t, qqq, order.Long, "25", "200"), + } + req, err := Account(positions, marks, ratio(t, "0.5"), usd) + require.NoError(t, err) + assertMoney(t, "15000", req.Gross) + assertMoney(t, "7500", req.Required) + }) + t.Run("multiplier not one", func(t *testing.T) { + es := future(t) + req, err := Account([]runtimeorder.Position{position(t, es, order.Long, "1", "5000")}, Marks{account.KeyOf(es): price("5000")}, ratio(t, "0.1"), usd) + require.NoError(t, err) + assertMoney(t, "250000", req.Gross) + assertMoney(t, "25000", req.Required) + }) + t.Run("missing mark", func(t *testing.T) { + _, err := Account([]runtimeorder.Position{position(t, spy, order.Long, "1", "1")}, Marks{}, ratio(t, "1"), usd) + assert.ErrorIs(t, err, ErrMissingMark) + }) + t.Run("currency mismatch", func(t *testing.T) { + jpy := jpySettled(t) + _, err := Account([]runtimeorder.Position{position(t, jpy, order.Long, "1", "150")}, Marks{account.KeyOf(jpy): price("150")}, ratio(t, "1"), usd) + assert.ErrorIs(t, err, ErrCurrencyMismatch) + }) + t.Run("nil policy", func(t *testing.T) { + _, err := Account(nil, nil, nil, usd) + assert.ErrorIs(t, err, ErrInvalidPolicy) + }) + t.Run("unusable currency", func(t *testing.T) { + _, err := Account(nil, nil, ratio(t, "1"), num.Currency{}) + assert.ErrorIs(t, err, ErrInvalidInput) + }) +} + +func TestRequirement_Within(t *testing.T) { + spy := equity(t, "SPY") + marks := Marks{account.KeyOf(spy): price("100")} + eq := money("10000") + + t.Run("exact limit is admitted", func(t *testing.T) { + req, err := Account([]runtimeorder.Position{position(t, spy, order.Long, "100", "100")}, marks, ratio(t, "1"), usd) + require.NoError(t, err) + ok, err := req.Within(eq) + require.NoError(t, err) + assert.True(t, ok) + }) + t.Run("one unit over the limit", func(t *testing.T) { + req, err := Account([]runtimeorder.Position{position(t, spy, order.Long, "101", "100")}, marks, ratio(t, "1"), usd) + require.NoError(t, err) + ok, err := req.Within(eq) + require.NoError(t, err) + assert.False(t, ok) + }) + t.Run("equity in another currency", func(t *testing.T) { + _, err := Requirement{Gross: money("0"), Required: money("0")}.Within(num.MustParseMoney("1", num.MustParseCurrency("EUR"))) + assert.Error(t, err) + }) +} + +func TestAssess(t *testing.T) { + spy, qqq := equity(t, "SPY"), equity(t, "QQQ") + marks := Marks{account.KeyOf(spy): price("100"), account.KeyOf(qqq): price("200")} + one := ratio(t, "1") + + t.Run("motivating SPY order from flat", func(t *testing.T) { + // #409: $10,000 equity, 100 SPY at 463.08 is 4.63× equity. + a, err := Assess(nil, nil, Change{Listing: spy, Resulting: qty("100"), Price: price("463.08")}, one, usd) + require.NoError(t, err) + assertMoney(t, "0", a.Current.Gross) + assertMoney(t, "46308", a.Prospective.Gross) + up, err := a.Increases() + require.NoError(t, err) + assert.True(t, up) + ok, err := a.Prospective.Within(money("10000")) + require.NoError(t, err) + assert.False(t, ok) + + // 21 shares fit: 9724.68 ≤ 10000. + a, err = Assess(nil, nil, Change{Listing: spy, Resulting: qty("21"), Price: price("463.08")}, one, usd) + require.NoError(t, err) + ok, err = a.Prospective.Within(money("10000")) + require.NoError(t, err) + assert.True(t, ok) + }) + t.Run("changed instrument valued at change price in both states", func(t *testing.T) { + // SPY's mark is 100, but the change prices it at 110 in both + // states, so the comparison is a pure quantity comparison. + positions := []runtimeorder.Position{position(t, spy, order.Long, "100", "95")} + a, err := Assess(positions, marks, Change{Listing: spy, Resulting: qty("150"), Price: price("110")}, one, usd) + require.NoError(t, err) + assertMoney(t, "11000", a.Current.Gross) + assertMoney(t, "16500", a.Prospective.Gross) + }) + t.Run("changed instrument needs no mark", func(t *testing.T) { + positions := []runtimeorder.Position{position(t, spy, order.Long, "100", "95")} + a, err := Assess(positions, Marks{}, Change{Listing: spy, Resulting: qty("100"), Price: price("110")}, one, usd) + require.NoError(t, err) + assertMoney(t, "11000", a.Current.Gross) + }) + t.Run("other positions valued at their marks in both states", func(t *testing.T) { + positions := []runtimeorder.Position{ + position(t, spy, order.Long, "10", "100"), + position(t, qqq, order.Short, "10", "150"), // mark 200 → 2000 gross + } + a, err := Assess(positions, marks, Change{Listing: spy, Resulting: qty("20"), Price: price("100")}, one, usd) + require.NoError(t, err) + assertMoney(t, "3000", a.Current.Gross) + assertMoney(t, "4000", a.Prospective.Gross) + }) + t.Run("partial reduction does not increase", func(t *testing.T) { + positions := []runtimeorder.Position{position(t, spy, order.Long, "100", "100")} + a, err := Assess(positions, marks, Change{Listing: spy, Resulting: qty("40"), Price: price("100")}, one, usd) + require.NoError(t, err) + assertMoney(t, "4000", a.Prospective.Gross) + up, err := a.Increases() + require.NoError(t, err) + assert.False(t, up) + }) + t.Run("close contributes nothing", func(t *testing.T) { + positions := []runtimeorder.Position{ + position(t, spy, order.Long, "100", "100"), + position(t, qqq, order.Long, "10", "200"), + } + a, err := Assess(positions, marks, Change{Listing: spy, Resulting: qty("0"), Price: price("100")}, one, usd) + require.NoError(t, err) + assertMoney(t, "12000", a.Current.Gross) + assertMoney(t, "2000", a.Prospective.Gross) + }) + t.Run("reversal uses the resulting position", func(t *testing.T) { + // 100 long, sell 200 → 100 short: 100 units, not 200 or 300. + positions := []runtimeorder.Position{position(t, spy, order.Long, "100", "100")} + a, err := Assess(positions, marks, Change{Listing: spy, Resulting: qty("100"), Price: price("100")}, one, usd) + require.NoError(t, err) + assertMoney(t, "10000", a.Prospective.Gross) + up, err := a.Increases() + require.NoError(t, err) + assert.False(t, up) + + // 100 long, sell 250 → 150 short grows exposure. + a, err = Assess(positions, marks, Change{Listing: spy, Resulting: qty("150"), Price: price("100")}, one, usd) + require.NoError(t, err) + up, err = a.Increases() + require.NoError(t, err) + assert.True(t, up) + }) + t.Run("already over the limit, reduction still does not increase", func(t *testing.T) { + positions := []runtimeorder.Position{position(t, spy, order.Long, "300", "100")} + a, err := Assess(positions, marks, Change{Listing: spy, Resulting: qty("200"), Price: price("100")}, one, usd) + require.NoError(t, err) + ok, err := a.Prospective.Within(money("10000")) + require.NoError(t, err) + assert.False(t, ok, "still over the limit") + up, err := a.Increases() + require.NoError(t, err) + assert.False(t, up, "but de-risking") + }) + t.Run("missing mark for another position", func(t *testing.T) { + positions := []runtimeorder.Position{position(t, qqq, order.Long, "1", "200")} + _, err := Assess(positions, Marks{}, Change{Listing: spy, Resulting: qty("1"), Price: price("100")}, one, usd) + assert.ErrorIs(t, err, ErrMissingMark) + }) + t.Run("more than one open position in the changed instrument", func(t *testing.T) { + positions := []runtimeorder.Position{ + position(t, spy, order.Long, "1", "100"), + position(t, spy, order.Long, "1", "100"), + } + _, err := Assess(positions, marks, Change{Listing: spy, Resulting: qty("1"), Price: price("100")}, one, usd) + assert.ErrorIs(t, err, ErrInvalidInput) + }) + t.Run("unconstructed change listing", func(t *testing.T) { + _, err := Assess(nil, nil, Change{Resulting: qty("1"), Price: price("100")}, one, usd) + assert.ErrorIs(t, err, ErrInvalidInput) + }) + t.Run("currency mismatch on a closing change", func(t *testing.T) { + _, err := Assess(nil, nil, Change{Listing: jpySettled(t), Resulting: qty("0"), Price: price("150")}, one, usd) + assert.ErrorIs(t, err, ErrCurrencyMismatch) + }) + t.Run("currency mismatch on an increasing change", func(t *testing.T) { + _, err := Assess(nil, nil, Change{Listing: jpySettled(t), Resulting: qty("1"), Price: price("150")}, one, usd) + assert.ErrorIs(t, err, ErrCurrencyMismatch) + }) + t.Run("currency mismatch on the current position", func(t *testing.T) { + jpy := jpySettled(t) + positions := []runtimeorder.Position{position(t, jpy, order.Long, "1", "150")} + _, err := Assess(positions, nil, Change{Listing: jpy, Resulting: qty("0"), Price: price("150")}, one, usd) + assert.ErrorIs(t, err, ErrCurrencyMismatch) + }) + t.Run("zero-value policy", func(t *testing.T) { + _, err := Assess(nil, nil, Change{Listing: spy, Resulting: qty("1"), Price: price("100")}, Ratio{}, usd) + assert.ErrorIs(t, err, ErrInvalidPolicy) + }) + t.Run("nil policy", func(t *testing.T) { + _, err := Assess(nil, nil, Change{Listing: spy, Resulting: qty("1"), Price: price("100")}, nil, usd) + assert.ErrorIs(t, err, ErrInvalidPolicy) + }) +} + +func TestAssessment_IncreasesCurrencyMismatch(t *testing.T) { + eur := num.MustParseMoney("1", num.MustParseCurrency("EUR")) + _, err := Assessment{Current: Requirement{Required: money("1")}, Prospective: Requirement{Required: eur}}.Increases() + assert.Error(t, err) +} + +func TestPolicyValidatedUpFront(t *testing.T) { + spy := equity(t, "SPY") + t.Run("flat account with zero-value Ratio", func(t *testing.T) { + _, err := Account(nil, nil, Ratio{}, usd) + assert.ErrorIs(t, err, ErrInvalidPolicy) + }) + t.Run("closing change with zero-value Ratio", func(t *testing.T) { + _, err := Assess(nil, nil, Change{Listing: spy, Resulting: qty("0"), Price: price("100")}, Ratio{}, usd) + assert.ErrorIs(t, err, ErrInvalidPolicy) + }) + t.Run("constructed Ratio validates", func(t *testing.T) { + assert.NoError(t, ratio(t, "0.5").Validate()) + }) +} + +func TestIncreases_ComparesGrossNotRoundedMargin(t *testing.T) { + // At ratio 0.1, growing gross from 1.00000000 to 1.00000001 leaves + // required margin at 0.10000000 after rounding. The smallest + // representable increase in gross must still count as an increase. + spy := equity(t, "SPY") + positions := []runtimeorder.Position{position(t, spy, order.Long, "1", "1")} + a, err := Assess(positions, nil, Change{Listing: spy, Resulting: qty("1.00000001"), Price: price("1")}, ratio(t, "0.1"), usd) + require.NoError(t, err) + require.True(t, a.Prospective.Required.Equal(a.Current.Required), "required margin rounds to the same value") + up, err := a.Increases() + require.NoError(t, err) + assert.True(t, up) +} + +func TestListingLevelIdentity(t *testing.T) { + inst, err := instrument.NewEquity("ARCX", "SPY") + require.NoError(t, err) + arca := listingAt(t, inst, "SPY", "ARCA", "1", usd) + bats := listingAt(t, inst, "SPY", "BATS", "1", usd) + require.True(t, arca.InstrumentID().Equal(bats.InstrumentID()), "same instrument") + one := ratio(t, "1") + + t.Run("each listing uses its own mark", func(t *testing.T) { + positions := []runtimeorder.Position{ + position(t, arca, order.Long, "10", "100"), + position(t, bats, order.Long, "10", "100"), + } + marks := Marks{account.KeyOf(arca): price("100"), account.KeyOf(bats): price("101")} + req, err := Account(positions, marks, one, usd) + require.NoError(t, err) + assertMoney(t, "2010", req.Gross) + }) + t.Run("a mark for one listing does not value another", func(t *testing.T) { + positions := []runtimeorder.Position{position(t, bats, order.Long, "10", "100")} + _, err := Account(positions, Marks{account.KeyOf(arca): price("100")}, one, usd) + assert.ErrorIs(t, err, ErrMissingMark) + }) + t.Run("two listings of one instrument are not duplicates", func(t *testing.T) { + positions := []runtimeorder.Position{ + position(t, arca, order.Long, "10", "100"), + position(t, bats, order.Long, "10", "100"), + } + marks := Marks{account.KeyOf(bats): price("101")} + a, err := Assess(positions, marks, Change{Listing: arca, Resulting: qty("20"), Price: price("100")}, one, usd) + require.NoError(t, err) + assertMoney(t, "2010", a.Current.Gross) // 10×100 + 10×101 + assertMoney(t, "3010", a.Prospective.Gross) // 20×100 + 10×101 + }) + t.Run("a change in another listing does not match the existing position", func(t *testing.T) { + // Open in ARCA; the change is a fresh position in BATS, so the + // ARCA position stays at its mark and needs one. + positions := []runtimeorder.Position{position(t, arca, order.Long, "10", "100")} + _, err := Assess(positions, Marks{}, Change{Listing: bats, Resulting: qty("5"), Price: price("100")}, one, usd) + assert.ErrorIs(t, err, ErrMissingMark) + + a, err := Assess(positions, Marks{account.KeyOf(arca): price("100")}, Change{Listing: bats, Resulting: qty("5"), Price: price("100")}, one, usd) + require.NoError(t, err) + assertMoney(t, "1000", a.Current.Gross) + assertMoney(t, "1500", a.Prospective.Gross) + }) +} diff --git a/internal/account/snapshot.go b/internal/account/snapshot.go index d32f60d..227d457 100644 --- a/internal/account/snapshot.go +++ b/internal/account/snapshot.go @@ -5,7 +5,6 @@ import ( "strings" "time" - "github.com/rustyeddy/trader/instrument" "github.com/rustyeddy/trader/internal/id" runtimeorder "github.com/rustyeddy/trader/internal/order" "github.com/rustyeddy/trader/num" @@ -205,18 +204,8 @@ func checkCashBalances(balances []num.Money) ([]num.Money, error) { return cloned, nil } -type listingKey struct { - instrumentID instrument.ID - provider string - venue string -} - -func keyFor(l instrument.Listing) listingKey { - return listingKey{instrumentID: l.InstrumentID(), provider: l.Provider(), venue: l.Venue()} -} - func checkPositions(accountID id.AccountID, broker string, positions []runtimeorder.Position) ([]runtimeorder.Position, error) { - seen := make(map[listingKey]struct{}, len(positions)) + seen := make(map[ListingKey]struct{}, len(positions)) cloned := make([]runtimeorder.Position, len(positions)) for i, p := range positions { validated, err := runtimeorder.NewPosition(p) @@ -231,10 +220,10 @@ func checkPositions(accountID id.AccountID, broker string, positions []runtimeor return nil, fmt.Errorf("entry %d: listing provider %s does not match snapshot broker %s", i, validated.Listing.Provider(), broker) } - key := keyFor(validated.Listing) + key := KeyOf(validated.Listing) if _, ok := seen[key]; ok { return nil, fmt.Errorf("entry %d: duplicate listing %s/%s/%s", - i, key.instrumentID, key.provider, key.venue) + i, key.InstrumentID, key.Provider, key.Venue) } seen[key] = struct{}{} cloned[i] = clonePosition(validated)