Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions internal/account/mark.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package account

import (
"fmt"
"time"

runtimeorder "github.com/rustyeddy/trader/internal/order"
"github.com/rustyeddy/trader/num"
)

// PositionMark is the current valuation price of one open position's
// listing, and when it was observed (ADR-066).
//
// A mark is "as of the reporter's last observation of that listing" —
// for the simulator, the last fill or bar close it saw — not a live
// price. In a multi-instrument account, different listings' marks can
// have different AsOf times; each is the latest observation at or
// before the snapshot's own AsOf.
type PositionMark struct {
// Listing identifies the open position this mark values.
Listing ListingKey
// Price is the valuation price. It must be positive.
Price num.Price
// AsOf is when Price was observed. It must be set and must not be
// after the snapshot's AsOf.
AsOf time.Time
}

// checkMarks validates marks against the snapshot's positions and
// returns them in positions order. Every mark must name an open
// position, at most once. A position may have no mark: marks are
// optional for a reporter that has none, and a consumer that needs one
// treats its absence as an error, never as zero or AvgPrice.
func checkMarks(positions []runtimeorder.Position, marks []PositionMark, asOf time.Time) ([]PositionMark, error) {
if len(marks) == 0 {
return nil, nil
}
open := make(map[ListingKey]struct{}, len(positions))
for _, p := range positions {
open[KeyOf(p.Listing)] = struct{}{}
}
byKey := make(map[ListingKey]PositionMark, len(marks))
for i, m := range marks {
if _, ok := open[m.Listing]; !ok {
return nil, fmt.Errorf("entry %d: no open position in listing %s/%s/%s",
i, m.Listing.InstrumentID, m.Listing.Provider, m.Listing.Venue)
}
if _, dup := byKey[m.Listing]; dup {
return nil, fmt.Errorf("entry %d: duplicate mark for listing %s/%s/%s",
i, m.Listing.InstrumentID, m.Listing.Provider, m.Listing.Venue)
}
if m.Price.IsZero() {
return nil, fmt.Errorf("entry %d: price must be positive", i)
}
if m.AsOf.IsZero() {
return nil, fmt.Errorf("entry %d: as-of time must be set", i)
}
if m.AsOf.After(asOf) {
return nil, fmt.Errorf("entry %d: as-of %s is after snapshot as-of %s", i, m.AsOf, asOf)
}
byKey[m.Listing] = m
}
ordered := make([]PositionMark, 0, len(byKey))
for _, p := range positions {
if m, ok := byKey[KeyOf(p.Listing)]; ok {
ordered = append(ordered, m)
}
}
return ordered, nil
}
100 changes: 100 additions & 0 deletions internal/account/mark_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package account

import (
"testing"
"time"

runtimeorder "github.com/rustyeddy/trader/internal/order"
"github.com/rustyeddy/trader/num"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// twoPositionParams is validParams with open EUR/USD and GBP/USD
// positions, in that order.
func twoPositionParams(t *testing.T) SnapshotParams {
t.Helper()
p := validParams(t)
gbp := mustListing(t, "GBP", "USD", "OANDA", "GBP_USD")
p.Positions = append(p.Positions, mustPosition(t, p.AccountID, gbp))
return p
}

func markFor(p runtimeorder.Position, price string, at time.Time) PositionMark {
return PositionMark{Listing: KeyOf(p.Listing), Price: num.MustParsePrice(price), AsOf: at}
}

func TestSnapshotMarks(t *testing.T) {
p := twoPositionParams(t)
earlier := p.AsOf.Add(-time.Hour)
eurMark := markFor(p.Positions[0], "1.1", p.AsOf)
gbpMark := markFor(p.Positions[1], "1.3", earlier)
// Supplied out of Positions order; returned in Positions order.
p.Marks = []PositionMark{gbpMark, eurMark}

s, err := NewSnapshot(p)
require.NoError(t, err)
assert.Equal(t, []PositionMark{eurMark, gbpMark}, s.Marks())

got, ok := s.Mark(KeyOf(p.Positions[1].Listing))
require.True(t, ok)
assert.Equal(t, gbpMark, got, "each mark keeps its own AsOf")

// Marks returns a copy.
s.Marks()[0].Price = num.MustParsePrice("99")
assert.Equal(t, eurMark, s.Marks()[0])
}

func TestSnapshotMarksOptional(t *testing.T) {
p := twoPositionParams(t)
p.Marks = []PositionMark{markFor(p.Positions[1], "1.3", p.AsOf)}
s, err := NewSnapshot(p)
require.NoError(t, err)
_, ok := s.Mark(KeyOf(p.Positions[0].Listing))
assert.False(t, ok, "a position may have no mark")

s, err = NewSnapshot(validParams(t))
require.NoError(t, err)
assert.Empty(t, s.Marks())
}

func TestSnapshotRejectsInvalidMarks(t *testing.T) {
other := mustListing(t, "AUD", "USD", "OANDA", "AUD_USD")
cases := []struct {
name string
mutate func(p *SnapshotParams)
}{
{"no open position in listing", func(p *SnapshotParams) {
p.Marks = []PositionMark{{Listing: KeyOf(other), Price: num.MustParsePrice("1"), AsOf: p.AsOf}}
}},
{"duplicate", func(p *SnapshotParams) {
m := markFor(p.Positions[0], "1", p.AsOf)
p.Marks = []PositionMark{m, m}
}},
{"zero price", func(p *SnapshotParams) {
p.Marks = []PositionMark{{Listing: KeyOf(p.Positions[0].Listing), AsOf: p.AsOf}}
}},
{"zero as-of", func(p *SnapshotParams) {
p.Marks = []PositionMark{{Listing: KeyOf(p.Positions[0].Listing), Price: num.MustParsePrice("1")}}
}},
{"as-of after snapshot", func(p *SnapshotParams) {
p.Marks = []PositionMark{markFor(p.Positions[0], "1", p.AsOf.Add(time.Second))}
}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
p := validParams(t)
tc.mutate(&p)
_, err := NewSnapshot(p)
assert.ErrorIs(t, err, ErrInvalidSnapshot)
})
}
}

func TestKeyOf(t *testing.T) {
l := mustEurUsdListing(t)
k := KeyOf(l)
assert.Equal(t, l.InstrumentID(), k.InstrumentID)
assert.Equal(t, l.Provider(), k.Provider)
assert.Equal(t, l.Venue(), k.Venue)
}
30 changes: 30 additions & 0 deletions internal/account/snapshot.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ type Snapshot struct {
financing num.Money

positions []runtimeorder.Position
marks []PositionMark
openOrders []runtimeorder.Order
}

Expand Down Expand Up @@ -109,6 +110,11 @@ type SnapshotParams struct {
// must case-insensitively equal Broker, and no two entries may name
// the same (instrument, provider, venue) listing.
Positions []runtimeorder.Position
// Marks is the current valuation price of open positions, at most
// one per position (see PositionMark). Every entry must name an
// entry of Positions by its ListingKey. It may omit positions, or be
// empty, when the reporter has no current price for them.
Marks []PositionMark
Comment on lines +113 to +117

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c67b0c1: accountWire gains marks (markWire: instrument/provider/venue, price, as_of, with omitempty so older entries still decode). On read, each mark's listing is resolved against the entry's own decoded positions; a mark naming no position is ErrCorruptEntry. TestWriterReaderRoundTripsAccountMarks round-trips two listings, one with a venue, with different AsOf values.

// OpenOrders is this account's outstanding orders. Every entry's
// Request.AccountID must equal AccountID, every entry's
// Request.Listing.Provider must case-insensitively equal Broker, no
Expand Down Expand Up @@ -162,6 +168,11 @@ func NewSnapshot(params SnapshotParams) (Snapshot, error) {
return Snapshot{}, fmt.Errorf("%w: positions: %v", ErrInvalidSnapshot, err)
}

marks, err := checkMarks(positions, params.Marks, params.AsOf)
if err != nil {
return Snapshot{}, fmt.Errorf("%w: marks: %v", ErrInvalidSnapshot, err)
}

openOrders, err := checkOpenOrders(params.AccountID, params.Broker, params.OpenOrders)
if err != nil {
return Snapshot{}, fmt.Errorf("%w: open orders: %v", ErrInvalidSnapshot, err)
Expand All @@ -183,6 +194,7 @@ func NewSnapshot(params SnapshotParams) (Snapshot, error) {
fees: params.Fees,
financing: params.Financing,
positions: positions,
marks: marks,
openOrders: openOrders,
}, nil
}
Expand Down Expand Up @@ -373,6 +385,24 @@ func (s Snapshot) Positions() []runtimeorder.Position {
return cloned
}

// Marks returns a copy of the open positions' current valuation
// prices, in Positions order. A position with no current price has no
// entry.
func (s Snapshot) Marks() []PositionMark {
return append([]PositionMark(nil), s.marks...)
}

// Mark returns the current valuation price of the open position in
// key's listing, if the snapshot has one.
func (s Snapshot) Mark(key ListingKey) (PositionMark, bool) {
for _, m := range s.marks {
if m.Listing == key {
return m, true
}
}
return PositionMark{}, false
}

// OpenOrders returns a deep copy of the account's outstanding orders.
// Mutating the returned slice, or any pointer/slice field it reaches,
// does not affect s.
Expand Down
Loading
Loading