package ibkr
import (
"context"
"errors"
"slices"
"strconv"
"strings"
"time"
)
// AccountCurrencySettlementProbe records an explicit currency-only Multi
// subscription. No amounts, account identities or cash authority are returned.
// API1050 EClient/EDecoder verify opcodes76/77 and callbacks73/74. Account
// protobuf starts at207; this experiment refuses that unimplemented protocol.
type AccountCurrencySettlementProbe struct {
Status, CancelStatus, ClockSource string
BrokerErrorCode int
SocketEpoch uint64
RequestedAt, CompletedAt, LastCallbackAt, ReadAt time.Time
Callbacks int
Rows []AccountStreamRow
RowsTruncated bool
}
type currencyProbeSnapshot struct {
account string
epoch uint64
done chan struct{}
terminal, conflict, malformed bool
brokerErrorCode int
requestedAt, completedAt, lastCallbackAt time.Time
callbacks int
rows map[string]AccountStreamRow
truncated bool
}
func (c *Connection) currencyProbeRawCaptureEnabled() bool {
c.packetLoggerMu.RLock()
packet := c.packetLogger != nil
c.packetLoggerMu.RUnlock()
return packet || c.logWireHex || (c.config != nil && c.config.PacketLogPath != "") || (c.wireTap != nil && c.wireTap.Enabled())
}
// RequestCurrencySettlementProbe is opt-in only. It uses the existing ready
// connector with exact account, empty model and ledgerAndNLV=true. Ordinary
// account reads retain priority through the existing diagnostic scheduling lane.
func (c *Connector) RequestCurrencySettlementProbe(ctx context.Context, timeout time.Duration) (out AccountCurrencySettlementProbe) {
out.Status, out.CancelStatus, out.ClockSource = "unavailable", "not_started", "local_receive"
if ctx == nil || ctx.Err() != nil {
out.Status = "cancelled"
return
}
origin, ok := c.CaptureSession()
if !ok {
return
}
conn := origin.connection
if version := conn.ServerVersion(); version < 103 || version >= 207 {
out.Status = "unsupported_protocol"
return
}
if conn.currencyProbeRawCaptureEnabled() {
out.Status = "skipped_raw_capture"
return
}
expected := accountSummaryExpectedAccount(conn)
if !expected.valid() {
out.Status = "scope_conflict"
return
}
conn.accountMu.RLock()
quarantineFull := len(conn.retiredCurrencyProbes) >= 256
conn.accountMu.RUnlock()
if quarantineFull {
out.Status = "skipped_quarantine_limit"
return
}
if timeout <= 0 || timeout > 2*time.Second {
timeout = 2 * time.Second
}
probeCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
flight := conn.summarySchedule.beginProbe(cancel)
if flight == nil {
out.Status = "skipped_busy"
return
}
defer func() {
if conn.summarySchedule.finishProbe(flight) && out.Status != "session_changed" && out.Status != "scope_conflict" {
out.Status = "yielded"
out.Rows = nil
}
}()
reqID, err := conn.nextRequestIDForForwarding()
if err != nil {
return
}
defer conn.discardRequestIDReservation(reqID)
if err = conn.claimRequestID(reqID); err != nil {
return
}
snap := ¤cyProbeSnapshot{account: string(expected), epoch: origin.epoch, done: make(chan struct{}), requestedAt: time.Now().UTC(), rows: make(map[string]AccountStreamRow)}
conn.accountMu.Lock()
if conn.currencyProbes == nil {
conn.currencyProbes = make(map[int]*currencyProbeSnapshot)
}
conn.currencyProbes[reqID] = snap
conn.accountMu.Unlock()
sentPossible := false
out.SocketEpoch = origin.epoch
defer func() {
// Quarantine first, then cancel on exactly the original session, then let
// the scheduling defer release ordinary readers. End is not cancellation.
conn.accountMu.Lock()
if conn.currencyProbes[reqID] == snap {
delete(conn.currencyProbes, reqID)
}
if conn.BrokerSessionEpoch() == origin.epoch {
if conn.retiredCurrencyProbes == nil {
conn.retiredCurrencyProbes = make(map[int]uint64)
}
conn.retiredCurrencyProbes[reqID] = origin.epoch
}
conn.accountMu.Unlock()
if !c.SessionCurrent(origin) {
out.Status, out.CancelStatus = "session_changed", "session_changed"
out.Rows = nil
return
}
if !expected.equal(accountSummaryExpectedAccount(conn)) {
out.Status = "scope_conflict"
out.Rows = nil
}
if !sentPossible {
out.CancelStatus = "not_needed"
return
}
cancelCtx, cancelSend := context.WithTimeout(context.Background(), time.Second)
defer cancelSend()
err := conn.sendMessageWithTypeContextForEpochGuarded(cancelCtx, conn.encodeMsg(cancelAccountUpdatesMulti, 1, reqID), RequestTypeGeneral, origin.epoch, true, func() error {
if !c.SessionCurrent(origin) {
return ErrIBKRUnavailable
}
return nil
})
if !c.SessionCurrent(origin) {
out.Status, out.CancelStatus = "session_changed", "session_changed"
out.Rows = nil
return
}
if err != nil {
out.CancelStatus, out.Rows = "failed", nil
conn.summarySchedule.mu.Lock()
if conn.BrokerSessionEpoch() == origin.epoch {
conn.summarySchedule.uncertainCurrencyProbe = true
}
conn.summarySchedule.mu.Unlock()
} else {
out.CancelStatus = "sent"
}
if !expected.equal(accountSummaryExpectedAccount(conn)) {
out.Status = "scope_conflict"
out.Rows = nil
}
out.ReadAt = time.Now().UTC()
}()
guard := func() error {
if !c.SessionCurrent(origin) {
return ErrIBKRUnavailable
}
if !expected.equal(accountSummaryExpectedAccount(conn)) {
return ErrAccountSummaryScopeConflict
}
if conn.currencyProbeRawCaptureEnabled() {
return errors.New("currency diagnostic raw capture enabled")
}
return nil
}
err = conn.sendMessageWithTypeContextForEpochGuarded(probeCtx, conn.encodeMsg(reqAccountUpdatesMulti, 1, reqID, string(expected), "", true), RequestTypeGeneral, origin.epoch, true, guard)
sentPossible = err == nil || SendDispositionOf(err) != SendDispositionDefinitelyUnsent
if err != nil {
out.Status = "send_error"
if errors.Is(err, context.DeadlineExceeded) {
out.Status = "timeout"
}
if errors.Is(err, context.Canceled) {
out.Status = "cancelled"
}
return
}
select {
case <-snap.done:
if err := guard(); err != nil {
out.Status = "session_changed"
if errors.Is(err, ErrAccountSummaryScopeConflict) {
out.Status = "scope_conflict"
}
return
}
conn.accountMu.RLock()
switch {
case snap.conflict:
out.Status = "scope_conflict"
case snap.malformed:
out.Status = "invalid_callback"
case snap.brokerErrorCode != 0:
out.Status, out.BrokerErrorCode = "broker_error", snap.brokerErrorCode
default:
out.Status = "completed"
if snap.callbacks == 0 {
out.Status = "completed_empty"
}
out.RequestedAt, out.CompletedAt, out.LastCallbackAt = snap.requestedAt, snap.completedAt, snap.lastCallbackAt
out.Callbacks, out.RowsTruncated = snap.callbacks, snap.truncated
out.Rows = make([]AccountStreamRow, 0, len(snap.rows))
for _, row := range snap.rows {
out.Rows = append(out.Rows, row)
}
slices.SortFunc(out.Rows, func(a, b AccountStreamRow) int {
return strings.Compare(a.Key+"\x00"+a.Currency+"\x00"+a.Source, b.Key+"\x00"+b.Currency+"\x00"+b.Source)
})
}
conn.accountMu.RUnlock()
case <-probeCtx.Done():
out.Status = "cancelled"
if errors.Is(probeCtx.Err(), context.DeadlineExceeded) {
out.Status = "timeout"
}
}
return
}
// The dispatcher holds an inbound epoch lease. Even unknown/retired Multi
// IDs are consumed without entering financial parsers or generic raw logs.
func (c *Connection) handleCurrencyProbeMessage(msgID int, fields []string, epoch uint64) {
if len(fields) < 3 {
return
}
reqID, err := strconv.Atoi(fields[2])
if err != nil {
return
}
c.accountMu.Lock()
defer c.accountMu.Unlock()
snap := c.currencyProbes[reqID]
if snap == nil || snap.epoch != epoch || snap.terminal {
return
}
terminal := func() { snap.terminal = true; close(snap.done) }
if fields[1] != "1" {
snap.malformed = true
terminal()
return
}
if msgID == msgAccountUpdateMultiEnd {
if len(fields) != 4 || fields[3] != "" {
snap.malformed = true
} else {
snap.completedAt = time.Now().UTC()
}
terminal()
return
}
if len(fields) != 9 || fields[8] != "" {
snap.malformed = true
terminal()
return
}
if !strings.EqualFold(strings.TrimSpace(fields[3]), snap.account) || fields[4] != "" {
snap.conflict = true
terminal()
return
}
at := time.Now().UTC()
snap.lastCallbackAt = at
if snap.callbacks < 1_000_000 {
snap.callbacks++
}
key, value, currency := fields[5], fields[6], fields[7]
field, _ := splitGatewayLedgerTag(key)
if !streamCashDiagnosticField(field) {
return
}
source := "currency_only_multi"
switch {
case currency == "BASE":
source = "base_currency_total"
case currency == "":
source = "unlabelled"
case !concreteAccountSummaryLedgerCurrency(currency):
currency, source = "", "invalid_currency"
}
status := streamCashValueStatus(field, value)
id := key + "\x00" + currency + "\x00" + source
row, exists := snap.rows[id]
if !exists && len(snap.rows) >= 128 {
snap.truncated = true
return
}
if !exists {
row = AccountStreamRow{Key: key, Currency: currency, Source: source, FirstReceivedAt: at}
}
row.ValueStatus, row.LastReceivedAt = status, at
if row.Callbacks < 1_000_000 {
row.Callbacks++
}
snap.rows[id] = row
}
func (c *Connection) consumeCurrencyProbeError(reqText string, code int, epoch uint64) bool {
reqID, err := strconv.Atoi(reqText)
if err != nil {
return false
}
c.accountMu.Lock()
defer c.accountMu.Unlock()
if retired, ok := c.retiredCurrencyProbes[reqID]; ok && retired == epoch {
return true
}
snap := c.currencyProbes[reqID]
if snap == nil || snap.epoch != epoch {
return false
}
if !snap.terminal && code > 0 && code < 2000 {
snap.brokerErrorCode, snap.terminal = code, true
close(snap.done)
}
return true
}
package ibkr
import (
"errors"
"fmt"
"maps"
"math"
"slices"
"strconv"
"strings"
"time"
)
// TWS reports settled cash for a margin account only as a dated schedule per
// native currency, on the full account-value feed (reqAccountUpdates, and
// reqAccountUpdatesMulti with ledgerAndNLV=false); the lightweight ledger feed
// and reqAccountSummary carry neither it nor a plain SettledCash. The value
// lists each settlement date with the currency's settled balance on that date,
// "20261002:1234.56;20261005:2345.67": the first point is today's settled cash
// and the last is the balance once every pending trade has settled, which is
// the trade-date cash. The "-S" twin is the securities segment's schedule.
const (
settledCashScheduleKey = "SettledCashByDate"
settledCashScheduleSegmentKey = "SettledCashByDate-S"
// maxSettledCashSchedulePoints bounds one parsed schedule. Settlement
// cycles span a few business days, so a longer list is not a schedule.
maxSettledCashSchedulePoints = 16
// maxSettledCashScheduleCells bounds the per-subscription receipt.
maxSettledCashScheduleCells = 32
)
var errSettledCashScheduleInvalid = errors.New("invalid settled-cash schedule")
// SettledCashPoint is one dated settled balance in the schedule's currency.
// Date is the broker's settlement date at UTC midnight.
type SettledCashPoint struct {
Date time.Time
Amount float64
}
// SettledCashSchedule is one native currency's settled-cash schedule from the
// bound account stream. Points come from the account total; SegmentPoints from
// the securities-segment twin when TWS sent one. Status is observed or
// invalid; an invalid schedule keeps no points and names why in Reason.
type SettledCashSchedule struct {
Currency string
Points []SettledCashPoint
SegmentPoints []SettledCashPoint
// ReceivedAt is the oldest receipt among included total/segment components.
// A missing component timestamp remains unknown rather than being refreshed
// by another component's newer callback.
ReceivedAt time.Time
Status string
Reason string
}
// SettledCashScheduleCapture is the schedule receipt of the already-running
// account subscription. StreamStatus uses AccountStreamObservation's status
// vocabulary; only initial_complete describes a fully downloaded stream.
type SettledCashScheduleCapture struct {
StreamStatus string
Truncated bool
Schedules map[string]SettledCashSchedule
}
type settledCashScheduleCell struct {
value string
receivedAt time.Time
}
// parseSettledCashSchedule reads one SettledCashByDate value strictly: every
// point is an exact YYYYMMDD calendar date and a finite amount other than
// IBKR's unset sentinel, dates strictly ascend, and nothing else is accepted.
func parseSettledCashSchedule(value string) ([]SettledCashPoint, error) {
value = strings.TrimSpace(value)
if value == "" {
return nil, fmt.Errorf("%w: empty", errSettledCashScheduleInvalid)
}
var points []SettledCashPoint
for part := range strings.SplitSeq(value, ";") {
if len(points) == maxSettledCashSchedulePoints {
return nil, fmt.Errorf("%w: more than %d points", errSettledCashScheduleInvalid, maxSettledCashSchedulePoints)
}
dateText, amountText, ok := strings.Cut(part, ":")
if !ok || len(dateText) != 8 {
return nil, fmt.Errorf("%w: a point is not date:amount", errSettledCashScheduleInvalid)
}
date, err := time.Parse("20060102", dateText)
if err != nil || date.Format("20060102") != dateText {
return nil, fmt.Errorf("%w: a date is not YYYYMMDD", errSettledCashScheduleInvalid)
}
amount, err := strconv.ParseFloat(amountText, 64)
if err != nil || math.IsNaN(amount) || math.IsInf(amount, 0) || math.Abs(amount) == math.MaxFloat64 {
return nil, fmt.Errorf("%w: an amount is not a finite number", errSettledCashScheduleInvalid)
}
if n := len(points); n > 0 && !date.After(points[n-1].Date) {
return nil, fmt.Errorf("%w: dates do not strictly ascend", errSettledCashScheduleInvalid)
}
points = append(points, SettledCashPoint{Date: date, Amount: amount})
}
return points, nil
}
// observeSettledCashScheduleLocked keeps the latest raw schedule per key and
// native currency. The caller holds accountMu and has bound the row to the
// subscription's account and epoch. A BASE row is the base-currency total of
// every currency and is never kept.
func (r *accountStreamReceipt) observeSettledCashScheduleLocked(field, value, currency string, at time.Time) {
if field != settledCashScheduleKey && field != settledCashScheduleSegmentKey || !concreteAccountSummaryLedgerCurrency(currency) {
return
}
id := field + "\x00" + currency
if _, exists := r.schedules[id]; !exists && len(r.schedules) >= maxSettledCashScheduleCells {
r.schedulesTruncated = true
return
}
if r.schedules == nil {
r.schedules = map[string]settledCashScheduleCell{}
}
r.schedules[id] = settledCashScheduleCell{value: value, receivedAt: at}
}
func (r *accountStreamReceipt) settledCashSchedules() map[string]SettledCashSchedule {
out := map[string]SettledCashSchedule{}
for _, id := range slices.Sorted(maps.Keys(r.schedules)) {
field, currency, _ := strings.Cut(id, "\x00")
cell := r.schedules[id]
s, exists := out[currency]
s.Currency = currency
if s.Status == "" {
s.Status = "observed"
}
if !exists || cell.receivedAt.Before(s.ReceivedAt) {
s.ReceivedAt = cell.receivedAt
}
points, err := parseSettledCashSchedule(cell.value)
switch {
case s.Status != "observed":
case err != nil:
s.Points, s.SegmentPoints, s.Status, s.Reason = nil, nil, "invalid", field+": "+err.Error()
case field == settledCashScheduleKey:
s.Points = points
default:
s.SegmentPoints = points
}
out[currency] = s
}
for currency, s := range out {
if s.Status == "observed" && len(s.Points) == 0 {
s.SegmentPoints, s.Status, s.Reason = nil, "invalid", "only the securities-segment schedule arrived"
out[currency] = s
}
}
return out
}
// CaptureSettledCashSchedulesForSession reads the schedules the already-running
// account subscription delivered within the caller's original binding, under
// the same barriers and scope checks as CaptureAccountStreamObservationForSession.
// It sends no request. Unlike that diagnostic it carries amounts, so it feeds
// the daemon's cash admission and is never logged.
func (c *Connector) CaptureSettledCashSchedulesForSession(binding ConnectorSessionBinding, expected string) *SettledCashScheduleCapture {
if c == nil || !accountCodeConcrete(expected) || binding.connector != c || binding.connection == nil {
return nil
}
c.publicationBarrier.RLock()
defer c.publicationBarrier.RUnlock()
binding.connection.inboundEpochMu.Lock()
defer binding.connection.inboundEpochMu.Unlock()
c.evidenceBarrier.Lock()
defer c.evidenceBarrier.Unlock()
if !c.SessionCurrent(binding) {
return nil
}
conn := binding.connection
conn.portfolioHealthMu.RLock()
health := conn.portfolioHealth
conn.portfolioHealthMu.RUnlock()
conn.accountMu.RLock()
defer conn.accountMu.RUnlock()
r := conn.accountStreamReceipt
out := &SettledCashScheduleCapture{StreamStatus: "no_subscription", Schedules: map[string]SettledCashSchedule{}}
if r.requestedAt.IsZero() {
return out
}
if !accountStreamReceiptBound(r, health, binding, expected) {
out.StreamStatus = "scope_or_generation_changed"
return out
}
out.StreamStatus = accountStreamReceiptStatus(health, r.accountReady)
out.Truncated = r.schedulesTruncated
out.Schedules = r.settledCashSchedules()
return out
}
package ibkr
import (
"math"
"strconv"
"strings"
"time"
)
// AccountSettlementObservation describes completed request callbacks without
// account identifiers or amounts. It cannot authorize a sweep.
type AccountSettlementObservation struct {
AsOf time.Time
Callbacks int
Rows []AccountSettlementRow
}
// AccountSettlementRow preserves the broker label and mathematical finiteness,
// not the value or availability. A finite IBKR unset sentinel remains unavailable.
type AccountSettlementRow struct {
Currency string
Source string
Finite bool
}
func observeAccountSettlementRow(out *AccountSettlementObservation, account, tag, value, currency, expected string, managed []string) {
field, prefixed := splitGatewayLedgerTag(tag)
if field != "SettledCash" {
return
}
disposition := accountSummaryRequestRowDisposition(account, tag, currency, expected, managed)
if disposition == accountSummaryRowReject {
return
}
source := "account_total"
switch {
case disposition == accountSummaryRowAcceptLedger && prefixed:
source = "broker_ledger_label"
case accountCodeConcrete(account) && disposition == accountSummaryRowIgnore && !strings.EqualFold(strings.TrimSpace(account), strings.TrimSpace(expected)):
return // managed sibling
case disposition == accountSummaryRowIgnore:
source = "aggregate_ambiguous"
}
if !concreteAccountSummaryLedgerCurrency(currency) && currency != "BASE" {
currency, source = "", "invalid_currency"
}
parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
out.Callbacks++
// Bound diagnostics even if a provider repeats or floods callbacks.
if len(out.Rows) < 128 {
out.Rows = append(out.Rows, AccountSettlementRow{Currency: currency, Source: source, Finite: err == nil && !math.IsNaN(parsed) && !math.IsInf(parsed, 0)})
}
}
package ibkr
import (
"context"
"errors"
"slices"
"strconv"
"sync"
"time"
)
// ErrAccountSummaryProbeCancelUncertain holds a competing ordinary summary
// when an uncancelled diagnostic may still occupy one broker subscription.
var ErrAccountSummaryProbeCancelUncertain = errors.New("account summary diagnostic cancellation uncertain; only one ordinary subscription available")
// AccountSettlementProbe is a value-free result of a SettledCash-only request.
// It is never parsed into an account snapshot or admitted as cash authority.
type AccountSettlementProbe struct {
Status string
CancelStatus string
BrokerErrorCode int
Observation *AccountSettlementObservation
}
type summaryProbeFlight struct {
cancel context.CancelFunc
done chan struct{}
yielded bool
}
type accountSummarySchedule struct {
mu sync.Mutex
ordinary map[int]uint64
stranded map[int]uint64
pending int
uncertainProbe bool
uncertainCurrencyProbe bool
probe *summaryProbeFlight
}
// Ordinary subscriptions retain their lease until their cancellation is sent.
// End marks a snapshot cutoff, but does not end an IBKR subscription.
func (s *accountSummarySchedule) enterOrdinary(ctx context.Context, reqID int, epoch uint64) error {
s.mu.Lock()
s.pending++
for s.probe != nil {
p := s.probe
p.yielded = true
p.cancel()
s.mu.Unlock()
select {
case <-ctx.Done():
s.mu.Lock()
s.pending--
s.mu.Unlock()
return ctx.Err()
case <-p.done:
}
s.mu.Lock()
}
s.pending--
if err := ctx.Err(); err != nil {
s.mu.Unlock()
return err
}
if s.uncertainProbe && len(s.ordinary) >= 2 {
s.mu.Unlock()
return ErrAccountSummaryProbeCancelUncertain
}
if _, exists := s.ordinary[reqID]; exists {
s.mu.Unlock()
return errors.New("account summary request already active")
}
if s.ordinary == nil {
s.ordinary = make(map[int]uint64)
}
s.ordinary[reqID] = epoch
s.mu.Unlock()
return nil
}
func (s *accountSummarySchedule) leaveOrdinary(reqID int, epoch uint64) {
s.mu.Lock()
defer s.mu.Unlock()
if got, ok := s.ordinary[reqID]; ok && got == epoch {
delete(s.ordinary, reqID)
delete(s.stranded, reqID)
if len(s.stranded) == 0 {
s.uncertainProbe = false
}
}
}
// strandedFor lists the session's subscriptions whose owner returned without
// a sent cancellation; IBKR keeps them open until the socket ends.
func (s *accountSummarySchedule) strandedFor(epoch uint64) []int {
s.mu.Lock()
defer s.mu.Unlock()
var ids []int
for reqID, got := range s.stranded {
if got == epoch {
ids = append(ids, reqID)
}
}
slices.Sort(ids)
return ids
}
func (s *accountSummarySchedule) beginProbe(cancel context.CancelFunc) *summaryProbeFlight {
s.mu.Lock()
defer s.mu.Unlock()
if s.pending != 0 || len(s.ordinary) != 0 || s.probe != nil || s.uncertainCurrencyProbe {
return nil
}
p := &summaryProbeFlight{cancel: cancel, done: make(chan struct{})}
s.probe = p
return p
}
func (s *accountSummarySchedule) finishProbe(p *summaryProbeFlight) bool {
s.mu.Lock()
defer s.mu.Unlock()
yielded := p.yielded
if s.probe == p {
s.probe = nil
close(p.done)
}
return yielded
}
func (s *accountSummarySchedule) reset() {
s.mu.Lock()
defer s.mu.Unlock()
clear(s.ordinary)
clear(s.stranded)
s.uncertainProbe = false
s.uncertainCurrencyProbe = false
if s.probe != nil {
s.probe.cancel()
}
}
// RequestSettlementCashProbe requests exactly SettledCash on the existing
// session. It skips ordinary subscriptions, yields to new authority reads,
// and returns only sanitized callback provenance after End. The response/send
// budget is at most two seconds, followed by at most one second to cancel.
func (c *Connector) RequestSettlementCashProbe(ctx context.Context, timeout time.Duration) (out AccountSettlementProbe) {
out.Status = "unavailable"
out.CancelStatus = "not_started"
if ctx == nil || ctx.Err() != nil {
out.Status = "cancelled"
return
}
origin, ok := c.CaptureSession()
if !ok {
return
}
conn := origin.connection
conn.accountMu.RLock()
quarantineFull := len(conn.retiredSummaryProbes) >= 256
conn.accountMu.RUnlock()
if quarantineFull {
out.Status = "skipped_quarantine_limit"
return
}
expected := accountSummaryExpectedAccount(conn)
if !expected.valid() {
out.Status = "scope_conflict"
return
}
if timeout <= 0 || timeout > 2*time.Second {
timeout = 2 * time.Second
}
probeCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
flight := conn.summarySchedule.beginProbe(cancel)
if flight == nil {
out.Status = "skipped_busy"
return
}
defer func() {
if conn.summarySchedule.finishProbe(flight) && out.Status != "session_changed" && out.Status != "scope_conflict" {
out.Status = "yielded"
out.Observation = nil
}
}()
reqID, err := conn.nextRequestIDForForwarding()
if err != nil {
return
}
defer conn.discardRequestIDReservation(reqID)
if err = conn.claimRequestID(reqID); err != nil {
return
}
conn.accountMu.Lock()
if conn.summarySnapshots == nil {
conn.summarySnapshots = make(map[int]*summarySnapshot)
}
snap := &summarySnapshot{expectedAccount: string(expected), done: make(chan struct{}), diagnosticOnly: true}
conn.summarySnapshots[reqID] = snap
conn.accountMu.Unlock()
sentPossible := false
// Quarantine before cancellation and before releasing the scheduling lane.
// Unknown/late tags for this ID must never seed the account cache.
defer func() {
conn.accountMu.Lock()
if conn.summarySnapshots[reqID] == snap {
delete(conn.summarySnapshots, reqID)
}
if conn.BrokerSessionEpoch() == origin.epoch {
if conn.retiredSummaryProbes == nil {
conn.retiredSummaryProbes = make(map[int]uint64)
}
conn.retiredSummaryProbes[reqID] = origin.epoch
}
conn.accountMu.Unlock()
if !c.SessionCurrent(origin) {
out.Status = "session_changed"
out.Observation = nil
out.CancelStatus = "session_changed"
return
}
if !expected.equal(accountSummaryExpectedAccount(conn)) {
out.Status = "scope_conflict"
out.Observation = nil
}
if !sentPossible {
out.CancelStatus = "not_needed"
return
}
cancelCtx, cancelSend := context.WithTimeout(context.Background(), time.Second)
defer cancelSend()
err := conn.cancelAccountSummaryForEpoch(cancelCtx, reqID, origin.epoch, func() error {
if !c.SessionCurrent(origin) {
return ErrIBKRUnavailable
}
return nil
})
if err != nil {
if !c.SessionCurrent(origin) {
out.Status = "session_changed"
out.CancelStatus = "session_changed"
out.Observation = nil
return
}
out.CancelStatus = "failed"
out.Observation = nil
// An uncertain cancellation cannot admit another diagnostic
// subscription until the next ordinary read re-sends it.
conn.summarySchedule.mu.Lock()
if conn.BrokerSessionEpoch() == origin.epoch {
conn.summarySchedule.uncertainProbe = true
}
conn.summarySchedule.mu.Unlock()
} else {
out.CancelStatus = "sent"
}
}()
guard := func() error {
if !c.SessionCurrent(origin) {
return ErrIBKRUnavailable
}
if !expected.equal(accountSummaryExpectedAccount(conn)) {
return ErrAccountSummaryScopeConflict
}
return nil
}
err = conn.sendMessageWithTypeContextForEpochGuarded(probeCtx, conn.encodeMsg(reqAccountSummary, "1", reqID, "All", "SettledCash"), RequestTypeGeneral, origin.epoch, true, guard)
sentPossible = err == nil || SendDispositionOf(err) != SendDispositionDefinitelyUnsent
if err != nil {
out.Status = "send_error"
if errors.Is(err, context.DeadlineExceeded) {
out.Status = "timeout"
}
if errors.Is(err, context.Canceled) {
out.Status = "cancelled"
}
return
}
select {
case <-snap.done:
conn.accountMu.RLock()
conflict, code := snap.scopeConflict, snap.brokerErrorCode
observation := snap.settlementObservation
observation.Rows = append([]AccountSettlementRow(nil), observation.Rows...)
conn.accountMu.RUnlock()
if err := guard(); err != nil {
out.Status = "session_changed"
if errors.Is(err, ErrAccountSummaryScopeConflict) {
out.Status = "scope_conflict"
}
return
}
if conflict {
out.Status = "scope_conflict"
return
}
if code != 0 {
out.Status = "broker_error"
out.BrokerErrorCode = code
return
}
observation.AsOf = time.Now().UTC()
out.Status = "completed"
if observation.Callbacks == 0 {
out.Status = "completed_empty"
}
out.Observation = &observation
case <-probeCtx.Done():
out.Status = "cancelled"
if errors.Is(probeCtx.Err(), context.DeadlineExceeded) {
out.Status = "timeout"
}
}
return
}
// consumeSummaryProbeError never logs broker prose from a diagnostic request.
func (c *Connection) consumeSummaryProbeError(reqText string, code int, epoch uint64) bool {
reqID, err := strconv.Atoi(reqText)
if err != nil {
return false
}
c.accountMu.Lock()
defer c.accountMu.Unlock()
if retired, ok := c.retiredSummaryProbes[reqID]; ok && retired == epoch {
return true
}
snap := c.summarySnapshots[reqID]
if snap == nil || !snap.diagnosticOnly {
return false
}
select {
case <-snap.done:
return true
default:
}
if code > 0 && code < 2000 {
snap.brokerErrorCode = code
close(snap.done)
}
return true
}
package ibkr
import (
"math"
"slices"
"strconv"
"strings"
"time"
)
// AccountStreamObservation is passive receipt metadata from the existing
// reqAccountUpdates subscription. No balance, account identifier or execution
// authority is exposed. All clocks are local receive/read clocks, not broker
// valuation timestamps; completion does not make individual values fresh.
type AccountStreamObservation struct {
Status, ClockSource string
SocketEpoch uint64
RequestedAt, DownloadEndAt, CompletedAt time.Time
ReadAt, LastCallbackAt time.Time
AccountReady string
AccountReadyAt time.Time
TradingType string
TradingTypeObserved bool
TradingTypeAt time.Time
Rows []AccountStreamRow
RowsTruncated bool
}
// AccountStreamRow describes an allowlisted exact wire key and currency.
// ValueStatus distinguishes an observed real zero from invalid/unset data
// without exposing the amount. Source labels are diagnostic, never authority.
type AccountStreamRow struct {
Key, Currency, Source, ValueStatus string
Callbacks int
FirstReceivedAt, LastReceivedAt time.Time
}
type accountStreamReceipt struct {
account, accountReady, tradingType string
epoch uint64
requestedAt, downloadEndAt time.Time
lastCallbackAt, accountReadyAt time.Time
tradingTypeAt time.Time
tradingTypeObserved bool
rows map[string]AccountStreamRow
truncated bool
// schedules keeps the raw SettledCashByDate values per key and native
// currency for CaptureSettledCashSchedulesForSession.
schedules map[string]settledCashScheduleCell
schedulesTruncated bool
}
// Called with accountMu held by the existing account-value handler. This does
// not admit a row to any financial snapshot or change subscription behavior.
// Settled-cash schedules are kept for the daemon's own cash admission.
func (c *Connection) observeAccountStreamValueLocked(account, key, value, currency string, at time.Time) {
r := &c.accountStreamReceipt
if r.requestedAt.IsZero() || r.epoch != c.BrokerSessionEpoch() || !accountCodeConcrete(r.account) || !strings.EqualFold(account, r.account) {
return
}
r.lastCallbackAt = at
if key == "AccountReady" {
r.accountReady = "invalid"
switch strings.ToLower(strings.TrimSpace(value)) {
case "true":
r.accountReady = "ready"
case "false":
r.accountReady = "not_ready"
}
r.accountReadyAt = at
return
}
if key == "TradingType-S" {
r.tradingTypeObserved, r.tradingTypeAt = true, at
r.tradingType = classifyStreamTradingType(value)
return
}
field, prefixed := splitGatewayLedgerTag(key)
if !streamCashDiagnosticField(field) {
return
}
r.observeSettledCashScheduleLocked(field, value, currency, at)
// The allowlist rejects reserved raw namespaces and suffixed storage keys.
// Invalid currencies retain only the fact of invalidity, never broker text.
source := "unlabelled"
if !concreteAccountSummaryLedgerCurrency(currency) && currency != "BASE" && currency != "" {
currency, source = "", "invalid_currency"
} else if settledCashScheduleField(field) && concreteAccountSummaryLedgerCurrency(currency) {
source = "currency_settlement_schedule"
} else if prefixed && concreteAccountSummaryLedgerCurrency(currency) {
source = "broker_ledger_label"
} else if field == "SettledCash" || field == "TotalCashValue" || strings.HasSuffix(field, "-S") || strings.HasSuffix(field, "-C") {
source = "account_total"
} else if field == "CashBalance" && concreteAccountSummaryLedgerCurrency(currency) {
source = "currency_cash_balance"
}
status := streamCashValueStatus(field, value)
if r.rows == nil {
r.rows = map[string]AccountStreamRow{}
}
id := key + "\x00" + currency
row, exists := r.rows[id]
if !exists && len(r.rows) >= 128 {
r.truncated = true
return
}
if !exists {
row = AccountStreamRow{Key: key, Currency: currency, FirstReceivedAt: at}
}
row.Source, row.ValueStatus, row.LastReceivedAt = source, status, at
if row.Callbacks < 1_000_000 {
row.Callbacks++
}
r.rows[id] = row
}
func streamCashDiagnosticField(field string) bool {
switch field {
case "SettledCash", "CashBalance", "TotalCashBalance", "TotalCashValue", "TotalCashValue-S", "TotalCashValue-C", "EquityWithLoanValue-S", "EquityWithLoanValue-C", "FuturesPNL",
settledCashScheduleKey, settledCashScheduleSegmentKey:
return true
}
return false
}
func settledCashScheduleField(field string) bool {
return field == settledCashScheduleKey || field == settledCashScheduleSegmentKey
}
// streamCashValueStatus classifies a diagnostic value without exposing it: a
// schedule is observed only when it parses strictly; a number is observed,
// unset (IBKR's sentinel) or invalid.
func streamCashValueStatus(field, value string) string {
if settledCashScheduleField(field) {
if _, err := parseSettledCashSchedule(value); err != nil {
return "invalid"
}
return "observed"
}
parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
switch {
case err != nil || math.IsNaN(parsed) || math.IsInf(parsed, 0):
return "invalid"
case parsed == math.MaxFloat64:
return "unset"
}
return "observed"
}
func classifyStreamTradingType(value string) string {
switch strings.ToUpper(strings.TrimSpace(value)) {
case "CASH":
return "cash"
case "MARGIN", "REGT", "PORTFOLIO", "REG-T-MARGIN", "REG T MARGIN", "REG-T MARGIN", "PORTFOLIO MARGIN", "PORTFOLIO-MARGIN":
return "margin"
default:
// Recognize display labels only, never ownership labels or internal tokens.
return "unknown"
}
}
func (c *Connection) observeAccountStreamEnd(account string, at time.Time) {
c.accountMu.Lock()
defer c.accountMu.Unlock()
r := &c.accountStreamReceipt
if !r.requestedAt.IsZero() && r.epoch == c.BrokerSessionEpoch() && accountCodeConcrete(r.account) && strings.EqualFold(strings.TrimSpace(account), r.account) && r.downloadEndAt.IsZero() {
r.downloadEndAt = at
}
}
// CaptureAccountStreamObservationForSession inspects the already-running
// subscription within the caller's original binding. It sends no request and
// never samples a successor session as a substitute for that binding.
func (c *Connector) CaptureAccountStreamObservationForSession(binding ConnectorSessionBinding, expected string) *AccountStreamObservation {
if c == nil || !accountCodeConcrete(expected) || binding.connector != c || binding.connection == nil {
return nil
}
c.publicationBarrier.RLock()
defer c.publicationBarrier.RUnlock()
binding.connection.inboundEpochMu.Lock()
defer binding.connection.inboundEpochMu.Unlock()
c.evidenceBarrier.Lock()
defer c.evidenceBarrier.Unlock()
if !c.SessionCurrent(binding) {
return nil
}
conn := binding.connection
conn.portfolioHealthMu.RLock()
health := conn.portfolioHealth
conn.portfolioHealthMu.RUnlock()
conn.accountMu.RLock()
defer conn.accountMu.RUnlock()
r := conn.accountStreamReceipt
out := &AccountStreamObservation{Status: "no_subscription", ClockSource: "local_receive", SocketEpoch: binding.epoch, ReadAt: time.Now().UTC(), AccountReady: "unknown", TradingType: "unknown", Rows: []AccountStreamRow{}}
if r.requestedAt.IsZero() {
return out
}
if !accountStreamReceiptBound(r, health, binding, expected) {
out.Status = "scope_or_generation_changed"
return out
}
out.RequestedAt, out.DownloadEndAt, out.CompletedAt = r.requestedAt, r.downloadEndAt, health.InitialCompletedAt
out.LastCallbackAt = r.lastCallbackAt
out.AccountReadyAt, out.TradingTypeAt, out.TradingTypeObserved = r.accountReadyAt, r.tradingTypeAt, r.tradingTypeObserved
if r.accountReady != "" {
out.AccountReady = r.accountReady
}
if r.tradingType != "" {
out.TradingType = r.tradingType
}
out.RowsTruncated = r.truncated
for _, row := range r.rows {
out.Rows = append(out.Rows, row)
}
slices.SortFunc(out.Rows, func(a, b AccountStreamRow) int {
return strings.Compare(a.Key+"\x00"+a.Currency, b.Key+"\x00"+b.Currency)
})
out.Status = accountStreamReceiptStatus(health, r.accountReady)
return out
}
// accountStreamReceiptBound reports whether the receipt belongs to the
// binding's socket, the expected account and the current subscription.
func accountStreamReceiptBound(r accountStreamReceipt, health PortfolioStreamHealth, binding ConnectorSessionBinding, expected string) bool {
return r.epoch == binding.epoch && strings.EqualFold(r.account, expected) && strings.EqualFold(health.Account, expected) &&
(r.requestedAt.Equal(health.RequestedAt) || !health.ScopeConflictAt.IsZero() || !health.InvalidPayloadAt.IsZero())
}
func accountStreamReceiptStatus(health PortfolioStreamHealth, accountReady string) string {
switch {
case !health.ScopeConflictAt.IsZero():
return "scope_conflict"
case !health.InvalidPayloadAt.IsZero():
return "invalid_payload"
case !health.DownloadShortAt.IsZero():
return "download_short"
case accountReady == "not_ready" || accountReady == "invalid":
return "account_not_ready"
case !health.InitialCompletedAt.IsZero():
return "initial_complete"
}
return "initial_pending"
}
package ibkr
import (
"context"
"errors"
"fmt"
"maps"
"math"
"slices"
"strconv"
"strings"
"time"
)
// ErrIBKRUnavailable is returned by request methods when the connector cannot
// reach IBKR (gateway disconnected, connector not started). Callers serving
// trading-critical reads (account values, fresh quotes) should refuse rather
var ErrIBKRUnavailable = errors.New("IBKR connection unavailable")
// ErrAccountSummaryScopeConflict means a one-shot account-summary request
// observed a row outside its expected single-account scope. The only aggregate
var ErrAccountSummaryScopeConflict = errors.New("account summary account scope conflict")
// RawAccountSummary is a point-in-time view of the account values returned by
// IBKR. Currency-denominated top-level fields use the account's base-currency
// row when IBKR supplied one. If a base row is absent, the parser selects a
// currency-specific row deterministically. Currency records the first such
// fallback; Raw preserves the currency suffix for every field.
//
// Fields are pointers when their absence is meaningful (IBKR may omit tags
// the user does not have permission for, e.g., margin fields on a cash
// account, or LookAhead* on cash). Callers must check for nil before
// dereferencing.
type RawAccountSummary struct {
AccountID string
AccountType string
NetLiquidation *float64
BuyingPower *float64
AvailableFunds *float64
ExcessLiquidity *float64
TotalCashValue *float64
MaintenanceMargin *float64
InitMarginReq *float64
GrossPositionValue *float64
UnrealizedPnL *float64
RealizedPnL *float64
Cushion *float64
LookAheadInitMargin *float64
LookAheadMaintMargin *float64
LookAheadAvailable *float64
LookAheadExcess *float64
Currency string
// BaseCurrency and its provenance are intentionally distinct from
// Currency. Currency is the legacy deterministic fallback used for numeric
// rows; it must never be treated as proof of the account's base unit.
BaseCurrency string
BaseCurrencyProvenance AccountBaseCurrencyProvenance
// CurrencyLedger holds the per-currency rollup the gateway emitted
// accounts. The "BASE" pseudo-currency entry IBKR emits is dropped
CurrencyLedger map[string]CurrencyLedger
// SettlementObservation is sanitized callback provenance, never cash authority.
SettlementObservation *AccountSettlementObservation
AsOf time.Time
// Raw is the unparsed map from IBKR keyed exactly as the gateway returned it
// diagnostic and forward-compatibility purposes.
Raw map[string]string
}
// AccountBaseCurrencyProvenance identifies the broker evidence used to prove
// an account summary's base currency.
type AccountBaseCurrencyProvenance string
const (
// AccountBaseCurrencyUnknown means no eligible broker field proved the base currency.
AccountBaseCurrencyUnknown AccountBaseCurrencyProvenance = "unknown"
// AccountBaseCurrencyExplicitTag means the dedicated Currency field supplied the value.
AccountBaseCurrencyExplicitTag AccountBaseCurrencyProvenance = "explicit_currency_tag"
// AccountBaseCurrencyValueSuffix means an allowlisted aggregate value suffix supplied it.
AccountBaseCurrencyValueSuffix AccountBaseCurrencyProvenance = "account_value_suffix"
// AccountBaseCurrencyUnitExchangeRate remains for wire/read-model
// compatibility only. A unit exchange rate is not proof of the account's
// base currency and accountBaseCurrencyEvidence never emits it.
AccountBaseCurrencyUnitExchangeRate AccountBaseCurrencyProvenance = "unit_exchange_rate"
)
// Proven reports whether a broker field established the base currency. Only
// or trades on a base-currency label must gate on this rather than on a
// non-empty currency string: RawAccountSummary.Currency is the legacy numeric-row
func (p AccountBaseCurrencyProvenance) Proven() bool {
return p == AccountBaseCurrencyExplicitTag || p == AccountBaseCurrencyValueSuffix
}
// accountBaseCurrencyValueTags is the closed allowlist of ordinary aggregate
// account-summary values whose three-letter suffix may prove the account base
// describe held currencies, not the account's base unit. UnrealizedPnL and
// RealizedPnL are ordinary summary tags too, but $LEDGER:ALL reuses those exact
var accountBaseCurrencyValueTags = []string{
"NetLiquidation",
"BuyingPower",
"AvailableFunds",
"ExcessLiquidity",
"TotalCashValue",
"MaintMarginReq",
"MaintenanceMarginReq",
"InitMarginReq",
"GrossPositionValue",
"Cushion",
"LookAheadInitMarginReq",
"LookAheadMaintMarginReq",
"LookAheadAvailableFunds",
"LookAheadExcessLiquidity",
}
// CurrencyLedger is one non-base-currency IBKR $LEDGER row. Monetary values
// are denominated in that row's currency, not converted to the account base
// value; the wire format does not preserve that distinction here.
type CurrencyLedger struct {
NetLiquidationObserved bool
ExchangeRateObserved bool
NetLiquidationByCurrency float64
CashBalance float64
StockMarketValueText string
OptionMarketValueText string
StockMarketValue float64
OptionMarketValue float64
UnrealizedPnL float64
RealizedPnL float64
ExchangeRate float64
// SettledCash is the row's SettledCash field and SettledCashObserved
// says the gateway sent one. That $LEDGER:ALL carries SettledCash per
// currency is an assumption the cash sweep verifies after install
// (internal-docs/design/cash-sweep.md); absent, it stays unobserved.
SettledCash float64
SettledCashObserved bool
}
// accountSummaryLedgerKeyPrefix namespaces $LEDGER rows admitted into a
// one-shot summary snapshot apart from account-level rows. $LEDGER:ALL reuses
// account-level tag names (UnrealizedPnL, RealizedPnL) for every held
// currency, and the account-level tags themselves arrive suffixed with the
// account's base currency — so in one flat `tag_currency` namespace the
// account total and the base-currency ledger slice share a key and the last
// writer wins, in wire-arrival order. With the namespace, lookupAccountValue
// reads only account-level keys and the ledger extraction reads only ledger
// keys. This is a storage namespace, not a wire form: since gateway 10.47,
// wire tags themselves may begin with '$' ("$LEDGER-…"), so the '$' alone
// guarantees nothing — the ':' separator is what keeps this prefix apart
// from every wire tag, and admission canonicalizes wire-prefixed tags before
// storing, so namespaced keys always carry the bare field name.
const accountSummaryLedgerKeyPrefix = "$LEDGER:"
// gatewayLedgerTagPrefix is the wire dialect Gateway/TWS 10.47 introduces: an
// client's Account=All inference — both routes land the row in the same
const gatewayLedgerTagPrefix = "$LEDGER-"
// splitGatewayLedgerTag strips the 10.47 wire prefix from a tag. It must run
// round, every 10.47 ledger row fails the closed allowlist and vanishes
func splitGatewayLedgerTag(tag string) (field string, wirePrefixed bool) {
if rest, ok := strings.CutPrefix(tag, gatewayLedgerTagPrefix); ok {
return rest, true
}
return tag, false
}
// dualUseSummaryTag reports whether field is simultaneously an ordinary
// account-level summary tag and a $LEDGER per-currency field. These are the
// legacy-shaped map that speaks the 10.47 wire prefix, an unprefixed such
// key is the account-level row — 10.47 finally disambiguates the two — and
// must not be read as a ledger slice.
func dualUseSummaryTag(field string) bool {
switch field {
case "UnrealizedPnL", "RealizedPnL":
return true
}
return false
}
// accountLevelBareTag reports whether a bare (unprefixed) field is always an
// account-level value, never a ledger slice. SettledCash is not in IBKR's
// $LEDGER cash-balance set; reqAccountUpdates sends it once, in the base
// currency, as the account's settled cash across every currency, so its
// bare `SettledCash_<BASE>` key must never read as that currency's settled
// cash. Only a wire-prefixed or ledger-namespaced SettledCash is a slice.
func accountLevelBareTag(field string) bool {
return field == "SettledCash"
}
// currencyLedgerField reports whether tag is one of the closed set of
// be projected into the typed ledger must never enter a one-account snapshot.
func currencyLedgerField(tag string) bool {
switch tag {
case "NetLiquidationByCurrency",
"CashBalance",
"SettledCash",
"StockMarketValue",
"OptionMarketValue",
"UnrealizedPnL",
"RealizedPnL",
"ExchangeRate":
return true
default:
return false
}
}
const (
defaultAccountSummaryTimeout = 5 * time.Second
// $LEDGER:ALL asks IBKR to emit per-currency rows (one block per
// MaintenanceMarginReq alias emitted by some gateway/account combinations.
accountSummaryTags = "NetLiquidation,BuyingPower,AvailableFunds,ExcessLiquidity,TotalCashValue,SettledCash,MaintMarginReq,InitMarginReq,GrossPositionValue,UnrealizedPnL,RealizedPnL,Cushion,LookAheadInitMarginReq,LookAheadMaintMarginReq,LookAheadAvailableFunds,LookAheadExcessLiquidity,AccountType,$LEDGER:ALL"
)
// RequestAccountSummary issues a synchronous reqAccountSummary request and
// returns a caller-owned parsed snapshot. ctx must be non-nil. The call blocks
// until the gateway emits accountSummaryEnd, ctx is cancelled, or timeout
// - Returns ErrIBKRUnavailable immediately if the connector is not
// - On timeout the request is cancelled (cancelAccountSummary sent) so the
// - timeout <= 0 falls back to defaultAccountSummaryTimeout (5s).
//
// request ID and normally reads only that request's rows. If the gateway emits
// streaming account-updates cache.
func (c *Connector) RequestAccountSummary(ctx context.Context, timeout time.Duration) (*RawAccountSummary, error) {
summary, _, err := c.RequestAccountSummaryWithProvenance(ctx, timeout)
return summary, err
}
// AccountSummaryProvenance identifies whether a returned account snapshot was
// cache. Callers that require current evidence must accept only Request.
type AccountSummaryProvenance string
const (
// AccountSummaryProvenanceRequest means the one-shot request supplied a
// complete row set and matching end marker.
AccountSummaryProvenanceRequest AccountSummaryProvenance = "request"
// AccountSummaryProvenanceCachedFallback means an unstamped streaming
AccountSummaryProvenanceCachedFallback AccountSummaryProvenance = "cached_fallback"
)
// RequestAccountSummaryWithProvenance preserves RequestAccountSummary's
// for this request. CachedFallback has no trustworthy source receipt even
func (c *Connector) RequestAccountSummaryWithProvenance(ctx context.Context, timeout time.Duration) (*RawAccountSummary, AccountSummaryProvenance, error) {
if !c.isConnected() {
return nil, "", ErrIBKRUnavailable
}
if timeout <= 0 {
timeout = defaultAccountSummaryTimeout
}
c.mu.RLock()
conn := c.conn
c.mu.RUnlock()
if conn == nil || !conn.IsConnected() {
return nil, "", ErrIBKRUnavailable
}
origin := ConnectorSessionBinding{connector: c, connection: conn, epoch: conn.BrokerSessionEpoch()}
expectedAccount := accountSummaryExpectedAccount(conn)
if !expectedAccount.valid() {
return nil, "", ErrAccountSummaryScopeConflict
}
reqID, err := conn.nextRequestIDForForwarding()
if err != nil {
return nil, "", err
}
defer conn.discardRequestIDReservation(reqID)
if err := conn.requestAccountSummaryForAccountContext(ctx, reqID, accountSummaryTags, string(expectedAccount)); err != nil {
return nil, "", fmt.Errorf("request account summary: %w", err)
}
// Always cancel the subscription on the way out: end-of-stream means IBKR
defer func() {
if conn.IsConnected() && conn.BrokerSessionEpoch() == origin.epoch {
cancelCtx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if cancelErr := conn.cancelAccountSummaryForEpoch(cancelCtx, reqID, origin.epoch, func() error {
if !c.SessionCurrent(origin) {
return ErrIBKRUnavailable
}
return nil
}); cancelErr != nil {
connectorLogger.Debugf("CancelAccountSummary(reqID=%d) failed: %v", reqID, cancelErr)
}
}
}()
raw, observation, err := conn.awaitAccountSummarySnapshotContext(ctx, reqID, timeout)
if err != nil {
return nil, "", fmt.Errorf("await account summary end: %w", err)
}
// Keep normal reads isolated from concurrent streaming account updates. An
var fallback map[string]string
if len(raw) == 0 && accountSummaryCacheAdmissible(conn, expectedAccount) {
fallback = conn.GetAccountSummary()
}
summary, provenance, err := accountSummaryFromRequestRows(raw, fallback, string(expectedAccount))
if err == nil && provenance == AccountSummaryProvenanceRequest && c.SessionCurrent(origin) {
observation.AsOf = summary.AsOf
summary.SettlementObservation = &observation
}
return summary, provenance, err
}
// accountCode is one concrete broker account code — the only identity an
// account-scoped request may carry or a snapshot may be stamped with. The zero
// value means "no account". newAccountCode is the only way to obtain a non-zero
// one, so the aggregates that keep being mistaken for an account — a
// comma-joined managedAccounts list, "All", a blank field — cannot reach a
// consumer that asked for one account. The underlying type stays string, so
type accountCode string
// newAccountCode returns the concrete account named by raw, and whether raw
// named one at all. It is accountCodeConcrete's validation with a value
// attached: a caller that forgets to check ok gets the zero accountCode rather
// than an aggregate wearing an account's type.
func newAccountCode(raw string) (accountCode, bool) {
raw = strings.TrimSpace(raw)
if !accountCodeConcrete(raw) {
return "", false
}
return accountCode(raw), true
}
func (a accountCode) valid() bool { return a != "" }
// equal compares two account codes the way the broker does — IBKR echoes codes
func (a accountCode) equal(other accountCode) bool {
return a.valid() && other.valid() && strings.EqualFold(string(a), string(other))
}
// managedAccountSet is the inventory of accounts one login carries, as
// accountCode: every bug in this family came from one field being asked to mean
// both "the account" and "the accounts this login can reach".
type managedAccountSet []accountCode
func newManagedAccountSet(codes []string) managedAccountSet {
out := make(managedAccountSet, 0, len(codes))
for _, raw := range codes {
if code, ok := newAccountCode(raw); ok {
out = append(out, code)
}
}
return out
}
func (m managedAccountSet) contains(a accountCode) bool {
return slices.ContainsFunc(m, a.equal)
}
// multiAccount reports whether the login carries more than one account, which
// pinned account.
func (m managedAccountSet) multiAccount() bool { return len(m) > 1 }
// accountSummaryCacheAdmissible reports whether the connection's streaming
// account cache may be read as expectedAccount's values.
// That cache is unstamped: one login's accounts share the map, no row keeps its
// account attribution, and nothing clears it when the account-updates
// subscription rebinds. So a read can never prove after the fact which account
// its rows describe — only the session's own identity can. A login carrying one
// account observes that concrete code and the cache can hold nothing else; a
// multi-account login observes the managedAccounts aggregate and never
// publish a sibling's values under the pinned account (issue #14).
func accountSummaryCacheAdmissible(conn *Connection, expectedAccount accountCode) bool {
if conn == nil || !expectedAccount.valid() {
return false
}
observed, ok := newAccountCode(conn.GetAccountCode())
return ok && observed.equal(expectedAccount)
}
// accountSummaryExpectedAccount resolves the single account a one-shot
// summary may represent. The configured pin is authoritative when the broker
// session confirms it as a managed account; the aggregate managedAccounts
// string itself is never a request scope. An unpinned session remains usable
// only when the broker exposes one concrete account.
func accountSummaryExpectedAccount(conn *Connection) accountCode {
if conn == nil {
return ""
}
observed, observedOK := newAccountCode(conn.GetAccountCode())
if conn.config != nil && strings.TrimSpace(conn.config.Account) != "" {
configured, ok := newAccountCode(conn.config.Account)
if !ok {
return ""
}
if configured.equal(observed) || conn.managedAccountMember(string(configured)) {
return configured
}
return ""
}
if observedOK {
return observed
}
return ""
}
func accountSummaryFromRequestRows(raw, fallback map[string]string, accountID string) (*RawAccountSummary, AccountSummaryProvenance, error) {
provenance := AccountSummaryProvenanceRequest
if len(raw) == 0 {
raw = fallback
provenance = AccountSummaryProvenanceCachedFallback
}
return parseAccountSummary(raw, accountID), provenance, nil
}
// parseAccountSummary converts the IBKR-format key/value map (as returned by
// Connection.GetAccountSummary) into a typed AccountSummary. The IBKR key
// format is `<tag>` for the account base currency and `<tag>_<currency>` for
// The $LEDGER:ALL tag (in accountSummaryTags) instructs the gateway to also
func parseAccountSummary(raw map[string]string, accountID string) *RawAccountSummary {
// Resolve the base currency before the value bindings: it is what tells an
// account-level tag apart from a same-named $LEDGER row.
baseCurrency, baseProvenance := accountBaseCurrencyEvidence(raw)
summary := &RawAccountSummary{
AccountID: accountID,
AsOf: time.Now().UTC(),
CurrencyLedger: make(map[string]CurrencyLedger),
Raw: make(map[string]string, len(raw)),
}
maps.Copy(summary.Raw, raw)
// Each binding accepts one or more accepted tag names — the parser
// canonical and legacy names interchangeable so a gateway that still
tagBindings := []struct {
tags []string
field **float64
}{
{[]string{"NetLiquidation"}, &summary.NetLiquidation},
{[]string{"BuyingPower"}, &summary.BuyingPower},
{[]string{"AvailableFunds"}, &summary.AvailableFunds},
{[]string{"ExcessLiquidity"}, &summary.ExcessLiquidity},
{[]string{"TotalCashValue"}, &summary.TotalCashValue},
{[]string{"MaintMarginReq", "MaintenanceMarginReq"}, &summary.MaintenanceMargin},
{[]string{"InitMarginReq"}, &summary.InitMarginReq},
{[]string{"GrossPositionValue"}, &summary.GrossPositionValue},
{[]string{"UnrealizedPnL"}, &summary.UnrealizedPnL},
{[]string{"RealizedPnL"}, &summary.RealizedPnL},
{[]string{"Cushion"}, &summary.Cushion},
{[]string{"LookAheadInitMarginReq"}, &summary.LookAheadInitMargin},
{[]string{"LookAheadMaintMarginReq"}, &summary.LookAheadMaintMargin},
{[]string{"LookAheadAvailableFunds"}, &summary.LookAheadAvailable},
{[]string{"LookAheadExcessLiquidity"}, &summary.LookAheadExcess},
}
for _, b := range tagBindings {
for _, tag := range b.tags {
val, currency, ok := lookupAccountValue(raw, tag, baseCurrency)
if !ok {
continue
}
parsed, err := strconv.ParseFloat(strings.TrimSpace(val), 64)
if err != nil {
continue
}
*b.field = &parsed
if summary.Currency == "" && currency != "" {
summary.Currency = currency
}
break
}
}
// AccountType is a string tag (e.g. "INDIVIDUAL", "IB-MARGIN") rather
if v, _, ok := lookupAccountValue(raw, "AccountType", baseCurrency); ok {
summary.AccountType = strings.TrimSpace(v)
}
summary.CurrencyLedger = extractCurrencyLedger(raw)
summary.BaseCurrency, summary.BaseCurrencyProvenance = baseCurrency, baseProvenance
return summary
}
func accountBaseCurrencyEvidence(raw map[string]string) (string, AccountBaseCurrencyProvenance) {
if rawCurrency := strings.ToUpper(strings.TrimSpace(raw["Currency"])); len(rawCurrency) == 3 && rawCurrency != "BASE" {
return rawCurrency, AccountBaseCurrencyExplicitTag
}
valueSuffix := ""
for _, tag := range accountBaseCurrencyValueTags {
prefix := tag + "_"
for key := range raw {
if !strings.HasPrefix(key, prefix) {
continue
}
ccy := strings.ToUpper(strings.TrimSpace(strings.TrimPrefix(key, prefix)))
if len(ccy) != 3 || ccy == "BASE" {
continue
}
if valueSuffix != "" && valueSuffix != ccy {
return "", AccountBaseCurrencyUnknown
}
valueSuffix = ccy
}
}
if valueSuffix != "" {
return valueSuffix, AccountBaseCurrencyValueSuffix
}
return "", AccountBaseCurrencyUnknown
}
// CurrencyLedgerSnapshot returns a caller-owned map derived from the
// connector's streaming account-summary cache. It neither blocks nor issues
func (c *Connector) CurrencyLedgerSnapshot() map[string]CurrencyLedger {
raw := c.AccountSummaryRaw()
return extractCurrencyLedger(raw)
}
// AccountSummaryRaw returns a defensive copy of the connector's current raw
// account-summary cache. The map uses IBKR keys: bare tags for base-currency
// values and `<tag>_<currency>` for currency-specific values.
//
// It is empty when no connection or observations are available, and also
// whenever the cache cannot be attributed to the session's expected account —
// the same admissibility rule CachedAccountSummary applies, because it is the
// same unstamped map. Emptiness alone does not describe connection state. The
// method is safe to call concurrently with streaming cache updates.
func (c *Connector) AccountSummaryRaw() map[string]string {
c.mu.RLock()
conn := c.conn
c.mu.RUnlock()
if !accountSummaryCacheAdmissible(conn, accountSummaryExpectedAccount(conn)) {
return map[string]string{}
}
return conn.GetAccountSummary()
}
// CachedAccountSummary returns a caller-owned typed snapshot of the connector's
// streaming account-summary cache, labeled with the account it belongs to. It
// does not issue gateway traffic and returns nil until at least one core
// account value has been observed, or whenever the cache is not admissible for
// the session's expected account. The method is safe to call concurrently with
// streaming cache updates.
func (c *Connector) CachedAccountSummary() *RawAccountSummary {
c.mu.RLock()
conn := c.conn
c.mu.RUnlock()
account := accountSummaryExpectedAccount(conn)
if !accountSummaryCacheAdmissible(conn, account) {
return nil
}
raw := conn.GetAccountSummary()
if len(raw) == 0 {
return nil
}
summary := parseAccountSummary(raw, string(account))
if summary.NetLiquidation == nil && summary.BuyingPower == nil &&
summary.AvailableFunds == nil && summary.TotalCashValue == nil {
return nil
}
return summary
}
// extractCurrencyLedger aggregates the raw map's $LEDGER rows by currency.
// The "BASE" pseudo-currency entry IBKR also emits is dropped — it duplicates
// row under accountSummaryLedgerKeyPrefix; in such a map the namespace is the
// only ledger source, because every legacy-shaped `<field>_<CCY>` key left in
// it is an account-level row (the base-suffixed UnrealizedPnL total, for one)
// that must not be misread as a ledger slice. The streaming cache predates
// the namespace and keeps the legacy flat shape, so a map carrying no
// namespaced ledger key falls back to the legacy scan.
func extractCurrencyLedger(raw map[string]string) map[string]CurrencyLedger {
if out, ok := namespacedCurrencyLedger(raw); ok {
return out
}
return legacyCurrencyLedger(raw)
}
// namespacedCurrencyLedger reads the internal one-shot ledger namespace. ok
// reports whether the namespace held at least one recognizable ledger key —
// distinct from the returned map being non-empty, because the zero-balance
// filter may drop every recognized row and that must still count as "the
// namespace answered" rather than falling back to a scan that would
// misattribute account-level rows. Namespaced keys always carry the bare
// canonical field name: admission strips the 10.47 wire prefix before
// storing.
func namespacedCurrencyLedger(raw map[string]string) (map[string]CurrencyLedger, bool) {
ledger := map[string]*CurrencyLedger{}
recognized := false
for k, v := range raw {
if !strings.HasPrefix(k, accountSummaryLedgerKeyPrefix) {
continue
}
field, ccy, ok := splitLedgerKey(strings.TrimPrefix(k, accountSummaryLedgerKeyPrefix))
if !ok {
continue
}
recognized = true
assignCurrencyLedgerValue(ledger, field, ccy, v)
}
return finishCurrencyLedger(ledger), recognized
}
// legacyCurrencyLedger parses a flat map with no internal namespace: the
// wire dialects meet here — bare tags (pre-10.47) and "$LEDGER-"-prefixed
// evidence, never by map iteration order:
// - A wire-prefixed key is the gateway's own statement of ledger origin and
// the prefix anywhere is the account-level row, not a ledger slice, and
// never serves as ledger.
func legacyCurrencyLedger(raw map[string]string) map[string]CurrencyLedger {
type cell struct {
bare, prefixed string
hasBare, hasPrefixed bool
}
cells := map[string]map[string]*cell{} // ccy → canonical field → forms seen
anyPrefixed := false
for k, v := range raw {
if strings.HasPrefix(k, accountSummaryLedgerKeyPrefix) {
// Defensive: the legacy scan must never re-read namespaced rows.
continue
}
idx := strings.LastIndexByte(k, '_')
if idx <= 0 || idx == len(k)-1 {
continue
}
field, wirePrefixed := splitGatewayLedgerTag(k[:idx])
ccy := k[idx+1:]
if !currencyLedgerField(field) || ccy == "" || ccy == "BASE" {
continue
}
if wirePrefixed {
anyPrefixed = true
}
byField := cells[ccy]
if byField == nil {
byField = map[string]*cell{}
cells[ccy] = byField
}
cl := byField[field]
if cl == nil {
cl = &cell{}
byField[field] = cl
}
if wirePrefixed {
cl.prefixed, cl.hasPrefixed = v, true
} else {
cl.bare, cl.hasBare = v, true
}
}
ledger := map[string]*CurrencyLedger{}
for ccy, byField := range cells {
for field, cl := range byField {
switch {
case cl.hasPrefixed && cl.hasBare:
if dualUseSummaryTag(field) || accountLevelBareTag(field) {
// Expected coexistence, not a duplicate: the bare form is
// the account-level total, the prefixed form the slice.
assignCurrencyLedgerValue(ledger, field, ccy, cl.prefixed)
continue
}
if cl.bare == cl.prefixed {
assignCurrencyLedgerValue(ledger, field, ccy, cl.prefixed)
continue
}
connectorLogger.Warnf("account summary ledger: %s_%s arrived in both the bare and %s form with different values; refusing the ambiguous duplicate", field, ccy, gatewayLedgerTagPrefix)
case cl.hasPrefixed:
assignCurrencyLedgerValue(ledger, field, ccy, cl.prefixed)
case accountLevelBareTag(field):
// The account's settled cash in its base currency, not a slice.
case dualUseSummaryTag(field) && anyPrefixed:
// In a prefixed-dialect map a bare dual-use key is the
// account-level row; reading it as ledger would resurrect the
// exact confusion the dialect exists to end.
default:
assignCurrencyLedgerValue(ledger, field, ccy, cl.bare)
}
}
}
return finishCurrencyLedger(ledger)
}
// splitLedgerKey parses a namespace-stripped `<field>_<CCY>` ledger key.
func splitLedgerKey(k string) (field, ccy string, ok bool) {
idx := strings.LastIndexByte(k, '_')
if idx <= 0 || idx == len(k)-1 {
return "", "", false
}
field, ccy = k[:idx], k[idx+1:]
if !currencyLedgerField(field) || ccy == "" || ccy == "BASE" {
return "", "", false
}
return field, ccy, true
}
// assignCurrencyLedgerValue parses and stores one canonical ledger field.
func assignCurrencyLedgerValue(ledger map[string]*CurrencyLedger, field, ccy, val string) {
parsed, err := strconv.ParseFloat(strings.TrimSpace(val), 64)
if err != nil || (field == "SettledCash" && (math.IsNaN(parsed) || math.IsInf(parsed, 0) || parsed == math.MaxFloat64)) {
return
}
row, ok := ledger[ccy]
if !ok {
row = &CurrencyLedger{}
ledger[ccy] = row
}
switch field {
case "NetLiquidationByCurrency":
row.NetLiquidationByCurrency, row.NetLiquidationObserved = parsed, true
case "CashBalance":
row.CashBalance = parsed
case "SettledCash":
row.SettledCash, row.SettledCashObserved = parsed, true
case "StockMarketValue":
row.StockMarketValue, row.StockMarketValueText = parsed, strings.TrimSpace(val)
case "OptionMarketValue":
row.OptionMarketValue, row.OptionMarketValueText = parsed, strings.TrimSpace(val)
case "UnrealizedPnL":
row.UnrealizedPnL = parsed
case "RealizedPnL":
row.RealizedPnL = parsed
case "ExchangeRate":
row.ExchangeRate, row.ExchangeRateObserved = parsed, true
}
}
// finishCurrencyLedger applies the zero-balance filter: currencies appearing
// only in margin-related fields (no NetLiquidationByCurrency, cash, or
func finishCurrencyLedger(ledger map[string]*CurrencyLedger) map[string]CurrencyLedger {
out := make(map[string]CurrencyLedger, len(ledger))
for ccy, row := range ledger {
if row == nil {
continue
}
if row.NetLiquidationByCurrency == 0 && row.CashBalance == 0 &&
row.StockMarketValue == 0 && row.OptionMarketValue == 0 {
continue
}
out[ccy] = *row
}
return out
}
// lookupAccountValue returns the value, currency, and ok flag for a tag.
// IBKR encodes BASE-currency values under the bare tag and non-BASE values
// under `<tag>_<currency>`. The bare form wins; then the account's own base
// currency when one is proven; then, only when the tag has exactly one
// This function reads only account-level keys: one-shot snapshots store every
// admitted $LEDGER row under accountSummaryLedgerKeyPrefix, which no tag
// prefix can match, so an account total can no longer be displaced by a
// legacy-shaped streaming cache, where $LEDGER:ALL's reuse of UnrealizedPnL
// namespace — the same ambiguity accountBaseCurrencyValueTags already refuses
// suffix resolved a CHF/EUR/USD account's UnrealizedPnL to the CHF slice
// rather than the account total.
func lookupAccountValue(raw map[string]string, tag, baseCcy string) (string, string, bool) {
if v, ok := raw[tag]; ok {
return v, "", true
}
prefix := tag + "_"
baseCcy = strings.ToUpper(strings.TrimSpace(baseCcy))
var bestKey, baseKey string
matches := 0
for k := range raw {
if !strings.HasPrefix(k, prefix) {
continue
}
matches++
if bestKey == "" || k < bestKey {
bestKey = k
}
if baseCcy != "" && strings.EqualFold(strings.TrimPrefix(k, prefix), baseCcy) {
baseKey = k
}
}
if baseKey != "" {
return raw[baseKey], strings.TrimPrefix(baseKey, prefix), true
}
if bestKey == "" {
return "", "", false
}
// Several currency rows and no base-currency one to choose among them: for
// rather than one currency's slice as the account total.
if matches > 1 && currencyLedgerField(tag) {
return "", "", false
}
return raw[bestKey], strings.TrimPrefix(bestKey, prefix), true
}
package ibkr
import (
"errors"
"sync"
"time"
)
// answerTimeoutWindow is how far back AnswerPath counts request timeouts.
const answerTimeoutWindow = 15 * time.Minute
// answerTimeoutsKept bounds the timeout clocks one connector remembers.
const answerTimeoutsKept = 256
// AnswerPath is one connection's request-answer health: whether the Gateway
// answers what this connection asks, which a connected socket alone does not
// establish. It is a diagnostic snapshot; the history stall it reports is the
// one admitHistoricalRequest enforces.
type AnswerPath struct {
ClientID int
// LastInboundAt is the newest frame of any kind on the current socket.
LastInboundAt time.Time
// InFlight counts history and contract-details requests awaiting an
// answer; OldestSentAt is when the oldest of them was sent.
InFlight int
OldestSentAt time.Time
// LastAnswerAt is the newest answer to a history or contract-details
// request: data, an end marker or a coded broker refusal.
LastAnswerAt time.Time
// Timeouts counts requests of either kind that waited out their budget
// without an answer during the last 15 minutes.
Timeouts int
// LimiterQueue is the pacing queue's current depth.
LimiterQueue int
// HistoryStalledSince is set while the Gateway holds history requests
// unanswered on this session (see historical_stall.go).
HistoryStalledSince time.Time
}
// answerClock keeps the answer and timeout clocks behind AnswerPath.
type answerClock struct {
mu sync.Mutex
answered time.Time
timeouts []time.Time
}
func (a *answerClock) noteAnswer(now time.Time) {
a.mu.Lock()
if now.After(a.answered) {
a.answered = now
}
a.mu.Unlock()
}
func (a *answerClock) noteTimeout(now time.Time) {
a.mu.Lock()
a.timeouts = append(a.timeouts, now)
if over := len(a.timeouts) - answerTimeoutsKept; over > 0 {
a.timeouts = append(a.timeouts[:0], a.timeouts[over:]...)
}
a.mu.Unlock()
}
func (a *answerClock) read(now time.Time) (time.Time, int) {
a.mu.Lock()
defer a.mu.Unlock()
count := 0
for _, at := range a.timeouts {
if now.Sub(at) <= answerTimeoutWindow {
count++
}
}
return a.answered, count
}
// noteContractDetailsOutcome records how one contract-details wait ended: an
// end marker or a coded refusal is an answer, a spent budget a timeout.
func (c *Connector) noteContractDetailsOutcome(err error, now time.Time) {
switch {
case errors.Is(err, ErrContractDetailsTimeout):
c.answers.noteTimeout(now)
default:
c.answers.noteAnswer(now)
}
}
// AnswerPath reports this connector's current request-answer health.
func (c *Connector) AnswerPath(now time.Time) AnswerPath {
if c == nil {
return AnswerPath{}
}
c.mu.RLock()
conn := c.conn
c.mu.RUnlock()
out := AnswerPath{}
if conn != nil {
out.ClientID = conn.config.ClientID
if nano := conn.lastHeartbeatNano.Load(); nano > 0 {
out.LastInboundAt = time.Unix(0, nano)
}
if conn.rateLimiter != nil {
out.LimiterQueue = conn.rateLimiter.GetMetrics().CurrentQueueDepth
}
}
oldest := func(at time.Time) {
out.InFlight++
if !at.IsZero() && (out.OldestSentAt.IsZero() || at.Before(out.OldestSentAt)) {
out.OldestSentAt = at
}
}
c.historicalMu.Lock()
for _, req := range c.historicalReqs {
oldest(req.sentAt)
}
c.historicalMu.Unlock()
c.contractDetailsMu.Lock()
for _, req := range c.contractDetailsReqs {
oldest(req.sentAt)
}
c.contractDetailsMu.Unlock()
out.LastAnswerAt, out.Timeouts = c.answers.read(now)
if since, stalled := c.HistoricalServiceStalled(); stalled {
out.HistoryStalledSince = since
}
return out
}
// DropSession ends the current broker session so that its owner redials it.
// It is for a session that is connected but no longer answering: requests in
// flight fail with the session change and nothing is resent. It reports
// whether a connected session was dropped.
func (c *Connector) DropSession(reason error) bool {
if c == nil {
return false
}
c.mu.RLock()
conn := c.conn
c.mu.RUnlock()
if conn == nil || !conn.IsConnected() {
return false
}
conn.handleDisconnection(reason)
return true
}
// SessionLossCause returns why the Connector's broker session ended: the
// transport error (EOF, reset), a heartbeat timeout, or the reason its owner
// passed to DropSession, unchanged so errors.Is works on it. It is nil while
// the session is connected. With ManagedConnectionLogging the loss itself
// logs at debug, and the owner reports it with this cause.
func (c *Connector) SessionLossCause() error {
if c == nil {
return nil
}
c.mu.RLock()
conn := c.conn
c.mu.RUnlock()
if conn == nil || conn.IsConnected() {
return nil
}
conn.statusMu.RLock()
defer conn.statusMu.RUnlock()
return conn.lastError
}
package ibkr
import (
"context"
"crypto/rand"
"encoding/binary"
"fmt"
"io"
"net"
"strconv"
"strings"
"time"
)
// TradingProtocolMax is the tested ceiling for all normal broker connections.
const TradingProtocolMax = maxClientVersion
// ConfigReadProtocol is the first protocol with the read-only config request.
// It is used only on a short-lived diagnostic socket, never for trading/data.
const ConfigReadProtocol = 219
// APISettings contains only allowlisted operational settings. Nil means absent,
// not false. The trusted-IP list, accounts, messages and order defaults are not
// retained; the list is reduced to whether a loopback address is on it.
type APISettings struct {
ReadOnly *bool
DownloadOpenOrders *bool
VolumeInLots *bool
RetainOrdersOnReconnect *bool
RejectAboveRate *bool
LocalhostOnly *bool
LoopbackTrusted *bool
CreateAPILog *bool
MarketDataInAPILog *bool
LoggingLevel *string
SocketPort *int
MasterClientID *int
}
// APIPrecautions names the order-precaution bypasses the application has on,
// in the broker's own order. Empty means every bypass is off.
type APIPrecautions struct {
Bypassed []string
}
// precautionBypassNames maps ApiPrecautionsConfig field numbers to short names.
var precautionBypassNames = map[int]string{
1: "order precautions", 2: "bond warning", 3: "negative-yield confirmation",
4: "called-bond warning", 5: "same-action pair-trade warning", 6: "flagged-accounts warning",
7: "price-based volatility warning", 8: "redirect-order warning", 9: "no-overfill protection",
10: "route marketable to BBO",
}
// LockAndExit is the application's daily log-off or restart as it reports it:
// Type is "restart" or "logoff", Time is 12-hour "HH:MM" and Period "AM" or "PM".
// The broker reports no time zone for it.
type LockAndExit struct {
Type string
Time string
Period string
}
// APIConfigDiagnostic is an observation of a separate bounded read-only session.
// State is observed, unsupported, unavailable, or rejected. Reason is a stable
// local code, never raw broker text. ServerVersion is negotiated, not a build.
type APIConfigDiagnostic struct {
State string
Reason string
ServerVersion int
AsOf time.Time
Settings *APISettings
Precautions *APIPrecautions
LockAndExit *LockAndExit
}
// ReadAPIConfig reads settings without changing the main connection or TWS.
// Only StartAPI and ReqConfig can be sent. No subscription, binding, order,
// update-config, reconnect, or fallback-port path exists on this socket.
func (c *Connector) ReadAPIConfig(ctx context.Context) APIConfigDiagnostic {
c.mu.RLock()
conn := c.conn
c.mu.RUnlock()
if conn == nil || !conn.IsConnected() {
return APIConfigDiagnostic{State: "unavailable", Reason: "not_connected", AsOf: time.Now().UTC()}
}
// Endpoint and TLS settings are immutable for the life of a Connection.
cfg := &ConnectionConfig{Host: conn.config.Host, Port: conn.config.Port,
TLSInsecureSkipVerify: conn.config.TLSInsecureSkipVerify, TLSServerName: conn.config.TLSServerName,
ConnectTimeout: 5 * time.Second}
return readAPIConfig(ctx, cfg, conn.UsingTLS())
}
func readAPIConfig(ctx context.Context, cfg *ConnectionConfig, tls bool) (out APIConfigDiagnostic) {
out.State, out.Reason = "unavailable", "transport_failed"
defer func() { out.AsOf = time.Now().UTC() }()
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
socket, err := (&Connection{config: cfg}).dialEndpoint(ctx, tls)
if err != nil {
return out
}
defer socket.Close()
deadline, _ := ctx.Deadline()
if err = socket.SetDeadline(deadline); err != nil {
return out
}
stop := context.AfterFunc(ctx, func() { _ = socket.Close() })
defer stop()
return probeAPIConfig(socket)
}
func probeAPIConfig(socket net.Conn) (out APIConfigDiagnostic) {
out.State, out.Reason = "unavailable", "transport_failed"
defer func() { out.AsOf = time.Now().UTC() }()
descriptor := []byte("v100..219\x00")
hello := append([]byte("API\x00"), binary.BigEndian.AppendUint32(nil, uint32(len(descriptor)))...)
hello = append(hello, descriptor...)
if err := writeDiagnosticBytes(socket, hello); err != nil {
return out
}
reply, err := readDiagnosticFrame(socket)
if err != nil {
return out
}
version, err := strconv.Atoi(strings.Split(string(reply), "\x00")[0])
if err != nil || version < minServerVersionRequired || version > ConfigReadProtocol {
out.Reason = "invalid_protocol"
return out
}
out.ServerVersion = version
if version < ConfigReadProtocol {
out.State, out.Reason = "unsupported", "config_requires_protocol_219"
return out
}
// A high random nonzero ID avoids client-0 order binding and the main client.
// A collision is rejected, never retried with another ID.
var entropy [4]byte
if _, err = rand.Read(entropy[:]); err != nil {
out.Reason = "client_id_unavailable"
return out
}
id := int32(1_000_000_000 + binary.BigEndian.Uint32(entropy[:])%1_000_000_000)
if err = writeDiagnosticProto(socket, 271, protoAppendInt32(nil, 1, id)); err != nil {
return out
}
const requestID = 1
requested := false
// Bound memory, time and unsolicited traffic. Ignore account/order callbacks
// completely; do not log, decode, bind, publish or retain them.
for range 128 {
reply, err = readDiagnosticFrame(socket)
if err != nil {
return out
}
if len(reply) < 4 {
out.Reason = "malformed_response"
return out
}
msg := binary.BigEndian.Uint32(reply[:4])
body := reply[4:]
switch msg {
case 209: // NextValidId proves StartAPI was accepted.
if !requested {
if err = writeDiagnosticProto(socket, 308, protoAppendInt32(nil, 1, requestID)); err != nil {
return out
}
requested = true
}
case 204: // ErrorMessage; farm status notifications are not request failures.
req, code, parseErr := diagnosticError(body)
if parseErr != nil {
out.Reason = "malformed_response"
return out
}
if code == 326 || (requested && req == requestID) {
out.State, out.Reason = "rejected", "broker_rejected_config"
return out
}
case 310:
if !requested {
out.Reason = "unexpected_config_response"
return out
}
parsed, err := parseAPIConfig(body, requestID)
if err != nil {
out.Reason = "malformed_config"
return out
}
out.State, out.Reason = "observed", ""
out.Settings, out.Precautions, out.LockAndExit = parsed.settings, parsed.precautions, parsed.lockAndExit
return out
}
}
out.Reason = "response_limit"
return out
}
func writeDiagnosticBytes(w io.Writer, b []byte) error {
n, err := w.Write(b)
if err == nil && n != len(b) {
err = io.ErrShortWrite
}
return err // No retries: an uncertain write closes this diagnostic socket.
}
func writeDiagnosticProto(w io.Writer, id uint32, body []byte) error {
frame := binary.BigEndian.AppendUint32(nil, uint32(4+len(body)))
frame = binary.BigEndian.AppendUint32(frame, id)
return writeDiagnosticBytes(w, append(frame, body...))
}
func readDiagnosticFrame(r io.Reader) ([]byte, error) {
var size [4]byte
if _, err := io.ReadFull(r, size[:]); err != nil {
return nil, err
}
n := binary.BigEndian.Uint32(size[:])
if n == 0 || n > 1<<20 {
return nil, fmt.Errorf("invalid diagnostic frame length")
}
payload := make([]byte, int(n))
_, err := io.ReadFull(r, payload)
return payload, err
}
func diagnosticError(body []byte) (id, code int, err error) {
id = -1
err = forEachProtoField(body, func(field, wire int, value []byte) error {
if field != 1 && field != 3 {
return nil
}
v, e := protoVarintValue(field, wire, value)
if e != nil {
return e
}
if field == 1 {
id = int(int32(v))
} else {
code = int(v)
}
return nil
})
return
}
type parsedAPIConfig struct {
settings *APISettings
precautions *APIPrecautions
lockAndExit *LockAndExit
}
// parseAPIConfig reads ConfigResponse: reqId (1), lockAndExit (2) and api (4),
// whose precautions (1) and settings (2) are the allowlisted messages. The
// messages (3) and orders (5) sections are skipped unread.
func parseAPIConfig(body []byte, requestID int) (parsedAPIConfig, error) {
var out parsedAPIConfig
id := -1
err := diagnosticFields(body, func(field, wire int, value []byte) error {
switch field {
case 1:
v, e := protoVarintValue(field, wire, value)
if e != nil {
return e
}
id = int(v)
case 2:
if wire != protoWireBytes {
return fmt.Errorf("invalid lock-and-exit wire type")
}
var e error
out.lockAndExit, e = parseLockAndExit(value)
return e
case 4:
if wire != protoWireBytes {
return fmt.Errorf("invalid api config wire type")
}
return diagnosticFields(value, func(f, w int, v []byte) error {
if f != 1 && f != 2 {
return nil
}
if w != protoWireBytes {
return fmt.Errorf("invalid api settings wire type")
}
var e error
if f == 1 {
out.precautions, e = parseAPIPrecautions(v)
} else {
out.settings, e = parseAPISettings(v)
}
return e
}, 1, 2)
}
return nil
}, 1, 2, 4)
if err != nil {
return parsedAPIConfig{}, err
}
if id != requestID || out.settings == nil {
return parsedAPIConfig{}, fmt.Errorf("missing or uncorrelated config")
}
return out, nil
}
// maxDiagnosticText bounds every retained string: a level name or a clock
// reading is short, and anything longer is not the field it claims to be.
const maxDiagnosticText = 32
func diagnosticText(field, wire int, value []byte) (string, error) {
if wire != protoWireBytes {
return "", fmt.Errorf("invalid wire type for text field %d", field)
}
if len(value) > maxDiagnosticText {
return "", fmt.Errorf("text field %d too long", field)
}
return strings.TrimSpace(string(value)), nil
}
func parseLockAndExit(body []byte) (*LockAndExit, error) {
out := &LockAndExit{}
err := diagnosticFields(body, func(field, wire int, value []byte) error {
var target *string
switch field {
case 1:
target = &out.Time
case 2:
target = &out.Period
case 3:
target = &out.Type
default:
return nil
}
text, err := diagnosticText(field, wire, value)
if err != nil {
return err
}
if field != 1 {
text = strings.ToLower(text)
}
*target = text
return nil
}, 1, 2, 3)
if err != nil {
return nil, err
}
return out, nil
}
func parseAPIPrecautions(body []byte) (*APIPrecautions, error) {
out := &APIPrecautions{}
seen := map[int]bool{}
err := forEachProtoField(body, func(field, wire int, value []byte) error {
name, known := precautionBypassNames[field]
if !known {
return nil
}
if seen[field] {
return fmt.Errorf("duplicate precaution")
}
seen[field] = true
v, err := protoVarintValue(field, wire, value)
if err != nil {
return err
}
if v > 1 {
return fmt.Errorf("invalid precaution boolean")
}
if v == 1 {
out.Bypassed = append(out.Bypassed, name)
}
return nil
})
if err != nil {
return nil, err
}
return out, nil
}
// loopbackTrusted reports whether the broker's comma- or space-separated
// trusted-IP list names a loopback address. The list itself is never retained.
func loopbackTrusted(list string) bool {
for entry := range strings.FieldsFuncSeq(list, func(r rune) bool { return r == ',' || r == ';' || r == ' ' || r == '\t' }) {
switch strings.Trim(strings.ToLower(entry), "[]") {
case "127.0.0.1", "::1", "localhost":
return true
}
}
return false
}
// Repeated ignored fields are permitted; retained singular fields must agree
// on a single value rather than hiding contradictory evidence.
func diagnosticFields(body []byte, visit func(int, int, []byte) error, unique ...int) error {
seen := map[int]bool{}
return forEachProtoField(body, func(f, w int, v []byte) error {
for _, field := range unique {
if f != field {
continue
}
if seen[f] {
return fmt.Errorf("duplicate config field")
}
seen[f] = true
}
return visit(f, w, v)
})
}
func parseAPISettings(body []byte) (*APISettings, error) {
out := &APISettings{}
seen := map[int]bool{}
err := forEachProtoField(body, func(field, wire int, value []byte) error {
var target **bool
switch field {
case 1:
target = &out.ReadOnly
case 3:
target = &out.DownloadOpenOrders
case 10:
target = &out.CreateAPILog
case 11:
target = &out.MarketDataInAPILog
case 24:
target = &out.VolumeInLots
case 26:
target = &out.RejectAboveRate
case 31:
target = &out.RetainOrdersOnReconnect
case 35:
target = &out.LocalhostOnly
case 8, 17, 18, 36:
default:
return nil
}
if seen[field] {
return fmt.Errorf("duplicate api setting")
}
seen[field] = true
if field == 17 || field == 36 {
// Text fields: the logging level is kept as a lower-case name; the
// trusted-IP list is reduced to one fact and dropped.
if field == 36 {
if wire != protoWireBytes {
return fmt.Errorf("invalid wire type for text field %d", field)
}
out.LoopbackTrusted = new(loopbackTrusted(string(value)))
return nil
}
text, err := diagnosticText(field, wire, value)
if err != nil {
return err
}
out.LoggingLevel = new(strings.ToLower(text))
return nil
}
v, err := protoVarintValue(field, wire, value)
if err != nil {
return err
}
if target != nil {
if v > 1 {
return fmt.Errorf("invalid api setting boolean")
}
*target = new(v == 1)
} else {
if v > maxProtoInt32 || (field == 8 && (v == 0 || v > 65535)) {
return fmt.Errorf("invalid api setting integer")
}
n := int(v)
if field == 8 {
out.SocketPort = &n
} else {
out.MasterClientID = &n
}
}
return nil
})
return out, err
}
package ibkr
import (
"fmt"
"strings"
"time"
)
// Backend-link episode coalescing. Five weeks of observed history show the
// TWS↔IBKR upstream link flapping on every logged day, overwhelmingly in the
// US evening/overnight band, with a median outage of 13 seconds and every
// restore reporting server-side data maintained (code 1102). Logging each
// blip individually made the most operationally important sequence in the
// log unreadable, so the connector logs per disturbance instead: the first
// loss and first restore of an episode at WARN, follow-up blips within
// backendEpisodeGap at INFO, and one WARN summary when the episode ends.
// Two conditions stay loud on every single event because they are the
// actionable ones: a loss while the trading session is open (an order
// transmitted then would be refused) and an outage exceeding
// backendOutageAttention (that is a wedge, not a blip).
const (
// backendEpisodeGap joins a new loss to the running episode when it
// arrives within this much of the previous restore. Observed flapping
// spaced losses ~7 minutes apart for hours.
backendEpisodeGap = 10 * time.Minute
// backendOutageAttention is the outage duration above which a restore is
// always logged at WARN: the self-healing blips cluster far below it
// (p90 42s), while everything observed above it needed a human.
backendOutageAttention = 5 * time.Minute
)
// MaintenanceWindow is a recurring broker maintenance window: a start/end
// wall-clock range in Loc, active on the listed start days. End at or before
// Start means the window crosses midnight (the day filter applies to the
// start day). This models the broker's schedule, not any market calendar —
// resets also happen on days markets are closed.
type MaintenanceWindow struct {
Days [7]bool // indexed by time.Weekday of the window's START day in Loc
StartMinute int // minutes since midnight in Loc, inclusive
EndMinute int // minutes since midnight in Loc, exclusive
Loc *time.Location
}
// Contains reports whether t falls inside the window.
func (w MaintenanceWindow) Contains(t time.Time) bool {
if w.Loc == nil {
return false
}
lt := t.In(w.Loc)
m := lt.Hour()*60 + lt.Minute()
if w.StartMinute < w.EndMinute {
return w.Days[lt.Weekday()] && m >= w.StartMinute && m < w.EndMinute
}
// Crosses midnight: after the start on a listed day, or before the end
// on the day after a listed day.
if m >= w.StartMinute {
return w.Days[lt.Weekday()]
}
if m < w.EndMinute {
return w.Days[(lt.Weekday()+6)%7]
}
return false
}
func maintenanceWindowsContain(windows []MaintenanceWindow, t time.Time) bool {
for _, w := range windows {
if w.Contains(t) {
return true
}
}
return false
}
// DefaultIBKRMaintenanceWindows returns IBKR's documented North America
// nightly reset schedule: 23:45–00:45 ET Saturday through Thursday and
// 23:45–00:30 ET on Friday.
func DefaultIBKRMaintenanceWindows() ([]MaintenanceWindow, error) {
return ParseMaintenanceWindows([]string{
"Sat-Thu 23:45-00:45 America/New_York",
"Fri 23:45-00:30 America/New_York",
})
}
// ParseMaintenanceWindows parses window specs of the form
// "DAY[-DAY] HH:MM-HH:MM TZ", e.g. "Sat-Thu 23:45-00:45 America/New_York".
// A day range wraps the week; an end time at or before the start crosses
// midnight. Every spec must parse or the whole call fails.
func ParseMaintenanceWindows(specs []string) ([]MaintenanceWindow, error) {
out := make([]MaintenanceWindow, 0, len(specs))
for _, spec := range specs {
w, err := parseMaintenanceWindow(spec)
if err != nil {
return nil, fmt.Errorf("maintenance window %q: %w", spec, err)
}
out = append(out, w)
}
return out, nil
}
var maintenanceDayNames = map[string]time.Weekday{
"sun": time.Sunday, "mon": time.Monday, "tue": time.Tuesday, "wed": time.Wednesday,
"thu": time.Thursday, "fri": time.Friday, "sat": time.Saturday,
}
func parseMaintenanceWindow(spec string) (MaintenanceWindow, error) {
fields := strings.Fields(spec)
if len(fields) != 3 {
return MaintenanceWindow{}, fmt.Errorf("want \"DAY[-DAY] HH:MM-HH:MM TZ\", got %d fields", len(fields))
}
var w MaintenanceWindow
first, last, ok := strings.Cut(strings.ToLower(fields[0]), "-")
from, okFrom := maintenanceDayNames[first]
if !okFrom {
return MaintenanceWindow{}, fmt.Errorf("unknown day %q", first)
}
if !ok {
w.Days[from] = true
} else {
to, okTo := maintenanceDayNames[last]
if !okTo {
return MaintenanceWindow{}, fmt.Errorf("unknown day %q", last)
}
for d := from; ; d = (d + 1) % 7 {
w.Days[d] = true
if d == to {
break
}
}
}
startRaw, endRaw, ok := strings.Cut(fields[1], "-")
if !ok {
return MaintenanceWindow{}, fmt.Errorf("want HH:MM-HH:MM, got %q", fields[1])
}
var err error
if w.StartMinute, err = parseWallMinute(startRaw); err != nil {
return MaintenanceWindow{}, err
}
if w.EndMinute, err = parseWallMinute(endRaw); err != nil {
return MaintenanceWindow{}, err
}
if w.Loc, err = time.LoadLocation(fields[2]); err != nil {
return MaintenanceWindow{}, fmt.Errorf("timezone %q: %w", fields[2], err)
}
return w, nil
}
func parseWallMinute(s string) (int, error) {
t, err := time.Parse("15:04", s)
if err != nil {
return 0, fmt.Errorf("time %q: want HH:MM", s)
}
return t.Hour()*60 + t.Minute(), nil
}
// SetBackendMaintenanceWindows installs the broker maintenance schedule used
// to annotate backend-link losses. Call before Start.
func (c *Connector) SetBackendMaintenanceWindows(windows []MaintenanceWindow) {
c.backendConnMu.Lock()
c.maintWindows = windows
c.backendConnMu.Unlock()
}
// SetBackendSessionOpen installs the predicate that decides whether a
// backend-link loss happened while the trading session was open — the case
// that always warns per event. Call before Start; nil disables the check.
func (c *Connector) SetBackendSessionOpen(open func(time.Time) bool) {
c.backendConnMu.Lock()
c.backendSessionOpen = open
c.backendConnMu.Unlock()
}
// SetBackendLogRequired installs the daemon's cached duty predicate. The interval
// includes both ends; unknown coverage must return true. Call only before Start.
// Nil retains standalone logging behavior. The callback must do no I/O or acquire
// connector locks: it also runs while backendConnMu is held.
func (c *Connector) SetBackendLogRequired(required func(time.Time, time.Time) bool) {
c.backendConnMu.Lock()
c.backendLogRequired = required
c.backendConnMu.Unlock()
}
// logRequiredAt reports whether the daemon's logging schedule wants attention
// at t. A standalone connector, without a schedule, always does.
func (c *Connector) logRequiredAt(t time.Time) bool {
c.backendConnMu.Lock()
required := c.backendLogRequired
c.backendConnMu.Unlock()
return required == nil || required(t, t)
}
// finalizeBackendEpisode closes the running flap episode after
// backendEpisodeGap of quiet and logs its one-line summary. Single-blip
// episodes are skipped: their loss and restore lines already told the whole
// story. gen guards against a fired timer that lost the race against a new
// loss extending the episode.
func (c *Connector) finalizeBackendEpisode(gen int) {
c.backendConnMu.Lock()
if !c.epActive || c.backendConnDown || gen != c.epGen {
c.backendConnMu.Unlock()
return
}
losses, inWindow, longest := c.epLosses, c.epLossesInWindow, c.epLongest
span := c.epLastRestore.Sub(c.epStart)
required := c.backendEpisodeWarned || c.backendLogRequired == nil || c.backendLogRequired(c.epStart, c.epLastRestore)
c.epActive = false
c.epTimer = nil
c.backendConnMu.Unlock()
if losses <= 1 {
return
}
annotation := ""
switch {
case inWindow == losses:
annotation = " (all inside IBKR maintenance window)"
case inWindow > 0:
annotation = fmt.Sprintf(" (%d of %d inside IBKR maintenance window)", inWindow, losses)
}
log := c.logWarn
if !required {
log = c.logInfo
}
log("TWS backend link episode ended: %d losses over %s, longest outage %s%s",
losses, span.Round(time.Second), longest.Round(time.Second), annotation)
}
func (c *Connector) stopBackendEpisodeTimer() {
c.backendConnMu.Lock()
if c.epTimer != nil {
c.epTimer.Stop()
c.epTimer = nil
}
c.backendConnMu.Unlock()
}
// CheckBackendLogRelevance promotes an ongoing quiet outage when duty begins,
// without fabricating a new loss or altering readiness/recovery state.
// The daemon calls this at its bounded schedule refresh cadence.
func (c *Connector) CheckBackendLogRelevance(now time.Time) {
c.warnLongDataFarmBreaks(now)
c.backendConnMu.Lock()
if !c.backendConnDown || c.backendLossWarned || c.backendLogRequired == nil || !c.backendLogRequired(c.backendConnAt, now) {
c.backendConnMu.Unlock()
return
}
c.backendLossWarned = true
c.backendEpisodeWarned = true
age := max(time.Duration(0), now.Sub(c.backendConnAt))
c.backendConnMu.Unlock()
c.logWarn("TWS backend remains disconnected after %s — gateway duty is required or uncertain", age.Round(time.Second))
}
package ibkr
import (
"context"
"errors"
"fmt"
"math"
"slices"
"strconv"
"strings"
"sync/atomic"
"time"
"unicode"
)
// Bond support: contract details for secType BILL or BOND by ISIN, CUSIP or
// contract id, and the yield ticks a bond quote carries. Canary's cash sweep
// (internal-docs/design/cash-sweep.md, Phase B) resolves and quotes
// government bills with these; bond_order.go builds the one order shape it
// sends.
// Bond identifier types a contract-details request can name.
const (
BondIdentifierISIN = "ISIN"
BondIdentifierCUSIP = "CUSIP"
)
// IBKR security types of a government bill or bond line. The TWS API lists
// Treasury bills as BILL and notes and bonds as BOND; a US bill asked as
// BOND finds no line (live read 2026-09-30).
const (
SecTypeBill = "BILL"
SecTypeBond = "BOND"
)
// IsBillOrBond reports whether secType is BILL or BOND, whatever its case
// and surrounding space: the one test every bill path uses.
func IsBillOrBond(secType string) bool {
switch strings.ToUpper(strings.TrimSpace(secType)) {
case SecTypeBill, SecTypeBond:
return true
}
return false
}
// BillOrBondSecType is BILL for a BILL security type and BOND for anything
// else: the wire type a bill-or-bond contract carries.
func BillOrBondSecType(secType string) string {
if strings.EqualFold(strings.TrimSpace(secType), SecTypeBill) {
return SecTypeBill
}
return SecTypeBond
}
// BondContractDetails is one bill or bond line: a decoded bondContractData
// frame (message 18), or an ordinary contractData frame (message 10) that
// answered a bond request, whose bond-only fields (coupon, issue date, the
// cusip field, bond and coupon types) stay empty. Identity fields come from
// the fixed frame prefix; the size rules, the security-identifier list, the
// long name and the hours are kept only when the whole frame decoded in the
// negotiated layout (Complete), so a gateway that adds or moves a trailing
// field can never shift a size rule into place. IBKR may leave the
// maturity, the currency or the identifiers empty (bond data licensing);
// such a line is kept, and the paths that need them refuse it.
type BondContractDetails struct {
ReqID int
ConID int
Symbol string
SecType string
// CUSIPField is the frame's cusip field as sent. IBKR may carry another
// identifier there for bonds outside the US; ISIN and CUSIP check shape.
CUSIPField string
Coupon float64
// Maturity is YYYYMMDD; IssueDate is as sent (YYYYMMDD when present).
Maturity string
IssueDate string
// Ratings is the frame's ratings field as sent; IBKR documents it as
// not delivered for bonds, so it is kept to show what arrives, never
// read for a decision.
Ratings string
BondType string
CouponType string
DescAppend string
Exchange string
Currency string
MarketName string
// LocalSymbol is the venue's own ticker, as an ordinary contractData
// frame sends it; PrimaryExch the listing venue, kept on a Complete
// one. A bondContractData frame carries neither.
LocalSymbol string
PrimaryExch string
// TradingClass and MinTick are the venue's; MinTick is in the quote's
// price convention.
TradingClass string
MinTick float64
// The remaining fields are set only on a Complete frame.
ValidExchanges string
LongName string
TimeZoneID string
TradingHours string
LiquidHours string
SecIDs map[string]string
MinSize float64
SizeIncrement float64
Complete bool
// FactorPriced is set when IBKR answered the request with notice 2130
// ("trading on the basis of currency price with factor"): the line's
// price applies to a factored principal, as on an inflation-linked
// bond, so face × price / 100 is not its value.
FactorPriced bool
}
// ISIN is the contract's ISIN when the broker named one: the secIdList
// entry, else a cusip field that has an ISIN's shape and check digit.
func (d BondContractDetails) ISIN() string {
if v := strings.ToUpper(strings.TrimSpace(d.SecIDs[BondIdentifierISIN])); ValidISIN(v) {
return v
}
if v := strings.ToUpper(strings.TrimSpace(d.CUSIPField)); ValidISIN(v) {
return v
}
return ""
}
// CUSIP is the contract's CUSIP when the broker named one: the secIdList
// entry, else a nine-character cusip field, else the CUSIP inside a US or
// CA ISIN.
func (d BondContractDetails) CUSIP() string {
if v := strings.ToUpper(strings.TrimSpace(d.SecIDs[BondIdentifierCUSIP])); ValidCUSIP(v) {
return v
}
if v := strings.ToUpper(strings.TrimSpace(d.CUSIPField)); ValidCUSIP(v) {
return v
}
if isin := d.ISIN(); strings.HasPrefix(isin, "US") || strings.HasPrefix(isin, "CA") {
return isin[2:11]
}
return ""
}
// MaturityDate parses Maturity; ok is false when the frame carried none.
func (d BondContractDetails) MaturityDate() (time.Time, bool) {
return parseBondDate(d.Maturity)
}
// IssueDateValue parses IssueDate; ok is false when the frame carried none.
func (d BondContractDetails) IssueDateValue() (time.Time, bool) {
return parseBondDate(d.IssueDate)
}
func parseBondDate(raw string) (time.Time, bool) {
raw = strings.TrimSpace(raw)
if len(raw) != 8 {
return time.Time{}, false
}
t, err := time.Parse("20060102", raw)
return t, err == nil
}
// ValidISIN reports whether s is an ISIN: two letters, nine alphanumerics
// and a check digit that satisfies the Luhn test over the letters' values.
func ValidISIN(s string) bool {
if len(s) != 12 {
return false
}
var digits strings.Builder
for i, r := range s {
switch {
case r >= 'A' && r <= 'Z':
if i == 11 {
return false
}
digits.WriteString(strconv.Itoa(int(r-'A') + 10))
case r >= '0' && r <= '9':
if i < 2 {
return false
}
digits.WriteRune(r)
default:
return false
}
}
return luhnValid(digits.String())
}
// ValidCUSIP reports whether s is a CUSIP: eight alphanumerics and the
// standard modulus-10 check digit.
func ValidCUSIP(s string) bool {
if len(s) != 9 {
return false
}
sum := 0
for i := range 8 {
var v int
switch r := s[i]; {
case r >= '0' && r <= '9':
v = int(r - '0')
case r >= 'A' && r <= 'Z':
v = int(r-'A') + 10
case r == '*':
v = 36
case r == '@':
v = 37
case r == '#':
v = 38
default:
return false
}
if i%2 == 1 {
v *= 2
}
sum += v/10 + v%10
}
check := s[8]
return check >= '0' && check <= '9' && int(check-'0') == (10-sum%10)%10
}
func luhnValid(digits string) bool {
sum := 0
double := false
for i := len(digits) - 1; i >= 0; i-- {
v := int(digits[i] - '0')
if double {
v *= 2
if v > 9 {
v -= 9
}
}
sum += v
double = !double
}
return len(digits) > 0 && sum%10 == 0
}
// minServerVerBondTradingHours is the server version from which a bond
// frame carries its time zone and trading hours.
const minServerVerBondTradingHours = 188
// bondWireDate keeps the date of a "YYYYMMDD[ HH:MM:SS[ TZ]]",
// "YYYYMMDD-HH:MM:SS" or "YYYY-MM-DD" wire value as YYYYMMDD; any other
// value keeps its first word, which reads as no date.
func bondWireDate(raw string) string {
raw = strings.TrimSpace(raw)
if i := strings.IndexByte(raw, ' '); i >= 0 {
raw = raw[:i]
}
if t, err := time.Parse(time.DateOnly, raw); err == nil {
return t.Format("20060102")
}
if i := strings.IndexByte(raw, '-'); i >= 0 {
raw = raw[:i]
}
return raw
}
func bondCurrencyCode(ccy string) bool {
if len(ccy) != 3 {
return false
}
for _, r := range ccy {
if r < 'A' || r > 'Z' {
return false
}
}
return true
}
// BondContractRequest names one bond: an identifier (ISIN or CUSIP) or a
// contract id, and the currency it trades in. Exchange defaults to SMART.
// SecTypes are the security types asked, in order (BILL, BOND); none asks
// BOND.
type BondContractRequest struct {
IDType string
ID string
ConID int
Currency string
Exchange string
SecTypes []string
}
// AskedSecTypes are the request's security types, upper-cased, each once,
// in order; none reads as BOND. Anything but BILL or BOND is refused.
func (r BondContractRequest) AskedSecTypes() ([]string, error) {
var out []string
for _, raw := range r.SecTypes {
secType := strings.ToUpper(strings.TrimSpace(raw))
if !IsBillOrBond(secType) {
return nil, fmt.Errorf("bond contract request security type %q is neither BILL nor BOND", raw)
}
if !slices.Contains(out, secType) {
out = append(out, secType)
}
}
if len(out) == 0 {
out = []string{SecTypeBond}
}
return out, nil
}
// bondWireForm is one way a reqContractDetails request names the bond.
type bondWireForm struct {
label string
contract Contract
}
// attempt is the form's lookup attempt before the gateway answers: the
// request as sent.
func (f bondWireForm) attempt() BondLookupAttempt {
c := f.contract
return BondLookupAttempt{Form: f.label, SecType: c.SecType, Symbol: c.Symbol, SecIDType: c.SecIDType, SecID: c.SecID,
ConID: c.ConID, Exchange: c.Exchange, Currency: c.Currency}
}
// wireForms are the reqContractDetails contracts for the request, in the
// order they are asked: every form of the first security type, then every
// form of the next. A contract id is asked once per type. An identifier is
// asked first the way IBKR documents a bond, as the contract's symbol (the
// type, SMART, the currency, nothing else), then by secIdType/secId, then
// by symbol with no exchange, each only when the one before found no line.
// The live reads of 2026-09-30 found the symbol form ending without a line
// and the secIdType form answered with code 200 for outstanding US bills
// asked as BOND and as BILL (F3, F4).
func (r BondContractRequest) wireForms() ([]bondWireForm, error) {
secTypes, err := r.AskedSecTypes()
if err != nil {
return nil, err
}
base := Contract{Exchange: strings.ToUpper(strings.TrimSpace(r.Exchange)), Currency: strings.ToUpper(strings.TrimSpace(r.Currency))}
if !bondCurrencyCode(base.Currency) {
return nil, fmt.Errorf("bond contract request needs a three-letter currency")
}
var forms []bondWireForm
if r.ConID > 0 {
for _, secType := range secTypes {
byConID := base
byConID.SecType, byConID.ConID = secType, r.ConID
forms = append(forms, bondWireForm{label: secType + " by contract id", contract: byConID})
}
return forms, nil
}
if base.Exchange == "" {
base.Exchange = "SMART"
}
idType, id := strings.ToUpper(strings.TrimSpace(r.IDType)), strings.ToUpper(strings.TrimSpace(r.ID))
if (idType != BondIdentifierISIN || !ValidISIN(id)) && (idType != BondIdentifierCUSIP || !ValidCUSIP(id)) {
return nil, fmt.Errorf("bond contract request needs a valid ISIN, CUSIP or contract id")
}
for _, secType := range secTypes {
bySymbol, bySecID := base, base
bySymbol.SecType, bySecID.SecType = secType, secType
bySymbol.Symbol = id
bySecID.SecIDType, bySecID.SecID = idType, id
noExchange := bySymbol
noExchange.Exchange = ""
forms = append(forms, bondWireForm{label: secType + " by symbol", contract: bySymbol},
bondWireForm{label: secType + " by secIdType " + idType, contract: bySecID},
bondWireForm{label: secType + " by symbol, no exchange", contract: noExchange})
}
return forms, nil
}
// description names the request in a lookup error: the types asked, in
// order, and the identifier.
func (r BondContractRequest) description() string {
ccy := strings.ToUpper(strings.TrimSpace(r.Currency))
asked := "BOND"
if secTypes, err := r.AskedSecTypes(); err == nil {
asked = strings.Join(secTypes, " then ")
}
if r.ConID > 0 {
return fmt.Sprintf("%s contract id %d in %s", asked, r.ConID, ccy)
}
exchange := strings.ToUpper(strings.TrimSpace(r.Exchange))
if exchange == "" {
exchange = "SMART"
}
return fmt.Sprintf("%s %s %s on %s in %s", asked, strings.ToUpper(strings.TrimSpace(r.IDType)), strings.ToUpper(strings.TrimSpace(r.ID)), exchange, ccy)
}
// linesNaming drops the lines that name a different identifier of the
// request's type: asked by symbol, the gateway matches free text, so a line
// must not contradict the identifier it was asked for. A line that names
// none (an ordinary contractData frame) is kept.
func (r BondContractRequest) linesNaming(lines []BondContractDetails) []BondContractDetails {
id := strings.ToUpper(strings.TrimSpace(r.ID))
if r.ConID > 0 || id == "" {
return lines
}
return slices.DeleteFunc(lines, func(d BondContractDetails) bool {
var named string
switch strings.ToUpper(strings.TrimSpace(r.IDType)) {
case BondIdentifierISIN:
named = d.ISIN()
case BondIdentifierCUSIP:
named = d.CUSIP()
}
return named != "" && named != id
})
}
// ErrBondContractNotFound means the gateway finished the request without a
// bond line: the identifier names nothing IBKR lists in that currency.
var ErrBondContractNotFound = errors.New("no bond contract line for the request")
// ContractDetailsRejection is the gateway's rejection of one bond
// contract-details request: IBKR's code and its own text, on one line. It
// is ErrContractNoDefinition for IBKR's code-200 no-definition verdict.
type ContractDetailsRejection struct {
Code int
Message string
}
// Error names IBKR's code and its text.
func (e *ContractDetailsRejection) Error() string {
return fmt.Sprintf("IBKR %d \"%s\"", e.Code, e.Message)
}
// Is reports IBKR's definitive missing-contract verdict as
// ErrContractNoDefinition, as the other contract-details paths classify it.
func (e *ContractDetailsRejection) Is(target error) bool {
return target == ErrContractNoDefinition && e.Code == 200 && strings.Contains(strings.ToUpper(e.Message), "NO SECURITY DEFINITION")
}
// brokerNoticeLine keeps a gateway notice's text readable on one line:
// control characters and runs of white space become one space, and the text
// is cut at 200 characters.
func brokerNoticeLine(message string) string {
line := strings.Join(strings.Fields(strings.Map(func(r rune) rune {
if unicode.IsControl(r) {
return ' '
}
return r
}, message)), " ")
if runes := []rune(line); len(runes) > 200 {
line = string(runes[:200]) + "…"
}
return line
}
// Bond lookup attempt outcomes.
const (
// BondAttemptLine: the form found the line the lookup returns.
BondAttemptLine = "line"
// BondAttemptNoLine: the search ended without a line the request
// keeps.
BondAttemptNoLine = "no_line"
// BondAttemptRejected: IBKR answered with an error code.
BondAttemptRejected = "rejected"
// BondAttemptFailed: no answer (a timeout, a changed session); the
// lookup ends here.
BondAttemptFailed = "failed"
)
// BondLookupAttempt is one request form a bond lookup asked, the request as
// sent (request id, security type, symbol or secIdType/secId or contract
// id, exchange, currency) and the gateway's answer: the outcome, IBKR's
// code and text, or Code 0 with a note when the search ended without a
// line. Lines counts the lines the form answered that the request kept;
// Frames are the frames that named the request id, in arrival order, and
// FramesOmitted counts those past the record's cap.
type BondLookupAttempt struct {
Form string
ReqID int
SecType string
Symbol string
SecIDType string
SecID string
ConID int
Exchange string
Currency string
Outcome string
Code int
Message string
Lines int
Frames []BondLookupFrame
FramesOmitted int
}
// String is the form and the gateway's answer, as a gap line shows it.
func (a BondLookupAttempt) String() string {
if a.Code != 0 {
return fmt.Sprintf("%s: IBKR %d \"%s\"", a.Form, a.Code, a.Message)
}
return a.Form + ": " + a.Message
}
// BondLookupTrace is how a lookup asked: the request, the server version
// the frames were decoded for, and every attempt in order, the one that
// found the line included.
type BondLookupTrace struct {
Request string
ServerVersion int
Attempts []BondLookupAttempt
}
// BondLookupError is a lookup no request form found a line with. It matches
// ErrContractNoDefinition or ErrBondContractNotFound through its attempts'
// answers, so the callers' classification holds.
type BondLookupError struct {
Request string
Attempts []BondLookupAttempt
}
// Error names the request and every form's answer.
func (e *BondLookupError) Error() string {
return "no bond line for " + e.Request + " (" + e.Answers() + ")"
}
// Answers is every attempt's form and the gateway's answer, in order.
func (e *BondLookupError) Answers() string {
parts := make([]string, 0, len(e.Attempts))
for _, a := range e.Attempts {
parts = append(parts, a.String())
}
return strings.Join(parts, "; ")
}
// Unwrap is each attempt's answer as an error: a rejection, or
// ErrBondContractNotFound for a search that ended without a line.
func (e *BondLookupError) Unwrap() []error {
out := make([]error, 0, len(e.Attempts))
for _, a := range e.Attempts {
if a.Code != 0 {
out = append(out, &ContractDetailsRejection{Code: a.Code, Message: a.Message})
} else {
out = append(out, ErrBondContractNotFound)
}
}
return out
}
// bondLookupMiss records a form's answer on its attempt when it is the
// gateway's own "no line" (a rejection or an empty search), which the next
// form may still answer; ok is false for any other failure (a timeout, a
// changed session), which ends the lookup.
func bondLookupMiss(attempt BondLookupAttempt, err error) (BondLookupAttempt, bool) {
if rejection, ok := errors.AsType[*ContractDetailsRejection](err); ok {
attempt.Outcome, attempt.Code, attempt.Message = BondAttemptRejected, rejection.Code, rejection.Message
return attempt, true
}
if errors.Is(err, ErrBondContractNotFound) {
note := "the search ended without a line"
if msg := err.Error(); msg != ErrBondContractNotFound.Error() {
note = strings.TrimPrefix(msg, ErrBondContractNotFound.Error()+": ")
}
attempt.Outcome, attempt.Message = BondAttemptNoLine, note
return attempt, true
}
attempt.Outcome, attempt.Message = BondAttemptFailed, brokerNoticeLine(err.Error())
return attempt, false
}
// BondContractDetails asks the connected gateway for the bond lines a
// request names and returns every decoded line. It is a read: one
// reqContractDetails per request form on the current socket, each answered
// by bondContractData or contractData frames and the end marker. A form the
// gateway answers without a line moves to the next; when none finds one the
// error is a *BondLookupError carrying each answer (ErrContractNoDefinition
// for code 200).
func (c *Connector) BondContractDetails(ctx context.Context, request BondContractRequest, timeout time.Duration) ([]BondContractDetails, error) {
lines, _, err := c.BondContractDetailsTraced(ctx, request, timeout)
return lines, err
}
// BondContractDetailsTraced is BondContractDetails with the lookup's trace:
// every form asked and the frames IBKR answered each with, reduced to
// identifiers. The trace is nil only when no form was asked.
func (c *Connector) BondContractDetailsTraced(ctx context.Context, request BondContractRequest, timeout time.Duration) ([]BondContractDetails, *BondLookupTrace, error) {
binding, ok := c.CaptureSession()
if !ok {
return nil, nil, ErrIBKRUnavailable
}
return c.bondContractDetails(ctx, binding, request, timeout)
}
// BondContractDetailsForSession is BondContractDetails on the exact socket
// generation binding names: an order preview reads its line's size, price
// and session rules from the same session it prices and previews on.
func (c *Connector) BondContractDetailsForSession(ctx context.Context, binding ConnectorSessionBinding, request BondContractRequest, timeout time.Duration) ([]BondContractDetails, error) {
if c == nil || !c.SessionCurrent(binding) {
return nil, fmt.Errorf("broker session changed before bond contract details")
}
lines, _, err := c.bondContractDetails(ctx, binding, request, timeout)
return lines, err
}
func (c *Connector) bondContractDetails(ctx context.Context, binding ConnectorSessionBinding, request BondContractRequest, timeout time.Duration) ([]BondContractDetails, *BondLookupTrace, error) {
forms, err := request.wireForms()
if err != nil {
return nil, nil, err
}
if timeout <= 0 {
timeout = 5 * time.Second
}
trace := &BondLookupTrace{Request: request.description(), ServerVersion: binding.connection.serverVersion}
lookupErr := &BondLookupError{Request: trace.Request}
for _, form := range forms {
lines, attempt, err := c.bondContractDetailsOnce(ctx, binding, form, timeout)
if err == nil {
named := request.linesNaming(stampAsked(lines, form.contract))
if attempt.Lines = len(named); len(named) > 0 {
attempt.Outcome = BondAttemptLine
trace.Attempts = append(trace.Attempts, attempt)
return named, trace, nil
}
err = fmt.Errorf("%w: the %d lines IBKR answered name another %s (%s)", ErrBondContractNotFound, len(lines),
strings.ToUpper(strings.TrimSpace(request.IDType)), strings.Join(linesNamed(lines, request.IDType), ", "))
}
attempt, miss := bondLookupMiss(attempt, err)
trace.Attempts = append(trace.Attempts, attempt)
if !miss {
return nil, trace, err
}
lookupErr.Attempts = append(lookupErr.Attempts, attempt)
}
return nil, trace, lookupErr
}
// linesNamed is the identifier of idType each line names, for a gap line.
func linesNamed(lines []BondContractDetails, idType string) []string {
out := make([]string, 0, len(lines))
for _, d := range lines {
named := d.CUSIP()
if strings.EqualFold(strings.TrimSpace(idType), BondIdentifierISIN) {
named = d.ISIN()
}
out = append(out, wireValue(named))
}
return out
}
// stampAsked names the type and currency a line was asked in when its
// frame carried none (bond data licensing may leave them empty), so every
// caller reads which type resolved, and in what currency, from the line
// itself. A line that names another currency keeps it.
func stampAsked(lines []BondContractDetails, asked Contract) []BondContractDetails {
for i := range lines {
if lines[i].SecType == "" {
lines[i].SecType = asked.SecType
}
if lines[i].Currency == "" {
lines[i].Currency = asked.Currency
}
}
return lines
}
// bondContractDetailsRequestMessage is reqContractDetails for a bill or
// bond exactly as IBKR's documented bond example and its official encoder
// (API 10.37, version 8) send it: the contract id, symbol, security type,
// exchange, currency and secIdType/secId as the form names them and every
// other field empty (includeExpired false, which the encoder sends as 0).
// Unlike the discovery request it adds no SMART default, so a form can ask
// with no exchange. A bond form names no primary exchange; a listing form
// (listing.go) may.
func (c *Connection) bondContractDetailsRequestMessage(ctx context.Context, contract Contract, reqID int) []byte {
c.registerReqAlias(ctx, reqID, contract)
return c.encodeMsg(reqContractData, 8, reqID, contract.ConID, contract.Symbol, contract.SecType,
"", "", "", "", // lastTradeDateOrContractMonth, strike, right, multiplier
contract.Exchange, contract.PrimaryExch, // exchange, primaryExchange
contract.Currency, "", "", // currency, localSymbol, tradingClass
0, // includeExpired
contract.SecIDType, contract.SecID, "") // secIdType, secId, issuerId
}
// bondContractDetailsOnce is one reqContractDetails for one request form.
// While it is in flight every frame that names its request id is recorded
// on the attempt and logged, one line per frame: a contract frame with its
// identifiers and why it is or is not a line, the end marker, an error
// with IBKR's code, and any other message that names the id.
func (c *Connector) bondContractDetailsOnce(ctx context.Context, binding ConnectorSessionBinding, form bondWireForm, timeout time.Duration) (lines []BondContractDetails, attempt BondLookupAttempt, err error) {
attempt = form.attempt()
fetchCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
conn := binding.connection
reqID, err := conn.reserveRequestID(nil)
if err != nil {
return nil, attempt, err
}
defer conn.discardRequestIDReservation(reqID)
attempt.ReqID = reqID
frames := &bondFrameLog{}
defer func() { attempt.Frames, attempt.FramesOmitted = frames.snapshot() }()
var factorPriced atomic.Bool
record := func(f BondLookupFrame) {
frames.add(f)
if f.Kind == BondFrameError && isBondFactorNotice(f.Code, f.Note) {
factorPriced.Store(true)
}
if f.contract() && !f.Line {
c.logWarn("bond lookup reqID %d (%s): %s", reqID, form.label, f)
return
}
c.logInfo("bond lookup reqID %d (%s): %s", reqID, form.label, f)
}
detailsCh := make(chan BondContractDetails, 64)
doneCh := make(chan struct{}, 1)
overflowCh := make(chan struct{}, 1)
layout := bondFrameLayoutFor(conn.serverVersion)
deliver := func(d BondContractDetails) {
select {
case detailsCh <- d:
default:
select {
case overflowCh <- struct{}{}:
default:
}
}
}
// A contract frame for this request is decoded in the layout it names
// the request in; either message is a line when its identity decodes.
decodeFrame := func(fields []string, decode func([]string, int, bondFrameLayout) (BondContractDetails, []string, error)) {
at, ours := bondFrameLayoutNaming(fields, reqID, layout)
if !ours {
return
}
f := bondFrameSummary(fields, at)
d, notes, err := decode(fields, reqID, at)
if err != nil {
f.Note = err.Error()
} else {
f.Line, f.Complete, f.Note = true, d.Complete, strings.Join(notes, "; ")
}
record(f)
if err == nil {
deliver(d)
}
}
bondHandlerID := conn.RegisterHandlerAtEpoch(msgBondContractData, func(fields []string, receiptEpoch uint64) {
if receiptEpoch == binding.epoch {
decodeFrame(fields, decodeBondContractData)
}
})
dataHandlerID := conn.RegisterHandlerAtEpoch(msgContractData, func(fields []string, receiptEpoch uint64) {
if receiptEpoch == binding.epoch {
decodeFrame(fields, decodeContractDataLine)
}
})
endHandlerID := conn.RegisterHandlerAtEpoch(msgContractDataEnd, func(fields []string, receiptEpoch uint64) {
if receiptEpoch != binding.epoch || len(fields) < 3 {
return
}
if id, _ := strconv.Atoi(strings.TrimSpace(fields[2])); id == reqID {
record(BondLookupFrame{MessageID: msgContractDataEnd, Kind: BondFrameContractDataEnd, Fields: len(fields)})
select {
case doneCh <- struct{}{}:
default:
}
}
})
releaseTap := conn.tapInboundFrames(func(fields []string, receiptEpoch uint64) {
if receiptEpoch != binding.epoch {
return
}
if f, ok := bondTapFrame(fields, reqID); ok {
record(f)
}
})
defer releaseTap()
defer conn.UnregisterHandler(msgBondContractData, bondHandlerID)
defer conn.UnregisterHandler(msgContractData, dataHandlerID)
defer conn.UnregisterHandler(msgContractDataEnd, endHandlerID)
req, releaseReq := c.registerBondContractDetailsRequest(reqID)
defer releaseReq()
msg := conn.bondContractDetailsRequestMessage(fetchCtx, form.contract, reqID)
if err := conn.sendMessageWithTypeContextForEpoch(fetchCtx, msg, RequestTypeGeneral, binding.epoch, true); err != nil {
return nil, attempt, err
}
var out []BondContractDetails
for {
select {
case d := <-detailsCh:
out = append(out, d)
case err := <-req.fail:
return nil, attempt, err
case <-overflowCh:
return nil, attempt, fmt.Errorf("bond contract details overflow")
case <-doneCh:
for {
select {
case d := <-detailsCh:
out = append(out, d)
continue
case <-overflowCh:
return nil, attempt, fmt.Errorf("bond contract details overflow")
default:
}
break
}
if !c.SessionCurrent(binding) {
return nil, attempt, fmt.Errorf("broker session changed during bond contract details")
}
if len(out) == 0 {
return nil, attempt, fmt.Errorf("%w: %s", ErrBondContractNotFound, frames.emptyNote())
}
if factorPriced.Load() {
for i := range out {
out[i].FactorPriced = true
}
}
return out, attempt, nil
case <-fetchCtx.Done():
return nil, attempt, fetchCtx.Err()
}
}
}
// bondFactorNoticeCode is the notice IBKR sends with the contract details
// of a factor-priced line (live read 2026-10-06 of an inflation-linked
// Bund: "Warning: DBRI product is trading on the basis of currency price
// with factor").
const bondFactorNoticeCode = 2130
// isBondFactorNotice reports whether a notice on a bond request says the
// line is factor-priced: its code, or, should IBKR renumber it, its text.
func isBondFactorNotice(code int, text string) bool {
return code == bondFactorNoticeCode || strings.Contains(strings.ToLower(text), "price with factor")
}
// Bond yield tick types. IBKR sends a bond's yields as price ticks: 50–52
// on the live feed, 103–105 on the delayed one. The value is passed through
// as the gateway sends it (percent, an assumption to verify post-install).
const (
tickBidYield = 50
tickAskYield = 51
tickLastYield = 52
tickDelayedBidYield = 103
tickDelayedAskYield = 104
tickDelayedLastYld = 105
)
// isBondYieldTick reports whether a price tick carries a yield, not a price.
func isBondYieldTick(tickType int) bool {
switch tickType {
case tickBidYield, tickAskYield, tickLastYield, tickDelayedBidYield, tickDelayedAskYield, tickDelayedLastYld:
return true
}
return false
}
// recordBondYield stores a yield tick. -1 is the gateway's "no value"
// sentinel; anything at or below it, or not finite, is not a yield.
func (s *Subscription) recordBondYield(tickType int, value float64) {
if math.IsNaN(value) || math.IsInf(value, 0) || value <= -1 {
return
}
v := value
switch tickType {
case tickBidYield, tickDelayedBidYield:
s.bidYield = &v
case tickAskYield, tickDelayedAskYield:
s.askYield = &v
case tickLastYield, tickDelayedLastYld:
s.lastYield = &v
}
}
// isBondMarketDataContract reports whether a quote request is for a bill or
// bond, which is subscribed without generic ticks: the stock tick list is
// not defined for them.
func isBondMarketDataContract(contract Contract) bool {
return IsBillOrBond(contract.SecType)
}
func cloneYield(v *float64) *float64 {
if v == nil {
return nil
}
return new(*v)
}
package ibkr
import (
"errors"
"fmt"
"math"
"strconv"
"strings"
"sync"
)
// Bond frames: decoding the frames IBKR answers a bill or bond
// contract-details request with, and the per-request wire record that says
// which frames arrived and why each became a line or did not
// (internal-docs/design/cash-sweep.md, F4).
//
// IBKR answers a bond request with bondContractData (message 18) and notes
// that bond market-data licensing leaves only a few fields of a bond
// description populated (the minimum tick, the exchange, the short name).
// A frame is therefore a line when its identity decodes: the request id,
// the typed fields up to the minimum tick and a positive contract id. An
// empty maturity, currency or identifier is a gap the line carries and the
// wire record names, never a reason to drop it: the sweep refuses a line
// without a maturity, and an order needs the size rules of a whole frame.
// An ordinary contractData frame (message 10) that answers the request is a
// line too, with the bond-only fields empty.
// Frame kinds a bond lookup records.
const (
BondFrameBondContractData = "bondContractData"
BondFrameContractData = "contractData"
BondFrameContractDataEnd = "contractDataEnd"
BondFrameError = "error"
BondFrameOther = "other"
)
// BondLookupFrame is one inbound frame that named a bond request's id,
// reduced to identifiers: the message, its raw field count, and for a
// contract frame the contract id, security type, identifiers, exchange,
// currency and maturity as sent (by their position in the layout, whether
// or not the frame decoded). An error frame carries IBKR's code. Line says
// the frame decoded into a line, Complete that the whole frame did; Note
// says why a contract frame is not a line, or what the line lacks.
type BondLookupFrame struct {
MessageID int
Kind string
Fields int
Layout string
ConID string
SecType string
Symbol string
CUSIP string
LocalSymbol string
Exchange string
Currency string
Maturity string
Code int
Line bool
Complete bool
Note string
}
// String is the frame as a log line shows it.
func (f BondLookupFrame) String() string {
var b strings.Builder
fmt.Fprintf(&b, "msg %d %s, %d fields", f.MessageID, f.Kind, f.Fields)
for _, kv := range []struct{ k, v string }{{"layout", f.Layout}, {"con_id", f.ConID}, {"sec_type", f.SecType}, {"symbol", f.Symbol},
{"cusip", f.CUSIP}, {"local_symbol", f.LocalSymbol}, {"exchange", f.Exchange}, {"currency", f.Currency}, {"maturity", f.Maturity}} {
if kv.v != "" {
fmt.Fprintf(&b, ", %s %q", kv.k, kv.v)
}
}
if f.Code != 0 {
fmt.Fprintf(&b, ", code %d", f.Code)
}
if f.contract() {
switch {
case f.Line && f.Complete:
b.WriteString(": a line, whole frame decoded")
case f.Line:
b.WriteString(": a line")
default:
b.WriteString(": not a line")
}
}
if f.Note != "" {
b.WriteString(": " + f.Note)
}
return b.String()
}
// contract reports whether the frame is contract data (message 10 or 18).
func (f BondLookupFrame) contract() bool {
return f.Kind == BondFrameBondContractData || f.Kind == BondFrameContractData
}
// bondFrameLayout is the layout a contract frame is decoded with: the
// negotiated server version's, per IBKR's EDecoder (API 10.37), where a
// server before 164 sends a message version after the message id. A frame
// that names the request where the other reading expects it is decoded
// with the other reading and says so.
type bondFrameLayout struct {
serverVersion int
versionField bool
}
func bondFrameLayoutFor(serverVersion int) bondFrameLayout {
return bondFrameLayout{serverVersion: serverVersion, versionField: serverVersion < minServerVerSizeRules}
}
// String names the layout: the server version, and a version-field
// reading other than the version's own.
func (l bondFrameLayout) String() string {
name := "server " + strconv.Itoa(l.serverVersion)
switch {
case l.versionField == (l.serverVersion < minServerVerSizeRules):
return name
case l.versionField:
return name + ", read with a message version"
}
return name + ", read without a message version"
}
// reqIDIndex is the field that carries the request id.
func (l bondFrameLayout) reqIDIndex() int {
if l.versionField {
return 2
}
return 1
}
// bondFrameLayoutNaming is the layout a frame that names reqID is decoded
// with: the negotiated one when the frame names the request where it
// expects, else the other version-field reading when the frame names it
// there. ok is false for a frame of another request.
func bondFrameLayoutNaming(fields []string, reqID int, negotiated bondFrameLayout) (bondFrameLayout, bool) {
id := strconv.Itoa(reqID)
if strings.TrimSpace(safeGet(fields, negotiated.reqIDIndex())) == id {
return negotiated, true
}
other := negotiated
other.versionField = !negotiated.versionField
if strings.TrimSpace(safeGet(fields, other.reqIDIndex())) == id {
return other, true
}
return negotiated, false
}
// errBondFrameOtherRequest marks a contract frame that answers another
// request.
var errBondFrameOtherRequest = errors.New("the frame answers another request")
// wireReader reads a frame field by field and keeps the first failure,
// naming the field and its position.
type wireReader struct {
fields []string
idx int
err error
}
func (r *wireReader) next(name string) (string, bool) {
if r.err != nil {
return "", false
}
if r.idx >= len(r.fields) {
r.err = fmt.Errorf("the frame ends before the %s (%d fields)", name, len(r.fields))
return "", false
}
v := r.fields[r.idx]
r.idx++
return v, true
}
func (r *wireReader) str(name string) string {
v, _ := r.next(name)
return strings.TrimSpace(v)
}
// integer reads an integer field; an empty field reads as zero.
func (r *wireReader) integer(name string) int {
raw, ok := r.next(name)
v := strings.TrimSpace(raw)
if !ok || v == "" {
return 0
}
n, err := strconv.Atoi(v)
if err != nil {
r.err = fmt.Errorf("the %s %q is not an integer (field %d)", name, wireValue(raw), r.idx-1)
return 0
}
return n
}
// number reads a finite numeric field; an empty field reads as zero.
func (r *wireReader) number(name string) float64 {
raw, ok := r.next(name)
v := strings.TrimSpace(raw)
if !ok || v == "" {
return 0
}
n, err := strconv.ParseFloat(v, 64)
if err != nil || math.IsNaN(n) || math.IsInf(n, 0) {
r.err = fmt.Errorf("the %s %q is not a finite number (field %d)", name, wireValue(raw), r.idx-1)
return 0
}
return n
}
// flag reads a 0/1 field, as IBKR sends booleans; empty reads false.
func (r *wireReader) flag(name string) {
raw, ok := r.next(name)
switch strings.TrimSpace(raw) {
case "", "0", "1":
return
}
if ok {
r.err = fmt.Errorf("the %s %q is not 0 or 1 (field %d)", name, wireValue(raw), r.idx-1)
}
}
// tagValues reads a counted tag/value list; entries with an empty tag or
// value are skipped.
func (r *wireReader) tagValues(name string) map[string]string {
count := r.integer(name + " count")
if r.err != nil {
return nil
}
if left := len(r.fields) - r.idx; count < 0 || count > left/2 {
r.err = fmt.Errorf("the %s count %d does not fit the %d fields left", name, count, left)
return nil
}
var out map[string]string
for range count {
tag, value := strings.ToUpper(r.str(name+" tag")), r.str(name+" value")
if tag != "" && value != "" {
if out == nil {
out = map[string]string{}
}
out[tag] = value
}
}
return out
}
// finish reports whether the whole frame was read: every field, or all
// but the one empty field IBKR's closing separator leaves.
func (r *wireReader) finish(layout bondFrameLayout) error {
if r.err != nil {
return r.err
}
rest := len(r.fields) - r.idx
if rest == 0 || (rest == 1 && strings.TrimSpace(r.fields[r.idx]) == "") {
return nil
}
return fmt.Errorf("%d fields past the %d the %s layout reads", rest, r.idx, layout)
}
// bondLineIdentity refuses a frame whose identity did not decode: a field
// up to the minimum tick that ended the frame or had the wrong type, no
// positive contract id, or a currency that is not a three-letter code.
func bondLineIdentity(d BondContractDetails, err error) error {
switch {
case err != nil:
return err
case d.ConID <= 0:
return errors.New("the frame carries no positive contract id")
case d.Currency != "" && !bondCurrencyCode(d.Currency):
return fmt.Errorf("the currency %q is not a three-letter code", wireValue(d.Currency))
}
return nil
}
// bondLineGaps names what a decoded line lacks.
func bondLineGaps(d BondContractDetails, rawMaturity string) []string {
var gaps []string
if _, ok := d.MaturityDate(); !ok {
if strings.TrimSpace(rawMaturity) == "" {
gaps = append(gaps, "no maturity")
} else {
gaps = append(gaps, fmt.Sprintf("maturity %q unreadable", wireValue(rawMaturity)))
}
}
if d.Currency == "" {
gaps = append(gaps, "no currency (the request's stands in)")
}
return gaps
}
// decodeBondContractData decodes one bondContractData frame in layout
// (IBKR's EDecoder.processBondContractDataMsg). The error says why the
// frame is not a line; the notes say what the line lacks. The size rules,
// secIdList, long name and hours are kept only when the whole frame
// decodes, so a field IBKR adds or moves can never shift into a size rule.
func decodeBondContractData(fields []string, reqID int, layout bondFrameLayout) (BondContractDetails, []string, error) {
r := &wireReader{fields: fields}
if id := r.integer("message id"); r.err != nil || id != msgBondContractData {
return BondContractDetails{}, nil, fmt.Errorf("the message id %q is not bondContractData", wireValue(safeGet(fields, 0)))
}
version := 6
if layout.versionField {
if version = r.integer("message version"); r.err == nil && version < 1 {
r.err = fmt.Errorf("the message version %d is not positive", version)
}
}
d := BondContractDetails{ReqID: -1}
if version >= 3 {
d.ReqID = r.integer("request id")
}
if r.err != nil {
return BondContractDetails{}, nil, r.err
}
if reqID != 0 && d.ReqID != reqID {
return BondContractDetails{}, nil, errBondFrameOtherRequest
}
d.Symbol = r.str("symbol")
d.SecType = strings.ToUpper(r.str("security type"))
d.CUSIPField = r.str("cusip")
d.Coupon = r.number("coupon")
maturity := r.str("maturity")
d.Maturity = bondWireDate(maturity)
d.IssueDate = bondWireDate(r.str("issue date"))
d.Ratings = wireValue(r.str("ratings"))
d.BondType = r.str("bond type")
d.CouponType = r.str("coupon type")
r.flag("convertible flag")
r.flag("callable flag")
r.flag("putable flag")
d.DescAppend = r.str("description")
d.Exchange = strings.ToUpper(r.str("exchange"))
d.Currency = strings.ToUpper(r.str("currency"))
d.MarketName = r.str("market name")
d.TradingClass = r.str("trading class")
d.ConID = r.integer("contract id")
d.MinTick = r.number("minimum tick")
if err := bondLineIdentity(d, r.err); err != nil {
return BondContractDetails{}, nil, err
}
notes := bondLineGaps(d, maturity)
tail := d
sv := layout.serverVersion
if sv >= minServerVerMdSizeMultiplier && sv < minServerVerSizeRules {
r.integer("md size multiplier")
}
r.str("order types")
tail.ValidExchanges = r.str("valid exchanges")
r.str("next option date")
r.str("next option type")
r.flag("next option partial flag")
r.str("notes")
if version >= 4 {
tail.LongName = r.str("long name")
}
if sv >= minServerVerBondTradingHours {
tail.TimeZoneID = r.str("time zone")
tail.TradingHours = r.str("trading hours")
tail.LiquidHours = r.str("liquid hours")
}
if version >= 6 {
r.str("ev rule")
r.number("ev multiplier")
}
if version >= 5 {
tail.SecIDs = r.tagValues("secIdList")
}
if sv >= minServerVerAggGroup {
r.integer("agg group")
}
if sv >= minServerVerMarketRules {
r.str("market rule ids")
}
if sv >= minServerVerSizeRules {
tail.MinSize = r.number("minimum size")
tail.SizeIncrement = r.number("size increment")
r.number("suggested size increment")
}
if err := r.finish(layout); err != nil {
return d, append(notes, "size rules, identifiers and hours not kept: "+err.Error()), nil
}
tail.Complete = true
return tail, notes, nil
}
// decodeContractDataLine decodes an ordinary contractData frame (message
// 10) that answered a bill or bond request into a line (IBKR's
// EDecoder.processContractDataMsg). The bond-only fields (coupon, issue
// date, the cusip field, bond and coupon types) stay empty; the maturity is
// the lastTradeDateOrContractMonth, else the lastTradeDate, else, on a
// whole frame, the real expiration date. The rest follows
// decodeBondContractData.
func decodeContractDataLine(fields []string, reqID int, layout bondFrameLayout) (BondContractDetails, []string, error) {
r := &wireReader{fields: fields}
if id := r.integer("message id"); r.err != nil || id != msgContractData {
return BondContractDetails{}, nil, fmt.Errorf("the message id %q is not contractData", wireValue(safeGet(fields, 0)))
}
version := 8
if layout.versionField {
if version = r.integer("message version"); r.err == nil && version < 1 {
r.err = fmt.Errorf("the message version %d is not positive", version)
}
}
d := BondContractDetails{ReqID: -1}
if version >= 3 {
d.ReqID = r.integer("request id")
}
if r.err != nil {
return BondContractDetails{}, nil, r.err
}
if reqID != 0 && d.ReqID != reqID {
return BondContractDetails{}, nil, errBondFrameOtherRequest
}
sv := layout.serverVersion
d.Symbol = r.str("symbol")
d.SecType = strings.ToUpper(r.str("security type"))
maturity := r.str("last trade date or contract month")
if sv >= minServerVerLastTradeDate {
if lastTradeDate := r.str("last trade date"); maturity == "" {
maturity = lastTradeDate
}
}
r.number("strike")
r.str("right")
d.Exchange = strings.ToUpper(r.str("exchange"))
d.Currency = strings.ToUpper(r.str("currency"))
d.LocalSymbol = r.str("local symbol")
d.MarketName = r.str("market name")
d.TradingClass = r.str("trading class")
d.ConID = r.integer("contract id")
d.MinTick = r.number("minimum tick")
d.Maturity = bondWireDate(maturity)
if err := bondLineIdentity(d, r.err); err != nil {
return BondContractDetails{}, nil, err
}
tail := d
realExpiration := ""
if sv >= minServerVerMdSizeMultiplier && sv < minServerVerSizeRules {
r.integer("md size multiplier")
}
r.str("multiplier")
r.str("order types")
tail.ValidExchanges = r.str("valid exchanges")
if version >= 2 {
r.integer("price magnifier")
}
if version >= 4 {
r.integer("underlying contract id")
}
if version >= 5 {
tail.LongName = r.str("long name")
tail.PrimaryExch = strings.ToUpper(r.str("primary exchange"))
}
if version >= 6 {
for _, name := range []string{"contract month", "industry", "category", "subcategory"} {
r.str(name)
}
tail.TimeZoneID = r.str("time zone")
tail.TradingHours = r.str("trading hours")
tail.LiquidHours = r.str("liquid hours")
}
if version >= 8 {
r.str("ev rule")
r.number("ev multiplier")
}
if version >= 7 {
tail.SecIDs = r.tagValues("secIdList")
}
if sv >= minServerVerAggGroup {
r.integer("agg group")
}
if sv >= minServerVerUnderlyingInfo {
r.str("underlying symbol")
r.str("underlying security type")
}
if sv >= minServerVerMarketRules {
r.str("market rule ids")
}
if sv >= minServerVerRealExpiration {
realExpiration = r.str("real expiration date")
}
if sv >= minServerVerStockType {
r.str("stock type")
}
if sv >= minServerVerFractionalSize && sv < minServerVerSizeRules {
r.number("size minimum tick")
}
if sv >= minServerVerSizeRules {
tail.MinSize = r.number("minimum size")
tail.SizeIncrement = r.number("size increment")
r.number("suggested size increment")
}
if sv >= minServerVerFundDataFields && d.SecType == "FUND" {
for range 7 {
r.str("fund field")
}
for range 3 {
r.flag("fund closed flag")
}
for range 7 {
r.str("fund field")
}
}
if sv >= minServerVerIneligibility {
r.tagValues("ineligibility reasons")
}
tailErr := r.finish(layout)
if tailErr == nil {
if _, ok := tail.MaturityDate(); !ok && realExpiration != "" {
tail.Maturity, maturity = bondWireDate(realExpiration), realExpiration
}
tail.Complete = true
d = tail
}
notes := append(bondLineGaps(d, maturity), "an ordinary contractData frame: coupon, issue date and cusip field not sent")
if tailErr != nil {
notes = append(notes, "size rules, identifiers and hours not kept: "+tailErr.Error())
}
return d, notes, nil
}
// bondFrameSummary reduces a bondContractData or contractData frame to its
// identifiers by their position in layout, whether or not it decodes.
func bondFrameSummary(fields []string, layout bondFrameLayout) BondLookupFrame {
msgID, _ := strconv.Atoi(strings.TrimSpace(safeGet(fields, 0)))
f := BondLookupFrame{MessageID: msgID, Fields: len(fields), Layout: layout.String()}
at := func(i int) string { return wireValue(safeGet(fields, i)) }
off := 0
if layout.versionField {
off = 1
}
switch msgID {
case msgBondContractData:
f.Kind = BondFrameBondContractData
f.Symbol, f.SecType, f.CUSIP, f.Maturity = at(2+off), at(3+off), at(4+off), at(6+off)
f.Exchange, f.Currency, f.ConID = at(15+off), at(16+off), at(19+off)
case msgContractData:
f.Kind = BondFrameContractData
f.Symbol, f.SecType, f.Maturity = at(2+off), at(3+off), at(4+off)
if layout.serverVersion >= minServerVerLastTradeDate {
off++ // the lastTradeDate follows the lastTradeDateOrContractMonth
}
f.Exchange, f.Currency, f.LocalSymbol, f.ConID = at(7+off), at(8+off), at(9+off), at(12+off)
}
return f
}
// bondTapFrame is the record of a frame other than contract data that
// names reqID: an error (text message 4, or the protobuf notice 204) with
// IBKR's code and text, or any other message by its id and field count.
// ok is false for a frame that does not name reqID; contract frames belong
// to the request's own handlers.
func bondTapFrame(fields []string, reqID int) (BondLookupFrame, bool) {
msgID, err := strconv.Atoi(strings.TrimSpace(safeGet(fields, 0)))
if err != nil {
return BondLookupFrame{}, false
}
id := strconv.Itoa(reqID)
f := BondLookupFrame{MessageID: msgID, Kind: BondFrameOther, Fields: len(fields)}
switch msgID {
case msgContractData, msgBondContractData, msgContractDataEnd:
return BondLookupFrame{}, false
case msgSystemNotification:
note, err := parseSystemNotificationPayload([]byte(safeGet(fields, 1)))
if err != nil || note.tickerID != int64(reqID) {
return BondLookupFrame{}, false
}
f.Kind, f.Code, f.Note = BondFrameError, note.code, brokerNoticeLine(note.message)
return f, true
case msgErrMsg:
// [4, reqID, code, text, …] from server 194, [4, version, reqID, code, text] before.
for _, at := range []int{1, 2} {
if strings.TrimSpace(safeGet(fields, at)) == id {
code, _ := strconv.Atoi(strings.TrimSpace(safeGet(fields, at+1)))
f.Kind, f.Code, f.Note = BondFrameError, code, brokerNoticeLine(safeGet(fields, at+2))
return f, true
}
}
return BondLookupFrame{}, false
}
if strings.TrimSpace(safeGet(fields, 1)) != id && strings.TrimSpace(safeGet(fields, 2)) != id {
return BondLookupFrame{}, false
}
return f, true
}
// wireValue keeps a wire field readable in a log or JSON line: printable
// ASCII only, trimmed, at most 40 characters.
func wireValue(raw string) string {
v := strings.TrimSpace(strings.Map(func(r rune) rune {
if r < 0x20 || r > 0x7e {
return '?'
}
return r
}, raw))
if len(v) > 40 {
v = v[:40] + "…"
}
return v
}
// bondFrameLogMax caps the frames one request form records; the rest are
// counted.
const bondFrameLogMax = 16
// bondFrameLog records the frames that name one request form's id. The
// reader goroutine adds; the lookup reads when the form ends.
type bondFrameLog struct {
mu sync.Mutex
frames []BondLookupFrame
omitted int
}
func (l *bondFrameLog) add(f BondLookupFrame) {
l.mu.Lock()
defer l.mu.Unlock()
if len(l.frames) < bondFrameLogMax {
l.frames = append(l.frames, f)
} else {
l.omitted++
}
}
func (l *bondFrameLog) snapshot() ([]BondLookupFrame, int) {
l.mu.Lock()
defer l.mu.Unlock()
return append([]BondLookupFrame(nil), l.frames...), l.omitted
}
// emptyNote says why a form that ended without a line found none: no frame
// named the request before its end marker, or the contract frames that did
// were not lines (the first one's reason).
func (l *bondFrameLog) emptyNote() string {
frames, omitted := l.snapshot()
refused, others, first := 0, omitted, ""
for _, f := range frames {
switch {
case f.contract() && !f.Line:
if refused++; first == "" {
first = f.Kind + ": " + f.Note
}
case f.Kind != BondFrameContractDataEnd:
others++
}
}
switch {
case refused == 1:
return "the search ended without a line: IBKR sent 1 contract frame that did not decode (" + first + ")"
case refused > 1:
return fmt.Sprintf("the search ended without a line: IBKR sent %d contract frames that did not decode (the first %s)", refused, first)
case others > 0:
return fmt.Sprintf("the search ended without a line: %d frames of other messages named the request before its end marker", others)
}
return "the search ended without a line: no frame named the request before its end marker"
}
package ibkr
import (
"fmt"
"math"
"strconv"
"strings"
"time"
)
// Bond orders (internal-docs/design/cash-sweep.md, Phase B order path). A
// BOND order is a DAY limit order on one contract id. Its quantity counts the
// instrument's order unit and its price is quoted per 100 of face; both must
// sit on the size and price grid the line's own contract details carry.
// ValidateOrder refuses any BOND order that does not, so neither encoder can
// send one, and NewBondLimitOrder refuses it at construction.
// BondOrderRules is the order grid of one bond line, read from its contract
// details: the minimum order size and the size increment in order units, and
// the minimum price increment in the quote's convention (per 100 of face).
type BondOrderRules struct {
MinTick float64
MinSize float64
SizeIncrement float64
}
// BondOrderRulesFrom reads the grid from a line's contract details. The size
// rules arrive only on a complete frame; a line without them, or without a
// minimum tick, cannot be ordered.
func BondOrderRulesFrom(d BondContractDetails) (BondOrderRules, error) {
r := BondOrderRules{MinTick: d.MinTick}
if d.Complete {
r.MinSize, r.SizeIncrement = d.MinSize, d.SizeIncrement
}
return r, r.Validate()
}
// Validate reports whether the grid can bound an order: a positive minimum
// tick and minimum size, and sizes in whole order units.
func (r BondOrderRules) Validate() error {
switch {
case !bondPositiveFinite(r.MinTick):
return fmt.Errorf("bond contract details carry no minimum tick")
case !bondPositiveFinite(r.MinSize):
return fmt.Errorf("bond contract details carry no minimum size")
case math.IsNaN(r.SizeIncrement) || math.IsInf(r.SizeIncrement, 0) || r.SizeIncrement < 0:
return fmt.Errorf("bond contract details carry an invalid size increment")
case !bondWholeUnits(r.MinSize) || (r.SizeIncrement > 0 && !bondWholeUnits(r.SizeIncrement)):
return fmt.Errorf("bond size rules (minimum %s, increment %s) are not whole order units", formatBondSize(r.MinSize), formatBondSize(r.SizeIncrement))
}
return nil
}
// Step is the size grid in order units: the size increment, or the minimum
// size when the line names no increment.
func (r BondOrderRules) Step() int {
if r.SizeIncrement > 0 {
return int(math.Round(r.SizeIncrement))
}
return int(math.Round(r.MinSize))
}
// Minimum is the minimum size in whole order units.
func (r BondOrderRules) Minimum() int {
return int(math.Round(r.MinSize))
}
// CheckQuantity refuses a quantity below the minimum size or off the size
// grid.
func (r BondOrderRules) CheckQuantity(quantity int) error {
if err := r.Validate(); err != nil {
return err
}
if quantity < r.Minimum() {
return fmt.Errorf("bond order quantity %d is below the contract's minimum size %d", quantity, r.Minimum())
}
if step := r.Step(); step > 0 && quantity%step != 0 {
return fmt.Errorf("bond order quantity %d is not a multiple of the contract's size step %d", quantity, step)
}
return nil
}
// CheckPrice refuses a price that is not positive or not on the minimum tick.
func (r BondOrderRules) CheckPrice(price float64) error {
if err := r.Validate(); err != nil {
return err
}
if !bondPositiveFinite(price) {
return fmt.Errorf("bond order limit price must be positive")
}
if !OnBondTick(price, r.MinTick) {
return fmt.Errorf("bond order limit price %s is not on the contract's minimum tick %s", strconv.FormatFloat(price, 'f', -1, 64), strconv.FormatFloat(r.MinTick, 'f', -1, 64))
}
return nil
}
// OnBondTick reports whether price is a whole number of ticks, allowing for
// the float noise of a decimal tick.
func OnBondTick(price, tick float64) bool {
if !bondPositiveFinite(price) || !bondPositiveFinite(tick) {
return false
}
n := price / tick
return math.Abs(n-math.Round(n)) <= 1e-6
}
// BondTickFloor moves price down onto the tick grid: a whole number of
// ticks, cleaned of float noise (a price already on the grid stays).
func BondTickFloor(price, tick float64) float64 {
return bondTickSnap(price, tick, math.Floor)
}
// BondTickCeil moves price up onto the tick grid, like BondTickFloor.
func BondTickCeil(price, tick float64) float64 {
return bondTickSnap(price, tick, math.Ceil)
}
// BondTickNearest rounds price to the nearest tick, like BondTickFloor.
func BondTickNearest(price, tick float64) float64 {
return bondTickSnap(price, tick, math.Round)
}
func bondTickSnap(price, tick float64, round func(float64) float64) float64 {
if !bondPositiveFinite(tick) {
return price
}
n := price / tick
if math.Abs(n-math.Round(n)) <= 1e-6 {
n = math.Round(n)
} else {
n = round(n)
}
// Ten decimals keep 1/256-of-a-point ticks exact and drop binary noise.
return math.Round(n*tick*1e10) / 1e10
}
// NewBondLimitOrder builds the one order shape Canary sends for a bill or
// bond (secType BILL stays BILL, anything else goes as BOND): a DAY
// limit order for a contract id, with the line's grid attached so the
// encoders re-check it. It refuses a quantity off the size grid and a price
// off the minimum tick.
func NewBondLimitOrder(contract Contract, rules BondOrderRules, action string, quantity int, price float64) (*Contract, *RawOrder, error) {
c := contract
c.SecType = BillOrBondSecType(c.SecType)
c.Symbol = strings.ToUpper(strings.TrimSpace(c.Symbol))
c.Currency = strings.ToUpper(strings.TrimSpace(c.Currency))
c.Exchange = strings.ToUpper(strings.TrimSpace(c.Exchange))
if c.Exchange == "" {
c.Exchange = "SMART"
}
c.BondRules = &rules
order := &RawOrder{Action: strings.ToUpper(strings.TrimSpace(action)), TotalQty: quantity, OrderType: "LMT", LmtPrice: price, LmtPriceSet: true, TIF: "DAY"}
probe := &IBKROrder{ConID: c.ConID, Symbol: c.Symbol, SecType: c.SecType, Exchange: c.Exchange, Currency: c.Currency,
Expiry: c.Expiry, Right: c.Right, Strike: c.Strike, Multiplier: multiplierToString(c.Multiplier), ComboLegs: c.ComboLegs,
Action: order.Action, TotalQty: order.TotalQty, OrderType: order.OrderType, LmtPrice: order.LmtPrice, LmtPriceSet: true, TIF: order.TIF,
BondRules: c.BondRules}
if err := ValidateOrder(probe); err != nil {
return nil, nil, err
}
return &c, order, nil
}
// validateBondOrder is ValidateOrder's BOND branch: a limit order for a
// positive contract id and one currency, DAY only, with no option or combo
// fields and a quantity and price on the line's grid.
func validateBondOrder(order *IBKROrder) error {
switch {
case order.ConID <= 0:
return fmt.Errorf("bond orders require a positive contract id")
case !bondCurrencyCode(strings.ToUpper(strings.TrimSpace(order.Currency))):
return fmt.Errorf("bond orders require a three-letter currency")
case !strings.EqualFold(strings.TrimSpace(order.OrderType), "LMT"):
return fmt.Errorf("bond orders must be limit orders, not %q", order.OrderType)
case order.TIF != "" && !strings.EqualFold(strings.TrimSpace(order.TIF), "DAY"):
return fmt.Errorf("bond orders must be DAY orders, not %q", order.TIF)
case order.Expiry != "" || order.Right != "" || order.Strike != 0 || order.Multiplier != "" || len(order.ComboLegs) != 0:
return fmt.Errorf("bond orders carry no expiry, right, strike, multiplier or combo legs")
case order.AuxPrice != 0 || order.TrailStopPrice != 0 || order.TrailingPercent != 0 || order.LmtPriceOffset != 0:
return fmt.Errorf("bond limit orders carry no stop or trail prices")
case order.BondRules == nil:
return fmt.Errorf("bond orders require the line's size and price rules from its contract details")
}
if err := order.BondRules.CheckQuantity(order.TotalQty); err != nil {
return err
}
return order.BondRules.CheckPrice(order.LmtPrice)
}
func cloneBondOrderRules(in *BondOrderRules) *BondOrderRules {
if in == nil {
return nil
}
out := *in
return &out
}
func bondPositiveFinite(v float64) bool {
return v > 0 && !math.IsNaN(v) && !math.IsInf(v, 0)
}
func bondWholeUnits(v float64) bool {
return math.Abs(v-math.Round(v)) <= 1e-9 && v < math.MaxInt32
}
func formatBondSize(v float64) string {
return strconv.FormatFloat(v, 'f', -1, 64)
}
// TradingWindow is one interval a line trades in: open inclusive, close
// exclusive, in UTC.
type TradingWindow struct {
Open time.Time
Close time.Time
}
// Bond session sources: which of the frame's hour lists the windows came
// from.
const (
BondHoursLiquid = "liquid_hours"
BondHoursTrading = "trading_hours"
)
// SessionWindows reads the line's liquid hours, else its trading hours, in
// the line's time zone. ok is false when a complete frame carried neither,
// the zone is unknown, or the list does not parse; a list that says CLOSED
// for every day it names parses to no windows.
func (d BondContractDetails) SessionWindows() (windows []TradingWindow, source string, ok bool) {
if !d.Complete {
return nil, "", false
}
return sessionWindows(d.TimeZoneID, d.LiquidHours, d.TradingHours)
}
func sessionWindows(tz, liquid, trading string) (windows []TradingWindow, source string, ok bool) {
if strings.TrimSpace(tz) == "" {
return nil, "", false
}
for _, candidate := range []struct{ source, hours string }{{BondHoursLiquid, liquid}, {BondHoursTrading, trading}} {
if strings.TrimSpace(candidate.hours) == "" {
continue
}
windows, err := ParseTradingHours(tz, candidate.hours)
if err != nil {
return nil, "", false
}
return windows, candidate.source, true
}
return nil, "", false
}
// ParseTradingHours reads IBKR's hours list in zone tz: segments separated
// by ";", each "YYYYMMDD:CLOSED", "YYYYMMDD:HHMM-HHMM[,HHMM-HHMM]" or
// "YYYYMMDD:HHMM-YYYYMMDD:HHMM". A close at or before its open without a
// date of its own falls on the next day. Any segment that does not parse
// refuses the whole list.
func ParseTradingHours(tz, hours string) ([]TradingWindow, error) {
loc, err := time.LoadLocation(strings.TrimSpace(tz))
if err != nil {
return nil, fmt.Errorf("trading hours time zone %q: %w", tz, err)
}
var out []TradingWindow
for segment := range strings.SplitSeq(hours, ";") {
segment = strings.TrimSpace(segment)
if segment == "" {
continue
}
date, rest, found := strings.Cut(segment, ":")
if !found {
return nil, fmt.Errorf("trading hours segment %q has no date", segment)
}
day, err := time.ParseInLocation("20060102", date, loc)
if err != nil {
return nil, fmt.Errorf("trading hours segment %q: %w", segment, err)
}
if strings.EqualFold(strings.TrimSpace(rest), "CLOSED") {
continue
}
for span := range strings.SplitSeq(rest, ",") {
from, to, found := strings.Cut(strings.TrimSpace(span), "-")
if !found {
return nil, fmt.Errorf("trading hours span %q has no end", span)
}
open, err := tradingHoursClock(day, from, loc)
if err != nil {
return nil, err
}
closeDay := day
d, t, dated := strings.Cut(to, ":")
if dated {
if closeDay, err = time.ParseInLocation("20060102", d, loc); err != nil {
return nil, fmt.Errorf("trading hours span %q: %w", span, err)
}
to = t
}
closeAt, err := tradingHoursClock(closeDay, to, loc)
if err != nil {
return nil, err
}
if !closeAt.After(open) && !dated {
closeAt = closeAt.AddDate(0, 0, 1)
}
if !closeAt.After(open) {
return nil, fmt.Errorf("trading hours span %q closes before it opens", span)
}
out = append(out, TradingWindow{Open: open.UTC(), Close: closeAt.UTC()})
}
}
return out, nil
}
func tradingHoursClock(day time.Time, hhmm string, loc *time.Location) (time.Time, error) {
hhmm = strings.TrimSpace(hhmm)
if len(hhmm) != 4 {
return time.Time{}, fmt.Errorf("trading hours time %q is not HHMM", hhmm)
}
h, errH := strconv.Atoi(hhmm[:2])
m, errM := strconv.Atoi(hhmm[2:])
if errH != nil || errM != nil || h < 0 || h > 24 || m < 0 || m > 59 || (h == 24 && m != 0) {
return time.Time{}, fmt.Errorf("trading hours time %q is not HHMM", hhmm)
}
return time.Date(day.Year(), day.Month(), day.Day(), h, m, 0, 0, loc), nil
}
package ibkr
import (
"encoding/binary"
"fmt"
"strconv"
)
const protoCancelOrderMsgID = cancelOrder + protoBufMsgID
func (c *Connection) encodeCancelOrderMessage(orderID int) ([]byte, error) {
if err := validateProtoInt32("orderID", orderID); err != nil {
return nil, err
}
if c.serverVersion >= minServerVerProtoBufPlaceOrder {
return encodeCancelOrderProtoFrame(orderID), nil
}
return c.encodeCancelOrderLegacyFrame(orderID), nil
}
func encodeCancelOrderProtoFrame(orderID int) []byte {
body := encodeCancelOrderProtoBody(orderID)
msg := make([]byte, 0, 4+len(body))
msg = binary.BigEndian.AppendUint32(msg, uint32(protoCancelOrderMsgID))
return append(msg, body...)
}
func encodeCancelOrderProtoBody(orderID int) []byte {
var body []byte
body = protoAppendInt32(body, 1, int32(orderID))
body = protoAppendMessage(body, 2, nil)
return body
}
func (c *Connection) encodeCancelOrderLegacyFrame(orderID int) []byte {
fields := []any{cancelOrder}
if c.serverVersion < minServerVerCMETaggingFields {
fields = append(fields, 1)
}
fields = append(fields, orderID)
if c.serverVersion >= minServerVerManualOrderTime {
fields = append(fields, "")
}
if c.serverVersion >= minServerVerRFQFields && c.serverVersion < minServerVerUndoRFQFields {
fields = append(fields, "", "", maxProtoInt32)
}
if c.serverVersion >= minServerVerCMETaggingFields {
fields = append(fields, "", maxProtoInt32)
}
return c.encodeMsg(fields...)
}
func summarizeCancelOrderProtoFrame(msgBytes []byte) []string {
fields := []string{strconv.Itoa(protoCancelOrderMsgID), "protobuf", "base_msg_id=" + strconv.Itoa(cancelOrder)}
if len(msgBytes) < 4 {
return append(fields, "decode_error=truncated")
}
summary, err := parseCancelOrderProtoSummary(msgBytes[4:])
if err != nil {
return append(fields, "decode_error="+err.Error())
}
fields = append(fields, "orderId="+strconv.Itoa(summary.orderID))
if summary.hasOrderCancel {
fields = append(fields, "orderCancel=true")
}
return fields
}
type cancelOrderProtoSummary struct {
orderID int
hasOrderCancel bool
}
func parseCancelOrderProtoSummary(body []byte) (cancelOrderProtoSummary, error) {
var summary cancelOrderProtoSummary
err := forEachProtoField(body, func(fieldNumber, wireType int, value []byte) error {
switch fieldNumber {
case 1:
v, err := protoVarintValue(fieldNumber, wireType, value)
if err != nil {
return err
}
summary.orderID = int(v)
case 2:
if wireType != protoWireBytes {
return fmt.Errorf("field_%d_not_message", fieldNumber)
}
if err := parseOrderCancelProtoSummary(value); err != nil {
return err
}
summary.hasOrderCancel = true
}
return nil
})
return summary, err
}
func parseOrderCancelProtoSummary(body []byte) error {
return forEachProtoField(body, func(fieldNumber, wireType int, _ []byte) error {
switch fieldNumber {
case 1, 2:
if wireType != protoWireBytes {
return fmt.Errorf("field_%d_not_string", fieldNumber)
}
case 3:
if wireType != protoWireVarint {
return fmt.Errorf("field_%d_not_varint", fieldNumber)
}
default:
return fmt.Errorf("unsupported_order_cancel_field_%d", fieldNumber)
}
return nil
})
}
package ibkr
import (
"context"
"fmt"
"sort"
"strings"
"time"
)
// ChartMaxBars bounds both acquisition and the returned chart series.
const ChartMaxBars = 2000
// ChartSeries preserves the actual resolved identity and requested price basis.
type ChartSeries struct {
Contract Contract
Bars []HistoricalBar
WhatToShow string
}
// FetchChartBars reads bounded bars through exact session-bound contract resolution.
// It shares request registration, pacing and cancellation with the historical client.
func (c *Connector) FetchChartBars(ctx context.Context, contract Contract, days int, interval string, timeout time.Duration) (ChartSeries, error) {
result := ChartSeries{}
if (interval != "5 mins" && interval != "30 mins" && interval != "1 day") || days < 1 || days > 1830 || (interval != "1 day" && days > 7) {
return result, fmt.Errorf("invalid chart range or interval")
}
// Reviewed terminal evidence settles the contract before any resolution
// request; the heuristic inactive mark is deliberately not consulted here.
if _, terminal := c.reviewedTerminalReason(MarketDataKeyForContract(normalizeMarketDataContract(contract))); terminal {
return result, ErrSymbolInactive
}
binding, ok := c.CaptureSession()
if !ok {
return result, fmt.Errorf("broker session unavailable")
}
resolved, err := c.ResolveOrderContractForSession(ctx, binding, contract, min(timeout, 10*time.Second))
if err != nil {
return result, err
}
if !c.SessionCurrent(binding) {
return result, fmt.Errorf("broker session changed")
}
result.Contract = resolved.Contract
result.WhatToShow = "TRADES"
if contract.SecType == "CASH" {
result.WhatToShow = "MIDPOINT"
}
result.Bars, err = c.fetchHistoricalWithContractOptions(ctx, resolved.Contract.Symbol, resolved.Contract, days, timeout, result.WhatToShow, historicalRequestOptions{formatDate: 2, chartBarSize: interval, chartOutsideRTH: interval != "1 day", strictDaily: true, waitForEnd: true, maxBars: ChartMaxBars})
if !c.SessionCurrent(binding) {
return ChartSeries{}, fmt.Errorf("broker session changed during history")
}
return result, err
}
// MarketClassification is the broker's own description of a contract's
// business: the coarse industry, the finer category, and the stock type
// (COMMON, ETF, ADR, ...). Any field may be empty; an ETF typically carries
// only its stock type.
type MarketClassification struct {
Industry string
Category string
StockType string
}
// MarketClassification preserves the classification of an exact
// session-bound contract.
func (c *Connector) MarketClassification(ctx context.Context, contract Contract, timeout time.Duration) (MarketClassification, error) {
binding, ok := c.CaptureSession()
if !ok {
return MarketClassification{}, fmt.Errorf("broker session unavailable")
}
resolved, err := c.ResolveOrderContractForSession(ctx, binding, contract, timeout)
if err != nil {
return MarketClassification{}, err
}
return MarketClassification{Industry: resolved.Industry, Category: resolved.Category, StockType: resolved.StockType}, nil
}
// FrontFuture identifies the nearest strictly future expiry from broker details.
// It is a dated contract, not a continuous or back-adjusted futures series.
func (c *Connector) FrontFuture(ctx context.Context, contract Contract, now time.Time) (Contract, error) {
return c.frontFuture(ctx, contract, now, 3*time.Second, nil)
}
func selectFrontFuture(want Contract, details []ContractDetailsLite, now time.Time) (Contract, error) {
candidates := []ContractDetailsLite{}
for _, d := range details {
if d.ConID <= 0 || d.SecType != "FUT" || d.Symbol != want.Symbol || d.Currency != want.Currency || !strings.EqualFold(d.Exchange, want.Exchange) || d.TradingClass != want.Symbol {
continue
}
date, err := time.Parse("20060102", d.Expiry)
if err != nil || date.Format("2006-01-02") <= now.UTC().Format("2006-01-02") {
continue
}
candidates = append(candidates, d)
}
sort.Slice(candidates, func(i, j int) bool { return candidates[i].Expiry < candidates[j].Expiry })
if len(candidates) == 0 {
return Contract{}, fmt.Errorf("front future contract unavailable")
}
d := candidates[0]
if len(candidates) > 1 && candidates[1].Expiry == d.Expiry && candidates[1].ConID != d.ConID {
return Contract{}, fmt.Errorf("front future contract ambiguous")
}
return Contract{ConID: d.ConID, Symbol: d.Symbol, SecType: d.SecType, Exchange: d.Exchange, Currency: d.Currency, Expiry: d.Expiry, LocalSymbol: d.LocalSymbol, TradingClass: d.TradingClass, Multiplier: d.Multiplier}, nil
}
package ibkr
import (
"bufio"
"bytes"
"context"
"crypto/tls"
"encoding/binary"
"errors"
"fmt"
"io"
"log"
"maps"
"math"
"math/rand"
"net"
"os"
"reflect"
"runtime/debug"
"slices"
"strconv"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
"github.com/osauer/canary/v2/pkg/ibkr/internal/logging"
)
// ConnectionStatus identifies the lifecycle state of a [Connection].
type ConnectionStatus int
const (
// StatusDisconnected means no protocol session is established.
StatusDisconnected ConnectionStatus = iota
// StatusConnecting means a connection or handshake is in progress.
StatusConnecting
// StatusConnected means the protocol session is ready.
StatusConnected
// StatusReconnecting means recovery of a lost session is in progress.
StatusReconnecting
// StatusFailed means the most recent connection attempt failed.
StatusFailed
)
const (
// defaultStartupReadBudget bounds the wait for nextValidId after
// START_API. IB Gateway sends the whole startup burst within milliseconds,
// a large account's open orders included; the budget leaves room for a
// slow machine and stays inside the daemon's 12 s handshake watchdog.
defaultStartupReadBudget = 5 * time.Second
// startupAccountsGrace bounds the wait for managedAccounts once
// nextValidId is in. IB Gateway sends the accounts first; the grace keeps
// the one-second window the startup read had before 2026-10-19 for one
// that sends them after, without spending the whole budget on a Gateway
// that never does.
startupAccountsGrace = time.Second
// startupMessageCap bounds the startup read for a Gateway that streams
// without ever sending nextValidId: two messages per open order leaves
// room for thousands of open orders.
startupMessageCap = 10000
)
var (
errStartAPIFailed = errors.New("ibkr start api failure")
errHandshakeNoData = errors.New("ibkr handshake: no response")
// errNoNextValidID marks a START_API the Gateway answered without
// nextValidId; SessionStartFailure reports it.
errNoNextValidID = errors.New("nextValidId not received during startup")
// errClientIDInUse marks the IBKR code-326 client-ID collision.
errClientIDInUse = errors.New("IBKR: client ID already in use")
// ErrRejectedBeforeHandshake marks a connect attempt the listener ended
// before the API handshake exchanged a byte: the TCP connection was
// accepted and then closed or reset — at the dial, at the first socket
// option, on the version-descriptor write, or in place of the reply.
// TWS and IB Gateway do this while their API is turning clients away
// (an unfinished login, a pending incoming-connection prompt, a
// trusted-IP rule), and so does an OS firewall that resets accepted
// connections to the app. A refused or timed-out dial is not this
// error: there, nothing answered. Test with errors.Is.
ErrRejectedBeforeHandshake = errors.New("listener ended the connection before the API handshake")
ibkrLogger = logging.Component("IBKR")
connectLogger = logging.Component("IBKR Connect")
wireLogger = logging.Component("IBKR Wire")
handshakeLogger = logging.Component("IBKR Handshake")
portfolioLogger = logging.Component("IBKR Portfolio")
marketLogger = logging.Component("IBKR MarketData")
)
func clientIDInUseError(clientID int, gatewayMsg string) error {
msg := fmt.Sprintf("gateway client ID %d is already in use; stop the stale IBKR API client or choose a free [gateway].client_id", clientID)
if strings.TrimSpace(gatewayMsg) != "" {
msg += ": " + strings.TrimSpace(gatewayMsg)
}
return fmt.Errorf("%w: %s", errClientIDInUse, msg)
}
// String returns the uppercase name of s, or "UNKNOWN" for an unrecognized value.
func (s ConnectionStatus) String() string {
switch s {
case StatusDisconnected:
return "DISCONNECTED"
case StatusConnecting:
return "CONNECTING"
case StatusConnected:
return "CONNECTED"
case StatusReconnecting:
return "RECONNECTING"
case StatusFailed:
return "FAILED"
default:
return "UNKNOWN"
}
}
// ConnectionConfig configures one TWS or IB Gateway protocol session.
type ConnectionConfig struct {
Host string
Port int
ClientID int
Account string
// PacketLogPath enables sensitive packet logging; %d expands to the client ID.
PacketLogPath string
LogWireHex bool // LogWireHex emits account-sensitive raw protocol frames.
// WireInterceptor records frames; nil uses the environment configuration.
WireInterceptor *WireInterceptor
// startAPI retry settings for the configured client ID.
MaxClientIDRetries int // Max attempts for transient startAPI failures (default 5)
// Reconnection settings (from hedge patterns)
// ManagedConnectionLogging delegates outage warnings to the owner: failed
// connection attempts, startAPI retries and a lost session (EOF, reset,
// failed heartbeat send, heartbeat timeout, disconnect) log at debug, and
// the owner reports the outage once. Protocol faults such as an oversized
// frame or a TLS alert still log at error. Returned errors and connection state are unchanged; standalone
// callers default to warnings.
ManagedConnectionLogging bool
AutoReconnect bool
MaxRetries int
InitialDelay time.Duration // Initial reconnect delay (5s)
MaxDelay time.Duration // Max reconnect delay (60s)
BackoffMultiplier float64 // Exponential backoff multiplier (2.0)
Jitter bool // Add random jitter to delays
// Connection timeouts
ConnectTimeout time.Duration
HeartbeatInterval time.Duration
// TLS options
UseTLS bool
EnableTLSFallback bool
TLSInsecureSkipVerify bool
TLSServerName string
}
// RawPosition contains the latest broker-reported values for one position.
type RawPosition struct {
ValuationAt time.Time
Account string
Contract Contract
Position float64
MarketPrice float64
MarketValue float64
AverageCost float64
UnrealizedPNL float64
RealizedPNL float64
}
// Contract identifies an instrument in TWS wire requests.
type Contract struct {
ConID int
Symbol string
SecType string // STK, OPT, FUT, etc.
Expiry string // For options/futures
Strike float64 // For options
Right string // P or C for options
Multiplier int
Exchange string
PrimaryExch string // Primary exchange for routing
Currency string
LocalSymbol string
TradingClass string
SecIDType string
SecID string
ComboLegs []ComboLeg
// BondRules is a BOND line's size and price grid from its contract
// details; every BOND order must carry it (NewBondLimitOrder).
BondRules *BondOrderRules
}
// ComboLeg is one exact contract inside an IBKR BAG contract. Ratio is a
// positive integer; Action describes the BAG's held direction. A parent SELL
// order reverses these actions to close or reduce the strategy.
type ComboLeg struct {
ConID int
Ratio int
Action string
Exchange string
OpenClose int
}
// DefaultConfig returns a new connection configuration with package defaults.
func DefaultConfig() *ConnectionConfig {
return &ConnectionConfig{
Host: "127.0.0.1",
Port: 4001, // IB Gateway port
ClientID: 1,
MaxClientIDRetries: 5,
AutoReconnect: true,
MaxRetries: 10,
InitialDelay: 5 * time.Second,
MaxDelay: 60 * time.Second,
BackoffMultiplier: 2.0,
Jitter: true,
ConnectTimeout: 10 * time.Second,
HeartbeatInterval: 30 * time.Second,
UseTLS: false,
EnableTLSFallback: true,
TLSInsecureSkipVerify: true,
TLSServerName: "",
LogWireHex: false,
}
}
func lookupEnvBool(key string) (bool, bool) {
if val, ok := os.LookupEnv(key); ok {
s := strings.TrimSpace(strings.ToLower(val))
switch s {
case "1", "true", "yes", "on":
return true, true
case "0", "false", "no", "off", "":
return false, true
default:
return false, true
}
}
return false, false
}
func (c *Connection) tlsAttempts() []bool {
if c == nil || c.config == nil {
return []bool{false}
}
base := c.config.UseTLS
seq := []bool{base}
if c.config.EnableTLSFallback {
seq = append(seq, !base)
}
return seq
}
// Connection owns one TWS protocol session and its request and receipt state.
type Connection struct {
// marketDataDefaultMode records successful reqMarketDataType writes under
// transportMu. A request-local override is framed with its restore inside
// that same transport section, so concurrent requests cannot inherit it.
marketDataDefaultMode int
marketDataModeEpoch uint64
marketDataModeDirty bool
displayChanged func()
displayAccount string
displayAccountValues map[string]string
accountValueTimes map[string]time.Time // guarded by accountMu
config *ConnectionConfig
status ConnectionStatus
statusMu sync.RWMutex
// Connection state
connectedAt time.Time
// lastHeartbeatNano holds the most recent heartbeat time as Unix
lastHeartbeatNano atomic.Int64
errorCount int
lastError error
// Reconnection control
reconnectChan chan struct{}
stopChan chan struct{}
stopOnce sync.Once
wg sync.WaitGroup
// TCP connection
conn net.Conn
reader *bufio.Reader
writer *bufio.Writer
scanner *bufio.Scanner
// Protocol state
serverVersion int
connTime string
reqIDSeq int
reqIDMu sync.Mutex
nextOrderID int
haveNextValidID bool
brokerIDExhausted bool
reservedOrderIDs map[int]struct{}
reservedRequestIDs map[int]struct{}
// reportedOwnOrderIDs maps the order IDs the broker reported on this
// socket generation as open orders of this client ID to what it reported
// with them: an order an earlier session or process placed may be
// claimed below the frontier to change it. Guarded by reqIDMu, cleared
// with the epoch.
reportedOwnOrderIDs map[int]ReportedOwnOrder
// reportedOrderPermIDs retains non-authorizing identity constraints from
// this client's status and open-order callbacks. Zero means conflicting
// positive permanent IDs were observed in this socket epoch.
reportedOrderPermIDs map[int]int
modifyPreviewIDs map[int]bool
modifyStatusUncertain map[int]uint64
modifyLocalIdentity map[int]OrderChangeIdentity
modifyStatusSerial uint64
modifyPreviewGeneration uint64
brokerSessionEpoch atomic.Uint64
// inboundEpochMu keeps late receipts attributable after outbound invalidation.
inboundEpochMu sync.RWMutex
// account holds the raw msgManagedAccts value, which is a
// comma-separated list for a login that carries several accounts.
// managedAccounts is that value split into concrete codes. Guarded by
// accountMu.
account string
managedAccounts []string
handshakeMu sync.RWMutex
handshakeReady chan struct{}
useTLS bool
// Outbound sequencing to guarantee single-writer semantics per client ID
transportMu sync.Mutex
transportCond *sync.Cond
transportPaused bool
// outboundSessionState is revoked at disconnect while brokerSessionEpoch and
// the inbound epoch remain available to attribute late decoded receipts.
outboundSessionState atomic.Uint64
// evidenceBarrier is installed by Connector and shared with receipt publication.
evidenceBarrier *sync.RWMutex
packetLogger PacketLogger
packetLoggerMu sync.RWMutex
packetLoggerCloser func() error
wireTap *WireInterceptor
// Order tracking (scaffold for tests and local state)
ordersMu sync.RWMutex
openOrders map[int]*IBKROrder
orderStatus map[int]string
// Request aliasing (reqID -> metadata) for logging/system notices
aliasMu sync.RWMutex
reqAlias map[int]reqAliasEntry
logWireHex bool
// Ensure write path runs serially
writeMu sync.Mutex
writeInProgress atomic.Bool
// publicationBarrier keeps protected sends inside one account publication.
publicationBarrier *sync.RWMutex
// Guard against repeated suspicious logs per symbol/payload.
suspectMu sync.Mutex
suspectFlags map[string]struct{}
suspectSummaries map[string]string
contractTimingMu sync.Mutex
contractTimings map[string]time.Duration
// Read loop coordination so outbound requests wait until reader is ready
readStartMu sync.Mutex
readStartCh chan struct{}
// Callbacks for status changes
onConnect func()
onDisconnect func(error)
// Message handling
msgHandlers map[int][]handlerEntry
handlersMu sync.RWMutex
handlerSeq uint64
whatIfOrdersMu sync.Mutex
whatIfOrderIDs map[int]struct{}
openOrderObserverMu sync.RWMutex
openOrderObserver func(msgID int, fields []string, epoch uint64)
orderStatusObserver func(fields []string, epoch uint64, disposition previewStatusDisposition)
// inboundTaps counts the armed frame taps (handlers under
// msgInboundTap); the reader skips the tap dispatch while it is zero.
inboundTaps atomic.Int32
// Market data type per reqID (1=RealTime,2=Frozen,3=Delayed,4=DelayedFrozen)
mktDataType map[int]int
mktDataTypeMu sync.RWMutex
optionContractMu sync.RWMutex
optionContractCache map[string]ContractDetailsLite
systemNoticeMu sync.RWMutex
systemNoticeHandler func(note *systemNotification, alias reqAliasEntry, epoch uint64) func()
errorPostActionMu sync.RWMutex
errorPostAction func(fields []string, epoch uint64) func()
// noticeLog is installed by the owning Connector so wire-notice severity
// can reflect state the connector already accounts for; see noticeLogContext.
noticeLogMu sync.RWMutex
noticeLog noticeLogContext
// suppressedCancelEchoes counts code-300 cancel echoes kept at debug while
// the backend link was broken; the Connector reports the count once when
// the link restores.
suppressedCancelEchoes atomic.Uint64
// suppressedDefinitionMisses counts code-200 notices kept at debug while
// the backend link was broken, reported on the same restore bookend.
suppressedDefinitionMisses atomic.Uint64
// Competing live session detection (error 10197)
competingMu sync.RWMutex
competingLiveSession bool
// Portfolio data storage
positions map[string]*RawPosition
positionsMu sync.RWMutex
// portfolioStaging is the current reqAccountUpdates initial generation.
// Published positions remain visible only with incomplete health until the
// matching accountDownloadEnd atomically replaces them, and only once the
// generation accounts for the stream's gross position value.
portfolioStaging map[string]*RawPosition
portfolioStagingActive bool
// positionsSnapshot is isolated reqPositions state. It must never mutate
positionsSnapshot map[string]*RawPosition
positionsSnapshotResult map[string]*RawPosition
positionsSnapshotActive bool
// portfolioProjectionMu makes cached rows and their receipt health atomic.
portfolioProjectionMu sync.RWMutex
portfolioHealthMu sync.RWMutex
portfolioHealth PortfolioStreamHealth
accountSummary map[string]string
// summarySnapshots accumulates account-summary rows per reqID so a
// synchronous reqAccountSummary read cannot be clobbered by the
// streaming reqAccountUpdates subscription, which writes the shared
// accountSummary map (issue #12). Guarded by accountMu.
summarySnapshots map[int]*summarySnapshot
summarySchedule accountSummarySchedule
retiredSummaryProbes map[int]uint64
currencyProbes map[int]*currencyProbeSnapshot
retiredCurrencyProbes map[int]uint64
accountMu sync.RWMutex
accountStreamReceipt accountStreamReceipt
// Completion signals for async operations
positionsEndChan chan struct{} // Signals when position sync is complete
acctSummaryEndChan chan struct{} // Signals when account summary is complete
// Rate limiting
rateLimiter *RateLimiter
ctx context.Context
cancel context.CancelFunc
// Tracks which reqIDs currently hold a market data slot. The error
marketDataSlotsMu sync.Mutex
marketDataSlots map[int]marketDataSlot
marketDataCensusAt time.Time
// Start API failure tracking for adaptive backoff
startAPIMu sync.Mutex
startAPIFailures int
lastStartAPIFailure time.Time
// sessionStartErr and sessionStartRead are the latest attempt's failed
// START_API and the messages the Gateway sent before it failed.
sessionStartErr error
sessionStartRead int
// startupReadBudget overrides defaultStartupReadBudget; tests shorten it.
startupReadBudget time.Duration
// accountsEpoch is the broker session epoch whose managedAccounts
// message has been processed.
accountsEpoch atomic.Uint64
// errorMessageAfterInitialEpochCheck is a deterministic receipt-race test seam.
errorMessageAfterInitialEpochCheck func()
// systemNoticeAfterInitialEpochCheck is the msg-204 equivalent; production leaves it nil.
systemNoticeAfterInitialEpochCheck func()
// messageAfterInitialEpochCheck covers ordinary authority frames.
messageAfterInitialEpochCheck func(msgID int)
}
type handlerEntry struct {
id uint64
fn func([]string)
fnAtEpoch func([]string, uint64)
}
// PortfolioStreamHealth is receipt metadata for the streaming
// reqAccountUpdates portfolio cache. It contains no positions or balances;
// callers use it only to decide whether a cached projection is current enough
type PortfolioStreamHealth struct {
Account string
RequestedAt time.Time
InitialCompletedAt time.Time
LastUpdateAt time.Time
// ProjectionGeneration advances only when the structural portfolio
// authority changes: scope/completeness/invalidity, contract set, or held
ProjectionGeneration uint64
// ScopeConflictAt is set when the stream emits a portfolio or completion
// frame for a blank or foreign account. Rows are retained as context, but
// no receipt is trustworthy until reqAccountUpdates is resubscribed.
ScopeConflictAt time.Time
// InvalidPayloadAt is set when a portfolio generation contains a malformed
InvalidPayloadAt time.Time
// DownloadShortAt is set when accountDownloadEnd closed a generation whose
// rows leave more than portfolioFootingTolerance of the stream's own
// GrossPositionValue unaccounted for. TWS answering a subscription shortly
// after its own login sends such a generation with a genuine end marker.
// The rows stay staged and unpublished and InitialCompletedAt stays zero
// until late rows make the generation foot or a resubscribe replaces it.
DownloadShortAt time.Time
}
// portfolioFootingTolerance is the share of the stream's GrossPositionValue a
// completed download may leave unaccounted for. A complete book foots to
// within rounding; a generation delivered while TWS was still loading the
// account covered about a third of it.
const portfolioFootingTolerance = 0.05
// portfolioGrossCoverage returns the share of the account-updates stream's
// GrossPositionValue that rows account for: the sum of absolute market values,
// each converted to the account base with the stream's own ExchangeRate. ok is
// false when the stream carries no positive gross position value or a row's
// currency has no exchange rate; the end marker then stands alone.
func portfolioGrossCoverage(rows map[string]*RawPosition, accountValues map[string]string) (coverage float64, ok bool) {
summary := parseAccountSummary(accountValues, "")
if summary.GrossPositionValue == nil || !finiteFloat(*summary.GrossPositionValue) || *summary.GrossPositionValue <= 0 {
return 0, false
}
base := strings.ToUpper(strings.TrimSpace(summary.BaseCurrency))
gross := 0.0
for _, row := range rows {
if row == nil || row.Position == 0 {
continue
}
rate := 1.0
if currency := strings.ToUpper(strings.TrimSpace(row.Contract.Currency)); currency != base {
ledger, known := summary.CurrencyLedger[currency]
if !known || !finiteFloat(ledger.ExchangeRate) || ledger.ExchangeRate <= 0 {
return 0, false
}
rate = ledger.ExchangeRate
}
gross += math.Abs(row.MarketValue) * rate
}
return gross / *summary.GrossPositionValue, true
}
// portfolioGenerationShort reports whether rows are provably short of the
// stream's gross position value, with the coverage for the log line.
func portfolioGenerationShort(rows map[string]*RawPosition, accountValues map[string]string) (float64, bool) {
coverage, ok := portfolioGrossCoverage(rows, accountValues)
return coverage, ok && coverage < 1-portfolioFootingTolerance
}
type reqAliasEntry struct {
symbol string
secType string
exchange string
primaryExch string
currency string
localSymbol string
tradingClass string
// conID is the exact identity the request carried, zero for a
// symbol-only lookup. A "no security definition" for a zero conID is a
// discovery miss the requester classifies; for a known conID it is a
// stale identity (the cached OKE conId of 2026-09-28) and stays loud.
conID int
// callerClassifiesMiss marks a request sent under
// WithDefinitionMissClassified: its requester records a "no security
// definition" answer itself, so the echo is INFO even for a known conID.
callerClassifiesMiss bool
}
func (c *Connection) registerReqAlias(ctx context.Context, reqID int, contract Contract) {
if reqID <= 0 || contract.Symbol == "" {
return
}
entry := reqAliasEntry{
callerClassifiesMiss: DefinitionMissClassified(ctx),
conID: contract.ConID,
symbol: strings.ToUpper(contract.Symbol),
secType: strings.ToUpper(contract.SecType),
exchange: strings.ToUpper(contract.Exchange),
primaryExch: strings.ToUpper(contract.PrimaryExch),
currency: strings.ToUpper(contract.Currency),
localSymbol: contract.LocalSymbol,
tradingClass: contract.TradingClass,
}
c.aliasMu.Lock()
c.reqAlias[reqID] = entry
c.aliasMu.Unlock()
}
func (c *Connection) lookupReqAlias(reqID int) (reqAliasEntry, bool) {
if reqID <= 0 {
return reqAliasEntry{}, false
}
c.aliasMu.RLock()
alias, ok := c.reqAlias[reqID]
c.aliasMu.RUnlock()
return alias, ok
}
// SetSystemNoticeHandler is retained for v2 source compatibility. Its private
// parameter types mean callers outside this package can only clear the handler.
func (c *Connection) SetSystemNoticeHandler(handler func(note *systemNotification, alias reqAliasEntry)) {
if handler == nil {
c.SetSystemNoticeHandlerAtEpoch(nil)
return
}
c.SetSystemNoticeHandlerAtEpoch(func(note *systemNotification, alias reqAliasEntry, _ uint64) {
handler(note, alias)
})
}
// SetSystemNoticeHandlerAtEpoch is retained for v2 source compatibility. Its
// private parameter types mean callers outside this package can only clear it.
func (c *Connection) SetSystemNoticeHandlerAtEpoch(handler func(note *systemNotification, alias reqAliasEntry, epoch uint64)) {
if handler == nil {
c.SetSystemNoticeHandlerAtEpochWithPostAction(nil)
return
}
c.SetSystemNoticeHandlerAtEpochWithPostAction(func(note *systemNotification, alias reqAliasEntry, epoch uint64) func() {
handler(note, alias, epoch)
return nil
})
}
// noticeLogContext lets the owning Connector shape the severity of wire
// notices it already accounts for elsewhere. Each probe is optional and runs
// on the read loop for every notice, so it must stay cheap and lock-light.
type noticeLogContext struct {
// backendLinkDown reports an announced TWS-to-IBKR link break (1100 or
// 2110, until the 1101/1102 restore). Requests sent into a dead link are
// never registered by the backend, so their teardown cancels draw code
// 300 ("Can't find EId") for every leg: a consequence of the outage the
// connector already warned about once, not a verdict, so those echoes stay
// in debug logs while the break holds (1,681 WARN lines in 80 minutes on
// 2026-10-02).
backendLinkDown func() bool
// backendLinkUnsettled reports a broken link or one restored less than
// BackendRestoreSettle ago. TWS answers the requests a loss cut with code
// 200 "no security definition" for every contract (IND, FUT and BILL
// lookups included), during the break and in the burst it flushes after
// the restore, so those misses are the outage's echo, not a verdict.
backendLinkUnsettled func() bool
// knownEntitlementGap reports whether a 354 repeats an entitlement gap the
// connector already warned about for this request's subscription key.
knownEntitlementGap func(reqID int, alias reqAliasEntry) bool
}
func (c *Connection) setNoticeLogContext(ctx noticeLogContext) {
c.noticeLogMu.Lock()
c.noticeLog = ctx
c.noticeLogMu.Unlock()
}
func (c *Connection) noticeLogContextSnapshot() noticeLogContext {
c.noticeLogMu.RLock()
defer c.noticeLogMu.RUnlock()
return c.noticeLog
}
// takeSuppressedCancelEchoes returns and resets the count of cancel echoes
// kept at debug during a backend-link break.
func (c *Connection) takeSuppressedCancelEchoes() uint64 {
return c.suppressedCancelEchoes.Swap(0)
}
// takeSuppressedDefinitionMisses returns and resets the count of code-200
// notices kept at debug during a backend-link break.
func (c *Connection) takeSuppressedDefinitionMisses() uint64 {
return c.suppressedDefinitionMisses.Swap(0)
}
// SetSystemNoticeHandlerAtEpochWithPostAction installs the Connector-owned
// callback. Its action runs after the inbound lock is released, avoiding a
// reconnect deadlock. External callers can only pass nil because its parameter
// types are private; the export remains for v2 source compatibility.
func (c *Connection) SetSystemNoticeHandlerAtEpochWithPostAction(handler func(note *systemNotification, alias reqAliasEntry, epoch uint64) func()) {
c.systemNoticeMu.Lock()
c.systemNoticeHandler = handler
c.systemNoticeMu.Unlock()
}
func (c *Connection) dispatchSystemNotice(note *systemNotification, alias reqAliasEntry, epochs ...uint64) func() {
epoch := c.BrokerSessionEpoch()
if len(epochs) > 0 {
epoch = epochs[0]
}
c.systemNoticeMu.RLock()
handler := c.systemNoticeHandler
c.systemNoticeMu.RUnlock()
if handler != nil {
return handler(note, alias, epoch)
}
return nil
}
// setErrorPostActionHandler installs the Connector-owned msgErr handler whose
// returned action may perform outbound recovery only after the inbound socket-
func (c *Connection) setErrorPostActionHandler(handler func(fields []string, epoch uint64) func()) {
c.errorPostActionMu.Lock()
c.errorPostAction = handler
c.errorPostActionMu.Unlock()
}
func (c *Connection) dispatchErrorPostAction(fields []string, epoch uint64) func() {
c.errorPostActionMu.RLock()
handler := c.errorPostAction
c.errorPostActionMu.RUnlock()
if handler == nil {
return nil
}
return handler(fields, epoch)
}
func (c *Connection) resetReadStartCh() {
c.readStartMu.Lock()
c.readStartCh = make(chan struct{})
c.readStartMu.Unlock()
}
func (c *Connection) signalReadStarted() {
c.readStartMu.Lock()
ch := c.readStartCh
c.readStartMu.Unlock()
if ch != nil {
select {
case <-ch:
// already closed
default:
close(ch)
}
}
}
func (c *Connection) waitForReadStart(timeout time.Duration) {
c.readStartMu.Lock()
ch := c.readStartCh
c.readStartMu.Unlock()
if ch == nil {
return
}
if timeout <= 0 {
<-ch
return
}
select {
case <-ch:
case <-time.After(timeout):
connectLogger.Warnf("Client %d: read loop start wait timed out after %s", c.config.ClientID, timeout)
}
}
// NewConnection constructs a disconnected session; nil uses [DefaultConfig].
func NewConnection(config *ConnectionConfig) *Connection {
if config == nil {
config = DefaultConfig()
} else {
configCopy := *config
config = &configCopy
// Fill in missing timeouts/intervals with safe defaults to avoid zero-value panics
def := DefaultConfig()
if config.ConnectTimeout == 0 {
config.ConnectTimeout = def.ConnectTimeout
}
if config.HeartbeatInterval == 0 {
config.HeartbeatInterval = def.HeartbeatInterval
}
if config.MaxClientIDRetries == 0 {
config.MaxClientIDRetries = def.MaxClientIDRetries
}
if config.MaxRetries == 0 {
config.MaxRetries = def.MaxRetries
}
if config.InitialDelay == 0 {
config.InitialDelay = def.InitialDelay
}
if config.MaxDelay == 0 {
config.MaxDelay = def.MaxDelay
}
if config.BackoffMultiplier == 0 {
config.BackoffMultiplier = def.BackoffMultiplier
}
}
ctx, cancel := context.WithCancel(context.Background())
conn := &Connection{
config: config,
status: StatusDisconnected,
reconnectChan: make(chan struct{}, 1),
stopChan: make(chan struct{}),
msgHandlers: make(map[int][]handlerEntry),
mktDataType: make(map[int]int),
positions: make(map[string]*RawPosition),
accountSummary: make(map[string]string),
summarySnapshots: make(map[int]*summarySnapshot),
reqIDSeq: 1,
reservedOrderIDs: make(map[int]struct{}),
reservedRequestIDs: make(map[int]struct{}),
reportedOwnOrderIDs: make(map[int]ReportedOwnOrder),
openOrders: make(map[int]*IBKROrder),
orderStatus: make(map[int]string),
reqAlias: make(map[int]reqAliasEntry),
logWireHex: config.LogWireHex,
suspectFlags: make(map[string]struct{}),
suspectSummaries: make(map[string]string),
contractTimings: make(map[string]time.Duration),
optionContractCache: make(map[string]ContractDetailsLite),
readStartCh: make(chan struct{}),
ctx: ctx,
cancel: cancel,
rateLimiter: NewRateLimiter(ctx),
marketDataSlots: make(map[int]marketDataSlot),
positionsEndChan: make(chan struct{}, 1),
acctSummaryEndChan: make(chan struct{}, 1),
serverVersion: 0,
useTLS: config.UseTLS,
}
// Use shared wire interceptor if provided, otherwise create per-connection (legacy)
if config.WireInterceptor != nil {
conn.wireTap = config.WireInterceptor
} else if interceptor, err := NewWireInterceptorFromEnv(config.ClientID); err != nil {
wireLogger.Warnf("Client %d: failed to initialize wire interceptor: %v", config.ClientID, err)
ibkrLogger.Warnf("[WIRE] Client %d: failed to initialize wire interceptor: %v", config.ClientID, err)
} else {
conn.wireTap = interceptor
}
conn.transportCond = sync.NewCond(&conn.transportMu)
conn.resetHandshakeReady()
if config.PacketLogPath != "" {
path := config.PacketLogPath
if strings.Contains(path, "%d") {
path = fmt.Sprintf(path, config.ClientID)
}
if logger, err := NewHexPacketLogger(path); err != nil {
ibkrLogger.Warnf("Client %d: failed to initialize packet logger: %v", config.ClientID, err)
} else {
conn.SetPacketLogger(logger)
config.PacketLogPath = path
}
}
return conn
}
// rejectedBeforeHandshake wraps err with ErrRejectedBeforeHandshake when it
// is the peer ending the connection — a reset, a broken pipe, or an EOF in
// place of the first byte — and returns every other error unchanged.
func rejectedBeforeHandshake(err error) error {
if err == nil {
return nil
}
if errors.Is(err, syscall.ECONNRESET) || errors.Is(err, syscall.EPIPE) ||
errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
return fmt.Errorf("%w: %w", ErrRejectedBeforeHandshake, err)
}
return err
}
func (c *Connection) dialEndpoint(ctx context.Context, useTLS bool) (net.Conn, error) {
addr := fmt.Sprintf("%s:%d", c.config.Host, c.config.Port)
dialer := net.Dialer{Timeout: c.config.ConnectTimeout}
conn, err := dialer.DialContext(ctx, "tcp", addr)
if err != nil {
// A reset here means the kernel completed the TCP handshake and the
// peer tore the connection down before the dialer looked at it.
return nil, fmt.Errorf("failed to connect to IBKR at %s: %w", addr, rejectedBeforeHandshake(err))
}
// Disable Nagle's algorithm so buffered protocol frames transmit immediately.
if tcpConn, ok := conn.(*net.TCPConn); ok {
if err := tcpConn.SetNoDelay(true); err != nil {
conn.Close()
// The only way this option fails on a socket the dial just
// returned is the peer having reset it in between: Darwin
// answers EINVAL once both directions are shut down.
return nil, fmt.Errorf("failed to set TCP_NODELAY: %w: %w", ErrRejectedBeforeHandshake, err)
}
}
if !useTLS {
return conn, nil
}
tlsCfg := &tls.Config{
InsecureSkipVerify: c.config.TLSInsecureSkipVerify,
}
serverName := c.config.TLSServerName
if serverName == "" && !c.config.TLSInsecureSkipVerify {
serverName = c.config.Host
}
if serverName != "" {
tlsCfg.ServerName = serverName
}
tlsConn := tls.Client(conn, tlsCfg)
// HandshakeContext bounds a server that accepts TCP but never completes TLS.
if err := tlsConn.HandshakeContext(ctx); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("tls handshake failed: %w", rejectedBeforeHandshake(err))
}
return tlsConn, nil
}
// closeConnection drops transport fields after the reader goroutine has exited.
func (c *Connection) closeConnection() {
c.closeSocket()
c.conn = nil
c.reader = nil
c.writer = nil
c.scanner = nil
}
// closeSocket unblocks a reader without clearing fields it may still access.
func (c *Connection) closeSocket() {
if c.conn != nil {
_ = c.conn.Close()
}
}
// SetPacketLogger installs a packet logger invoked for every outbound frame.
// Passing nil disables logging. Frames may contain account IDs, order
// references, and order details; callers must protect the sink as sensitive
// data and use it only for short-lived debugging.
func (c *Connection) SetPacketLogger(logger PacketLogger) {
c.packetLoggerMu.Lock()
if c.packetLoggerCloser != nil {
if err := c.packetLoggerCloser(); err != nil {
ibkrLogger.Warnf("packet logger close error: %v", err)
}
c.packetLoggerCloser = nil
}
c.packetLogger = logger
if logger != nil {
if closer, ok := logger.(interface{ Close() error }); ok {
c.packetLoggerCloser = closer.Close
}
}
c.packetLoggerMu.Unlock()
}
// logConnectAttempt leaves the daemon responsible for bounded outage warnings.
func (c *Connection) logConnectAttempt(format string, args ...any) {
c.logOwnedWarn(connectLogger, format, args...)
}
// logOwnedWarn logs a failed connection attempt or a lost session. On a
// managed connection the owner reports the outage once, so the line is a
// debug diagnostic; standalone callers keep the warning.
func (c *Connection) logOwnedWarn(l logging.Entry, format string, args ...any) {
if c.config.ManagedConnectionLogging {
l.Debugf(format, args...)
return
}
l.Warnf(format, args...)
}
// logReadFailure logs a read error that ends the stream. A transport loss on
// a managed connection is its owner's outage and logs at debug. Any other
// read error keeps its level, error when fault is set: an oversized frame is
// a protocol fault no outage explains, and a timeout inside a frame leaves
// the stream misaligned.
func (c *Connection) logReadFailure(l logging.Entry, fault bool, err error, format string, args ...any) {
switch {
case c.config.ManagedConnectionLogging && transportLoss(err):
l.Debugf(format, args...)
case fault:
l.Errorf(format, args...)
default:
l.Warnf(format, args...)
}
}
// transportLoss reports whether err is the peer or the network ending the
// socket (EOF, reset, broken pipe) rather than a protocol fault or a read
// timeout. crypto/tls reports a TLS alert as a *net.OpError with Op "remote
// error" or "local error"; that is a protocol fault on a live socket.
func transportLoss(err error) bool {
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, net.ErrClosed) {
return true
}
if opErr, ok := errors.AsType[*net.OpError](err); ok {
return !opErr.Timeout() && opErr.Op != "remote error" && opErr.Op != "local error"
}
return false
}
func (c *Connection) ensurePacketLogger() {
if c.config == nil || c.config.PacketLogPath == "" {
return
}
c.packetLoggerMu.RLock()
loggerPresent := c.packetLogger != nil
c.packetLoggerMu.RUnlock()
if loggerPresent {
return
}
logger, err := NewHexPacketLogger(c.config.PacketLogPath)
if err != nil {
ibkrLogger.Warnf("Client %d: unable to open packet logger: %v", c.config.ClientID, err)
return
}
c.SetPacketLogger(logger)
}
// isClientIDInUseError reports whether err wraps the client-ID collision sentinel.
func isClientIDInUseError(err error) bool {
return errors.Is(err, errClientIDInUse)
}
// Connect establishes and handshakes the configured TWS or Gateway session.
func (c *Connection) Connect(ctx context.Context) error {
clientID := c.config.ClientID
// Limit to 5 retries max. Retries are for transient startAPI/handshake
// failures on the same configured ID; a 326 collision is terminal because
// neighboring IDs are reserved for other ibkr lanes.
maxRetries := max(min(c.config.MaxClientIDRetries, 5), 1)
// Attempt narration stays at debug so an unavailable gateway does not flood logs.
connectLogger.Debugf("Starting connection process with Client ID %d, MaxRetries=%d",
clientID, maxRetries)
for attempt := range maxRetries {
c.config.ClientID = clientID
connectLogger.Debugf("Attempting connection with Client ID %d (attempt %d/%d)",
clientID, attempt+1, maxRetries)
err := c.connectWithClientID(ctx)
if err == nil {
return nil
}
if errors.Is(err, errClientIDInUse) {
connectLogger.Errorf("Client ID %d already in use; not auto-walking to another reserved client ID", clientID)
return err
}
if errors.Is(err, errStartAPIFailed) {
c.logConnectAttempt("startAPI failed for Client ID %d; retrying", clientID)
continue
}
// Non-client ID error (dial refused / TLS handshake / net error) —
// return immediately. Logs at Debug, not Error: the daemon owns the
// deduped connect verdict (see the connect-narration note above and
// server.connectWithFailover), so at Error this single line floods
// ibkr-daemon.log on every demand-driven reconnect cycle while the
// gateway is down — 66,900 identical "connection refused" lines over a
// 7h outage, observed 2026-07-08. The real 326 collision above stays at
// Error; the daemon paces the retries themselves via reconnectBackoff.
connectLogger.Debugf("Connection failed with non-client ID error: %v", err)
return err
}
return fmt.Errorf("failed to connect after %d attempts with client ID %d", maxRetries, clientID)
}
// connectWithClientID attempts connection with specific client ID
func (c *Connection) connectWithClientID(ctx context.Context) error {
c.setStatus(StatusConnecting)
c.ensurePacketLogger()
attempts := c.tlsAttempts()
var lastErr error
for idx, useTLS := range attempts {
if ctx != nil {
if err := ctx.Err(); err != nil {
return err
}
}
if idx > 0 {
c.logConnectAttempt("Client %d: retrying with tls=%v after error: %v", c.config.ClientID, useTLS, lastErr)
}
outboundEpoch := c.beginOutboundSession()
c.resetHandshakeReady()
c.resetOrderIDReadiness()
if err := c.connectAttempt(ctx, useTLS, outboundEpoch); err != nil {
lastErr = err
c.closeConnection()
c.resumeTransport()
if ctx != nil {
if cerr := ctx.Err(); cerr != nil {
return cerr
}
}
if errors.Is(err, errHandshakeNoData) && idx+1 < len(attempts) {
c.logConnectAttempt("Client %d: handshake returned no data (tls=%v); attempting fallback", c.config.ClientID, useTLS)
continue
}
return err
}
return nil
}
if lastErr != nil {
return lastErr
}
return fmt.Errorf("failed to connect to IBKR (client %d)", c.config.ClientID)
}
func (c *Connection) connectAttempt(ctx context.Context, useTLS bool, outboundEpoch uint64) error {
addr := fmt.Sprintf("%s:%d", c.config.Host, c.config.Port)
connectLogger.Debugf("Client %d: Connecting to %s (tls=%v)...", c.config.ClientID, addr, useTLS)
netConn, err := c.dialEndpoint(ctx, useTLS)
if err != nil {
c.setStatus(StatusDisconnected)
c.sessionNotStarted(0, nil) // this attempt never reached START_API
return err
}
var cancelOnce sync.Once
var cancelWatcherDone chan struct{}
if ctx != nil {
if cerr := ctx.Err(); cerr != nil {
_ = netConn.Close()
return cerr
}
cancelWatcherDone = make(chan struct{})
go func(conn net.Conn, done <-chan struct{}, watchCtx context.Context) {
select {
case <-watchCtx.Done():
cancelOnce.Do(func() { _ = conn.Close() })
case <-done:
}
}(netConn, cancelWatcherDone, ctx)
}
defer func() {
if cancelWatcherDone != nil {
close(cancelWatcherDone)
}
}()
c.conn = netConn
c.reader = bufio.NewReader(netConn)
c.writer = bufio.NewWriter(netConn)
c.scanner = bufio.NewScanner(netConn)
c.scanner.Split(c.scanMessages)
c.scanner.Buffer(make([]byte, 4096), 1024*1024) // 1MB max message
connectLogger.Infof("Client %d: Starting handshake...", c.config.ClientID)
if err := c.handshake(); err != nil {
if c.config.ManagedConnectionLogging {
connectLogger.Debugf("Client %d: Handshake failed: %v", c.config.ClientID, err)
} else {
connectLogger.Errorf("Client %d: Handshake failed: %v", c.config.ClientID, err)
}
c.setStatus(StatusDisconnected)
c.sessionNotStarted(0, nil) // this attempt never reached START_API
cancelOnce.Do(func() { _ = netConn.Close() })
return fmt.Errorf("handshake failed: %w", err)
}
connectLogger.Infof("Client %d: Handshake successful (serverVersion=%d)", c.config.ClientID, c.serverVersion)
connectLogger.Infof("Client %d: Starting API...", c.config.ClientID)
if err := c.startAPI(); err != nil {
if !errors.Is(err, errNoNextValidID) {
c.sessionNotStarted(0, nil) // the latest attempt failed otherwise
}
if isClientIDInUseError(err) {
connectLogger.Warnf("Client %d: startAPI rejected client ID in use: %v", c.config.ClientID, err)
c.setStatus(StatusDisconnected)
return err
}
delay := c.registerStartAPIFailure()
c.logConnectAttempt("Client %d: Failed to start API: %v (backing off %s)", c.config.ClientID, err, delay)
if delay > 0 {
select {
case <-time.After(delay):
case <-ctx.Done():
return fmt.Errorf("%w: context cancelled during startAPI backoff: %w", errStartAPIFailed, ctx.Err())
}
}
return fmt.Errorf("%w: %v", errStartAPIFailed, err)
}
connectLogger.Infof("Client %d: API started successfully", c.config.ClientID)
c.resetStartAPIFailure()
c.lastHeartbeatNano.Store(time.Now().UnixNano())
c.statusMu.Lock()
c.status = StatusConnected
c.connectedAt = time.Now()
c.errorCount = 0
c.lastError = nil
c.statusMu.Unlock()
if !c.activateOutboundSession(outboundEpoch) {
c.setStatus(StatusDisconnected)
return fmt.Errorf("connection invalidated before outbound session publication")
}
c.useTLS = useTLS
connectLogger.Infof("Connection established (Client ID: %d, Server Version: %d, tls=%v)", c.config.ClientID, c.serverVersion, c.useTLS)
c.resetReadStartCh()
c.wg.Add(2)
go c.heartbeatMonitor()
go c.readMessages()
c.waitForReadStart(500 * time.Millisecond)
c.signalHandshakeReady()
if c.onConnect != nil {
c.onConnect()
}
return nil
}
// Disconnect closes the protocol session and stops its background work.
func (c *Connection) Disconnect() error {
// Invalidate queued protected sends before publishing the disconnect.
c.invalidateOutboundSession(false)
c.statusMu.Lock()
wasConnected := c.status == StatusConnected
c.status = StatusDisconnected
c.statusMu.Unlock()
// Signal shutdown first - this stops new requests from being queued
c.stopOnce.Do(func() {
close(c.stopChan)
})
// Stop the rate limiter - this drains the queue and waits for in-flight requests
if c.rateLimiter != nil {
c.rateLimiter.Stop()
}
// Do not flush: the buffer may hold a partial frame that IBKR would reject.
// Cancel context
if c.cancel != nil {
c.cancel()
}
// Close the socket before waiting, but retain fields until the reader exits.
c.closeSocket()
// Bound the wait in case a platform socket close fails to unblock Read.
waitDone := make(chan struct{})
go func() {
c.wg.Wait()
close(waitDone)
}()
select {
case <-waitDone:
c.closeConnection()
case <-time.After(2 * time.Second):
// The parked reader may still dereference the transport fields, so
connectLogger.Warnf("Disconnect: goroutines still running after 2s; closing socket to unblock (Client ID: %d)", c.config.ClientID)
}
// Log only a real connected-to-disconnected transition at info.
if wasConnected {
connectLogger.Infof("Connection closed (Client ID: %d)", c.config.ClientID)
} else {
connectLogger.Debugf("Connection closed (Client ID: %d)", c.config.ClientID)
}
if wasConnected && c.onDisconnect != nil {
c.onDisconnect(nil)
}
if c.wireTap != nil {
_ = c.wireTap.Close()
}
c.SetPacketLogger(nil)
return nil
}
// reconnectWithBackoff retries a lost session using configured exponential backoff.
func (c *Connection) reconnectWithBackoff(ctx context.Context) {
defer c.wg.Done()
attempt := 0
for {
select {
case <-c.stopChan:
return
case <-c.reconnectChan:
// Reset attempt counter on new reconnect request
attempt = 0
case <-time.After(c.calculateBackoff(attempt)):
if attempt >= c.config.MaxRetries {
c.setStatus(StatusFailed)
connectLogger.Errorf("Reconnection failed after %d attempts", attempt)
return
}
attempt++
connectLogger.Warnf("Reconnection attempt %d/%d (Client ID: %d)",
attempt, c.config.MaxRetries, c.config.ClientID)
c.setStatus(StatusReconnecting)
connectCtx, cancel := context.WithTimeout(ctx, c.config.ConnectTimeout)
err := c.Connect(connectCtx)
cancel()
if err == nil {
connectLogger.Infof("Reconnection successful (Client ID: %d)", c.config.ClientID)
return
}
c.statusMu.Lock()
c.errorCount++
c.lastError = err
c.statusMu.Unlock()
}
}
}
// calculateBackoff calculates delay with exponential backoff and optional jitter
func (c *Connection) calculateBackoff(attempt int) time.Duration {
if attempt == 0 {
return 0
}
// Calculate exponential backoff
delay := float64(c.config.InitialDelay) * math.Pow(c.config.BackoffMultiplier, float64(attempt-1))
// Cap at max delay
if delay > float64(c.config.MaxDelay) {
delay = float64(c.config.MaxDelay)
}
// Add jitter if enabled (±10% randomization)
if c.config.Jitter {
jitter := delay * 0.1 * (rand.Float64()*2 - 1)
delay += jitter
}
return time.Duration(delay)
}
// heartbeatMonitor checks connection health periodically
func (c *Connection) heartbeatMonitor() {
defer c.wg.Done()
ticker := time.NewTicker(c.config.HeartbeatInterval)
defer ticker.Stop()
for {
select {
case <-c.stopChan:
return
case <-ticker.C:
c.statusMu.RLock()
status := c.status
c.statusMu.RUnlock()
lastHeartbeat := time.Unix(0, c.lastHeartbeatNano.Load())
if status != StatusConnected {
continue
}
// Liveness is an answer, never a send: the reader loop records
// every frame this socket receives, the reply to the request
// below included. A Gateway that accepts requests and answers
// none therefore goes stale here within two intervals and is
// redialled; counting a successful send as liveness had kept such
// a session "connected" indefinitely.
if silent := time.Since(lastHeartbeat); silent >= c.config.HeartbeatInterval*2 {
c.logOwnedWarn(connectLogger, "Heartbeat timeout: the Gateway answered nothing for %s (Client ID: %d)", silent.Round(time.Second), c.config.ClientID)
c.handleDisconnection(fmt.Errorf("heartbeat timeout: no answer for %s", silent.Round(time.Second)))
return
}
// Send heartbeat request to IBKR. A failed send is left to the
// answer timeout above, which ends the session; on a managed
// connection that loss is the owner's to report.
if err := c.RequestCurrentTime(); err != nil {
c.logOwnedWarn(connectLogger, "Failed to send heartbeat: %v", err)
}
}
}
}
// handleDisconnection triggers reconnection if auto-reconnect is enabled
func (c *Connection) handleDisconnection(err error) {
// Preserve brokerSessionEpoch for late inbound receipt attribution, but
// revoke all outbound authority before any reconnect can reuse the object.
c.invalidateOutboundSession(true)
c.statusMu.Lock()
if c.status != StatusConnected {
c.statusMu.Unlock()
return
}
c.status = StatusDisconnected
c.lastError = err
c.statusMu.Unlock()
c.pauseTransport()
c.logOwnedWarn(connectLogger, "Disconnection detected (Client ID: %d): %v", c.config.ClientID, err)
if c.onDisconnect != nil {
c.onDisconnect(err)
}
if c.config.AutoReconnect {
select {
case c.reconnectChan <- struct{}{}:
c.wg.Add(1)
go c.reconnectWithBackoff(context.Background())
default:
// Reconnection already in progress
}
}
}
// Status returns the current connection lifecycle state.
func (c *Connection) Status() ConnectionStatus {
c.statusMu.RLock()
defer c.statusMu.RUnlock()
return c.status
}
// IsConnected reports whether the protocol session is connected.
func (c *Connection) IsConnected() bool {
return c.Status() == StatusConnected
}
// setStatus updates connection status safely
func (c *Connection) setStatus(status ConnectionStatus) {
c.statusMu.Lock()
c.status = status
c.statusMu.Unlock()
}
// SetOnConnect replaces the callback invoked after a successful connection.
func (c *Connection) SetOnConnect(fn func()) {
c.onConnect = fn
}
// SetOnDisconnect replaces the callback invoked after a connection is lost.
func (c *Connection) SetOnDisconnect(fn func(error)) {
c.onDisconnect = fn
}
// GetConnectionInfo returns a detached snapshot of connection diagnostics.
func (c *Connection) GetConnectionInfo() map[string]any {
c.statusMu.RLock()
defer c.statusMu.RUnlock()
info := map[string]any{
"client_id": c.config.ClientID,
"host": c.config.Host,
"port": c.config.Port,
"status": c.status.String(),
"error_count": c.errorCount,
"connected_at": c.connectedAt,
"last_heartbeat": time.Unix(0, c.lastHeartbeatNano.Load()),
"server_version": c.serverVersion,
}
if c.lastError != nil {
info["last_error"] = c.lastError.Error()
}
return info
}
// ServerVersion returns the protocol version negotiated with TWS or IB Gateway.
func (c *Connection) ServerVersion() int {
c.statusMu.RLock()
defer c.statusMu.RUnlock()
return c.serverVersion
}
// UsingTLS reports whether the established session negotiated TLS.
func (c *Connection) UsingTLS() bool {
c.statusMu.RLock()
defer c.statusMu.RUnlock()
return c.useTLS
}
// Protocol constants aligned with IBKR CLIENT_VERSION 66
const (
// Handshake advertises compatibility starting at client version 100
minClientVersion = 100
// Minimum server version we accept: 124 = MIN_SERVER_VER_SYNT_REALTIME_BARS
minServerVersionRequired = 124
// Maximum tested version (TWS API Gateway v10.30+)
maxClientVersion = 203
// Version 203 = MIN_SERVER_VER_PROTOBUF_PLACE_ORDER
minServerVerProtoBufPlaceOrder = 203
protoBufMsgID = 200
// Version 200 = MIN_SERVER_VER_PARAMETRIZED_DAYS_OF_EXECUTIONS (TWS API
// 10.30): reqExecutions carries lastNDays and specificDates after side.
minServerVerParametrizedDaysOfExecutions = 200
minServerVerManualOrderTime = 169
minServerVerRFQFields = 187
minServerVerUndoRFQFields = 190
minServerVerCMETaggingFields = 192
// Required for startApi optional capabilities
minServerVerStartAPICapab = 72
// Message IDs from IBKR protocol
msgTickPrice = 1
msgTickSize = 2
msgOrderStatus = 3
msgErrMsg = 4
msgOpenOrder = 5
msgAcctValue = 6
msgPortfolioValue = 7
msgAcctUpdateTime = 8
msgNextValidID = 9
msgContractData = 10
msgExecDetails = 11
msgExecDetailsEnd = 55
msgCommissionReport = 59
msgMarketDepth = 12
msgMarketDepthL2 = 13
msgNewsBulletins = 14
msgManagedAccts = 15
msgReceiveFA = 16
msgHistoricalData = 17
msgHistoricalDataEnd = 108
msgCurrentTimeMillis = 109
msgBondContractData = 18
msgTickOptionComputation = 21
msgTickGeneric = 45
msgTickString = 46
msgTickEFP = 47
msgCurrentTime = 49
msgRealTimeBars = 50
msgFundamentalData = 51
msgContractDataEnd = 52
msgOpenOrderEnd = 53
msgAcctDownloadEnd = 54
msgDeltaNeutralValidation = 56
msgTickSnapshotEnd = 57
msgMarketDataType = 58
msgPosition = 61
msgPositionEnd = 62
msgAccountSummary = 63
msgAccountSummaryEnd = 64
msgVerifyMessageAPI = 65
msgVerifyCompleted = 66
msgDisplayGroupList = 67
msgDisplayGroupUpdated = 68
msgVerifyAndAuthMessageAPI = 69
msgVerifyAndAuthCompleted = 70
msgPositionMulti = 71
msgPositionMultiEnd = 72
msgAccountUpdateMulti = 73
msgAccountUpdateMultiEnd = 74
msgSecurityDefinitionOptionalParameter = 75
msgSecurityDefinitionOptionalParameterEnd = 76
msgSoftDollarTiers = 77
msgFamilyCodes = 78
msgSymbolSamples = 79
msgMktDepthExchanges = 80
msgTickNews = 81
msgSmartComponents = 82
msgTickReqParams = 83
msgNewsProviders = 84
msgNewsArticle = 85
msgHistoricalNews = 86
msgHistoricalNewsEnd = 87
msgHeadTimestamp = 88
msgHistogramData = 89
msgHistoricalDataUpdate = 90
msgRerouteMktDataReq = 91
msgRerouteMktDepthReq = 92
msgMarketRule = 93
msgPnL = 94
msgPnLSingle = 95
msgHistoricalTicks = 96
msgHistoricalTicksBidAsk = 97
msgHistoricalTicksLast = 98
msgTickByTick = 99
msgOrderBound = 100
msgWSHMetaData = 104
msgWSHEventData = 105
msgSystemNotification = 204
// msgInboundTap is no IBKR message: handlers registered under it see
// every current-session inbound frame (tapInboundFrames).
msgInboundTap = -1
// Outgoing message IDs
reqMktData = 1
cancelMktData = 2
placeOrder = 3
cancelOrder = 4
reqOpenOrders = 5
reqAcctData = 6
reqExecutions = 7
reqIds = 8
reqContractData = 9
reqMktDepth = 10
cancelMktDepth = 11
reqNewsBulletins = 12
cancelNewsBulletins = 13
setServerLogLevel = 14
reqAutoOpenOrders = 15
reqAllOpenOrders = 16
reqManagedAccts = 17
reqFA = 18
replaceFA = 19
reqHistoricalData = 20
exerciseOptions = 21
cancelHistoricalData = 25
reqCurrentTime = 49
reqRealTimeBars = 50
cancelRealTimeBars = 51
reqFundamentalData = 52
cancelFundamentalData = 53
reqCalcImpliedVolatility = 54
reqCalcOptionPrice = 55
cancelCalcImpliedVolatility = 56
cancelCalcOptionPrice = 57
reqGlobalCancel = 58
reqMarketDataType = 59
reqPositions = 61
reqAccountSummary = 62
cancelAccountSummary = 63
cancelPositions = 64
verifyRequest = 65
verifyMessage = 66
queryDisplayGroups = 67
subscribeToGroupEvents = 68
updateDisplayGroup = 69
unsubscribeFromGroupEvents = 70
startAPI = 71
reqPositionsMulti = 74
cancelPositionsMulti = 75
reqAccountUpdatesMulti = 76
cancelAccountUpdatesMulti = 77
reqSecDefOptParams = 78
reqWSHMetaData = 100
cancelWSHMetaData = 101
reqWSHEventData = 102
cancelWSHEventData = 103
// PnL subscription opcodes (TWS API EClient: REQ_PNL / CANCEL_PNL /
// TWS protocol's outbound and inbound id spaces are separate.
reqPnL = 92
cancelPnL = 93
reqPnLSingle = 94
cancelPnLSingle = 95
)
// suppressedMessageLogIDs keeps high-volume price, size, and computation
// frames out of debug logs during regular trading hours.
var suppressedMessageLogIDs = map[int]bool{
msgTickPrice: true, // Tick price updates (1)
msgTickSize: true, // Tick size updates (2)
msgTickString: true, // Tick string updates (46)
msgTickGeneric: true, // Generic tick updates (45)
msgMarketDataType: true, // Market data type (58)
msgTickNews: true, // Tick news (81)
msgAccountSummary: true, // Account summary (63)
msgAccountSummaryEnd: true, // Account summary end (64)
msgAccountUpdateMulti: true,
msgAccountUpdateMultiEnd: true,
msgPosition: true, // Position updates (61)
msgPositionEnd: true, // Position sync complete (62)
15: true, // Managed accounts
9: true, // Next valid ID
4: true, // Error messages (handled separately)
msgCurrentTimeMillis: true, // Heartbeat variant with ms precision (109)
}
var placeOrderBaseFields = []string{
"3", "0", "0", "", "", "", "0.0", "", "", "SMART", "", "USD", "", "", "", "", "BUY", "0", "LMT", "0", "", "DAY", "", "", "", "0", "", "1", "0", "0", "0", "0", "0", "0", "0", "", "0", "", "", "", "", "", "", "", "0", "", "-1", "0", "", "", "0", "", "", "1", "1", "", "0", "", "", "", "", "", "0", "", "", "", "", "0", "", "", "", "", "", "", "", "", "", "", "0", "", "", "0", "0", "", "", "0", "", "0", "0", "0", "0", "", "", "", "", "", "", "0", "", "", "", "", "0", "0", "0", "", ""}
// handshake performs the initial IBKR protocol handshake
func (c *Connection) handshake() error {
attemptPayloads := []string{
fmt.Sprintf("v%d..%d", minClientVersion, maxClientVersion),
fmt.Sprintf("v%d", maxClientVersion),
}
var sawNoData bool
// peerEnded keeps the first no-data verdict in which the peer ended the
// connection (EOF or reset in place of the reply) rather than staying
// silent, so the final error still says which one it was.
var peerEnded error
for idx, payload := range attemptPayloads {
if err := c.sendHandshakePayload(payload); err != nil {
return fmt.Errorf("failed to send handshake payload %q: %w", payload, rejectedBeforeHandshake(err))
}
err := c.readHandshakeResponse()
if err == nil {
return nil
}
if errors.Is(err, errHandshakeNoData) {
sawNoData = true
// Both a hang-up and a silence retry the same way — the TLS
// fallback's cue — but only the hang-up is a rejection.
if peerEnded == nil && errors.Is(err, ErrRejectedBeforeHandshake) {
peerEnded = err
}
c.logConnectAttempt("Client %d: no response to payload %q (attempt %d/%d)", c.config.ClientID, payload, idx+1, len(attemptPayloads))
continue
}
return err
}
if sawNoData {
if peerEnded != nil {
return fmt.Errorf("no response from IBKR gateway after %d attempts: %w", len(attemptPayloads), peerEnded)
}
return fmt.Errorf("%w: no response from IBKR gateway after %d attempts", errHandshakeNoData, len(attemptPayloads))
}
return fmt.Errorf("handshake failed: no valid response format detected")
}
func (c *Connection) sendHandshakePayload(versionDescriptor string) error {
descriptorBytes := append([]byte(versionDescriptor), '\x00')
var lengthBuf [4]byte
binary.BigEndian.PutUint32(lengthBuf[:], uint32(len(descriptorBytes)))
var frame bytes.Buffer
frame.Grow(4 + len(lengthBuf) + len(descriptorBytes))
frame.WriteString("API\x00")
frame.Write(lengthBuf[:])
frame.Write(descriptorBytes)
handshakeLogger.Infof("Client %d: sending descriptor %q", c.config.ClientID, versionDescriptor)
return c.withTransport(true, func() error {
_, err := c.conn.Write(frame.Bytes())
return err
})
}
func (c *Connection) readHandshakeResponse() error {
const handshakeDeadline = 10 * time.Second
if err := c.conn.SetReadDeadline(time.Now().Add(handshakeDeadline)); err != nil {
return fmt.Errorf("handshake set deadline: %w", err)
}
defer c.conn.SetReadDeadline(time.Time{})
head, err := c.reader.Peek(4)
if err != nil {
if isHandshakeNoDataErr(err) {
return handshakeNoData(err)
}
return fmt.Errorf("handshake peek failed: %w", err)
}
first := head[0]
if first == '-' || (first >= '0' && first <= '9') {
return c.readAsciiHandshake()
}
return c.readLengthPrefixedHandshake()
}
func (c *Connection) readLengthPrefixedHandshake() error {
var lengthBuf [4]byte
if _, err := io.ReadFull(c.reader, lengthBuf[:]); err != nil {
if isHandshakeNoDataErr(err) {
return handshakeNoData(err)
}
return fmt.Errorf("handshake read frame length: %w", err)
}
frameLen := int(binary.BigEndian.Uint32(lengthBuf[:]))
if frameLen == 0 {
return errHandshakeNoData
}
if frameLen < 0 || frameLen > 4096 {
return fmt.Errorf("handshake frame length out of bounds: %d", frameLen)
}
payload := make([]byte, frameLen)
if _, err := io.ReadFull(c.reader, payload); err != nil {
if isHandshakeNoDataErr(err) {
return handshakeNoData(err)
}
return fmt.Errorf("handshake read frame payload: %w", err)
}
fieldsRaw := bytes.Split(payload, []byte{0})
fields := make([]string, 0, len(fieldsRaw))
for i, raw := range fieldsRaw {
// Drop a trailing empty field if the payload ended with a null delimiter
if i == len(fieldsRaw)-1 && len(raw) == 0 {
continue
}
fields = append(fields, string(raw))
}
if len(fields) == 0 || fields[0] == "" {
return errHandshakeNoData
}
serverVersion, err := strconv.Atoi(fields[0])
if err != nil {
return fmt.Errorf("invalid server version string %q: %w", fields[0], err)
}
if serverVersion == -1 {
if len(fields) < 2 {
return fmt.Errorf("handshake redirect requested but no target provided")
}
return fmt.Errorf("handshake redirect requested: %s", fields[1])
}
connTime := ""
if serverVersion >= 20 {
if len(fields) >= 2 {
connTime = fields[1]
} else {
handshakeLogger.Warnf("Client %d: server version %d provided no time string", c.config.ClientID, serverVersion)
}
}
if serverVersion < minServerVersionRequired {
return fmt.Errorf("server version %d is too old (minimum: %d)", serverVersion, minServerVersionRequired)
}
c.serverVersion = serverVersion
c.connTime = connTime
handshakeLogger.Infof("Client %d: Server Version %d, Time %s (v100 frame)", c.config.ClientID, c.serverVersion, c.connTime)
return nil
}
func (c *Connection) readAsciiHandshake() error {
verStr, err := c.readHandshakeCString()
if err != nil {
if isHandshakeNoDataErr(err) {
return handshakeNoData(err)
}
return fmt.Errorf("handshake read version string: %w", err)
}
if verStr == "" {
return errHandshakeNoData
}
serverVersion, err := strconv.Atoi(verStr)
if err != nil {
return fmt.Errorf("invalid server version string %q: %w", verStr, err)
}
if serverVersion == -1 {
redirect, err := c.readHandshakeCString()
if err != nil {
return fmt.Errorf("handshake read redirect target: %w", err)
}
return fmt.Errorf("handshake redirect requested: %s", redirect)
}
connTime := ""
if serverVersion >= 20 {
timeStr, err := c.readHandshakeCString()
if err != nil {
if !isHandshakeNoDataErr(err) {
return fmt.Errorf("handshake read time: %w", err)
}
} else {
connTime = timeStr
}
}
if serverVersion < minServerVersionRequired {
return fmt.Errorf("server version %d is too old (minimum: %d)", serverVersion, minServerVersionRequired)
}
c.serverVersion = serverVersion
c.connTime = connTime
handshakeLogger.Infof("Client %d: Server Version %d, Time %s (ascii)", c.config.ClientID, c.serverVersion, c.connTime)
return nil
}
func (c *Connection) readHandshakeCString() (string, error) {
data, err := c.reader.ReadString('\x00')
if err != nil {
return "", err
}
return strings.TrimSuffix(data, "\x00"), nil
}
// handshakeNoData is the no-data verdict for a failed read of the handshake
// reply. The peer ending the connection (EOF, reset) is a rejection before
// the handshake and says so in the chain; a timeout is a listener that has
// not answered. Both satisfy errHandshakeNoData for the retry and TLS
// fallback logic.
func handshakeNoData(err error) error {
if wrapped := rejectedBeforeHandshake(err); wrapped != err {
return fmt.Errorf("%w: %w", errHandshakeNoData, wrapped)
}
return errHandshakeNoData
}
// isHandshakeNoDataErr reports whether err means "the peer hung up before
// - io.EOF / io.ErrUnexpectedEOF: graceful close after we sent the request.
// - net.Error with Timeout(): the server accepted but did not reply in time.
func isHandshakeNoDataErr(err error) bool {
if err == nil {
return false
}
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
return true
}
if errors.Is(err, syscall.ECONNRESET) {
return true
}
if netErr, ok := errors.AsType[net.Error](err); ok {
return netErr.Timeout()
}
return false
}
// startAPI sends the start API message to initialize the connection
func (c *Connection) startAPI() error {
fields := []any{startAPI, 2, c.config.ClientID}
if c.serverVersion >= minServerVerStartAPICapab {
// Optional capabilities placeholder – currently unused but must be omitted
fields = append(fields, "")
}
msg := c.encodeMsg(fields...)
lengthBytes := make([]byte, 4)
binary.BigEndian.PutUint32(lengthBytes, uint32(len(msg)))
// Debug: hex dump START_API message
c.logOutgoingMessageHex(msg)
sendErr := c.withTransport(true, func() error {
if c.writer == nil {
return fmt.Errorf("%w: buffered writer not initialized before startAPI", errStartAPIFailed)
}
if _, err := c.writer.Write(lengthBytes); err != nil {
return fmt.Errorf("%w: failed to send startAPI length: %w", errStartAPIFailed, err)
}
if _, err := c.writer.Write(msg); err != nil {
return fmt.Errorf("%w: failed to send startAPI payload: %w", errStartAPIFailed, err)
}
c.logPacketOutbound(msg)
if err := c.writer.Flush(); err != nil {
return fmt.Errorf("%w: failed to flush startAPI payload: %w", errStartAPIFailed, err)
}
return nil
})
if sendErr != nil {
return sendErr
}
// Read the startup burst until nextValidId has set the order-ID
// namespace and managedAccounts has named the accounts. With "Download
// open orders on connection" on, the burst opens with an openOrder and an
// orderStatus per open order and openOrderEnd, then managedAccounts and
// nextValidId, so its length grows with the open orders. Every message
// read is processed: the open orders are wanted. Until 2026-10-19 the read
// stopped after ten messages, and four open orders pushed nextValidId past
// it. Only time spent waiting on the wire counts against the budget, not
// the handlers' work on each message.
budget := c.startupReadBudget
if budget <= 0 {
budget = defaultStartupReadBudget
}
remaining := budget
defer c.conn.SetReadDeadline(time.Time{}) // Clear deadline
// Track if we get error 326 (client ID already in use)
var clientIDError error
read := 0
var stopped string
epoch := c.BrokerSessionEpoch()
graced := false
for read < startupMessageCap && clientIDError == nil && !(c.BrokerIDNamespaceReady() && c.accountsEpoch.Load() == epoch) {
if !graced && c.BrokerIDNamespaceReady() {
remaining, graced = min(remaining, startupAccountsGrace), true
}
c.conn.SetReadDeadline(time.Now().Add(remaining))
began := time.Now()
msgBytes, err := c.readMessage()
remaining -= time.Since(began)
if err != nil {
// Capture a client-ID-collision lastError observed mid-read so
// the caller's retry loop branches on errClientIDInUse rather
// than the read error itself.
c.statusMu.RLock()
if errors.Is(c.lastError, errClientIDInUse) {
clientIDError = c.lastError
}
c.statusMu.RUnlock()
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
stopped = fmt.Sprintf(" within %s", budget)
break // The budget ran out before nextValidId
}
if errors.Is(err, io.EOF) {
// EOF after startAPI: if the gateway told us our client ID
c.statusMu.RLock()
lastErr := c.lastError
c.statusMu.RUnlock()
if errors.Is(lastErr, errClientIDInUse) {
return lastErr
}
if clientIDError != nil {
return clientIDError
}
return fmt.Errorf("%w: connection closed by server after startAPI", errStartAPIFailed)
}
// Log but don't fail on read errors during initialization
c.logReadFailure(connectLogger, true, err, "Error reading initial message: %v", err)
stopped = fmt.Sprintf(" (read error: %v)", err)
break
}
read++
// Process the initial message
c.processMessage(msgBytes)
c.statusMu.RLock()
if errors.Is(c.lastError, errClientIDInUse) {
clientIDError = c.lastError
}
c.statusMu.RUnlock()
}
// If we detected client ID error, return it
if clientIDError != nil {
return clientIDError
}
if !c.BrokerIDNamespaceReady() {
return c.sessionNotStarted(read, fmt.Errorf("%w: %w after %d messages%s", errStartAPIFailed, errNoNextValidID, read, stopped))
}
return nil
}
// sessionNotStarted records a START_API the Gateway accepted without the
// session starting, with the messages it sent first, and returns err. An
// attempt that fails before START_API clears it with a nil err, so the
// failure reported is always the latest attempt's.
func (c *Connection) sessionNotStarted(read int, err error) error {
c.startAPIMu.Lock()
c.sessionStartErr, c.sessionStartRead = err, read
c.startAPIMu.Unlock()
return err
}
// SessionStartFailure reports why the latest connect attempt's API session
// did not start after the Gateway accepted the socket and answered the
// version handshake, and how many messages the Gateway sent after START_API.
// It is readable while Connect is still retrying, and nil before such a
// failure and once a session starts.
func (c *Connection) SessionStartFailure() (messages int, err error) {
if c == nil {
return 0, nil
}
c.startAPIMu.Lock()
defer c.startAPIMu.Unlock()
return c.sessionStartRead, c.sessionStartErr
}
// readMessages continuously reads messages from the connection.
// Wrapped in a panic guard because this goroutine is the only consumer of
// malformed wire data without letting the only receipt reader die silently.
func (c *Connection) readMessages() {
defer c.wg.Done()
defer func() {
if r := recover(); r != nil {
connectLogger.Errorf("readMessages panic recovered (Client ID: %d): %v\n%s",
c.config.ClientID, r, debug.Stack())
c.handleDisconnection(fmt.Errorf("reader panic: %v", r))
}
}()
c.signalReadStarted()
epoch := c.BrokerSessionEpoch()
for {
select {
case <-c.stopChan:
return
default:
if c.conn == nil {
c.handleDisconnection(io.EOF)
return
}
// Read message with timeout
c.conn.SetReadDeadline(time.Now().Add(5 * time.Second))
msgBytes, err := c.readMessage()
if err != nil {
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
// Timeout is expected, continue
continue
}
if err == io.EOF {
c.logOwnedWarn(ibkrLogger, "Connection closed by server")
c.handleDisconnection(err)
return
}
if errors.Is(err, net.ErrClosed) {
// Only this side closes its own socket: Disconnect and
// the connect retry both do so to unblock this read. The
// reader is being told to stop, not losing the stream.
connectLogger.Debugf("Reader stopped on closed socket (Client ID: %d)", c.config.ClientID)
c.handleDisconnection(err)
return
}
// Any other error means stream alignment is uncertain —
// errors before disconnect). Fail fast: log, signal
c.logReadFailure(ibkrLogger, true, err, "Error reading message: %v", err)
c.handleDisconnection(err)
return
}
// Process the message
c.processMessageAtEpoch(msgBytes, epoch)
// Receipt freshness belongs to the exact socket that delivered the
// frame. A retired reader must not make its successor look healthy.
c.recordHeartbeatAtEpoch(epoch, time.Now().UnixNano())
}
}
}
// processMessage handles incoming messages from IBKR
func (c *Connection) processMessage(msgBytes []byte) {
c.processMessageAtEpoch(msgBytes, c.BrokerSessionEpoch())
}
func (c *Connection) processMessageAtEpoch(msgBytes []byte, epoch uint64) {
fields := c.decodeMessage(msgBytes)
if len(fields) == 0 {
return
}
// First field is always the message ID
msgID, err := strconv.Atoi(fields[0])
if err != nil {
ibkrLogger.Warnf("[WARNING] Invalid message ID: %v", err)
return
}
if c.wireTap != nil {
c.wireTap.RecordInbound(msgID, msgBytes, fields)
}
if epoch != c.BrokerSessionEpoch() {
// Only epoch-aware order handlers may observe a stale frame, solely so
// their owner can fail closed. Never run legacy handlers or mutate the
// current session's request, farm, portfolio, or local-order state.
c.dispatchStaleInboundMessage(msgID, fields, epoch)
return
}
// Run after the receipt and every lease have been released.
switch msgID {
case msgTickPrice, msgTickSize, msgTickString, msgTickGeneric, msgMarketDataType, msgAcctValue, msgPortfolioValue, msgAcctUpdateTime, msgAcctDownloadEnd, msgAccountSummary, msgManagedAccts, msgPnL, msgPnLSingle, msgErrMsg, msgSystemNotification:
if c.displayChanged != nil {
defer c.displayChanged()
}
}
// The first atomic check above is an inexpensive fast path. Every ordinary
// and legacy callbacks, so a retired reader cannot contaminate the next
// socket's portfolio/account/request authority after reconnect begins.
// they must run outbound recovery only after release. Broker-scope frames
// cannot deadlock behind a reader that is itself waiting for publication.
// The two small typed receipt handlers below take their own leases around
switch msgID {
case msgErrMsg, msgSystemNotification, msgCurrentTimeMillis, msgMarketDataType:
case msgPnL, msgPnLSingle:
if c.publicationBarrier != nil {
c.publicationBarrier.RLock()
defer c.publicationBarrier.RUnlock()
}
c.inboundEpochMu.RLock()
defer c.inboundEpochMu.RUnlock()
if epoch != c.BrokerSessionEpoch() {
return
}
if c.evidenceBarrier != nil {
c.evidenceBarrier.RLock()
defer c.evidenceBarrier.RUnlock()
}
case msgManagedAccts, msgAccountSummary:
if c.publicationBarrier != nil {
c.publicationBarrier.Lock()
defer c.publicationBarrier.Unlock()
}
c.inboundEpochMu.RLock()
defer c.inboundEpochMu.RUnlock()
if epoch != c.BrokerSessionEpoch() {
return
}
if c.evidenceBarrier != nil {
c.evidenceBarrier.RLock()
defer c.evidenceBarrier.RUnlock()
}
default:
if c.messageAfterInitialEpochCheck != nil {
c.messageAfterInitialEpochCheck(msgID)
}
c.inboundEpochMu.RLock()
defer c.inboundEpochMu.RUnlock()
if epoch != c.BrokerSessionEpoch() {
c.dispatchStaleInboundMessage(msgID, fields, epoch)
return
}
}
// Only log unusual messages for debugging
if msgID != 0 && !suppressedMessageLogIDs[msgID] && msgID != msgCurrentTime {
ibkrLogger.Debugf("Received message ID %d with %d fields", msgID, len(fields))
}
if orderID, ok := inboundOrderID(msgID, fields); ok {
c.observeInboundOrderIDAtEpoch(orderID, epoch)
}
statusDisposition := previewStatusOrdinary
if msgID == msgOpenOrder {
c.observeReportedOwnOrderAtEpoch(fields, epoch)
} else if msgID == msgOrderStatus {
statusDisposition = c.observeReportedOrderStatusAtEpoch(fields, epoch)
}
if msgID == msgOpenOrder || msgID == msgOpenOrderEnd {
c.openOrderObserverMu.RLock()
observer := c.openOrderObserver
c.openOrderObserverMu.RUnlock()
if observer != nil {
observer(msgID, fields, epoch)
}
}
// A diagnostics tap (a bond lookup's wire record) sees every
// current-session frame before the frame's own handlers run.
if c.inboundTaps.Load() > 0 {
c.dispatchHandlers(msgInboundTap, fields, epoch)
}
if msgID == msgOrderStatus {
c.dispatchOrderStatusObserver(fields, epoch, statusDisposition)
}
// Handle common messages
switch msgID {
case msgNextValidID:
if id, ok := parseNextValidOrderID(fields); ok {
c.observeNextValidOrderIDAtEpoch(id, epoch)
if c.config.ClientID == 1 {
ibkrLogger.Infof("Next Valid Order ID: %d", id)
}
}
case msgCurrentTimeMillis:
if len(fields) > 1 {
if ms, err := strconv.ParseInt(fields[1], 10, 64); err == nil {
c.recordHeartbeatAtEpoch(epoch, ms*int64(time.Millisecond))
}
}
case msgManagedAccts:
c.accountsEpoch.Store(epoch)
if acct := managedAccountsField(fields); acct != "" {
c.accountMu.Lock()
c.account = acct
c.managedAccounts = parseManagedAccounts(acct)
c.accountMu.Unlock()
// The canonical primary client logs this account-wide notice; auxiliary
if c.config.ClientID == 1 {
ibkrLogger.Infof("Managed Accounts: %s", acct)
}
}
case msgErrMsg:
if c.errorMessageAfterInitialEpochCheck != nil {
c.errorMessageAfterInitialEpochCheck()
}
c.processErrorMessageAtEpoch(fields, epoch)
case msgCurrentTime:
// IBKR heartbeat - silently process to maintain connection
case msgPosition:
c.handlePosition(fields)
case msgPositionEnd:
portfolioLogger.Infof("Position sync complete")
c.completePositionsSnapshot()
// Signal that positions are complete
select {
case c.positionsEndChan <- struct{}{}:
default:
// Channel already has a signal
}
case msgPositionMulti, msgPositionMultiEnd:
// Retired request IDs carry private holdings too; consume their late
// callbacks without falling through to generic unhandled-frame logs.
c.dispatchHandlers(msgID, fields, epoch)
return
case msgAccountSummary:
c.handleAccountSummaryUnderBrokerScopeLease(fields)
case msgAccountSummaryEnd:
// Wire-cadence event: the daemon re-polls the summary every few
portfolioLogger.Debugf("Account summary sync complete")
c.signalSummaryEnd(fields)
// Legacy shared signal for WaitForAccountSummaryEnd callers
select {
case c.acctSummaryEndChan <- struct{}{}:
default:
// Channel already has a signal
}
case msgAccountUpdateMulti, msgAccountUpdateMultiEnd:
c.handleCurrencyProbeMessage(msgID, fields, epoch)
case msgPortfolioValue:
c.handlePortfolioValue(fields)
case msgAcctValue:
c.handleAccountValue(fields)
case msgAcctUpdateTime:
// fields: [msgID, version, HH:MM]. fields[1] is the protocol
if len(fields) > 2 {
// Wire-cadence event, streamed continuously while account
portfolioLogger.Debugf("Account update time: %s", fields[2])
}
c.portfolioProjectionMu.Lock()
c.portfolioHealthMu.Lock()
if c.portfolioHealth.ScopeConflictAt.IsZero() && c.portfolioHealth.InvalidPayloadAt.IsZero() {
c.portfolioHealth.LastUpdateAt = time.Now().UTC()
}
c.portfolioHealthMu.Unlock()
c.portfolioProjectionMu.Unlock()
case msgAcctDownloadEnd:
portfolioLogger.Infof("Account download complete")
account := ""
if len(fields) > 2 {
account = fields[2]
}
completedAt := time.Now().UTC()
c.observeAccountStreamEnd(account, completedAt)
c.completePortfolioDownload(account, completedAt)
case msgMarketDataType:
c.processMarketDataTypeAtEpoch(fields, epoch)
c.dispatchHandlers(msgMarketDataType, fields, epoch)
case msgSystemNotification:
if c.systemNoticeAfterInitialEpochCheck != nil {
c.systemNoticeAfterInitialEpochCheck()
}
c.processSystemNoticeMessageAtEpoch(fields, epoch)
case msgTickNews:
// News tick - handle silently for now
case msgTickString:
// String ticks carry broker trade times as well as exchange names.
c.dispatchHandlers(msgTickString, fields, epoch)
case msgTickGeneric:
// Generic tick data (e.g., 106 = Option Implied Volatility)
if c.dispatchHandlers(msgTickGeneric, fields, epoch) {
return
}
case msgSecurityDefinitionOptionalParameter, msgSecurityDefinitionOptionalParameterEnd:
// reqSecDefOptParams (78) responses arrive once per exchange (75) before
c.dispatchHandlers(msgID, fields, epoch)
return
default:
// Check for registered handler
if c.dispatchHandlers(msgID, fields, epoch) {
} else if isBenignUnhandledMessage(msgID) {
// Contract details may arrive even when the connector did not register a handler.
return
} else {
ibkrLogger.Warnf("[WARNING] Unhandled message ID %d: %v", msgID, fields)
}
}
}
func (c *Connection) dispatchStaleInboundMessage(msgID int, fields []string, epoch uint64) {
switch msgID {
case msgOpenOrder, msgOrderStatus, msgExecDetails:
if msgID == msgOrderStatus {
c.dispatchOrderStatusObserver(fields, epoch, previewStatusOrdinary)
}
c.dispatchEpochHandlers(msgID, fields, epoch)
case msgErrMsg:
// The Connector-owned handler publishes only an exact stale receipt;
_ = c.dispatchErrorPostAction(fields, epoch)
c.dispatchEpochHandlers(msgID, fields, epoch)
case msgSystemNotification:
c.dispatchStaleSystemNotice(fields, epoch)
}
}
// processErrorMessageAtEpoch keeps the final epoch check, all current-session
// side effects, and legacy handler delivery in one inbound-generation read
// section. The scoped defer is intentional: a panicking handler must not leave
// receipt handlers run only after the lock is released and never reach the
// current-session legacy surface.
func (c *Connection) processErrorMessageAtEpoch(fields []string, epoch uint64) {
current := false
var postLease []func()
func() {
c.inboundEpochMu.RLock()
defer c.inboundEpochMu.RUnlock()
if epoch != c.BrokerSessionEpoch() {
return
}
current = true
if len(fields) > 3 {
code, _ := strconv.Atoi(fields[3])
if c.consumeSummaryProbeError(fields[2], code, epoch) || c.consumeCurrencyProbeError(fields[2], code, epoch) {
// Keep pacing, connection loss and all existing numeric-code recovery,
// but never pass diagnostic broker prose to log/recovery surfaces.
fields = append([]string(nil), fields...)
for i := 4; i < len(fields); i++ {
fields[i] = ""
}
if len(fields) > 4 {
fields[4] = "settlement diagnostic broker notice"
}
}
}
if post := c.handleErrorMessage(fields, epoch); post != nil {
postLease = append(postLease, post)
}
if post := c.dispatchErrorPostAction(fields, epoch); post != nil {
postLease = append(postLease, post)
}
// Also forward to registered error handlers for higher-level recovery.
c.dispatchHandlers(msgErrMsg, fields, epoch)
}()
if !current {
c.dispatchEpochHandlers(msgErrMsg, fields, epoch)
return
}
for _, post := range postLease {
post()
}
}
// processSystemNoticeMessageAtEpoch gives msg-204 the same final socket-
// mutations, and legacy handler delivery complete under inboundEpochMu.R.
// Stale receipts reach only the epoch-aware owner; outbound actions run later.
func (c *Connection) processSystemNoticeMessageAtEpoch(fields []string, epoch uint64) {
current := false
var postLease func()
func() {
c.inboundEpochMu.RLock()
defer c.inboundEpochMu.RUnlock()
if epoch != c.BrokerSessionEpoch() {
return
}
current = true
postLease = c.handleSystemNotificationAtEpoch(fields, epoch)
c.dispatchHandlers(msgSystemNotification, fields, epoch)
}()
if !current {
c.dispatchStaleSystemNotice(fields, epoch)
return
}
if postLease != nil {
postLease()
}
}
func isBenignUnhandledMessage(msgID int) bool {
switch msgID {
case msgContractData, msgBondContractData, msgContractDataEnd, msgCommissionReport:
return true
default:
return false
}
}
// getErrorDescription returns a human-readable description for IBKR error codes
func getErrorDescription(code int) string {
switch code {
// Low-numbered error codes (1-99)
case 1:
return "Requested market data is not available"
case 2:
return "Requested market data is not subscribed"
case 3:
return "Requested market data cannot be retrieved"
case 4:
return "Market data request error"
// Connection and System Status (2100-2199)
case 2104:
return "Market data farm connected"
case 2106:
return "Historical data farm connected"
case 2107:
return "Historical data farm connected (inactive)"
case 2108:
return "Market data farm disconnected"
case 2110:
return "Connectivity between TWS and server is broken"
case 2119:
return "Market data farm connection is OK"
case 2158:
return "Security definition data farm connected"
// Market Data Errors (300-399)
case 320:
return "Reading request error - Invalid ticker or exchange"
case 321:
return "Error validating request"
case 322:
return "Error processing request - Duplicate ticker ID"
case 326:
return "Unable to connect as client id is already in use. Retry with unique client id"
case 354:
return "Requested market data is not subscribed"
// Order and Trading Errors (100-199)
case 110:
return "Price does not conform to minimum price variation"
case 161:
return "Cancel attempted when order is not in a cancellable state"
case 162:
return "Historical market data service error"
// Connection Errors (500-599)
case 502:
return "Couldn't connect to TWS"
case 503:
return "The TWS is out of date and must be upgraded"
case 504:
return "Not connected to TWS"
// Account and Position Errors (400-449)
case 430:
return "The account code is required for this operation"
case 431:
return "Invalid account code"
default:
return ""
}
}
// handleErrorMessage processes error messages from IBKR
func (c *Connection) handleErrorMessage(fields []string, epoch uint64) (postLease func()) {
if len(fields) < 3 {
ibkrLogger.Warnf("[WARNING] Invalid error message: %v", fields)
return
}
// Expected layout: [msgId(4), version, reqId, errorCode, errorMsg]
reqID := ""
errorCode := ""
errorMsg := ""
if len(fields) > 2 {
reqID = fields[2]
}
if len(fields) > 3 {
errorCode = fields[3]
}
if len(fields) > 4 {
errorMsg = fields[4]
} else if len(fields) > 3 {
errorMsg = fields[3]
}
// Check the error code to determine if it's informational or an actual error
code, _ := strconv.Atoi(errorCode)
if code == 100 && epoch == c.BrokerSessionEpoch() && c.rateLimiter != nil {
c.rateLimiter.recordRateLimitError()
}
// Log important errors for debugging
if code >= 300 && code < 400 {
ibkrLogger.Debugf("[cid=%d] Market data error for reqID %s: code=%s, msg=%s", c.config.ClientID, reqID, errorCode, errorMsg)
} else if code == 200 || code == 162 || code == 10197 {
// Market data subscription errors
ibkrLogger.Debugf("[cid=%d] Market data subscription error for reqID %s: code=%s, msg=%s", c.config.ClientID, reqID, errorCode, errorMsg)
}
// Sometimes IBKR sends the error code in the message field
if msgCode, err := strconv.Atoi(errorMsg); err == nil {
// The message is an error code, look up its description
if desc := getErrorDescription(msgCode); desc != "" {
errorMsg = desc
} else {
errorMsg = fmt.Sprintf("Error code %d", msgCode)
}
}
// Get human-readable description for the main error code
description := getErrorDescription(code)
if description != "" && errorMsg == errorCode {
// Only replace if errorMsg is just the code repeated
errorMsg = description
}
// IBKR informational codes (not actual errors)
switch code {
case 2104, 2106, 2107, 2158, 2119, 2169:
// These are normal connection confirmations, suppress them
return
}
// Warning level codes
if code >= 2100 && code < 2200 {
ibkrLogger.Warnf("[cid=%d] %s", c.config.ClientID, errorMsg)
} else if code == 502 || code == 503 || code == 504 {
// Connection errors - these are critical
ibkrLogger.Errorf("[cid=%d] Critical Error: %s (Code %d)", c.config.ClientID, errorMsg, code)
c.handleDisconnection(fmt.Errorf("connection error %d: %s", code, errorMsg))
} else if code == 326 {
// Client ID already in use — surface as the sentinel so the
// matching this exact format string.
ibkrLogger.Infof("[cid=%d] System notice: %s", c.config.ClientID, errorMsg)
c.statusMu.Lock()
c.lastError = clientIDInUseError(c.config.ClientID, errorMsg)
c.statusMu.Unlock()
} else if code == 200 {
ibkrLogger.Warnf("[cid=%d] Market Data Error (ReqID %s): %s", c.config.ClientID, reqID, errorMsg)
if rid, err := strconv.Atoi(reqID); err == nil {
c.releaseMarketDataSlotAtEpoch(rid, epoch)
}
} else if code == 320 || code == 321 || code == 322 || code == 354 {
// Market data errors
ibkrLogger.Warnf("[cid=%d] Market Data Error (ReqID %s): %s", c.config.ClientID, reqID, errorMsg)
if code == 354 {
if rid, err := strconv.Atoi(reqID); err == nil {
c.releaseMarketDataSlotAtEpoch(rid, epoch)
}
}
} else if code == 10197 {
// Competing live session blocks real-time data; switch to delayed
ibkrLogger.Warnf("[cid=%d] Market Data Error (ReqID %s): %s", c.config.ClientID, reqID, errorMsg)
if c.markCompetingLiveSession(reqID) {
// Never enter outbound pacing/transport while holding inboundEpochMu.R:
// reconnect pauses transport before taking inboundEpochMu.W. The exact
// epoch-bound send below runs only after the inbound lease is released.
postLease = func() {
if err := c.setMarketDataTypeAtEpoch(3, epoch); err != nil {
ibkrLogger.Errorf("[cid=%d] Failed to request delayed market data after 10197: %v", c.config.ClientID, err)
} else {
ibkrLogger.Warnf("[cid=%d] Forced delayed market data after 10197 (%s)", c.config.ClientID, errorMsg)
}
}
}
} else if code < 0 || code == 0 {
// Codes -1 or 0 often contain system messages
if errorMsg != "" && errorMsg != "0" {
// Check if this is one of the informational messages we want to suppress
if errorMsg == "Market data farm connected" ||
errorMsg == "Historical data farm connected" ||
errorMsg == "Historical data farm connected (inactive)" ||
errorMsg == "Security definition data farm connected" {
// Suppress these repetitive connection confirmations
return
}
// Only log if it's not just the code number
if _, err := strconv.Atoi(errorMsg); err != nil {
ibkrLogger.Infof("[cid=%d] System: %s", c.config.ClientID, errorMsg)
// Some gateway builds emit the client-ID-in-use notice
errLower := strings.ToLower(errorMsg)
if strings.Contains(errLower, "unable to connect as client id") ||
strings.Contains(errLower, "client id is already in use") ||
strings.Contains(errLower, "client id already in use") {
c.statusMu.Lock()
c.lastError = fmt.Errorf("%w: %s", errClientIDInUse, errorMsg)
c.statusMu.Unlock()
}
}
}
} else {
// Other errors
ibkrLogger.Warnf("[cid=%d] Error (ReqID %s): %s (Code %d)", c.config.ClientID, reqID, errorMsg, code)
}
return postLease
}
// HasCompetingLiveSession returns true if IBKR reported code 10197 for this connection.
func (c *Connection) HasCompetingLiveSession() bool {
c.competingMu.RLock()
defer c.competingMu.RUnlock()
return c.competingLiveSession
}
func (c *Connection) markCompetingLiveSession(reqID string) bool {
c.competingMu.Lock()
already := c.competingLiveSession
c.competingLiveSession = true
c.competingMu.Unlock()
if already {
return false
}
ibkrLogger.Warnf("[cid=%d] 10197 competing live session detected (reqID=%s) – requesting delayed market data", c.config.ClientID, reqID)
return true
}
type marketDataSlot struct {
epoch uint64
acquired time.Time
owner string
}
// logMarketDataSlotCensus reports bounded owner/age counts, never contracts or
// request IDs. One record per minute is enough to diagnose persistent holders.
func (c *Connection) logMarketDataSlotCensus() {
now := time.Now()
c.marketDataSlotsMu.Lock()
defer c.marketDataSlotsMu.Unlock()
if now.Sub(c.marketDataCensusAt) < time.Minute {
return
}
c.marketDataCensusAt = now
owners := map[string]int{}
var oldest time.Duration
for _, slot := range c.marketDataSlots {
owners[slot.owner]++
oldest = max(oldest, now.Sub(slot.acquired))
}
marketLogger.Warnf("market data pool saturated: holders=%d owners=%v oldest=%s", len(c.marketDataSlots), owners, oldest.Round(time.Second))
}
// acquireMarketDataSlot acquires a market data slot and records the holding
func (c *Connection) acquireMarketDataSlot(ctx context.Context, reqID int, owner ...string) error {
label := "unknown"
if len(owner) != 0 {
label = owner[0]
}
return c.acquireMarketDataSlotForEpoch(ctx, reqID, c.BrokerSessionEpoch(), label)
}
func (c *Connection) acquireMarketDataSlotForEpoch(ctx context.Context, reqID int, epoch uint64, owner string) error {
if c.rateLimiter == nil {
return nil
}
if c.rateLimiter.marketDataSubs.Count() == cap(c.rateLimiter.marketDataSubs.ch) {
c.logMarketDataSlotCensus()
}
if err := c.rateLimiter.AcquireMarketDataSlot(ctx); err != nil {
return err
}
c.marketDataSlotsMu.Lock()
err := ctx.Err()
if err == nil && c.BrokerSessionEpoch() != epoch {
err = fmt.Errorf("broker socket generation changed during market-data slot admission")
}
if err == nil {
if _, exists := c.marketDataSlots[reqID]; exists {
err = fmt.Errorf("market-data request already owns a slot")
}
}
if err != nil {
c.marketDataSlotsMu.Unlock()
c.rateLimiter.ReleaseMarketDataSlot()
return err
}
c.marketDataSlots[reqID] = marketDataSlot{epoch: epoch, acquired: time.Now(), owner: owner}
c.marketDataSlotsMu.Unlock()
return nil
}
// releaseMarketDataSlot releases a market data slot iff this reqID currently
// never over-releases.
func (c *Connection) releaseMarketDataSlot(reqID int) {
c.releaseMarketDataSlotAtEpoch(reqID, 0)
}
// releaseMarketDataSlotAtEpoch releases reqID only when its slot belongs to
// epoch. A zero epoch is the compatibility form for a current synchronous
// caller. Exact stale cleanup uses a nonzero epoch so a retired subscription
// can never release a successor socket's reused request ID.
func (c *Connection) releaseMarketDataSlotAtEpoch(reqID int, epoch uint64) {
if c.rateLimiter == nil {
return
}
c.marketDataSlotsMu.Lock()
slot, held := c.marketDataSlots[reqID]
if held && (epoch == 0 || slot.epoch == epoch) {
delete(c.marketDataSlots, reqID)
} else {
held = false
}
c.marketDataSlotsMu.Unlock()
if held {
c.rateLimiter.ReleaseMarketDataSlot()
}
}
func (c *Connection) registerStartAPIFailure() time.Duration {
c.startAPIMu.Lock()
defer c.startAPIMu.Unlock()
c.startAPIFailures++
c.lastStartAPIFailure = time.Now()
switch {
case c.startAPIFailures == 1:
return 2 * time.Second
case c.startAPIFailures == 2:
return 5 * time.Second
case c.startAPIFailures <= 4:
return 15 * time.Second
case c.startAPIFailures <= 6:
return 30 * time.Second
default:
return time.Minute
}
}
func (c *Connection) resetStartAPIFailure() {
c.startAPIMu.Lock()
c.startAPIFailures = 0
c.lastStartAPIFailure = time.Time{}
c.sessionStartErr, c.sessionStartRead = nil, 0
c.startAPIMu.Unlock()
}
// handlePosition processes position updates from IBKR
func (c *Connection) handlePosition(fields []string) {
// IBKR Position message format (msgID 61, version 3):
// 2: account
// The actual position message format might vary based on version
if len(fields) < 13 {
portfolioLogger.Errorf("Position message too short: %d fields", len(fields))
return
}
// Parse contract details
conID, _ := strconv.Atoi(fields[3])
var positionSize, avgCost float64
var contract Contract
if len(fields) == 13 {
// Stock format (13 fields)
multiplier, _ := strconv.ParseFloat(fields[6], 64)
if multiplier == 0 {
multiplier = 1
}
contract = Contract{
ConID: conID,
Symbol: fields[4],
SecType: fields[5],
Multiplier: int(multiplier),
Exchange: fields[7],
Currency: fields[8],
LocalSymbol: fields[9],
TradingClass: fields[10],
}
positionSize, _ = strconv.ParseFloat(fields[11], 64)
avgCost, _ = strconv.ParseFloat(fields[12], 64)
} else if len(fields) == 15 {
// Options format (15 fields) - includes expiry, strike, right
strike, _ := strconv.ParseFloat(fields[7], 64)
multiplier, _ := strconv.Atoi(fields[9])
if multiplier == 0 {
multiplier = 100 // Default for options
}
contract = Contract{
ConID: conID,
Symbol: fields[4],
SecType: fields[5],
Expiry: fields[6],
Strike: strike,
Right: fields[8],
Multiplier: multiplier,
Exchange: fields[10],
Currency: fields[11],
LocalSymbol: fields[12],
TradingClass: fields[13],
}
// Position at field 14, avgCost might be missing
if fields[14] != "" {
positionSize, _ = strconv.ParseFloat(fields[14], 64)
}
} else if len(fields) >= 16 {
// The wire layout (EDecoder position v3): account, conId, symbol,
// secType, lastTradeDate, strike, right, multiplier, exchange,
// currency, localSymbol, tradingClass, position, avgCost. Every
// secType carries the same slots; a stock leaves the multiplier
// empty and is one unit, as the streaming updatePortfolio decoder
// reads it. Only contractual types default to 100.
strike, _ := strconv.ParseFloat(fields[7], 64)
multiplier, _ := strconv.Atoi(fields[9])
if multiplier == 0 {
multiplier = 1
if portfolioMultiplierIsContractual(fields[5]) {
multiplier = 100
}
}
contract = Contract{
ConID: conID,
Symbol: fields[4],
SecType: fields[5],
Expiry: fields[6],
Strike: strike,
Right: fields[8],
Multiplier: multiplier,
Exchange: fields[10],
Currency: fields[11],
LocalSymbol: fields[12],
TradingClass: fields[13],
}
positionSize, _ = strconv.ParseFloat(fields[14], 64)
avgCost, _ = strconv.ParseFloat(fields[15], 64)
} else {
portfolioLogger.Errorf("Unexpected position message format with %d fields", len(fields))
return
}
c.portfolioProjectionMu.Lock()
defer c.portfolioProjectionMu.Unlock()
if !c.positionsSnapshotActive {
// msgPosition belongs only to the singleton reqPositions stream. An
// unsolicited/late row cannot mutate the streaming account-updates
// authority or a completed one-shot result.
return
}
key := cachedPositionKey(fields[2], contract)
if positionSize == 0 {
delete(c.positionsSnapshot, key)
portfolioLogger.Debugf("Position closed: %s %s", fields[2], key)
return
}
next := &RawPosition{
Account: fields[2],
Contract: contract,
Position: positionSize,
AverageCost: avgCost,
}
if existing := c.positionsSnapshot[key]; existing != nil && existing.Contract.ConID == contract.ConID {
next.Contract = mergeCachedPositionContract(existing.Contract, next.Contract)
next.MarketPrice = existing.MarketPrice
next.MarketValue = existing.MarketValue
next.UnrealizedPNL = existing.UnrealizedPNL
next.RealizedPNL = existing.RealizedPNL
}
c.positionsSnapshot[key] = next
portfolioLogger.Debugf("Position: %s %s %.2f @ %.2f",
fields[2], key, positionSize, avgCost)
}
// cachedPositionKey preserves exact account+contract identity whenever the
// broker supplies a positive ConID. Symbol-only keys caused same-symbol stock
// own scope checks. Descriptive fallbacks remain only for legacy zero-ConID rows.
func cachedPositionKey(account string, contract Contract) string {
account = strings.ToUpper(strings.TrimSpace(account))
secType := strings.ToUpper(strings.TrimSpace(contract.SecType))
if contract.ConID > 0 {
return fmt.Sprintf("%s|%s:%d", account, secType, contract.ConID)
}
if secType == "OPT" || secType == "FOP" {
return fmt.Sprintf("%s|%s:%s_%s_%s%.0f", account, secType, contract.Symbol, contract.Expiry, contract.Right, contract.Strike)
}
return fmt.Sprintf("%s|%s:%s", account, secType, contract.Symbol)
}
func mergeCachedPositionContract(existing, incoming Contract) Contract {
if incoming.Exchange == "" {
incoming.Exchange = existing.Exchange
}
if incoming.PrimaryExch == "" {
incoming.PrimaryExch = existing.PrimaryExch
}
if incoming.Currency == "" {
incoming.Currency = existing.Currency
}
if incoming.LocalSymbol == "" {
incoming.LocalSymbol = existing.LocalSymbol
}
if incoming.TradingClass == "" {
incoming.TradingClass = existing.TradingClass
}
return incoming
}
func normalizedContractIdentity(contract Contract) Contract {
contract.Symbol = strings.ToUpper(strings.TrimSpace(contract.Symbol))
contract.SecType = strings.ToUpper(strings.TrimSpace(contract.SecType))
contract.Expiry = strings.TrimSpace(contract.Expiry)
contract.Right = strings.ToUpper(strings.TrimSpace(contract.Right))
contract.Exchange = strings.ToUpper(strings.TrimSpace(contract.Exchange))
contract.PrimaryExch = strings.ToUpper(strings.TrimSpace(contract.PrimaryExch))
contract.Currency = strings.ToUpper(strings.TrimSpace(contract.Currency))
contract.LocalSymbol = strings.ToUpper(strings.TrimSpace(contract.LocalSymbol))
contract.TradingClass = strings.ToUpper(strings.TrimSpace(contract.TradingClass))
contract.SecIDType = strings.ToUpper(strings.TrimSpace(contract.SecIDType))
contract.SecID = strings.TrimSpace(contract.SecID)
return contract
}
// samePortfolioStructure intentionally excludes prices, cost basis, and PnL.
// position the daemon is evaluating. Exact contract identity, route, account,
// and quantity are structural and must invalidate a captured evidence binding.
func samePortfolioStructure(existing, next *RawPosition) bool {
if existing == nil || next == nil {
return existing == next
}
return strings.EqualFold(strings.TrimSpace(existing.Account), strings.TrimSpace(next.Account)) &&
existing.Position == next.Position &&
sameContractIdentity(existing.Contract, next.Contract)
}
func sameContractIdentity(a, b Contract) bool {
a = normalizedContractIdentity(a)
b = normalizedContractIdentity(b)
return reflect.DeepEqual(a, b)
}
func (c *Connection) lockEvidenceChange() func() {
if c == nil || c.evidenceBarrier == nil {
return func() {}
}
c.evidenceBarrier.RLock()
return c.evidenceBarrier.RUnlock
}
func (c *Connection) lockBrokerScopeChange() func() {
if c == nil {
return func() {}
}
if c.publicationBarrier != nil {
c.publicationBarrier.Lock()
}
if c.evidenceBarrier != nil {
c.evidenceBarrier.RLock()
}
return func() {
if c.evidenceBarrier != nil {
c.evidenceBarrier.RUnlock()
}
if c.publicationBarrier != nil {
c.publicationBarrier.Unlock()
}
}
}
// handleAccountSummary processes account summary updates from IBKR
func (c *Connection) handleAccountSummary(fields []string) {
unlockEvidence := c.lockBrokerScopeChange()
defer unlockEvidence()
c.handleAccountSummaryUnderBrokerScopeLease(fields)
}
// handleAccountSummaryUnderBrokerScopeLease applies one summary row while the
// owns the exact-generation read lease in publication-before-inbound order.
func (c *Connection) handleAccountSummaryUnderBrokerScopeLease(fields []string) {
// Expected fields:
// 3: account
if len(fields) < 7 {
ibkrLogger.Warnf("[WARNING] Invalid account summary message: expected at least 7 fields, got %d", len(fields))
return
}
tag := fields[4]
value := fields[5]
currency := fields[6]
account := strings.TrimSpace(fields[3])
reqID, reqIDErr := strconv.Atoi(strings.TrimSpace(fields[2]))
// A registered one-shot is an account-scoped authority read. Keep it
// if any concrete row is outside the expected account. IBKR labels every
// $LEDGER:ALL row as Account=All, including fields this client does not
// model. Admit only the typed currency fields and ignore the other aggregate
// rows; none of them may enter or invalidate the account-scoped snapshot.
c.accountMu.Lock()
key := tag
if currency != "" && currency != "BASE" {
key = fmt.Sprintf("%s_%s", tag, currency)
}
if reqIDErr == nil {
if epoch, retired := c.retiredSummaryProbes[reqID]; retired && epoch == c.BrokerSessionEpoch() {
c.accountMu.Unlock()
return
}
if snap := c.summarySnapshots[reqID]; snap != nil {
// End freezes this request even if its waiter has not yet run.
// Continuing subscription updates must not rewrite the cutoff.
select {
case <-snap.done:
c.accountMu.Unlock()
return
default:
}
disposition := accountSummaryRequestRowDisposition(account, tag, currency, snap.expectedAccount, c.managedAccounts)
observeAccountSettlementRow(&snap.settlementObservation, account, tag, value, currency, snap.expectedAccount, c.managedAccounts)
if snap.scopeConflict || disposition == accountSummaryRowReject {
snap.scopeConflict = true
c.accountMu.Unlock()
return
}
if snap.diagnosticOnly {
c.accountMu.Unlock()
return
}
if disposition == accountSummaryRowIgnore {
c.accountMu.Unlock()
return
}
if disposition == accountSummaryRowAcceptLedger {
// Ledger rows live in their own key namespace so a base-currency
// slice can never overwrite the same-named account-level total
// (or be overwritten by it) depending on wire arrival order.
// Account=All inference and 10.47's own "$LEDGER-" wire label —
field, _ := splitGatewayLedgerTag(tag)
key = fmt.Sprintf("%s%s_%s", accountSummaryLedgerKeyPrefix, field, currency)
}
snap.observedRows++
snap.values[key] = value
c.accountMu.Unlock()
return
}
}
field, wirePrefixed := splitGatewayLedgerTag(tag)
if strings.HasPrefix(tag, accountSummaryLedgerKeyPrefix) || strings.ContainsRune(tag, '_') || (wirePrefixed && (!currencyLedgerField(field) || !concreteAccountSummaryLedgerCurrency(currency))) {
c.accountMu.Unlock()
return
}
// Unregistered account-summary traffic is context only. It may seed a
// single-account session, but a foreign concrete row can never overwrite
// the account-bound shared cache.
// A login carrying several accounts is never seeded from this path. Its
// c.account is the managedAccounts aggregate, which is not a concrete code,
// so the guard below used to fall through and adopt whichever account the
// row named — routinely the unpinned sibling, because reqAccountSummary is
// issued with group "All" and awaitAccountSummarySnapshot deregisters on
// accountMismatchesConnected then read as a configured-vs-connected
// divergence and refused every broker write. The aggregate is the state
incoming, incomingOK := newAccountCode(account)
bound, boundOK := newAccountCode(c.account)
if !incomingOK || (boundOK && !bound.equal(incoming)) {
c.accountMu.Unlock()
return
}
if !boundOK {
if newManagedAccountSet(c.managedAccounts).multiAccount() {
c.accountMu.Unlock()
return
}
c.account = account
}
c.accountSummary[key] = value
c.accountMu.Unlock()
// Log important values
switch tag {
case "NetLiquidation", "BuyingPower", "TotalCashValue", "GrossPositionValue":
portfolioLogger.Debugf("%s: %s %s", tag, value, currency)
}
}
type accountSummaryRowDisposition uint8
const (
accountSummaryRowReject accountSummaryRowDisposition = iota
accountSummaryRowAccept
// accountSummaryRowAcceptLedger admits a row through the Account=All
// reuses account-level tag names (UnrealizedPnL, RealizedPnL) for every
// held currency's ledger slice, so under a shared key the account total
// and the base-currency slice would overwrite each other in wire-arrival
accountSummaryRowAcceptLedger
accountSummaryRowIgnore
)
// accountSummaryRequestRowDisposition admits ordinary rows only from the
// expected concrete account. managed is the login's managedAccounts list, which
// decides what the other rows mean. For Account=All, it accepts only typed
// ledger fields and ignores every unmodeled aggregate field. Unregistered
// traffic never calls this helper and cannot seed the streaming cache through
// it.
func accountSummaryRequestRowDisposition(account, tag, currency, expectedAccount string, managed []string) accountSummaryRowDisposition {
expectedAccount = strings.TrimSpace(expectedAccount)
if !accountCodeConcrete(expectedAccount) {
return accountSummaryRowReject
}
// Gateway 10.47's wire prefix is the gateway's own statement of ledger
// round, every prefixed ledger row fails the allowlist and is dropped
field, wirePrefixed := splitGatewayLedgerTag(tag)
ledgerRow := currencyLedgerField(field) && concreteAccountSummaryLedgerCurrency(currency)
account = strings.TrimSpace(account)
if accountCodeConcrete(account) {
if strings.EqualFold(account, expectedAccount) {
// Internal storage keys are generated after wire admission. A raw
// callback cannot assert that provenance by naming the namespace.
if strings.HasPrefix(tag, accountSummaryLedgerKeyPrefix) || strings.ContainsRune(tag, '_') || (wirePrefixed && !ledgerRow) {
return accountSummaryRowIgnore
}
if wirePrefixed && ledgerRow {
// The gateway labeled the row a ledger slice and named the
// pinned account: ledger namespace, fully attributed.
return accountSummaryRowAcceptLedger
}
return accountSummaryRowAccept
}
// reqAccountSummary is issued with group "All", so a login carrying
// several accounts answers with every one of them. A recognized
// managedAccounts sibling is that expected traffic, not evidence the
// read was misrouted: drop the row and let the pinned account's own
// rows complete the snapshot. An account the login does not manage
if code, ok := newAccountCode(account); ok && newManagedAccountSet(managed).contains(code) {
return accountSummaryRowIgnore
}
return accountSummaryRowReject
}
if !strings.EqualFold(account, "All") {
return accountSummaryRowReject
}
// An aggregate-labeled ledger row carries no account of its own. On a
// single-account login that is unambiguous; on a multi-account login it
// cannot be attributed to the pinned account, and admitting it would report
// a sibling's currency exposure as the pinned account's — wire-prefixed
// rows are withheld exactly like their bare twins.
// SettledCash is also explicitly requested as an account total. An
// unprefixed Account=All callback does not prove native currency scope,
// even on a single-account login. Only the broker ledger prefix proves it.
managedSet := newManagedAccountSet(managed)
if ledgerRow && (!accountLevelBareTag(field) || wirePrefixed) && len(managed) == 1 && len(managedSet) == 1 && managedSet.contains(accountCode(expectedAccount)) {
return accountSummaryRowAcceptLedger
}
return accountSummaryRowIgnore
}
func concreteAccountSummaryLedgerCurrency(currency string) bool {
if len(currency) != 3 || currency == "BASE" {
return false
}
for i := range len(currency) {
if currency[i] < 'A' || currency[i] > 'Z' {
return false
}
}
return true
}
// handlePortfolioValue handles portfolio position updates (from reqAccountUpdates)
func (c *Connection) handlePortfolioValue(fields []string) {
unlockEvidence := c.lockEvidenceChange()
defer unlockEvidence()
// Expected fields for msgPortfolioValue (7):
// 19: accountName
if len(fields) < 20 {
ibkrLogger.Warnf("[WARNING] Invalid portfolio value message: expected at least 20 fields, got %d", len(fields))
c.portfolioProjectionMu.Lock()
c.invalidatePortfolioGeneration(time.Now().UTC())
c.portfolioProjectionMu.Unlock()
return
}
c.portfolioProjectionMu.Lock()
defer c.portfolioProjectionMu.Unlock()
if !c.acceptPortfolioAccountFrame(fields[19], time.Now().UTC()) {
return
}
// A malformed row invalidates the whole staged generation. Treating a bad
// false all-clear at accountDownloadEnd.
conID, conIDErr := strconv.Atoi(strings.TrimSpace(fields[2]))
secType := strings.ToUpper(strings.TrimSpace(fields[4]))
strikeRaw := strings.TrimSpace(fields[6])
strike := 0.0
var strikeErr error
if strikeRaw != "" {
strike, strikeErr = strconv.ParseFloat(strikeRaw, 64)
}
multiplierRaw := strings.TrimSpace(fields[8])
multiplier := 1
var multiplierErr error
if multiplierRaw != "" {
multiplier, multiplierErr = strconv.Atoi(multiplierRaw)
}
// IB omits multiplier — or encodes it as zero — on every frame whose
// contract has no real multiplier: stocks, cash, bonds, bills, funds and
// CFDs. One unit is the correct normalization for all of them, and the
// downstream renderers already treat the field that way. Derivatives do
// carry a real multiplier, so normalizing theirs to 1 would understate
// the row; they still require an explicit positive value, and options
// need an explicit strike.
derivativeIdentity := secType == "OPT" || secType == "FOP" || secType == "WAR"
requiresDerivativeTerms := secType == "OPT" || secType == "FOP"
right := strings.ToUpper(strings.TrimSpace(fields[7]))
requiresMultiplier := portfolioMultiplierIsContractual(secType)
if !requiresMultiplier && multiplierErr == nil && multiplier == 0 {
multiplier = 1
}
position, positionErr := strconv.ParseFloat(strings.TrimSpace(fields[13]), 64)
marketPrice, marketPriceErr := strconv.ParseFloat(strings.TrimSpace(fields[14]), 64)
marketValue, marketValueErr := strconv.ParseFloat(strings.TrimSpace(fields[15]), 64)
averageCost, averageCostErr := strconv.ParseFloat(strings.TrimSpace(fields[16]), 64)
unrealizedPNL, unrealizedPNLErr := strconv.ParseFloat(strings.TrimSpace(fields[17]), 64)
realizedPNL, realizedPNLErr := strconv.ParseFloat(strings.TrimSpace(fields[18]), 64)
if conIDErr != nil || conID <= 0 || strings.TrimSpace(fields[3]) == "" || secType == "" ||
strikeErr != nil || !finiteFloat(strike) || (derivativeIdentity && strikeRaw == "") ||
(requiresDerivativeTerms && (strings.TrimSpace(fields[5]) == "" || (right != "C" && right != "P"))) ||
multiplierErr != nil || multiplier <= 0 || (requiresMultiplier && multiplierRaw == "") ||
positionErr != nil || !finiteFloat(position) || marketPriceErr != nil || !finiteFloat(marketPrice) ||
marketValueErr != nil || !finiteFloat(marketValue) || averageCostErr != nil || !finiteFloat(averageCost) ||
unrealizedPNLErr != nil || !finiteFloat(unrealizedPNL) || realizedPNLErr != nil || !finiteFloat(realizedPNL) {
c.invalidatePortfolioGeneration(time.Now().UTC())
portfolioLogger.Warnf("Portfolio generation contained a malformed required field; generation discarded")
return
}
contract := Contract{
ConID: conID,
Symbol: fields[3],
SecType: fields[4],
Expiry: fields[5],
Strike: strike,
Right: fields[7],
Multiplier: multiplier,
PrimaryExch: fields[9],
Currency: fields[10],
LocalSymbol: fields[11],
TradingClass: fields[12],
}
key := cachedPositionKey(fields[19], contract)
target := c.positions
if c.portfolioStagingActive {
target = c.portfolioStaging
}
if position == 0 {
if c.portfolioStagingActive {
delete(target, key)
} else {
c.positionsMu.Lock()
_, changed := target[key]
delete(target, key)
if changed {
c.advancePortfolioProjectionGeneration()
}
c.positionsMu.Unlock()
}
portfolioLogger.Debugf("Position closed: %s", key)
return
}
next := &RawPosition{
Account: fields[19],
ValuationAt: time.Now().UTC(),
Contract: contract,
Position: position,
MarketPrice: marketPrice,
MarketValue: marketValue,
AverageCost: averageCost,
UnrealizedPNL: unrealizedPNL,
RealizedPNL: realizedPNL,
}
if c.portfolioStagingActive {
if existing := target[key]; existing != nil && existing.Contract.ConID == contract.ConID {
next.Contract = mergeCachedPositionContract(existing.Contract, next.Contract)
}
target[key] = next
c.retryShortPortfolioDownloadLocked(next.ValuationAt)
} else {
c.positionsMu.Lock()
existing := target[key]
if existing != nil && existing.Contract.ConID == contract.ConID {
next.Contract = mergeCachedPositionContract(existing.Contract, next.Contract)
}
structuralChange := !samePortfolioStructure(existing, next)
target[key] = next
if structuralChange {
c.advancePortfolioProjectionGeneration()
}
c.positionsMu.Unlock()
}
// Seed the option contract cache from portfolio data so SubscribeOption
// exact Position stream when it names the same ConID. We never fabricate
// and applyContractDetailLite only overwrites Exchange if the cache
if contract.SecType == "OPT" && conID != 0 {
cacheKey := optionContractKey(contract.Symbol, contract.TradingClass, contract.Expiry, contract.Strike, contract.Right)
detail := ContractDetailsLite{
Symbol: contract.Symbol,
SecType: contract.SecType,
Expiry: contract.Expiry,
Strike: contract.Strike,
Right: contract.Right,
Exchange: "",
PrimaryExch: contract.PrimaryExch,
ConID: conID,
LocalSymbol: contract.LocalSymbol,
TradingClass: contract.TradingClass,
}
c.optionContractMu.Lock()
c.optionContractCache[cacheKey] = detail
c.optionContractMu.Unlock()
}
portfolioLogger.Debugf("Updated: %s %.2f @ %.2f, PnL: %.2f",
key, position, marketPrice, unrealizedPNL)
}
// portfolioMultiplierIsContractual reports whether a portfolio frame's
// secType names a contract that actually carries a multiplier. Only these
// may fail the generation for a missing or zero one: every other secType
// IB never intended to carry the field.
func portfolioMultiplierIsContractual(secType string) bool {
switch secType {
case "OPT", "FOP", "WAR", "FUT":
return true
}
return false
}
// managedAccountMember reports whether account is one of the codes the
// gateway listed in msgManagedAccts. One TWS login can hold several
// unlinked accounts, and account updates can stream all of them.
func (c *Connection) managedAccountMember(account string) bool {
account = strings.TrimSpace(account)
if !accountCodeConcrete(account) {
return false
}
c.accountMu.RLock()
defer c.accountMu.RUnlock()
return slices.ContainsFunc(c.managedAccounts, func(managed string) bool {
return strings.EqualFold(managed, account)
})
}
// portfolioStreamAccount returns the concrete account the account-updates
// stream is bound to, or "" when no account-scoped subscribe has landed.
func (c *Connection) portfolioStreamAccount() string {
c.portfolioHealthMu.RLock()
defer c.portfolioHealthMu.RUnlock()
bound := strings.TrimSpace(c.portfolioHealth.Account)
if !accountCodeConcrete(bound) {
return ""
}
return bound
}
func (c *Connection) acceptPortfolioAccountFrame(account string, observedAt time.Time) bool {
account = strings.TrimSpace(account)
// Snapshot the managed list before taking portfolioHealthMu: no path in
// this file nests the two locks, and this keeps it that way.
sibling := c.managedAccountMember(account)
c.portfolioHealthMu.Lock()
defer c.portfolioHealthMu.Unlock()
if !c.portfolioHealth.ScopeConflictAt.IsZero() || !c.portfolioHealth.InvalidPayloadAt.IsZero() {
return false
}
if !accountCodeConcrete(account) {
c.latchPortfolioScopeConflictLocked(observedAt)
return false
}
bound := strings.TrimSpace(c.portfolioHealth.Account)
if accountCodeConcrete(bound) {
if strings.EqualFold(account, bound) {
return true
}
if sibling {
// Steady state for an unlinked multi-account login: drop the row
// and leave the stream healthy for the bound account.
portfolioLogger.Debugf("Dropping portfolio frame for sibling managed account")
return false
}
c.latchPortfolioScopeConflictLocked(observedAt)
return false
}
c.portfolioHealth.Account = account
return true
}
func (c *Connection) completePortfolioDownload(account string, completedAt time.Time) bool {
unlockEvidence := c.lockEvidenceChange()
defer unlockEvidence()
c.portfolioProjectionMu.Lock()
defer c.portfolioProjectionMu.Unlock()
account = strings.TrimSpace(account)
sibling := c.managedAccountMember(account)
accountValues := c.GetAccountSummary()
c.portfolioHealthMu.Lock()
defer c.portfolioHealthMu.Unlock()
if !c.portfolioHealth.ScopeConflictAt.IsZero() || !c.portfolioHealth.InvalidPayloadAt.IsZero() {
c.portfolioStaging = nil
c.portfolioStagingActive = false
return false
}
if !accountCodeConcrete(account) {
c.latchPortfolioScopeConflictLocked(completedAt)
c.portfolioStaging = nil
c.portfolioStagingActive = false
return false
}
bound := strings.TrimSpace(c.portfolioHealth.Account)
if accountCodeConcrete(bound) && !strings.EqualFold(account, bound) {
if sibling {
// A sibling account's end marker says nothing about the bound
// account's download, which may still be staging.
return false
}
c.latchPortfolioScopeConflictLocked(completedAt)
c.portfolioStaging = nil
c.portfolioStagingActive = false
return false
}
if !accountCodeConcrete(bound) && accountCodeConcrete(account) {
c.portfolioHealth.Account = account
}
if !c.portfolioStagingActive {
return false
}
if coverage, short := portfolioGenerationShort(c.portfolioStaging, accountValues); short {
if c.portfolioHealth.DownloadShortAt.IsZero() {
portfolioLogger.Warnf("Portfolio download ended with rows covering %.0f%% of gross position value; holding the book as not current until it is complete", coverage*100)
}
c.portfolioHealth.DownloadShortAt = completedAt.UTC()
return false
}
c.publishPortfolioStagingLocked(completedAt)
return true
}
// publishPortfolioStagingLocked atomically replaces the published rows with a
// complete staged generation. It is called with portfolioProjectionMu and
// portfolioHealthMu held.
func (c *Connection) publishPortfolioStagingLocked(completedAt time.Time) {
next := make(map[string]*RawPosition, len(c.portfolioStaging))
maps.Copy(next, c.portfolioStaging)
c.positionsMu.Lock()
c.positions = next
c.positionsMu.Unlock()
c.portfolioStaging = nil
c.portfolioStagingActive = false
c.portfolioHealth.InitialCompletedAt = completedAt.UTC()
c.portfolioHealth.DownloadShortAt = time.Time{}
c.advancePortfolioProjectionGenerationLocked()
}
// retryShortPortfolioDownloadLocked publishes a staged generation whose end
// marker already arrived once late rows make it foot. It is called with
// portfolioProjectionMu held and portfolioHealthMu released.
func (c *Connection) retryShortPortfolioDownloadLocked(observedAt time.Time) {
c.portfolioHealthMu.RLock()
pending := c.portfolioStagingActive && !c.portfolioHealth.DownloadShortAt.IsZero()
c.portfolioHealthMu.RUnlock()
if !pending {
return
}
accountValues := c.GetAccountSummary()
c.portfolioHealthMu.Lock()
defer c.portfolioHealthMu.Unlock()
if _, short := portfolioGenerationShort(c.portfolioStaging, accountValues); short {
return
}
portfolioLogger.Warnf("Portfolio download now accounts for gross position value; the book is current")
c.publishPortfolioStagingLocked(observedAt)
}
// latchPortfolioScopeConflictLocked is called with portfolioHealthMu held.
// receiving rows at the account-update cadence until a resubscribe lands.
func (c *Connection) latchPortfolioScopeConflictLocked(observedAt time.Time) {
changed := c.portfolioHealth.ScopeConflictAt.IsZero()
if changed {
portfolioLogger.Warnf("Portfolio stream named an account outside this login; stream health is unavailable until resubscribe")
}
c.portfolioHealth.RequestedAt = time.Time{}
c.portfolioHealth.InitialCompletedAt = time.Time{}
c.portfolioHealth.LastUpdateAt = time.Time{}
c.portfolioHealth.DownloadShortAt = time.Time{}
c.portfolioHealth.ScopeConflictAt = observedAt.UTC()
if changed {
c.advancePortfolioProjectionGenerationLocked()
}
}
func finiteFloat(value float64) bool {
return !math.IsNaN(value) && !math.IsInf(value, 0)
}
// invalidatePortfolioGeneration is called with portfolioProjectionMu held.
// It retains the last published rows only as context while ensuring neither a
// later end marker nor heartbeat can make the malformed generation current.
func (c *Connection) invalidatePortfolioGeneration(observedAt time.Time) {
c.portfolioHealthMu.Lock()
changed := c.portfolioHealth.InvalidPayloadAt.IsZero()
c.portfolioHealth.RequestedAt = time.Time{}
c.portfolioHealth.InitialCompletedAt = time.Time{}
c.portfolioHealth.LastUpdateAt = time.Time{}
c.portfolioHealth.DownloadShortAt = time.Time{}
c.portfolioHealth.InvalidPayloadAt = observedAt.UTC()
if changed {
c.advancePortfolioProjectionGenerationLocked()
}
c.portfolioHealthMu.Unlock()
c.portfolioStaging = nil
c.portfolioStagingActive = false
}
func (c *Connection) resetPortfolioStreamHealth(account string, requestedAt time.Time) {
if c.displayChanged != nil {
defer c.displayChanged()
}
unlockEvidence := c.lockEvidenceChange()
defer unlockEvidence()
c.resetPortfolioStreamHealthUnderEvidence(account, requestedAt)
}
// resetPortfolioStreamHealthUnderEvidence is used by epoch-bound dispatch,
// which already owns the evidence barrier after pacing.
func (c *Connection) resetPortfolioStreamHealthUnderEvidence(account string, requestedAt time.Time) {
c.portfolioProjectionMu.Lock()
defer c.portfolioProjectionMu.Unlock()
c.portfolioHealthMu.Lock()
generation := c.portfolioHealth.ProjectionGeneration
c.portfolioHealth = PortfolioStreamHealth{Account: strings.TrimSpace(account), RequestedAt: requestedAt.UTC(), ProjectionGeneration: generation}
c.advancePortfolioProjectionGenerationLocked()
c.portfolioHealthMu.Unlock()
c.portfolioStaging = make(map[string]*RawPosition)
c.accountMu.Lock()
c.accountStreamReceipt = accountStreamReceipt{account: strings.TrimSpace(account), epoch: c.BrokerSessionEpoch(), requestedAt: requestedAt.UTC()}
c.accountMu.Unlock()
c.portfolioStagingActive = true
}
func (c *Connection) advancePortfolioProjectionGeneration() {
c.portfolioHealthMu.Lock()
c.advancePortfolioProjectionGenerationLocked()
c.portfolioHealthMu.Unlock()
}
func (c *Connection) advancePortfolioProjectionGenerationLocked() {
c.portfolioHealth.ProjectionGeneration++
}
// handleAccountValue handles account value updates (from reqAccountUpdates)
func (c *Connection) handleAccountValue(fields []string) {
// Expected fields for msgAcctValue (6):
// 5: accountName
if len(fields) < 6 {
ibkrLogger.Warnf("[WARNING] Invalid account value message: expected at least 6 fields, got %d", len(fields))
return
}
key := fields[2]
value := fields[3]
currency := fields[4]
account := strings.TrimSpace(fields[5])
// Streaming account-value rows carry the account they belong to.
// TWS shares the account-updates service across API clients and streams
// every account of a multi-account login over the one subscription, so a
// blindly clobbers the bound account's values in the shared map
// (issue #12). Drop rows naming a different concrete account; rows with
// an empty or aggregate account name pass through because single-account
// The subscription's bound account is authoritative. c.account holds the
// raw managedAccounts value, which is a comma-separated list for a
// multi-account login and therefore never concrete — comparing against it
// alone disabled this guard for exactly the logins that need it most
// (issue #14: a sibling's zeroed batch and its JOINT AccountType
// overwrote the pinned account's summary).
bound := c.portfolioStreamAccount()
c.accountMu.Lock()
if !accountCodeConcrete(bound) {
bound = strings.TrimSpace(c.account)
}
if accountCodeConcrete(account) && accountCodeConcrete(bound) && !strings.EqualFold(account, bound) {
c.accountMu.Unlock()
portfolioLogger.Debugf("Dropping %s update for foreign account %s (bound %s)", key, account, bound)
return
}
mapKey := key
if currency != "" && currency != "BASE" {
mapKey = fmt.Sprintf("%s_%s", key, currency)
}
c.accountSummary[mapKey] = value
if accountCodeConcrete(account) && strings.EqualFold(account, bound) {
c.observeAccountStreamValueLocked(account, key, value, currency, time.Now().UTC())
if c.displayAccount != account {
c.displayAccount = account
c.displayAccountValues = map[string]string{}
c.accountValueTimes = map[string]time.Time{}
}
c.displayAccountValues[mapKey] = value
c.accountValueTimes[mapKey] = time.Now().UTC()
}
c.accountMu.Unlock()
// Log important values
switch key {
case "NetLiquidation", "BuyingPower", "TotalCashValue", "UnrealizedPnL", "RealizedPnL":
portfolioLogger.Debugf("%s: %s %s", key, value, currency)
}
}
func (c *Connection) dispatchStaleSystemNotice(fields []string, epoch uint64) {
if len(fields) < 2 {
return
}
note, err := parseSystemNotificationPayload([]byte(fields[1]))
if err != nil {
return
}
// Alias and recovery state belong to the current epoch. The exact-origin
// Connector handler may use the numeric ID only to publish a stale order
// receipt and latch uncertainty; it must reject all other mutations.
c.dispatchSystemNotice(note, reqAliasEntry{}, epoch)
}
func (c *Connection) handleSystemNotificationAtEpoch(fields []string, epoch uint64) func() {
if len(fields) < 2 {
ibkrLogger.Warnf("[IBKR cid=%d] System notice: missing payload", c.config.ClientID)
return nil
}
note, err := parseSystemNotificationPayload([]byte(fields[1]))
if err != nil {
ibkrLogger.Warnf("[IBKR cid=%d] System notice decode error: %v", c.config.ClientID, err)
return nil
}
if note.code == 100 && epoch == c.BrokerSessionEpoch() && c.rateLimiter != nil {
c.rateLimiter.recordRateLimitError()
}
scope := "global"
symbolAlias := ""
aliasEntry := reqAliasEntry{}
if note.tickerID >= 0 {
if entry, ok := c.lookupReqAlias(int(note.tickerID)); ok {
aliasEntry = entry
symbolAlias = entry.symbol
if symbolAlias != "" {
label := symbolAlias
if entry.secType == "CASH" && entry.currency != "" {
// A pair's symbol is only its base currency.
label += "." + entry.currency
}
if entry.secType != "" {
label += " " + entry.secType
}
scope = fmt.Sprintf("reqID=%d (%s)", note.tickerID, label)
} else {
scope = fmt.Sprintf("reqID=%d", note.tickerID)
}
} else {
scope = fmt.Sprintf("reqID=%d", note.tickerID)
}
}
codeLabel := fmt.Sprintf("code=%d", note.code)
if desc := getErrorDescription(note.code); desc != "" && !strings.Contains(note.message, desc) {
codeLabel = fmt.Sprintf("code=%d (%s)", note.code, desc)
}
// Treat documented market-data error codes (300-399) as warnings so they
shouldWarn := note.code == 200 || (note.code >= 300 && note.code < 400 && note.code != 366)
if !shouldWarn {
msgLower := strings.ToLower(note.message)
// For non-cataloged codes, fall back to a substring check; this keeps
shouldWarn = strings.Contains(msgLower, "error")
}
// Definition-missing on derivative/probe requests is routine, not a
// directions when only one is listed (the fx cache absorbs the miss).
// The requester classifies the rejection either way, so the wire echo
// stale/zero-value warnings, so the wire echo is debug-grade.
indicativeDisclaimer := note.code == 2129
definitionProbe := note.code == 200 && (aliasEntry.secType == "OPT" || aliasEntry.secType == "CASH")
// A symbol-only stock lookup that IBKR cannot resolve is a discovery miss
// (screen candidates, warrants and units among them: 37 lines on the
// morning of 2026-10-04); the requester already classifies it. Only a
// known conID that stops resolving is an operator-grade warning.
symbolLookupMiss := note.code == 200 && aliasEntry.secType == "STK" && aliasEntry.conID == 0 && aliasEntry.symbol != ""
// A requester that marked its context records the verdict itself.
classifiedMiss := note.code == 200 && aliasEntry.callerClassifiesMiss
upperMsg := strings.ToUpper(note.message)
// Echoes of the connector's own actions carry no new information: 300
// answers a cancel for a ticker the gateway no longer holds (1,690 lines
// on 2026-10-02 while TWS was connected but not serving), and 162 "query
// cancelled" acknowledges the cancel the requester sent on its own
// timeout, which the requester has already reported. 162 "no data" is
// a verdict the requester records per contract.
noticeCtx := c.noticeLogContextSnapshot()
cancelEcho := note.code == 300
if cancelEcho && noticeCtx.backendLinkDown != nil && noticeCtx.backendLinkDown() {
// Counted for the backend-restore bookend, which attributes them to
// the break; echoes outside one stay in debug uncounted.
c.suppressedCancelEchoes.Add(1)
}
// A code 200 inside a backend-link outage, or within its restore settle,
// is a dependent of the outage the connector announced once (1100/2110):
// a dozen IND, FUT and BILL misses warned within 5 s of each loss on
// 2026-10-12. Outside an outage a code 200 for a known conID keeps its
// warning: that is a stale identity, not an echo.
// Owner instruction (Oliver, in chat, 2026-10-13 09:04 CEST): dependents
// of an owned outage log through the owner.
outageMiss := note.code == 200 && noticeCtx.backendLinkUnsettled != nil && noticeCtx.backendLinkUnsettled()
if outageMiss && noticeCtx.backendLinkDown != nil && noticeCtx.backendLinkDown() {
c.suppressedDefinitionMisses.Add(1)
}
queryCancelled := note.code == 162 && strings.Contains(upperMsg, "QUERY CANCELLED")
noDataVerdict := note.code == 162 && strings.Contains(upperMsg, "NO DATA")
// A 162 refusal for want of a market data permission reaches its
// requester as a not_entitled failure, which the requester records and
// flags once; the echo of every repeat would warn again.
notEntitledVerdict := note.code == 162 && classifyHistoricalRequestFailure(&HistoricalRequestError{Code: note.code, Message: note.message}) == HistoricalFailureNotEntitled
// Repeat probes of an entitlement gap the connector has warned about
// once are informational.
repeatGap := note.code == 354 && noticeCtx.knownEntitlementGap != nil && noticeCtx.knownEntitlementGap(int(note.tickerID), aliasEntry)
parserMisalign := noticeSignalsParserMisalignment(note.code, note.message)
context := ""
if parserMisalign {
context = c.parserContext(symbolAlias)
}
msgText := note.message
if context != "" {
msgText = fmt.Sprintf("%s | frame=%s", msgText, context)
}
if note.code == 326 {
c.setClientIDInUseErrorAtEpoch(epoch, note.message)
}
if note.timestamp.IsZero() {
format := "[IBKR cid=%d] System notice %s %s: %s"
args := []any{c.config.ClientID, scope, codeLabel, msgText}
switch {
case parserMisalign:
ibkrLogger.Errorf(format, args...)
case definitionProbe, indicativeDisclaimer, cancelEcho, queryCancelled, outageMiss:
ibkrLogger.Debugf(format, args...)
case repeatGap:
ibkrLogger.Infof(format+" (known entitlement gap; repeat probe)", args...)
case classifiedMiss, notEntitledVerdict:
ibkrLogger.Infof(format+" (the requester records this verdict)", args...)
case symbolLookupMiss:
ibkrLogger.Infof(format+" (symbol-only lookup; the requester classifies the miss)", args...)
case noDataVerdict:
ibkrLogger.Infof(format, args...)
case shouldWarn:
ibkrLogger.Warnf(format, args...)
default:
ibkrLogger.Infof(format, args...)
}
return c.dispatchSystemNotice(note, aliasEntry, epoch)
}
format := "[IBKR cid=%d] System notice %s %s @ %s: %s"
args := []any{c.config.ClientID, scope, codeLabel, note.timestamp.UTC().Format(time.RFC3339), msgText}
switch {
case parserMisalign:
ibkrLogger.Errorf(format, args...)
case definitionProbe, cancelEcho, queryCancelled, outageMiss:
ibkrLogger.Debugf(format, args...)
case repeatGap:
ibkrLogger.Infof(format+" (known entitlement gap; repeat probe)", args...)
case classifiedMiss, notEntitledVerdict:
ibkrLogger.Infof(format+" (the requester records this verdict)", args...)
case symbolLookupMiss:
ibkrLogger.Infof(format+" (symbol-only lookup; the requester classifies the miss)", args...)
case noDataVerdict:
ibkrLogger.Infof(format, args...)
case shouldWarn:
ibkrLogger.Warnf(format, args...)
default:
ibkrLogger.Infof(format, args...)
}
return c.dispatchSystemNotice(note, aliasEntry, epoch)
}
func (c *Connection) setClientIDInUseErrorAtEpoch(epoch uint64, message string) {
c.reqIDMu.Lock()
defer c.reqIDMu.Unlock()
if c.brokerSessionEpoch.Load() != epoch {
return
}
c.statusMu.Lock()
c.lastError = clientIDInUseError(c.config.ClientID, message)
c.statusMu.Unlock()
}
// Message encoding/decoding methods
// sendMessage sends a length-prefixed message with rate limiting
func (c *Connection) sendMessage(msg []byte) error {
if len(msg) == 0 {
return definitelyUnsent(fmt.Errorf("cannot send an empty broker message"))
}
// Check connection status before queueing - reject if disconnecting
c.statusMu.RLock()
status := c.status
c.statusMu.RUnlock()
if status != StatusConnected {
return fmt.Errorf("cannot send message: connection status is %v", status)
}
// Use rate limiter for all messages
return c.rateLimiter.Submit(RequestTypeGeneral, func() error {
c.writeMu.Lock()
defer c.writeMu.Unlock()
if c.writeInProgress.Load() {
wireLogger.Errorf("CONCURRENT WRITE DETECTED: previous send still in progress")
}
c.writeInProgress.Store(true)
defer c.writeInProgress.Store(false)
lengthBytes := make([]byte, 4)
binary.BigEndian.PutUint32(lengthBytes, uint32(len(msg)))
if err := c.waitForHandshakeReady(); err != nil {
return err
}
return c.withTransport(false, func() error {
if c.writer == nil {
return fmt.Errorf("ibkr: send before writer initialised (connection state inconsistent)")
}
fields := c.decodeOutboundMessage(msg)
msgID := determineMessageID(c.serverVersion, msg)
c.logSuspiciousOutbound(msgID, fields)
if c.wireTap != nil {
c.wireTap.RecordOutbound(msgID, msg, fields)
}
// Debug: hex dump outgoing message
c.logOutgoingMessageHex(msg)
if _, err := c.writer.Write(lengthBytes); err != nil {
return err
}
if _, err := c.writer.Write(msg); err != nil {
return err
}
c.logPacketOutbound(msg)
if err := c.writer.Flush(); err != nil {
return err
}
if c.writer.Buffered() > 0 {
wireLogger.Errorf("flush incomplete: %d bytes still buffered after Flush()", c.writer.Buffered())
}
return nil
})
})
}
// sendMessageWithType sends a message with specific request type for rate limiting
func (c *Connection) sendMessageWithType(msg []byte, reqType RequestType) error {
return c.sendMessageWithTypeContext(context.Background(), msg, reqType)
}
func brokerSendError(err error, disposition SendDisposition, typed bool) error {
if err == nil || !typed {
return err
}
return WithSendDisposition(err, disposition)
}
func brokerSendMayHaveBeenWritten(err error) bool {
switch SendDispositionOf(err) {
case SendDispositionMayHaveWritten, SendDispositionUnknown:
return true
default:
return false
}
}
// sendMessageWithTypeContext sends a message with caller-owned cancellation
func (c *Connection) sendMessageWithTypeContext(ctx context.Context, msg []byte, reqType RequestType) error {
return c.sendMessageWithTypeContextForEpoch(ctx, msg, reqType, 0, false)
}
func (c *Connection) sendMessageWithTypeContextForEpoch(ctx context.Context, msg []byte, reqType RequestType, epoch uint64, requireEpoch bool) error {
return c.sendMessageWithTypeContextForEpochGuarded(ctx, msg, reqType, epoch, requireEpoch, nil)
}
// sendMessageWithTypeContextForEpochGuarded is the exact broker-instruction
// frame byte can be written. A guard failure is a definite pre-wire refusal
// and is never retried.
func (c *Connection) sendMessageWithTypeContextForEpochGuarded(ctx context.Context, msg []byte, reqType RequestType, epoch uint64, requireEpoch bool, guard func() error) error {
return c.sendMessageWithMarketDataMode(ctx, msg, reqType, epoch, requireEpoch, guard, 0)
}
func (c *Connection) sendMessageWithMarketDataMode(ctx context.Context, msg []byte, reqType RequestType, epoch uint64, requireEpoch bool, guard func() error, dataType int) error {
return c.sendBoundFrames(ctx, msg, reqType, epoch, requireEpoch, guard, dataType, nil)
}
// sendBoundFrames holds one session, evidence and transport admission for a bounded frame group.
func (c *Connection) sendBoundFrames(ctx context.Context, msg []byte, reqType RequestType, epoch uint64, requireEpoch bool, guard func() error, dataType int, additional [][]byte) error {
// Broker instructions and epoch-bound authority reads must be exactly-once
protectedSend := requireEpoch || reqType == RequestTypeOrder
if len(msg) == 0 {
return brokerSendError(fmt.Errorf("cannot send an empty broker message"), SendDispositionDefinitelyUnsent, protectedSend)
}
for _, frame := range additional {
if len(frame) == 0 {
return brokerSendError(fmt.Errorf("cannot send an empty broker message"), SendDispositionDefinitelyUnsent, protectedSend)
}
}
outboundState := c.outboundSessionState.Load()
// Check connection status before queueing - reject if disconnecting
c.statusMu.RLock()
status := c.status
c.statusMu.RUnlock()
if status != StatusConnected {
return brokerSendError(fmt.Errorf("cannot send message: connection status is %v", status), SendDispositionDefinitelyUnsent, protectedSend)
}
sendCtx := ctx
cancel := func() {}
if protectedSend {
sendCtx, cancel = context.WithCancel(ctx)
}
var dispatchEntered atomic.Bool
maxRetries := 3
if protectedSend {
maxRetries = 0
}
err := c.rateLimiter.SubmitWithRetriesContextFunc(sendCtx, reqType, func(dispatchCtx context.Context) error {
dispatchEntered.Store(true)
if err := dispatchCtx.Err(); err != nil {
return brokerSendError(err, SendDispositionDefinitelyUnsent, protectedSend)
}
if err := c.waitForHandshakeReadyContext(dispatchCtx); err != nil {
return brokerSendError(err, SendDispositionDefinitelyUnsent, protectedSend)
}
if err := dispatchCtx.Err(); err != nil {
return brokerSendError(err, SendDispositionDefinitelyUnsent, protectedSend)
}
err := c.withSessionTransport(outboundState, func() error {
if protectedSend && c.publicationBarrier != nil {
// Daemon connector publication takes the exclusive side before it
// after pacing and transport admission: a queued sender must never
c.publicationBarrier.RLock()
defer c.publicationBarrier.RUnlock()
}
if protectedSend && c.evidenceBarrier != nil {
// Structural portfolio/session writers take the read side. Holding
// validated projection generation the first-byte authority.
c.evidenceBarrier.Lock()
defer c.evidenceBarrier.Unlock()
}
// Recheck cancellation while holding transportMu. If the limiter or
// caller returned while this dispatch was queued, no later socket
// write can escape after cancel closes sendCtx.
if err := dispatchCtx.Err(); err != nil {
return brokerSendError(err, SendDispositionDefinitelyUnsent, protectedSend)
}
if requireEpoch {
// Epoch and namespace readiness are one allocator state. Reading
c.reqIDMu.Lock()
currentEpoch := c.brokerSessionEpoch.Load()
ready := c.haveNextValidID
c.reqIDMu.Unlock()
if currentEpoch != epoch {
return brokerSendError(fmt.Errorf("broker socket generation changed before request send"), SendDispositionDefinitelyUnsent, true)
}
if !ready {
return brokerSendError(fmt.Errorf("broker id namespace not ready for request send"), SendDispositionDefinitelyUnsent, true)
}
}
if c.writer == nil {
return brokerSendError(fmt.Errorf("ibkr: send before writer initialised (connection state inconsistent)"), SendDispositionDefinitelyUnsent, protectedSend)
}
if protectedSend && (outboundState&1 != 0 || c.outboundSessionState.Load() != outboundState) {
return brokerSendError(fmt.Errorf("broker outbound session changed before request send"), SendDispositionDefinitelyUnsent, true)
}
frames := append([][]byte{msg}, additional...)
defaultMode := c.marketDataDefaultMode
currentEpoch := c.BrokerSessionEpoch()
if defaultMode == 0 || c.marketDataModeEpoch != currentEpoch {
defaultMode = 1
}
repairMode := c.marketDataModeDirty && c.marketDataModeEpoch == currentEpoch && determineMessageID(c.serverVersion, msg) == reqMktData
if dataType != 0 || repairMode {
requestedMode := dataType
if requestedMode == 0 {
requestedMode = defaultMode
}
frames = [][]byte{c.encodeMsg(reqMarketDataType, 1, requestedMode), msg, c.encodeMsg(reqMarketDataType, 1, defaultMode)}
}
if guard != nil {
if err := guard(); err != nil {
return brokerSendError(fmt.Errorf("broker wire guard: %w", err), SendDispositionDefinitelyUnsent, protectedSend)
}
}
// The outbound generation and invalidation flag are protected by
// transportMu, which is still held here. This is the last lifecycle
// read before the first byte and therefore the write linearization
// point relative to Disconnect/connection loss.
if err := dispatchCtx.Err(); err != nil {
return brokerSendError(err, SendDispositionDefinitelyUnsent, protectedSend)
}
if protectedSend && (outboundState&1 != 0 || c.outboundSessionState.Load() != outboundState) {
return brokerSendError(fmt.Errorf("broker outbound session changed before request send"), SendDispositionDefinitelyUnsent, true)
}
bufferedBefore := c.writer.Buffered()
accepted := 0
if dataType != 0 || repairMode {
c.marketDataDefaultMode, c.marketDataModeEpoch, c.marketDataModeDirty = defaultMode, currentEpoch, true
}
for _, frame := range frames {
fields := c.decodeOutboundMessage(frame)
msgID := determineMessageID(c.serverVersion, frame)
c.logSuspiciousOutbound(msgID, fields)
c.logOutgoingMessageHex(frame)
lengthBytes := make([]byte, 4)
binary.BigEndian.PutUint32(lengthBytes, uint32(len(frame)))
n, err := c.writer.Write(lengthBytes)
accepted += n
if err != nil {
return brokerSendError(err, bufferedWriteDisposition(c.writer, bufferedBefore, accepted), protectedSend)
}
n, err = c.writer.Write(frame)
accepted += n
if err != nil {
return brokerSendError(err, bufferedWriteDisposition(c.writer, bufferedBefore, accepted), protectedSend)
}
if c.wireTap != nil {
c.wireTap.RecordOutbound(msgID, frame, fields)
}
c.logPacketOutbound(frame)
}
if err := c.writer.Flush(); err != nil {
return brokerSendError(err, bufferedWriteDisposition(c.writer, bufferedBefore, accepted), protectedSend)
}
if dataType != 0 || repairMode {
c.marketDataModeDirty = false
}
// Only explicit mode setters change the default, and only after a
// successful send. Temporary request overrides leave it untouched.
if dataType == 0 && determineMessageID(c.serverVersion, msg) == reqMarketDataType {
fields := c.decodeOutboundMessage(msg)
if len(fields) >= 3 {
if mode, err := strconv.Atoi(fields[2]); err == nil && mode >= 1 && mode <= 4 {
c.marketDataDefaultMode, c.marketDataModeEpoch, c.marketDataModeDirty = mode, c.BrokerSessionEpoch(), false
}
}
}
return nil
})
if errors.Is(err, errOutboundSessionEnded) {
return brokerSendError(err, SendDispositionDefinitelyUnsent, protectedSend)
}
return err
}, maxRetries)
// SubmitWithRetriesContext has its own completion deadline. Cancel its
// child before returning so a queued dispatch cannot later reach the wire.
cancel()
if !protectedSend || err == nil {
return err
}
if _, ok := errors.AsType[*SendDispositionError](err); ok {
return err
}
// A limiter-level return after dispatch began is conservatively uncertain:
// the transport callback may still be finishing. The caller must poison or
// reconcile. A request that never entered is proven pre-wire because the
// canceled child is rechecked under transportMu.
if !dispatchEntered.Load() {
return brokerSendError(err, SendDispositionDefinitelyUnsent, true)
}
return brokerSendError(err, SendDispositionUnknown, true)
}
// bufferedWriteDisposition distinguishes a writer refusal that accepted no
// failed flush; newly accepted bytes still buffered prove zero wire exposure.
func bufferedWriteDisposition(writer *bufio.Writer, bufferedBefore, accepted int) SendDisposition {
if writer == nil || bufferedBefore != 0 {
return SendDispositionMayHaveWritten
}
if accepted >= 0 && writer.Buffered() >= accepted {
return SendDispositionDefinitelyUnsent
}
return SendDispositionMayHaveWritten
}
func (c *Connection) logOutgoingMessageHex(msg []byte) {
if !c.logWireHex || len(msg) < 4 {
return
}
var msgType int32
var hasNullAfterType bool
if c.serverVersion >= 100 && len(msg) >= 4 {
msgType = int32(binary.BigEndian.Uint32(msg[:4]))
if len(msg) > 4 && msg[4] == 0x00 {
hasNullAfterType = true
}
}
// Show first 80 bytes or entire message if shorter
dumpLen := min(len(msg), 80)
var hexStr strings.Builder
for i := range dumpLen {
hexStr.WriteString(fmt.Sprintf("%02x ", msg[i]))
if (i+1)%16 == 0 {
hexStr.WriteString("\n ")
}
}
if dumpLen < len(msg) {
hexStr.WriteString(fmt.Sprintf("... (%d more bytes)", len(msg)-dumpLen))
}
log.Printf("[WIRE OUT] msgType=%d len=%d nullAfterType=%v\n %s",
msgType, len(msg), hasNullAfterType, hexStr.String())
}
// logSuspiciousOutbound inspects encoded payloads to highlight frames that
// frequently trigger IBKR MART/320 parser faults (e.g., reqID/conID set to 0).
type protocolWarning struct {
Summary string
Key string
Symbol string
// Expected marks a frame the broker resolves itself: a market-data
// request naming symbol, security type, exchange and currency needs no
// conID. It is kept as parser context but is not a misalignment.
Expected bool
}
func (c *Connection) logSuspiciousOutbound(msgID int, fields []string) {
if len(fields) == 0 {
return
}
var warning protocolWarning
var ok bool
var category string
switch msgID {
case reqMktData:
warning, ok = summarizeReqMktDataFields(fields)
category = "reqMktData"
case reqContractData:
warning, ok = summarizeReqContractFields(fields)
category = "reqContractData"
case reqHistoricalData:
warning, ok = summarizeReqHistoricalFields(fields)
category = "reqHistoricalData"
default:
return
}
if !ok {
return
}
if warning.Symbol != "" {
c.recordSuspiciousSummary(warning.Symbol, warning.Summary)
}
if !c.shouldLogSuspicious(warning.Key) {
return
}
switch {
case warning.Expected:
ibkrLogger.Debugf("%s for %s sent by contract description: %s", category, warning.Symbol, warning.Summary)
case warning.Symbol != "":
ibkrLogger.Warnf("[WARNING] Protocol misalignment for %s via %s: %s", warning.Symbol, category, warning.Summary)
default:
ibkrLogger.Warnf("[WARNING] Protocol misalignment (%s): %s", category, warning.Summary)
}
}
func (c *Connection) recordSuspiciousSummary(symbol, summary string) {
if symbol == "" || summary == "" {
return
}
c.suspectMu.Lock()
c.suspectSummaries[symbol] = summary
c.suspectMu.Unlock()
}
func (c *Connection) latestSuspiciousSummary(symbol string) string {
if symbol == "" {
return ""
}
c.suspectMu.Lock()
summary := c.suspectSummaries[symbol]
c.suspectMu.Unlock()
return summary
}
func (c *Connection) allSuspiciousSummaries() []string {
c.suspectMu.Lock()
defer c.suspectMu.Unlock()
if len(c.suspectSummaries) == 0 {
return nil
}
keys := make([]string, 0, len(c.suspectSummaries))
for sym := range c.suspectSummaries {
keys = append(keys, sym)
}
slices.Sort(keys)
result := make([]string, 0, len(keys))
for _, sym := range keys {
result = append(result, fmt.Sprintf("%s: %s", sym, c.suspectSummaries[sym]))
}
return result
}
func (c *Connection) parserContext(symbol string) string {
if symbol != "" {
if summary := c.latestSuspiciousSummary(symbol); summary != "" {
return summary
}
}
if summaries := c.allSuspiciousSummaries(); len(summaries) > 0 {
return strings.Join(summaries, "; ")
}
return ""
}
func (c *Connection) observeContractTiming(symbol string, elapsed time.Duration, resolved bool) {
if symbol == "" || elapsed <= 0 {
return
}
c.contractTimingMu.Lock()
if prev, ok := c.contractTimings[symbol]; !ok || elapsed > prev {
c.contractTimings[symbol] = elapsed
}
c.contractTimingMu.Unlock()
if elapsed >= 500*time.Millisecond || !resolved {
status := "resolved"
if !resolved {
status = "pending"
}
ibkrLogger.Infof("Contract detail latency %s: %s (%s)", symbol, elapsed, status)
}
}
func (c *Connection) shouldLogSuspicious(key string) bool {
if key == "" {
return false
}
c.suspectMu.Lock()
defer c.suspectMu.Unlock()
if _, exists := c.suspectFlags[key]; exists {
return false
}
c.suspectFlags[key] = struct{}{}
return true
}
func summarizeReqMktDataFields(fields []string) (protocolWarning, bool) {
if len(fields) < 8 {
return protocolWarning{}, false
}
reqID := fieldValue(fields, 2)
conID := fieldValue(fields, 3)
symbol := fieldValue(fields, 4)
secType := fieldValue(fields, 5)
exchange := fieldValue(fields, 10)
primary := fieldValue(fields, 11)
currency := fieldValue(fields, 12)
// buildReqMktDataFields: deltaNeutral at 15, then ticks, snapshot and
// regulatory snapshot; a combo carries its leg count before them.
tail := 16
if secType == "BAG" {
tail++
}
generic := fieldValue(fields, tail)
snapshot := fieldValue(fields, tail+1)
regSnap := fieldValue(fields, tail+2)
if reqID != "0" && reqID != "" && conID != "0" {
return protocolWarning{}, false
}
summary := fmt.Sprintf("reqID=%s conID=%s symbol=%s exch=%s primary=%s ticks=%s snap=%s regSnap=%s",
reqID, conID, symbol, exchange, primary, generic, snapshot, regSnap)
if conID == "0" {
summary += " (contract details pending)"
}
key := fmt.Sprintf("mkt:%s:%s", symbol, conID)
expected := reqID != "0" && reqID != "" && conID == "0" && symbol != "" && secType != "" && exchange != "" && currency != ""
return protocolWarning{Summary: summary, Key: key, Symbol: symbol, Expected: expected}, true
}
func summarizeReqContractFields(fields []string) (protocolWarning, bool) {
// Contract detail REQUESTS are supposed to have conID=0 - that's how you ASK for the conID!
// Only market data and historical requests with conID=0 are problematic.
return protocolWarning{}, false
}
func summarizeReqHistoricalFields(fields []string) (protocolWarning, bool) {
if len(fields) < 6 {
return protocolWarning{}, false
}
reqID := fieldValue(fields, 1)
if fieldValue(fields, 2) == "" {
return protocolWarning{}, false
}
conID := fieldValue(fields, 2)
symbol := fieldValue(fields, 3)
whatToShow := fieldValue(fields, 19)
if reqID != "0" && conID != "0" {
return protocolWarning{}, false
}
summary := fmt.Sprintf("reqID=%s conID=%s symbol=%s what=%s", reqID, conID, symbol, whatToShow)
if conID == "0" {
summary += " (contract details pending)"
}
key := fmt.Sprintf("hist:%s:%s", symbol, reqID)
return protocolWarning{Summary: summary, Key: key, Symbol: symbol}, true
}
func fieldValue(fields []string, idx int) string {
if idx < 0 || idx >= len(fields) {
return ""
}
return fields[idx]
}
func (c *Connection) logPacketOutbound(payload []byte) {
c.packetLoggerMu.RLock()
logger := c.packetLogger
c.packetLoggerMu.RUnlock()
if logger == nil || len(payload) == 0 {
return
}
msgID := determineMessageID(c.serverVersion, payload)
label := fmt.Sprintf("out msgID=%d", msgID)
clone := make([]byte, len(payload))
copy(clone, payload)
logger.Outbound(label, clone)
}
func determineMessageID(serverVersion int, payload []byte) int {
if len(payload) == 0 {
return 0
}
if serverVersion >= 100 && len(payload) >= 4 {
return int(binary.BigEndian.Uint32(payload[:4]))
}
idx := bytes.IndexByte(payload, '\x00')
if idx == -1 {
idx = len(payload)
}
id, err := strconv.Atoi(string(payload[:idx]))
if err != nil {
return -1
}
return id
}
func managedAccountsField(fields []string) string {
if len(fields) <= 1 {
return ""
}
if len(fields) > 2 {
if _, err := strconv.Atoi(strings.TrimSpace(fields[1])); err == nil {
return strings.TrimSpace(fields[2])
}
}
return strings.TrimSpace(fields[1])
}
// accountCodeConcrete reports whether account names one concrete account
// code usable in account-scoped requests (reqAcctData, reqPnL). The
// aggregate "All" and comma-separated managedAccounts lists are session
// aggregates, not account codes — TWS rejects them with error 321.
func accountCodeConcrete(account string) bool {
account = strings.TrimSpace(account)
if account == "" || strings.EqualFold(account, "All") {
return false
}
return !strings.ContainsAny(account, ", \t")
}
// parseManagedAccounts splits a raw msgManagedAccts value into its concrete
// account codes. Aggregates and empty entries are dropped.
func parseManagedAccounts(managed string) []string {
var out []string
for entry := range strings.SplitSeq(managed, ",") {
entry = strings.TrimSpace(entry)
if accountCodeConcrete(entry) {
out = append(out, entry)
}
}
return out
}
// firstConcreteAccountCode extracts a usable account code from a
// managedAccounts-style value: a concrete code passes through, a
// empty) yield "" — TWS resolves an empty acctCode to the session's
// account for single-account logins.
func firstConcreteAccountCode(account string) string {
account = strings.TrimSpace(account)
if strings.EqualFold(account, "All") {
return ""
}
first, _, _ := strings.Cut(account, ",")
first = strings.TrimSpace(first)
if accountCodeConcrete(first) {
return first
}
return ""
}
// readMessage reads a length-prefixed message
func (c *Connection) readMessage() ([]byte, error) {
// Read message length (4 bytes)
lengthBytes := make([]byte, 4)
// Debug: Reading message length
if _, err := io.ReadFull(c.reader, lengthBytes); err != nil {
// Only log non-timeout errors (timeouts are expected when no messages)
// and not a socket this side closed on purpose.
if netErr, ok := err.(net.Error); (!ok || !netErr.Timeout()) && !errors.Is(err, net.ErrClosed) {
c.logReadFailure(connectLogger, false, err, "Client %d: Failed to read length: %v", c.config.ClientID, err)
}
return nil, err
}
msgLength := binary.BigEndian.Uint32(lengthBytes)
// Debug: Message length = %d bytes
if msgLength == 0 {
return []byte{}, nil
}
// Sanity cap: 16 MB. Some IBKR responses can exceed the old 1 MB cap;
// gateway that's gone rogue (or a wire that's been hijacked).
if msgLength > 16*1024*1024 {
return nil, fmt.Errorf("message too large: %d bytes", msgLength)
}
// Read message body
msgBytes := make([]byte, msgLength)
// Debug: Reading message body
if _, err := io.ReadFull(c.reader, msgBytes); err != nil {
c.logReadFailure(connectLogger, false, err, "Client %d: Failed to read body: %v", c.config.ClientID, err)
return nil, err
}
// Debug: Successfully read message
return msgBytes, nil
}
func (c *Connection) resetHandshakeReady() {
c.handshakeMu.Lock()
c.handshakeReady = make(chan struct{})
c.handshakeMu.Unlock()
}
func (c *Connection) signalHandshakeReady() {
c.handshakeMu.RLock()
ch := c.handshakeReady
c.handshakeMu.RUnlock()
if ch == nil {
return
}
select {
case <-ch:
default:
close(ch)
}
}
func (c *Connection) waitForHandshakeReady() error {
return c.waitForHandshakeReadyContext(context.Background())
}
func (c *Connection) waitForHandshakeReadyContext(ctx context.Context) error {
if ctx == nil {
ctx = context.Background()
}
c.handshakeMu.RLock()
ch := c.handshakeReady
c.handshakeMu.RUnlock()
if ch == nil {
return fmt.Errorf("handshake readiness channel not initialized")
}
select {
case <-ch:
return nil
default:
}
timeout := c.config.ConnectTimeout
if timeout <= 0 {
timeout = 10 * time.Second
}
select {
case <-ch:
return nil
case <-ctx.Done():
return ctx.Err()
case <-c.ctx.Done():
return fmt.Errorf("connection context closed before handshake ready: %w", c.ctx.Err())
case <-time.After(timeout):
return fmt.Errorf("timeout waiting for handshake readiness")
}
}
// encodeMsg encodes fields into IBKR message format.
// The IBKR protocol uses null-terminated fields within a length-prefixed frame.
// We strictly maintain field order per the TWS API reference (e.g., reqMktData v11)
func (c *Connection) encodeMsg(fields ...any) []byte {
var buf bytes.Buffer
for i, field := range fields {
if i == 0 && c.serverVersion >= 100 {
// For v100+: encode msgID as 4-byte binary, NO null terminator
switch v := field.(type) {
case int:
if v < math.MinInt32 || v > math.MaxInt32 {
return nil
}
binary.Write(&buf, binary.BigEndian, int32(v))
case int32:
binary.Write(&buf, binary.BigEndian, v)
case int64:
if v < math.MinInt32 || v > math.MaxInt32 {
return nil
}
binary.Write(&buf, binary.BigEndian, int32(v))
default:
return nil
}
continue
}
switch v := field.(type) {
case int:
buf.WriteString(strconv.Itoa(v))
case int64:
buf.WriteString(strconv.FormatInt(v, 10))
case float64:
buf.WriteString(strconv.FormatFloat(v, 'f', -1, 64))
case string:
buf.WriteString(v)
case bool:
if v {
buf.WriteString("1")
} else {
buf.WriteString("0")
}
default:
buf.WriteString(fmt.Sprintf("%v", v))
}
buf.WriteByte('\x00')
}
return append([]byte(nil), buf.Bytes()...)
}
func ensureASCII(label, value string) error {
if value == "" {
return nil
}
if !isASCIIPrintable(value) {
return fmt.Errorf("%s contains non-ASCII characters", label)
}
return nil
}
func isASCIIPrintable(s string) bool {
for _, r := range s {
if r == '\t' || r == '\n' || r == '\r' {
continue
}
if r < 0x20 || r > 0x7e {
return false
}
}
return true
}
// decodeMessage decodes an IBKR message payload into trimmed string fields.
// Empty fields are dropped; exact-position tests should call decodeFields.
func (c *Connection) decodeMessage(msgBytes []byte) []string {
if len(msgBytes) == 0 {
return []string{}
}
if c.serverVersion >= 100 && len(msgBytes) >= 4 {
msgType := binary.BigEndian.Uint32(msgBytes[:4])
result := []string{strconv.Itoa(int(msgType))}
remaining := msgBytes[4:]
if msgType == uint32(msgSystemNotification) {
result = append(result, string(remaining))
return result
}
if c.serverVersion >= minServerVerProtoBufPlaceOrder {
if fields, ok := summarizeInboundOrderProtoCallback(int(msgType), remaining); ok {
return fields
}
}
raw := bytes.SplitSeq(remaining, []byte{'\x00'})
for field := range raw {
result = append(result, string(field))
}
return result
}
var result []string
raw := bytes.SplitSeq(msgBytes, []byte{'\x00'})
for field := range raw {
result = append(result, string(field))
}
return result
}
type systemNotification struct {
tickerID int64
timestamp time.Time
code int
message string
advancedOrderRejectJSON string
}
func parseSystemNotificationPayload(payload []byte) (*systemNotification, error) {
var note systemNotification
buf := payload
for len(buf) > 0 {
tag, n := binary.Uvarint(buf)
if n <= 0 {
return nil, fmt.Errorf("invalid protobuf tag for system notification")
}
buf = buf[n:]
fieldNum := int(tag >> 3)
wireType := int(tag & 0x7)
switch wireType {
case 0: // varint
val, m := binary.Uvarint(buf)
if m <= 0 {
return nil, fmt.Errorf("invalid varint for system notification field %d", fieldNum)
}
buf = buf[m:]
switch fieldNum {
case 1:
if val == math.MaxUint64 {
note.tickerID = -1
} else {
note.tickerID = int64(val)
}
case 2:
note.timestamp = time.Unix(0, int64(val)*int64(time.Millisecond))
case 3:
note.code = int(val)
}
case 2: // length-delimited (message string)
length, m := binary.Uvarint(buf)
if m <= 0 {
return nil, fmt.Errorf("invalid length for system notification field %d", fieldNum)
}
buf = buf[m:]
if length > uint64(len(buf)) {
return nil, fmt.Errorf("system notification field %d length overflow", fieldNum)
}
val := buf[:length]
buf = buf[length:]
switch fieldNum {
case 4:
note.message = string(val)
case 5:
note.advancedOrderRejectJSON = string(val)
}
default:
return nil, fmt.Errorf("unsupported wire type %d in system notification", wireType)
}
}
if note.message == "" {
return nil, fmt.Errorf("system notification missing message text")
}
return ¬e, nil
}
// snapshotHandlers returns a copy of the handler list for a message ID.
// without racing handler removal from callbacks running under other locks.
func (c *Connection) snapshotHandlers(msgID int) []func([]string) {
c.handlersMu.RLock()
defer c.handlersMu.RUnlock()
entries := c.msgHandlers[msgID]
if len(entries) == 0 {
return nil
}
fns := make([]func([]string), 0, len(entries))
for _, entry := range entries {
if entry.fn != nil {
fns = append(fns, entry.fn)
}
}
return fns
}
// dispatchHandlers invokes a stable copy of both legacy and epoch-aware
func (c *Connection) dispatchHandlers(msgID int, fields []string, epoch uint64) bool {
c.handlersMu.RLock()
entries := append([]handlerEntry(nil), c.msgHandlers[msgID]...)
c.handlersMu.RUnlock()
for _, entry := range entries {
if entry.fnAtEpoch != nil {
entry.fnAtEpoch(fields, epoch)
} else if entry.fn != nil {
entry.fn(fields)
}
}
return len(entries) > 0
}
func (c *Connection) dispatchEpochHandlers(msgID int, fields []string, epoch uint64) bool {
c.handlersMu.RLock()
entries := append([]handlerEntry(nil), c.msgHandlers[msgID]...)
c.handlersMu.RUnlock()
called := false
for _, entry := range entries {
if entry.fnAtEpoch != nil {
entry.fnAtEpoch(fields, epoch)
called = true
}
}
return called
}
// UnregisterHandler removes a previously registered handler for a message type.
func (c *Connection) UnregisterHandler(msgID int, handlerID uint64) {
c.handlersMu.Lock()
defer c.handlersMu.Unlock()
entries := c.msgHandlers[msgID]
if len(entries) == 0 {
return
}
for i, entry := range entries {
if entry.id == handlerID {
entries = append(entries[:i], entries[i+1:]...)
break
}
}
if len(entries) == 0 {
delete(c.msgHandlers, msgID)
} else {
c.msgHandlers[msgID] = entries
}
}
// RequestContractDetails sends a request to retrieve contract details for a contract.
func (c *Connection) RequestContractDetails(contract Contract) (int, error) {
if !c.IsConnected() {
return 0, fmt.Errorf("not connected to IBKR")
}
reqID, err := c.nextRequestID()
if err != nil {
return 0, err
}
if err := c.sendContractDetailsRequest(contract, reqID); err != nil {
return 0, err
}
return reqID, nil
}
func (c *Connection) sendContractDetailsRequest(contract Contract, reqID int) error {
return c.sendMessage(c.contractDetailsRequestMessage(context.Background(), contract, reqID))
}
// sendContractDetailsRequestContext is sendContractDetailsRequest with
// caller-owned cancellation and pacing priority while queued in the rate
// limiter. The chain prewarm and per-leg option resolution use it so a
// background-tagged fan-out rides the limiter's background lane instead of
// pre-booking the message bucket ahead of interactive reads.
func (c *Connection) sendContractDetailsRequestContext(ctx context.Context, contract Contract, reqID int) error {
return c.sendMessageWithTypeContext(ctx, c.contractDetailsRequestMessage(ctx, contract, reqID), RequestTypeGeneral)
}
func (c *Connection) sendContractDetailsRequestForEpoch(ctx context.Context, contract Contract, reqID int, epoch uint64) error {
return c.sendMessageWithTypeContextForEpoch(ctx, c.contractDetailsRequestMessage(ctx, contract, reqID), RequestTypeGeneral, epoch, true)
}
func (c *Connection) contractDetailsRequestMessage(ctx context.Context, contract Contract, reqID int) []byte {
c.registerReqAlias(ctx, reqID, contract)
// Handle strike field: IB API expects empty string (not "0") for non-option contracts
strikeField := ""
if contract.Strike != 0 {
strikeField = strconv.FormatFloat(contract.Strike, 'f', -1, 64)
}
multiplierField := ""
if strings.EqualFold(contract.SecType, "OPT") && contract.Multiplier != 0 {
multiplierField = strconv.Itoa(contract.Multiplier)
}
// Equity primary exchange must be empty during stock discovery (conID=0).
// SPY/ETF options need SMART+ARCA to match TWS' SPY ARCA chain source.
primaryField := ""
if contract.PrimaryExch != "" && (contract.ConID != 0 || strings.EqualFold(contract.SecType, "OPT")) {
primaryField = contract.PrimaryExch
}
// LocalSymbol and TradingClass can be empty during discovery.
localSymbol := contract.LocalSymbol
tradingClass := contract.TradingClass
// Discovery requests need SMART defaults. An exact positive-ConID lookup
exchangeField := contract.Exchange
if contract.ConID == 0 {
exchangeField = ifEmpty(exchangeField, "SMART")
}
fields := []any{
reqContractData,
8, // version
reqID, // request id
contract.ConID,
contract.Symbol,
contract.SecType,
contract.Expiry,
strikeField, // use empty string for stocks, actual value for options
contract.Right,
multiplierField,
exchangeField,
primaryField,
ifEmpty(contract.Currency, "USD"),
localSymbol,
tradingClass,
0, // includeExpired = false
contract.SecIDType,
contract.SecID,
"", // issuerId (required for server >= 147, added for server 203)
}
msg := c.encodeMsg(fields...)
// By-fields STK discovery legitimately sends primary='' with conID=0
// (see primaryField above); trace the wire shape only under debug.
if contract.SecType == "STK" && contract.PrimaryExch == "" && logging.LevelEnabled(logging.LevelDebug) {
wireLogger.Debugf("[cid=%d] reqContractData by-fields STK discovery symbol=%s reqID=%d local=%q class=%q fields=%v",
c.config.ClientID, contract.Symbol, reqID, localSymbol, tradingClass, c.decodeMessage(msg))
}
return msg
}
func ifEmpty(s, d string) string {
if s == "" {
return d
}
return s
}
// Public API methods
// SetMarketDataType sets the market data type (live, delayed, etc.)
func (c *Connection) SetMarketDataType(dataType int) error {
if dataType < 1 || dataType > 4 {
return fmt.Errorf("invalid market data type %d", dataType)
}
return c.setMarketDataTypeAtEpoch(dataType, c.BrokerSessionEpoch())
}
// restoreFrozenMarketDataTypeUnlessCompeting restores frozen mode when safe.
func (c *Connection) restoreFrozenMarketDataTypeUnlessCompeting() error {
c.competingMu.RLock()
defer c.competingMu.RUnlock()
if c.competingLiveSession {
return nil
}
return c.SetMarketDataType(2)
}
func (c *Connection) setMarketDataTypeAtEpoch(dataType int, epoch uint64) error {
msg := c.encodeMsg(reqMarketDataType, 1, dataType)
return c.sendMessageWithTypeContextForEpochGuarded(context.Background(), msg, RequestTypeGeneral, epoch, true, nil)
}
// MarketDataType returns the current market data type for a reqID.
func (c *Connection) MarketDataType(reqID int) int {
c.mktDataTypeMu.RLock()
defer c.mktDataTypeMu.RUnlock()
if v, ok := c.mktDataType[reqID]; ok {
return v
}
return 0
}
func (c *Connection) recordHeartbeatAtEpoch(epoch uint64, heartbeatNano int64) bool {
if c == nil || heartbeatNano <= 0 {
return false
}
c.inboundEpochMu.RLock()
defer c.inboundEpochMu.RUnlock()
if epoch != c.BrokerSessionEpoch() {
return false
}
c.lastHeartbeatNano.Store(heartbeatNano)
return true
}
func (c *Connection) processMarketDataTypeAtEpoch(fields []string, epoch uint64) bool {
if len(fields) < 4 {
return false
}
rid, err := strconv.Atoi(fields[2])
if err != nil {
return false
}
dt, err := strconv.Atoi(fields[3])
if err != nil {
return false
}
c.inboundEpochMu.RLock()
defer c.inboundEpochMu.RUnlock()
if epoch != c.BrokerSessionEpoch() {
return false
}
c.mktDataTypeMu.Lock()
c.mktDataType[rid] = dt
c.mktDataTypeMu.Unlock()
ibkrLogger.Debugf("[cid=%d] MarketDataType notice: reqID=%d, type=%d", c.config.ClientID, rid, dt)
return true
}
// PlaceOrder sends a placeOrder request using the v45+ wire format. The default
// but does not grant application submit authority. A nil error means the frame
// was written, not that IBKR accepted or finalized the order.
func (c *Connection) PlaceOrder(order *IBKROrder) error {
if !tradingEnabled {
return definitelyUnsent(ErrTradingDisabled)
}
return c.placeOrder(context.Background(), order, nil, nil)
}
// PlacePaperOrder validates gate against the connection and sends a paper
// not application submit authority. A nil error means the frame was written,
// not that IBKR accepted or finalized the order.
func (c *Connection) PlacePaperOrder(gate PaperOrderGate, order *IBKROrder) error {
if err := gate.validateConnection(c); err != nil {
return definitelyUnsent(err)
}
return c.placeOrder(context.Background(), order, nil, nil)
}
func (c *Connection) placePaperOrderForEpochGuarded(ctx context.Context, gate PaperOrderGate, order *IBKROrder, epoch uint64, guard func() error) error {
if err := gate.validateConnection(c); err != nil {
return definitelyUnsent(err)
}
return c.placeOrder(ctx, order, &epoch, guard)
}
func (c *Connection) placeOrderForEpochGuarded(ctx context.Context, order *IBKROrder, epoch uint64, guard func() error) error {
if !tradingEnabled {
return definitelyUnsent(ErrTradingDisabled)
}
return c.placeOrder(ctx, order, &epoch, guard)
}
func (c *Connection) placeOrder(ctx context.Context, order *IBKROrder, expectedEpoch *uint64, guard func() error) error {
if ctx == nil {
return definitelyUnsent(fmt.Errorf("broker order context is nil"))
}
if err := ctx.Err(); err != nil {
return definitelyUnsent(err)
}
if order == nil {
return definitelyUnsent(fmt.Errorf("order is nil"))
}
if !c.IsConnected() {
return definitelyUnsent(fmt.Errorf("not connected to IBKR"))
}
if c.serverVersion > 0 && c.serverVersion < minServerVerProtoBufPlaceOrder {
return definitelyUnsent(fmt.Errorf("server version %d is too old for placeOrder v45+ encoding; upgrade TWS/IB Gateway", c.serverVersion))
}
epoch, err := preparePlaceOrder(order, c, expectedEpoch)
if err != nil {
return definitelyUnsent(err)
}
if !order.Transmit {
order.Transmit = true
}
order.WhatIf = false
c.clearWhatIfOrderID(order.OrderID)
if err := c.sendPlaceOrderFrameGuarded(ctx, order, epoch, guard); err != nil {
return err
}
now := time.Now()
c.ordersMu.Lock()
order.Status = "Submitted"
order.SubmittedTime = now
if order.CreatedTime.IsZero() {
order.CreatedTime = now
}
if order.Remaining == 0 {
order.Remaining = order.TotalQty
}
c.openOrders[order.OrderID] = order
c.ordersMu.Unlock()
return nil
}
// CancelOrder sends a cancelOrder request for an existing order ID. The
// raw write but does not grant application cancel authority. A nil error means
// the frame was written, not that IBKR confirmed cancellation.
func (c *Connection) CancelOrder(orderID int) error {
if !tradingEnabled {
return definitelyUnsent(ErrTradingDisabled)
}
epoch, err := c.captureBrokerInstructionEpoch()
if err != nil {
return definitelyUnsent(err)
}
return c.cancelOrderForEpoch(orderID, epoch)
}
// CancelPaperOrder validates gate against the connection and sends a paper
// not application cancel authority. A nil error means the frame was written,
// not that IBKR confirmed cancellation.
func (c *Connection) CancelPaperOrder(gate PaperOrderGate, orderID int) error {
if err := gate.validateConnection(c); err != nil {
return definitelyUnsent(err)
}
epoch, err := c.captureBrokerInstructionEpoch()
if err != nil {
return definitelyUnsent(err)
}
return c.cancelOrderForEpoch(orderID, epoch)
}
func (c *Connection) cancelPaperOrderForEpochGuarded(ctx context.Context, gate PaperOrderGate, orderID int, epoch uint64, guard func() error) error {
if err := gate.validateConnection(c); err != nil {
return definitelyUnsent(err)
}
return c.cancelOrderForEpochGuarded(ctx, orderID, epoch, guard)
}
func (c *Connection) cancelOrderForEpoch(orderID int, epoch uint64) error {
return c.cancelOrderForEpochGuarded(context.Background(), orderID, epoch, nil)
}
func (c *Connection) cancelOrderForEpochGuarded(ctx context.Context, orderID int, epoch uint64, guard func() error) error {
if ctx == nil {
return definitelyUnsent(fmt.Errorf("broker cancel context is nil"))
}
if err := ctx.Err(); err != nil {
return definitelyUnsent(err)
}
if !c.IsConnected() {
return definitelyUnsent(fmt.Errorf("not connected to IBKR"))
}
msg, err := c.encodeCancelOrderMessage(orderID)
if err != nil {
return definitelyUnsent(err)
}
if err := c.sendMessageWithTypeContextForEpochGuarded(ctx, msg, RequestTypeOrder, epoch, true, guard); err != nil {
return err
}
return nil
}
func clonePlaceOrderFields() []string {
fields := make([]string, len(placeOrderBaseFields))
copy(fields, placeOrderBaseFields)
return fields
}
func assignPlaceOrderFields(fields []string, order *IBKROrder) {
setIntField(fields, 1, order.OrderID)
setIntField(fields, 2, order.ConID)
setStringField(fields, 3, order.Symbol)
setStringField(fields, 4, order.SecType)
setStringField(fields, 5, order.Expiry)
if order.Strike != 0 {
setFloatField(fields, 6, order.Strike)
}
setStringField(fields, 7, order.Right)
setStringField(fields, 8, order.Multiplier)
if order.Exchange != "" {
setStringField(fields, 9, order.Exchange)
}
setStringField(fields, 10, order.PrimaryExch)
if order.Currency != "" {
setStringField(fields, 11, order.Currency)
}
setStringField(fields, 12, order.LocalSymbol)
setStringField(fields, 13, order.TradingClass)
setStringField(fields, 14, order.SecIDType)
setStringField(fields, 15, order.SecID)
setStringField(fields, 16, strings.ToUpper(order.Action))
setIntField(fields, 17, order.TotalQty)
setStringField(fields, 18, strings.ToUpper(order.OrderType))
if order.OrderType != "MKT" && order.LmtPrice != 0 {
setFloatField(fields, 19, order.LmtPrice)
}
if order.AuxPrice != 0 {
setFloatField(fields, 20, order.AuxPrice)
}
setStringField(fields, 21, strings.ToUpper(order.TIF))
setStringField(fields, 22, order.OcaGroup)
setStringField(fields, 23, order.Account)
if order.OpenClose != "" {
setStringField(fields, 24, order.OpenClose)
}
setIntFieldWithZero(fields, 25, order.Origin)
setStringField(fields, 26, order.OrderRef)
setBoolField(fields, 27, order.Transmit)
setIntFieldWithZero(fields, 28, order.ParentID)
setBoolField(fields, 29, order.BlockOrder)
setBoolField(fields, 30, order.SweepToFill)
setIntField(fields, 31, order.DisplaySize)
setIntFieldWithZero(fields, 32, order.TriggerMethod)
setBoolField(fields, 33, order.OutsideRth)
setBoolField(fields, 34, order.Hidden)
if order.DiscretionaryAmt != 0 {
setFloatField(fields, 36, order.DiscretionaryAmt)
}
setStringField(fields, 37, order.GoodAfterTime)
setStringField(fields, 38, order.GoodTillDate)
setStringField(fields, 39, order.FaGroup)
setStringField(fields, 40, order.FaMethod)
setStringField(fields, 41, order.FaPercentage)
setStringField(fields, 42, order.FaProfile)
setStringField(fields, 43, order.ModelCode)
setIntFieldWithZero(fields, 44, order.ShortSaleSlot)
setStringField(fields, 45, order.DesignatedLocation)
if order.ExemptCode != 0 {
setIntFieldWithZero(fields, 46, order.ExemptCode)
}
setIntFieldWithZero(fields, 47, order.OcaType)
setStringField(fields, 48, order.Rule80A)
setStringField(fields, 49, order.SettlingFirm)
setBoolField(fields, 50, order.AllOrNone)
setIntField(fields, 51, order.MinQty)
if order.PercentOffset != 0 {
setFloatField(fields, 52, order.PercentOffset)
}
setBoolField(fields, 53, order.ETradeOnly)
setBoolField(fields, 54, order.FirmQuoteOnly)
if order.NbboPriceCap != 0 {
setFloatField(fields, 55, order.NbboPriceCap)
}
setIntField(fields, 56, order.AuctionStrategy)
if order.StartingPrice != 0 {
setFloatField(fields, 57, order.StartingPrice)
}
if order.StockRefPrice != 0 {
setFloatField(fields, 58, order.StockRefPrice)
}
if order.Delta != 0 {
setFloatField(fields, 59, order.Delta)
}
if order.StockRangeLower != 0 {
setFloatField(fields, 60, order.StockRangeLower)
}
if order.StockRangeUpper != 0 {
setFloatField(fields, 61, order.StockRangeUpper)
}
setBoolField(fields, 62, order.OverridePercentageConstraints)
if order.Volatility != 0 {
setFloatField(fields, 63, order.Volatility)
}
setIntField(fields, 64, order.VolatilityType)
setStringField(fields, 65, order.DeltaNeutralOrderType)
if order.DeltaNeutralAuxPrice != 0 {
setFloatField(fields, 66, order.DeltaNeutralAuxPrice)
}
setIntField(fields, 67, order.DeltaNeutralConID)
setStringField(fields, 68, order.DeltaNeutralSettlingFirm)
setStringField(fields, 69, order.DeltaNeutralClearingAccount)
setStringField(fields, 70, order.DeltaNeutralClearingIntent)
setStringField(fields, 71, order.DeltaNeutralOpenClose)
setBoolField(fields, 72, order.DeltaNeutralShortSale)
setIntField(fields, 73, order.DeltaNeutralShortSaleSlot)
setStringField(fields, 74, order.DeltaNeutralDesignatedLocation)
setIntField(fields, 75, order.ContinuousUpdate)
setIntField(fields, 76, order.ReferencePriceType)
if order.TrailStopPrice != 0 {
setFloatField(fields, 77, order.TrailStopPrice)
}
if order.TrailingPercent != 0 {
setFloatField(fields, 78, order.TrailingPercent)
}
if order.BasisPoints != 0 {
setFloatField(fields, 79, order.BasisPoints)
}
setIntField(fields, 80, order.BasisPointsType)
setIntField(fields, 81, order.ScaleInitLevelSize)
setIntField(fields, 82, order.ScaleSubsLevelSize)
if order.ScalePriceIncrement != 0 {
setFloatField(fields, 83, order.ScalePriceIncrement)
}
if order.ScalePriceAdjustValue != 0 {
setFloatField(fields, 84, order.ScalePriceAdjustValue)
}
setIntField(fields, 85, order.ScalePriceAdjustInterval)
if order.ScaleProfitOffset != 0 {
setFloatField(fields, 86, order.ScaleProfitOffset)
}
setBoolField(fields, 87, order.ScaleAutoReset)
setIntField(fields, 88, order.ScaleInitPosition)
setIntField(fields, 89, order.ScaleInitFillQty)
setBoolField(fields, 90, order.ScaleRandomPercent)
setStringField(fields, 91, order.HedgeType)
setStringField(fields, 92, order.HedgeParam)
setBoolField(fields, 93, order.OptOutSmartRouting)
setStringField(fields, 94, order.ClearingAccount)
setStringField(fields, 95, order.ClearingIntent)
setBoolField(fields, 96, order.NotHeld)
setBoolField(fields, placeOrderFieldWhatIf, order.WhatIf)
}
func setStringField(fields []string, idx int, value string) {
if idx >= len(fields) || value == "" {
return
}
fields[idx] = value
}
func setIntField(fields []string, idx int, value int) {
if idx >= len(fields) || value == 0 {
return
}
fields[idx] = strconv.Itoa(value)
}
func setIntFieldWithZero(fields []string, idx int, value int) {
if idx >= len(fields) {
return
}
fields[idx] = strconv.Itoa(value)
}
func setFloatField(fields []string, idx int, value float64) {
if idx >= len(fields) {
return
}
fields[idx] = strconv.FormatFloat(value, 'f', -1, 64)
}
func setBoolField(fields []string, idx int, value bool) {
if idx >= len(fields) {
return
}
if value {
fields[idx] = "1"
} else {
fields[idx] = "0"
}
}
// GetNextOrderID reserves the next broker order ID after TWS has supplied a
// 32-bit broker namespace is exhausted; callers must not send an order then.
func (c *Connection) GetNextOrderID() int {
id, _, err := c.reserveNextOrderID()
if err != nil {
return 0
}
return id
}
// reserveNextOrderID returns both the reserved ID and the exact socket epoch
func (c *Connection) reserveNextOrderID() (int, uint64, error) {
c.reqIDMu.Lock()
defer c.reqIDMu.Unlock()
return c.reserveNextOrderIDLocked()
}
func (c *Connection) reserveNextOrderIDForEpoch(expectedEpoch uint64) (int, uint64, error) {
c.reqIDMu.Lock()
defer c.reqIDMu.Unlock()
if epoch := c.brokerSessionEpoch.Load(); epoch != expectedEpoch {
return 0, epoch, fmt.Errorf("%w: broker socket generation changed before order ID reservation", ErrBrokerIDNamespaceConflict)
}
return c.reserveNextOrderIDLocked()
}
func (c *Connection) reserveNextOrderIDLocked() (int, uint64, error) {
epoch := c.brokerSessionEpoch.Load()
if !c.haveNextValidID || c.brokerIDExhausted {
return 0, epoch, fmt.Errorf("broker order ID unavailable before nextValidId or after namespace exhaustion")
}
id := max(c.nextOrderID, c.reqIDSeq)
if id <= 0 {
id = 1
}
if id > maxProtoInt32 {
c.brokerIDExhausted = true
return 0, epoch, fmt.Errorf("broker order ID unavailable before nextValidId or after namespace exhaustion")
}
if id == maxProtoInt32 {
c.brokerIDExhausted = true
} else {
c.nextOrderID = id + 1
}
c.reservedOrderIDs[id] = struct{}{}
return id, epoch, nil
}
func (c *Connection) observeNextValidOrderID(id int) {
if id <= 0 || id > maxProtoInt32 {
return
}
c.reqIDMu.Lock()
c.observeNextValidOrderIDLocked(id)
c.reqIDMu.Unlock()
}
func (c *Connection) observeNextValidOrderIDAtEpoch(id int, epoch uint64) {
if id <= 0 || id > maxProtoInt32 {
return
}
c.reqIDMu.Lock()
defer c.reqIDMu.Unlock()
if c.brokerSessionEpoch.Load() != epoch {
return
}
c.observeNextValidOrderIDLocked(id)
}
func (c *Connection) observeNextValidOrderIDLocked(id int) {
c.haveNextValidID = true
if id > c.nextOrderID {
c.nextOrderID = id
}
if c.reqIDSeq > c.nextOrderID {
c.nextOrderID = c.reqIDSeq
}
}
func (c *Connection) resetOrderIDReadiness() {
c.inboundEpochMu.Lock()
defer c.inboundEpochMu.Unlock()
// The canonical order is inbound generation then evidence. msgErr handling
// holds the inbound read side while its side effects may take evidence read
// locks; reversing these here creates a writer-preference deadlock when an
// evidence writer is already queued.
unlockEvidence := c.lockEvidenceChange()
defer unlockEvidence()
c.reqIDMu.Lock()
// Epoch, readiness, and reservation provenance are one state transition.
// reset epoch, never a mixed combination.
c.brokerSessionEpoch.Add(1)
c.haveNextValidID = false
clear(c.reservedOrderIDs)
clear(c.reservedRequestIDs)
clear(c.reportedOwnOrderIDs)
clear(c.reportedOrderPermIDs)
clear(c.modifyPreviewIDs)
clear(c.modifyStatusUncertain)
clear(c.modifyLocalIdentity)
c.reqIDMu.Unlock()
c.invalidateUnstampedObservationAuthority()
c.mktDataTypeMu.Lock()
clear(c.mktDataType)
c.mktDataTypeMu.Unlock()
c.lastHeartbeatNano.Store(0)
}
// invalidateUnstampedObservationAuthority clears every connection-local
// account, portfolio, quote-routing, and subscription-budget observation that
// lacks an exact socket epoch in its public contract. A reused AutoReconnect
// briefly publishing the prior account/portfolio as current.
func (c *Connection) invalidateUnstampedObservationAuthority() {
c.portfolioProjectionMu.Lock()
c.positionsMu.Lock()
clear(c.positions)
clear(c.positionsSnapshot)
clear(c.positionsSnapshotResult)
c.positionsSnapshotActive = false
c.positionsMu.Unlock()
c.portfolioStaging = nil
c.portfolioStagingActive = false
c.portfolioHealthMu.Lock()
generation := c.portfolioHealth.ProjectionGeneration + 1
c.portfolioHealth = PortfolioStreamHealth{ProjectionGeneration: generation}
c.portfolioHealthMu.Unlock()
c.portfolioProjectionMu.Unlock()
c.accountMu.Lock()
c.account = ""
c.managedAccounts = nil
clear(c.accountSummary)
c.displayAccount = ""
c.displayAccountValues = nil
c.accountValueTimes = nil
c.accountStreamReceipt = accountStreamReceipt{}
clear(c.summarySnapshots)
clear(c.retiredSummaryProbes)
clear(c.currencyProbes)
clear(c.retiredCurrencyProbes)
c.accountMu.Unlock()
c.summarySchedule.reset()
c.aliasMu.Lock()
clear(c.reqAlias)
c.aliasMu.Unlock()
c.optionContractMu.Lock()
clear(c.optionContractCache)
c.optionContractMu.Unlock()
c.competingMu.Lock()
c.competingLiveSession = false
c.competingMu.Unlock()
c.marketDataSlotsMu.Lock()
heldSlots := len(c.marketDataSlots)
clear(c.marketDataSlots)
c.marketDataSlotsMu.Unlock()
if c.rateLimiter != nil {
for range heldSlots {
c.rateLimiter.ReleaseMarketDataSlot()
}
}
}
// captureBrokerInstructionEpoch binds an instruction without an allocator
// claim (currently cancelOrder) to the exact ready socket generation at the
// authority handoff. The returned epoch must be carried to the wire check.
func (c *Connection) captureBrokerInstructionEpoch() (uint64, error) {
if c == nil {
return 0, fmt.Errorf("no active connection")
}
c.reqIDMu.Lock()
defer c.reqIDMu.Unlock()
epoch := c.brokerSessionEpoch.Load()
if !c.haveNextValidID {
return epoch, fmt.Errorf("broker id namespace not ready for instruction send")
}
return epoch, nil
}
// BrokerSessionEpoch identifies the current monotonically advancing socket generation.
func (c *Connection) BrokerSessionEpoch() uint64 {
if c == nil {
return 0
}
return c.brokerSessionEpoch.Load()
}
// BrokerIDNamespaceReady reports whether nextValidId established the shared
func (c *Connection) BrokerIDNamespaceReady() bool {
if c == nil {
return false
}
c.reqIDMu.Lock()
ready := c.haveNextValidID && !c.brokerIDExhausted
c.reqIDMu.Unlock()
return ready
}
func (c *Connection) setOpenOrderSnapshotObserver(observer func(msgID int, fields []string, epoch uint64)) {
c.openOrderObserverMu.Lock()
c.openOrderObserver = observer
c.openOrderObserverMu.Unlock()
}
func (c *Connection) observeInboundOrderIDAtEpoch(id int, expectedEpoch uint64) {
if id <= 0 || id > maxProtoInt32 {
return
}
c.reqIDMu.Lock()
if c.brokerSessionEpoch.Load() != expectedEpoch {
c.reqIDMu.Unlock()
return
}
c.advanceBrokerIDPastLocked(id)
c.reqIDMu.Unlock()
}
// ReportedOwnOrder is what the broker reported with an open order of this
// connection's client ID on the current socket generation: its permanent ID,
// account and order reference. Conflicted means the broker reported the same
// order ID twice with a different permanent ID, account or reference; such an
// ID is never claimed.
type ReportedOwnOrder struct {
PermID int
Account string
OrderRef string
Conflicted bool
}
// OrderChangeIdentity names the open order an explicit-ID send changes, as
// the caller's records hold it: the broker's permanent ID, the account and
// the order reference. It never reaches the wire. An order ID this process
// did not place is claimed below the frontier only when the broker reported
// it on the bound socket generation with this permanent ID and account, and
// this reference when the caller names one (claimOrderIDLocked, under
// reqIDMu).
type OrderChangeIdentity struct {
PermID int
Account string
OrderRef string
}
// matches reports whether the broker's report names the order the caller
// means to change.
func (r ReportedOwnOrder) matches(change *OrderChangeIdentity) bool {
if change == nil || r.Conflicted || change.PermID <= 0 || r.PermID != change.PermID ||
!strings.EqualFold(strings.TrimSpace(r.Account), strings.TrimSpace(change.Account)) {
return false
}
// A report without a reference does not name an order that has one.
want := strings.TrimSpace(change.OrderRef)
return want == "" || strings.TrimSpace(r.OrderRef) == want
}
// observeReportedOwnOrderAtEpoch records an open order the broker reports for
// this connection's client ID, with its permanent ID and account, so a change
// of it may claim its ID below the frontier. With open orders downloaded on
// connection the broker reports every one a previous process placed; on
// 2026-10-20 the daemon could not resize its own stops after moving from TWS
// to IB Gateway because only orders this process placed counted as owned.
//
// Only exact reports count: a transmitted order (permanent ID set) of this
// client ID, not a what-if, naming an account, and the configured account
// when one is configured. Other clients' orders, which the Master API client
// also receives, never count. Nothing counts on a client-0 connection: TWS
// reports hand orders as client 0, so there a report cannot tell an order
// this client placed from one entered by hand. A second report of the same
// ID with another permanent ID or account marks it conflicted.
func (c *Connection) observeReportedOwnOrderAtEpoch(fields []string, expectedEpoch uint64) {
if c.config == nil || c.config.ClientID == 0 {
return
}
ev, ok := ParseOrderLifecycleEvent(fields)
if len(fields) > 1 && fields[1] != "protobuf" {
ev, ok = legacyOpenOrderReport(fields, c.serverVersion)
}
if !ok || ev.Type != OrderLifecycleEventOpenOrder || ev.WhatIf || !ev.ClientIDPresent || ev.ClientID != c.config.ClientID ||
ev.OrderID <= 0 || ev.OrderID > maxProtoInt32 || ev.PermID <= 0 {
return
}
account := strings.TrimSpace(ev.Account)
if account == "" {
return
}
configured := strings.TrimSpace(c.config.Account)
foreignAccount := configured != "" && !strings.EqualFold(configured, account)
// Share the final wire guard's evidence interval. A report either lands
// before the identity comparison or after that protected write finishes.
unlockEvidence := c.lockEvidenceChange()
defer unlockEvidence()
c.reqIDMu.Lock()
defer c.reqIDMu.Unlock()
if c.brokerSessionEpoch.Load() != expectedEpoch {
return
}
if c.reportedOwnOrderIDs == nil {
c.reportedOwnOrderIDs = make(map[int]ReportedOwnOrder)
}
// A foreign-account report is only a non-authorizing tombstone. Keep it
// until the socket epoch resets, so reversing two contradictory reports
// cannot erase the conflict and accidentally grant ownership.
conflicted := c.observeOrderPermIDLocked(ev.OrderID, ev.PermID)
next := ReportedOwnOrder{PermID: ev.PermID, Account: account, OrderRef: strings.TrimSpace(ev.OrderRef), Conflicted: foreignAccount || conflicted}
if prior, seen := c.reportedOwnOrderIDs[ev.OrderID]; seen && (prior.Conflicted || prior.PermID != next.PermID ||
!strings.EqualFold(prior.Account, next.Account) || prior.OrderRef != next.OrderRef) {
next.Conflicted = true
}
c.reportedOwnOrderIDs[ev.OrderID] = next
}
// A status cannot grant ownership, but a different positive permanent ID
// is negative identity evidence even when its matching openOrder is delayed.
func (c *Connection) observeReportedOrderStatusAtEpoch(fields []string, expectedEpoch uint64) previewStatusDisposition {
if c.config == nil {
return previewStatusOrdinary
}
ev, ok := ParseOrderLifecycleEvent(fields)
if !ok || ev.Type != OrderLifecycleEventStatus || ev.OrderID <= 0 || ev.OrderID > maxProtoInt32 ||
(ev.ClientIDPresent && ev.ClientID != c.config.ClientID) {
return previewStatusOrdinary
}
disposition := c.orderPreviewDisposition(ev.OrderID, expectedEpoch)
unlockEvidence := c.lockEvidenceChange()
defer unlockEvidence()
c.reqIDMu.Lock()
defer c.reqIDMu.Unlock()
if c.brokerSessionEpoch.Load() != expectedEpoch {
return previewStatusOrdinary
}
if c.modifyPreviewIDs[ev.OrderID] {
disposition = previewStatusUncertain
}
// A later terminal/unknown status must win over an older working report
// collected by recovery. Matching working statuses are normal snapshot data.
if disposition == previewStatusUncertain || (c.modifyStatusUncertain[ev.OrderID] != 0 && ev.Status != "Submitted" && ev.Status != "PreSubmitted") {
if c.modifyStatusUncertain == nil {
c.modifyStatusUncertain = make(map[int]uint64)
}
c.modifyStatusSerial++
c.modifyStatusUncertain[ev.OrderID] = c.modifyStatusSerial
}
// Omitted client identity cannot establish a permanent-ID contradiction.
if c.config.ClientID != 0 && ev.ClientIDPresent && ev.PermID > 0 && c.observeOrderPermIDLocked(ev.OrderID, ev.PermID) {
if report, exists := c.reportedOwnOrderIDs[ev.OrderID]; exists {
report.Conflicted = true
c.reportedOwnOrderIDs[ev.OrderID] = report
}
}
return disposition
}
// observeOrderPermIDLocked never creates an ownership report. reqIDMu is
// held by the caller, and resetOrderIDReadiness clears the constraints.
func (c *Connection) observeOrderPermIDLocked(id, permID int) bool {
if c.reportedOrderPermIDs == nil {
c.reportedOrderPermIDs = make(map[int]int)
}
if prior, seen := c.reportedOrderPermIDs[id]; seen && prior != permID {
c.reportedOrderPermIDs[id] = 0
return true
}
c.reportedOrderPermIDs[id] = permID
return false
}
// Legacy (pre-protobuf) openOrder layout, as IB's EDecoder reads it: a
// message version field below server version 145, then the order ID, eleven
// contract fields, action, quantity, type, limit, aux price, TIF, OCA group,
// account, open/close, origin, order reference, client ID and permanent ID.
const (
minServerVerOrderContainer = 145
legacyOpenOrderAccount = 19
legacyOpenOrderRef = 22
legacyOpenOrderClientID = 23
legacyOpenOrderPermID = 24
)
// legacyOpenOrderReport reads the identity of a legacy openOrder frame (order
// ID, account, reference, client ID, permanent ID) for the inherited-order
// report, by the layout of serverVersion. Servers below protobuf (201) send
// these; Canary cannot place or change an order there, but it can cancel one,
// and a cancel of an order an earlier process placed needs the report.
// What-if responses are not told apart here; one has no permanent ID unless
// it previews a change of an order the broker already names, whose identity
// it then repeats.
func legacyOpenOrderReport(fields []string, serverVersion int) (OrderLifecycleEvent, bool) {
if len(fields) == 0 || strings.TrimSpace(fields[0]) != strconv.Itoa(msgOpenOrder) {
return OrderLifecycleEvent{}, false
}
start := 1
if serverVersion < minServerVerOrderContainer {
start = 2
}
if len(fields) <= start+legacyOpenOrderPermID {
return OrderLifecycleEvent{}, false
}
clientID, err := strconv.Atoi(strings.TrimSpace(fields[start+legacyOpenOrderClientID]))
if err != nil {
return OrderLifecycleEvent{}, false
}
ev := OrderLifecycleEvent{
Type: OrderLifecycleEventOpenOrder,
OrderID: orderEventInt(fields[start]),
Account: strings.TrimSpace(fields[start+legacyOpenOrderAccount]),
OrderRef: strings.TrimSpace(fields[start+legacyOpenOrderRef]),
ClientID: clientID,
ClientIDPresent: true,
PermID: orderEventInt(fields[start+legacyOpenOrderPermID]),
}
return ev, ev.OrderID > 0
}
// ReportedOwnOrder returns what the broker reported on the current socket
// generation with this client's open order id, and whether it reported it.
func (c *Connection) ReportedOwnOrder(id int) (ReportedOwnOrder, bool) {
c.reqIDMu.Lock()
defer c.reqIDMu.Unlock()
reported, ok := c.reportedOwnOrderIDs[id]
return reported, ok
}
// orderChangeWireGuard freezes the caller's intended identity and checks it
// after transport admission, while sendMessage holds the evidence barrier.
// Locally placed orders may precede their first report, but a contradictory
// report never permits a change, including one of this process's own orders.
func (c *Connection) orderChangeWireGuard(id int, epoch uint64, change *OrderChangeIdentity, locallyPlaced bool, next func() error) func() error {
var want *OrderChangeIdentity
if change != nil {
copy := *change
want = ©
}
return func() error {
if c.modifyStatusIsUncertain(id, epoch) {
return fmt.Errorf("%w: order status changed during its preview; refresh open orders and preview again; no order sent", ErrBrokerIDNamespaceConflict)
}
report, reported := c.reportedOwnOrderAtEpoch(id, epoch)
if want != nil && ((!reported && !locallyPlaced) || (reported && !report.matches(want))) {
return fmt.Errorf("%w: broker report no longer identifies the order to change; no order sent", ErrBrokerIDNamespaceConflict)
}
if next != nil {
return next()
}
return nil
}
}
// reportedOwnOrderAtEpoch is ReportedOwnOrder for one socket generation: a
// caller bound to an earlier generation sees no report.
func (c *Connection) reportedOwnOrderAtEpoch(id int, epoch uint64) (ReportedOwnOrder, bool) {
c.reqIDMu.Lock()
defer c.reqIDMu.Unlock()
if c.brokerSessionEpoch.Load() != epoch {
return ReportedOwnOrder{}, false
}
reported, ok := c.reportedOwnOrderIDs[id]
return reported, ok
}
func (c *Connection) advanceBrokerIDPastLocked(id int) {
if id <= 0 {
return
}
delete(c.reservedOrderIDs, id)
delete(c.reservedRequestIDs, id)
if id >= maxProtoInt32 {
c.brokerIDExhausted = true
return
}
next := id + 1
if c.reqIDSeq < next {
c.reqIDSeq = next
}
if c.nextOrderID < next {
c.nextOrderID = next
}
}
func inboundOrderID(msgID int, fields []string) (int, bool) {
if msgID != msgOpenOrder && msgID != msgOrderStatus {
return 0, false
}
var raw string
if len(fields) > 1 && fields[1] == "protobuf" {
raw = summaryFieldValue(fields, "orderId=")
} else {
start := 1
if len(fields) > 2 && isNumeric(fields[1]) && isNumeric(fields[2]) {
start = 2
}
if len(fields) <= start {
return 0, false
}
raw = fields[start]
}
id, err := strconv.Atoi(strings.TrimSpace(raw))
return id, err == nil && id > 0 && id <= maxProtoInt32
}
func parseNextValidOrderID(fields []string) (int, bool) {
for _, idx := range []int{2, 1} {
if idx >= len(fields) {
continue
}
raw := strings.TrimSpace(fields[idx])
if raw == "" {
continue
}
id, err := strconv.Atoi(raw)
if err != nil || id <= 0 {
continue
}
return id, true
}
return 0, false
}
func (c *Connection) markWhatIfOrderID(orderID int) {
if orderID <= 0 {
return
}
c.whatIfOrdersMu.Lock()
defer c.whatIfOrdersMu.Unlock()
if c.whatIfOrderIDs == nil {
c.whatIfOrderIDs = make(map[int]struct{})
}
c.whatIfOrderIDs[orderID] = struct{}{}
}
func (c *Connection) clearWhatIfOrderID(orderID int) {
if orderID <= 0 {
return
}
c.whatIfOrdersMu.Lock()
defer c.whatIfOrdersMu.Unlock()
delete(c.whatIfOrderIDs, orderID)
}
// IsWhatIfOrderID reports whether orderID is currently reserved for broker
// WhatIf evaluation callbacks rather than a working broker order.
func (c *Connection) IsWhatIfOrderID(orderID int) bool {
if c == nil || orderID <= 0 {
return false
}
c.whatIfOrdersMu.Lock()
defer c.whatIfOrdersMu.Unlock()
_, ok := c.whatIfOrderIDs[orderID]
return ok
}
// RequestMarketData subscribes to market data for a symbol within ctx.
func (c *Connection) RequestMarketData(ctx context.Context, symbol string) (int, error) {
secType, exchange, currency, primaryExchange := classifySymbol(symbol)
localSymbol, tradingClassHint := contractDisplayHints(symbol, secType)
// Dual-class shares (BRK.B, BF.B) translate to IBKR's space-form
// before going on the wire — see dualClassWireSymbol.
wireSymbol := dualClassWireSymbol(symbol)
if base, _, ok := FxPair(symbol); ok {
wireSymbol = base
}
contract := Contract{
Symbol: wireSymbol,
SecType: secType,
Exchange: exchange,
PrimaryExch: primaryExchange,
Currency: currency,
LocalSymbol: localSymbol,
TradingClass: tradingClassHint,
}
// For equities IBKR expects primary exchange blank unless explicitly requested.
if contract.SecType == "STK" {
contract.PrimaryExch = ""
}
return c.RequestMarketDataWithContract(ctx, contract, "100,101,104,106,165,221,233,236", false, false)
}
// RequestMarketDataWithContract issues reqMktData for contract within ctx.
func (c *Connection) RequestMarketDataWithContract(ctx context.Context, contract Contract, genericTicks string, snapshot bool, regulatorySnap bool) (int, error) {
return c.requestMarketDataWithContract(ctx, contract, genericTicks, snapshot, regulatorySnap, nil)
}
func (c *Connection) requestMarketDataWithContract(ctx context.Context, contract Contract, genericTicks string, snapshot bool, regulatorySnap bool, beforeSend func(reqID int) func()) (int, error) {
if !c.IsConnected() {
return 0, fmt.Errorf("not connected to IBKR")
}
if err := c.requireServerVersion("RequestMarketData"); err != nil {
return 0, err
}
if contract.Symbol == "" {
return 0, fmt.Errorf("contract symbol is required for market data")
}
if contract.Currency == "" {
contract.Currency = "USD"
}
if err := ensureASCII("symbol", contract.Symbol); err != nil {
return 0, err
}
reqID, err := c.nextRequestID()
if err != nil {
return 0, err
}
// Copy the contract to avoid caller mutations affecting queued send.
contractCopy := contract
c.registerReqAlias(ctx, reqID, contractCopy)
fields := c.buildReqMktDataFields(contractCopy, reqID, genericTicks, snapshot, regulatorySnap)
msg := c.encodeMsg(fields...)
owner := "quote"
if contract.SecType == "OPT" {
owner = "option_quote"
}
if err := c.acquireMarketDataSlot(ctx, reqID, owner); err != nil {
return 0, fmt.Errorf("market data subscription limit reached: %w", err)
}
var cleanup func()
if beforeSend != nil {
cleanup = beforeSend(reqID)
}
marketLogger.Debugf("Requesting market data for %s (ReqID: %d, SecType: %s, Exchange: %s, Primary: %s, ConID: %d)",
contractCopy.Symbol, reqID, contractCopy.SecType, contractCopy.Exchange, contractCopy.PrimaryExch, contractCopy.ConID)
if err := c.sendMessageWithTypeContext(ctx, msg, RequestTypeMarketData); err != nil {
if cleanup != nil {
cleanup()
}
c.releaseMarketDataSlot(reqID)
return 0, fmt.Errorf("failed to request market data: %w", err)
}
return reqID, nil
}
// requestMarketDataWithContractForEpoch is the exact-socket form used by
// broker-write previews. It binds both request-ID allocation and the final
// transport check to expectedEpoch, preventing reconnect-crossing requests.
func (c *Connection) requestMarketDataWithContractForEpoch(ctx context.Context, contract Contract, genericTicks string, snapshot bool, regulatorySnap bool, expectedEpoch uint64, beforeSend func(reqID int) func()) (int, error) {
return c.requestMarketDataWithContractForEpochMode(ctx, contract, genericTicks, snapshot, regulatorySnap, expectedEpoch, true, 0, beforeSend)
}
// requestSharedMarketDataWithContractForEpoch is the reconnect-safe form used
// when a shared read subscription must be re-issued on its originating socket.
// Unlike broker-write evidence, a shared subscription may be replayed after reconnect.
func (c *Connection) requestSharedMarketDataWithContractForEpoch(ctx context.Context, contract Contract, genericTicks string, expectedEpoch uint64, beforeSend func(reqID int) func()) (int, error) {
return c.requestMarketDataWithContractForEpochMode(ctx, contract, genericTicks, false, false, expectedEpoch, false, 0, beforeSend)
}
func (c *Connection) requestMarketDataWithContractForEpochMode(ctx context.Context, contract Contract, genericTicks string, snapshot bool, regulatorySnap bool, expectedEpoch uint64, requireExactContract bool, dataType int, beforeSend func(reqID int) func()) (int, error) {
if !c.IsConnected() {
return 0, fmt.Errorf("not connected to IBKR")
}
if err := c.requireServerVersion("RequestMarketData"); err != nil {
return 0, err
}
if requireExactContract && contract.ConID <= 0 && !isExplicitSessionFXContract(contract) {
return 0, fmt.Errorf("exact market-data contract requires positive ConID or explicit CASH/IDEALPRO pair")
}
if contract.Symbol == "" {
return 0, fmt.Errorf("contract symbol is required for market data")
}
if contract.Currency == "" {
contract.Currency = "USD"
}
if err := ensureASCII("symbol", contract.Symbol); err != nil {
return 0, err
}
reqID, epoch, err := c.reserveNextRequestIDForEpoch(expectedEpoch)
if err != nil {
return 0, err
}
if _, err := c.claimRequestIDForEpoch(reqID, epoch); err != nil {
return 0, err
}
contractCopy := contract
c.registerReqAlias(ctx, reqID, contractCopy)
msg := c.encodeMsg(c.buildReqMktDataFields(contractCopy, reqID, genericTicks, snapshot, regulatorySnap)...)
owner := "epoch_quote"
if contract.SecType == "OPT" {
owner = "option_quote"
}
if err := c.acquireMarketDataSlotForEpoch(ctx, reqID, expectedEpoch, owner); err != nil {
return 0, fmt.Errorf("market data subscription limit reached: %w", err)
}
var cleanup func()
if beforeSend != nil {
cleanup = beforeSend(reqID)
}
if err := c.sendMessageWithMarketDataMode(ctx, msg, RequestTypeMarketData, expectedEpoch, true, nil, dataType); err != nil {
if cleanup != nil {
cleanup()
}
c.releaseMarketDataSlotAtEpoch(reqID, expectedEpoch)
return 0, fmt.Errorf("failed to request exact market data: %w", err)
}
return reqID, nil
}
func (c *Connection) requireServerVersion(method string) error {
if c.serverVersion == 0 {
return fmt.Errorf("%s: server version not negotiated", method)
}
if c.serverVersion < minServerVersionRequired {
return fmt.Errorf("%s: server version %d is too old (minimum: %d)", method, c.serverVersion, minServerVersionRequired)
}
return nil
}
func (c *Connection) buildReqMktDataFields(contract Contract, reqID int, genericTicks string, snapshot bool, regulatorySnap bool) []any {
// All fields required for serverVersion >= 124
// Per official IBKR API reqMktData message version 11
strikeField := ""
if contract.Strike != 0 {
strikeField = strconv.FormatFloat(contract.Strike, 'f', -1, 64)
}
multiplierField := ""
if contract.Multiplier != 0 {
multiplierField = strconv.Itoa(contract.Multiplier)
}
fields := []any{
reqMktData,
11, // message version
reqID,
contract.ConID,
contract.Symbol,
contract.SecType,
contract.Expiry,
strikeField,
contract.Right,
multiplierField,
contract.Exchange,
contract.PrimaryExch,
contract.Currency,
contract.LocalSymbol,
contract.TradingClass,
}
if contract.SecType == "BAG" {
fields = append(fields, 0) // combo legs count
}
fields = append(fields,
false, // deltaNeutral
genericTicks,
snapshot,
regulatorySnap,
"", // mktDataOptions (chart options)
)
return fields
}
// optionContractKey is the canonical OPRA-style identifier for an OPT
// only ever populated by the v2-read migration.
func optionContractKey(symbol, tradingClass, expiry string, strike float64, right string) string {
return strings.ToUpper(strings.TrimSpace(symbol)) + "|" +
strings.ToUpper(strings.TrimSpace(tradingClass)) + "|" +
strings.TrimSpace(expiry) + "|" +
strconv.FormatFloat(strike, 'f', 6, 64) + "|" +
strings.ToUpper(strings.TrimSpace(right))
}
func applyContractDetailLite(detail ContractDetailsLite, contract *Contract) {
if contract == nil {
return
}
optionPrimaryHint := ""
if strings.EqualFold(contract.SecType, "OPT") {
optionPrimaryHint = optionUnderlyingPrimaryExchangeHint(contract.Symbol)
}
if detail.Exchange != "" {
contract.Exchange = detail.Exchange
}
if detail.PrimaryExch != "" {
contract.PrimaryExch = detail.PrimaryExch
}
if detail.ConID != 0 {
contract.ConID = detail.ConID
}
if detail.LocalSymbol != "" {
contract.LocalSymbol = detail.LocalSymbol
}
if detail.TradingClass != "" {
contract.TradingClass = detail.TradingClass
}
if optionPrimaryHint != "" {
// Cached OPT details can contain the option listing venue as
// PrimaryExch; during contract resolution SPY-style stock
// options still want the underlying chain source hint. The
// market-data request normalizer clears this field again once
// a concrete option ConID is known.
contract.PrimaryExch = optionPrimaryHint
}
}
func normalizeResolvedOptionMarketDataContract(contract *Contract) {
if contract == nil || !strings.EqualFold(contract.SecType, "OPT") || contract.ConID == 0 {
return
}
// PrimaryExch is useful while resolving stock-option contracts
contract.PrimaryExch = ""
}
// RequestHistoricalData submits an HMDS request for historical data and
// list past whatToShow mirrors the reqHistoricalData wire message
func (c *Connection) RequestHistoricalData(ctx context.Context, contract Contract, endDateTime, duration, barSize, whatToShow string, useRTH bool, includeExpired bool, formatDate int, keepUpToDate bool, beforeSend func(int)) (int, error) {
return c.requestHistoricalDataWithIDGuard(ctx, contract, endDateTime, duration, barSize, whatToShow, useRTH, includeExpired, formatDate, keepUpToDate, nil, beforeSend)
}
// requestHistoricalDataWithIDGuard is the narrow internal form used when a
// monotonic broker namespace. The guard is bounded; it never guesses from
// broker prose or silently sends an id that the caller rejected.
func (c *Connection) requestHistoricalDataWithIDGuard(ctx context.Context, contract Contract, endDateTime, duration, barSize, whatToShow string, useRTH bool, includeExpired bool, formatDate int, keepUpToDate bool, requestIDAllowed func(int) bool, beforeSend func(int)) (int, error) {
if !c.IsConnected() {
return 0, fmt.Errorf("not connected to IBKR")
}
if err := c.requireServerVersion("RequestHistoricalData"); err != nil {
return 0, err
}
// Defensive assertion: Prevent byte-shift MART/BOE errors by blocking conID=0 requests
if contract.ConID == 0 {
ibkrLogger.Errorf("[cid=%d] PROTOCOL VIOLATION: attempted historical request with conID=0 for symbol=%s exchange=%s (would cause MART byte-shift error at IBKR gateway)",
c.config.ClientID, contract.Symbol, contract.Exchange)
return 0, fmt.Errorf("PROTOCOL VIOLATION: attempted historical request with conID=0 for symbol=%s exchange=%s (would cause MART byte-shift error at IBKR gateway)",
contract.Symbol, contract.Exchange)
}
duration = normalizeHistoricalDuration(duration)
reqID, err := c.reserveRequestID(requestIDAllowed)
if err != nil {
return 0, err
}
multiplier := ""
if contract.Multiplier != 0 {
multiplier = strconv.Itoa(contract.Multiplier)
}
// Handle strike field: IB API expects empty string (not "0") for non-option contracts
strikeField := ""
if contract.Strike != 0 {
strikeField = strconv.FormatFloat(contract.Strike, 'f', -1, 64)
}
fields := make([]any, 0, 34)
fields = append(fields,
reqHistoricalData,
reqID,
contract.ConID,
contract.Symbol,
contract.SecType,
contract.Expiry,
strikeField, // use empty string for stocks/indices, actual value for options
contract.Right,
multiplier,
contract.Exchange,
contract.PrimaryExch,
contract.Currency,
contract.LocalSymbol,
contract.TradingClass, // Always sent (MIN_SERVER_VER_TRADING_CLASS=68 < 124)
)
fields = append(fields, includeExpired)
if contract.SecIDType != "" || contract.SecID != "" {
fields = append(fields, contract.SecIDType, contract.SecID)
}
fields = append(fields,
endDateTime,
barSize, // IBKR API encodes barSizeSetting before durationStr (see twsapi v10)
duration, // durationStr follows barSizeSetting
useRTH, // useRTH flag is encoded before whatToShow
whatToShow, // whatToShow string must follow useRTH
formatDate,
)
if contract.SecType == "BAG" {
fields = append(fields, 0) // combo legs count (unsupported)
}
// Always sent for serverVersion >= 124
fields = append(fields, keepUpToDate)
// Always sent (MIN_SERVER_VER_LINKING=70 < 124)
fields = append(fields, "") // chart options (unused)
msg := c.encodeMsg(fields...)
// Enhanced diagnostics: Log contract details when wire hex logging is enabled
if c.logWireHex {
wireLogger.Debugf("[cid=%d] Historical reqID=%d conID=%d symbol=%s exchange=%s primary=%s fields=%d msgLen=%d",
c.config.ClientID, reqID, contract.ConID, contract.Symbol, contract.Exchange, contract.PrimaryExch, len(fields), len(msg))
}
if beforeSend != nil {
beforeSend(reqID)
}
if err := c.sendMessageWithTypeContext(ctx, msg, RequestTypeHistorical); err != nil {
return 0, fmt.Errorf("failed to request historical data: %w", err)
}
return reqID, nil
}
// normalizeHistoricalDuration coerces legacy day-based durations into IBKR-compliant
// year tokens above the 365-day limit, preventing IBKR error 321.
func normalizeHistoricalDuration(duration string) string {
parts := strings.Fields(strings.TrimSpace(duration))
if len(parts) != 2 {
return duration
}
value, err := strconv.Atoi(parts[0])
if err != nil || value <= 0 {
return duration
}
unit := strings.ToUpper(parts[1])
switch unit {
case "D", "DAY", "DAYS":
if value > 365 {
return formatHistoricalDuration(value)
}
return fmt.Sprintf("%d D", value)
default:
return duration
}
}
// CancelHistoricalData cancels an active historical request and honors ctx
func (c *Connection) CancelHistoricalData(ctx context.Context, reqID int) error {
if reqID <= 0 || reqID > maxProtoInt32 {
return fmt.Errorf("historical request ID must be a positive signed 32-bit integer")
}
if !c.IsConnected() {
return fmt.Errorf("not connected to IBKR")
}
msg := c.encodeMsg(cancelHistoricalData, 1, reqID)
return c.sendMessageWithTypeContext(ctx, msg, RequestTypeHistorical)
}
// RequestSecDefOptParams issues msg 78 (reqSecDefOptParams) to enumerate the
// option chain (expirations + strikes) for an underlying. The IBKR wire format
// wire so callers can register their per-request handler atomically.
func (c *Connection) RequestSecDefOptParams(underlyingSymbol, futFopExchange, underlyingSecType string, underlyingConId int, beforeSend func(int)) (int, error) {
return c.requestSecDefOptParamsContext(context.Background(), underlyingSymbol, futFopExchange, underlyingSecType, underlyingConId, beforeSend)
}
func (c *Connection) requestSecDefOptParamsContext(ctx context.Context, underlyingSymbol, futFopExchange, underlyingSecType string, underlyingConId int, beforeSend func(int)) (int, error) {
if !c.IsConnected() {
return 0, fmt.Errorf("not connected to IBKR")
}
if underlyingConId == 0 {
return 0, fmt.Errorf("reqSecDefOptParams: underlying conID required")
}
reqID, err := c.nextRequestID()
if err != nil {
return 0, err
}
msg := c.encodeMsg(
reqSecDefOptParams,
reqID,
underlyingSymbol,
futFopExchange,
underlyingSecType,
underlyingConId,
)
if beforeSend != nil {
beforeSend(reqID)
}
if err := c.sendMessageWithTypeContext(ctx, msg, RequestTypeGeneral); err != nil {
return 0, fmt.Errorf("failed to request sec def opt params: %w", err)
}
return reqID, nil
}
// RequestMarketDataWithPrimary subscribes to market data with an explicit
// primary-exchange hint, with ctx bounding contract resolution.
func (c *Connection) RequestMarketDataWithPrimary(ctx context.Context, symbol string, primaryExchange string) (int, error) {
if !c.IsConnected() {
return 0, fmt.Errorf("not connected to IBKR")
}
if err := c.requireServerVersion("RequestMarketDataWithPrimary"); err != nil {
return 0, err
}
if err := ensureASCII("symbol", symbol); err != nil {
return 0, err
}
if err := ensureASCII("primary exchange", primaryExchange); err != nil {
return 0, err
}
reqID, err := c.nextRequestID()
if err != nil {
return 0, err
}
// Determine security type and base exchange based on symbol
secType, exchange, currency, primaryHint := classifySymbol(symbol)
if primaryExchange == "" {
primaryExchange = primaryHint
}
localSymbol, tradingClassHint := contractDisplayHints(symbol, secType)
// Dual-class shares (BRK.B, BF.B) translate to IBKR's space-form
// before going on the wire — see dualClassWireSymbol.
wireSymbol := dualClassWireSymbol(symbol)
if base, _, ok := FxPair(symbol); ok {
wireSymbol = base
}
contract := Contract{
Symbol: wireSymbol,
SecType: secType,
Exchange: exchange,
PrimaryExch: primaryExchange,
Currency: currency,
LocalSymbol: localSymbol,
TradingClass: tradingClassHint,
}
msg := c.encodeMsg(c.buildReqMktDataFields(contract, reqID, "100,101,104,106,165,221,233,236", false, false)...)
if err := c.acquireMarketDataSlot(ctx, reqID, "primary_quote"); err != nil {
return 0, fmt.Errorf("market data subscription limit reached: %w", err)
}
marketLogger.Debugf("Requesting market data for %s (ReqID: %d, SecType: %s, Exch: %s, Primary: %s)",
symbol, reqID, secType, exchange, contract.PrimaryExch)
if err := c.sendMessageWithType(msg, RequestTypeMarketData); err != nil {
c.releaseMarketDataSlot(reqID)
return 0, fmt.Errorf("failed to request market data: %w", err)
}
return reqID, nil
}
// RequestOptionsMarketData subscribes to market data for an option contract.
func (c *Connection) RequestOptionsMarketData(ctx context.Context, symbol string, expiry string, strike float64, right string) (int, error) {
if !c.IsConnected() {
return 0, fmt.Errorf("not connected to IBKR")
}
if err := c.requireServerVersion("RequestOptionsMarketData"); err != nil {
return 0, err
}
reqID, err := c.nextRequestID()
if err != nil {
return 0, err
}
secType := "OPT"
exchange := "SMART"
primaryExchange := "CBOE"
if hint := optionUnderlyingPrimaryExchangeHint(symbol); hint != "" {
primaryExchange = hint
}
currency := "USD"
expiryFormatted := expiry
if len(expiry) == 10 && strings.Contains(expiry, "-") {
expiryFormatted = strings.ReplaceAll(expiry, "-", "")
}
localSymbol, tradingClassHint := contractDisplayHints(symbol, secType)
contract := Contract{
Symbol: symbol,
SecType: secType,
Expiry: expiryFormatted,
Strike: strike,
Right: strings.ToUpper(right),
Multiplier: 100,
Exchange: exchange,
PrimaryExch: primaryExchange,
Currency: currency,
LocalSymbol: localSymbol,
TradingClass: tradingClassHint,
}
if err := c.resolveOptionContract(ctx, &contract, 5*time.Second); err != nil {
return 0, fmt.Errorf("resolve option contract failed: %w", err)
}
normalizeResolvedOptionMarketDataContract(&contract)
msg := c.encodeMsg(c.buildReqMktDataFields(contract, reqID, "100,101,104,106,221,236", false, false)...)
if err := c.acquireMarketDataSlot(ctx, reqID, "option_quote"); err != nil {
return 0, fmt.Errorf("market data subscription limit reached: %w", err)
}
marketLogger.Infof("Requesting options market data for %s %s %.2f %s (ReqID: %d)",
symbol, expiryFormatted, strike, right, reqID)
if err := c.sendMessageWithType(msg, RequestTypeMarketData); err != nil {
c.releaseMarketDataSlot(reqID)
return 0, fmt.Errorf("failed to request options market data: %w", err)
}
return reqID, nil
}
func (c *Connection) resolveOptionContract(ctx context.Context, contract *Contract, timeout time.Duration) error {
if contract == nil {
return fmt.Errorf("option contract is nil")
}
if contract.ConID != 0 {
return nil
}
if timeout <= 0 {
timeout = 5 * time.Second
}
if c.applyCachedOptionContract(contract) {
return nil
}
var lastErr error
for _, att := range optionContractResolutionAttempts(*contract) {
if err := ctx.Err(); err != nil {
return err
}
detail, err := c.fetchOptionContractDetail(ctx, att.Contract, timeout)
if err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
lastErr = err
continue
}
if detail == nil || detail.ConID == 0 {
lastErr = fmt.Errorf("contract details unavailable for option %s %s %.2f%s (exchange=%s)", contract.Symbol, contract.Expiry, contract.Strike, contract.Right, att.Label)
continue
}
key := optionContractKey(contract.Symbol, contract.TradingClass, contract.Expiry, contract.Strike, contract.Right)
applyContractDetailLite(*detail, contract)
c.optionContractMu.Lock()
c.optionContractCache[key] = *detail
c.optionContractMu.Unlock()
return nil
}
if lastErr != nil {
return lastErr
}
return fmt.Errorf("contract details unavailable for option %s %s %.2f%s", contract.Symbol, contract.Expiry, contract.Strike, contract.Right)
}
func (c *Connection) applyCachedOptionContract(contract *Contract) bool {
if c == nil || contract == nil {
return false
}
key := optionContractKey(contract.Symbol, contract.TradingClass, contract.Expiry, contract.Strike, contract.Right)
c.optionContractMu.RLock()
cached, ok := c.optionContractCache[key]
c.optionContractMu.RUnlock()
if !ok || cached.ConID == 0 {
return false
}
applyContractDetailLite(cached, contract)
return true
}
type optionContractRouteAttempt struct {
Contract Contract
Label string
}
func optionContractResolutionAttempts(contract Contract) []optionContractRouteAttempt {
attempts := []optionContractRouteAttempt{{Contract: contract, Label: optionContractRouteLabel(contract)}}
if contract.PrimaryExch != "" && !strings.EqualFold(contract.Exchange, contract.PrimaryExch) {
alt := contract
alt.Exchange = contract.PrimaryExch
alt.PrimaryExch = ""
attempts = append(attempts, optionContractRouteAttempt{Contract: alt, Label: optionContractRouteLabel(alt)})
}
if !strings.EqualFold(contract.Exchange, "CBOE") {
alt := contract
alt.Exchange = "CBOE"
alt.PrimaryExch = ""
attempts = append(attempts, optionContractRouteAttempt{Contract: alt, Label: optionContractRouteLabel(alt)})
}
if !strings.EqualFold(contract.Exchange, "SMART") {
alt := contract
alt.Exchange = "SMART"
alt.PrimaryExch = ""
attempts = append(attempts, optionContractRouteAttempt{Contract: alt, Label: optionContractRouteLabel(alt)})
}
seen := make(map[string]struct{})
dedup := make([]optionContractRouteAttempt, 0, len(attempts))
for _, att := range attempts {
key := strings.ToUpper(att.Contract.Exchange) + "|" + strings.ToUpper(att.Contract.PrimaryExch)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
dedup = append(dedup, att)
}
return dedup
}
func optionContractRouteLabel(contract Contract) string {
exchange := strings.ToUpper(strings.TrimSpace(contract.Exchange))
primary := strings.ToUpper(strings.TrimSpace(contract.PrimaryExch))
if primary == "" {
return exchange
}
return exchange + "+" + primary
}
// fetchContractDetailFirst returns the first contractData frame the gateway
func (c *Connection) fetchContractDetailFirst(ctx context.Context, contract Contract, timeout time.Duration) (*ContractDetailsLite, error) {
if timeout <= 0 {
timeout = 5 * time.Second
}
detailsCh := make(chan ContractDetailsLite, 1)
serverVersion := c.serverVersion
reqID, err := c.nextRequestID()
if err != nil {
return nil, err
}
dataHandlerID := c.RegisterHandler(msgContractData, func(fields []string) {
if lite, ok := parseContractDetailsLite(fields, reqID, serverVersion); ok {
select {
case detailsCh <- *lite:
default:
}
}
})
defer c.UnregisterHandler(msgContractData, dataHandlerID)
if err := c.sendContractDetailsRequest(contract, reqID); err != nil {
return nil, err
}
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case detail := <-detailsCh:
return &detail, nil
case <-ctx.Done():
return nil, ctx.Err()
case <-timer.C:
return nil, fmt.Errorf("contract details timeout for %s", contract.Symbol)
}
}
func (c *Connection) fetchOptionContractDetail(ctx context.Context, contract Contract, timeout time.Duration) (*ContractDetailsLite, error) {
if timeout <= 0 {
timeout = 5 * time.Second
}
detailsCh := make(chan ContractDetailsLite, 8)
// The end marker is buffered so a waiter busy with the last detail still
// sees it; stopCh releases the reader instead of dropping queued details.
doneCh := make(chan struct{}, 1)
stopCh := make(chan struct{})
defer close(stopCh)
serverVersion := c.serverVersion
reqID, err := c.nextRequestID()
if err != nil {
return nil, err
}
dataHandlerID := c.RegisterHandler(msgContractData, func(fields []string) {
if lite, ok := parseContractDetailsLite(fields, reqID, serverVersion); ok {
select {
case detailsCh <- *lite:
case <-stopCh:
}
}
})
endHandlerID := c.RegisterHandler(msgContractDataEnd, func(fields []string) {
if len(fields) < 3 {
return
}
if id, err := strconv.Atoi(fields[2]); err == nil && id == reqID {
select {
case doneCh <- struct{}{}:
default:
}
}
})
err = c.sendContractDetailsRequestContext(ctx, contract, reqID)
if err != nil {
c.UnregisterHandler(msgContractData, dataHandlerID)
c.UnregisterHandler(msgContractDataEnd, endHandlerID)
return nil, err
}
timer := time.NewTimer(timeout)
defer timer.Stop()
defer c.UnregisterHandler(msgContractData, dataHandlerID)
defer c.UnregisterHandler(msgContractDataEnd, endHandlerID)
var selected *ContractDetailsLite
prefer := func(candidate ContractDetailsLite) bool {
if !optionDetailMatchesRequest(candidate, contract) {
return false
}
if selected == nil {
return true
}
// Prefer details that match the requested exchange or primary.
if !strings.EqualFold(selected.Exchange, contract.Exchange) && strings.EqualFold(candidate.Exchange, contract.Exchange) {
return true
}
if !strings.EqualFold(selected.PrimaryExch, contract.PrimaryExch) && strings.EqualFold(candidate.PrimaryExch, contract.PrimaryExch) {
return true
}
return false
}
for {
select {
case detail := <-detailsCh:
if prefer(detail) {
// copy
d := detail
selected = &d
}
case <-doneCh:
for _, detail := range drainQueuedContractDetails(detailsCh, nil) {
if prefer(detail) {
d := detail
selected = &d
}
}
if selected != nil {
return selected, nil
}
return nil, fmt.Errorf("contract details unavailable for option %s %s %.2f%s", contract.Symbol, contract.Expiry, contract.Strike, contract.Right)
case <-timer.C:
return nil, fmt.Errorf("timeout waiting for option contract details for %s %s %.2f%s", contract.Symbol, contract.Expiry, contract.Strike, contract.Right)
case <-ctx.Done():
return nil, ctx.Err()
}
}
}
func optionDetailMatchesRequest(candidate ContractDetailsLite, contract Contract) bool {
if candidate.ConID == 0 {
return false
}
requestedClass := strings.TrimSpace(contract.TradingClass)
if requestedClass != "" && !strings.EqualFold(candidate.TradingClass, requestedClass) {
return false
}
return true
}
// ContractDetailsRequestError preserves a terminal broker code from an option
// prewarm route without exposing broker-provided free text.
type ContractDetailsRequestError struct {
Code int
}
// Error describes the failed contract-details request and its broker code.
func (e *ContractDetailsRequestError) Error() string {
return fmt.Sprintf("contract details request failed (IBKR %d)", e.Code)
}
// PrewarmOptionChainResult reports per-expiry outcome of a bulk prewarm:
type PrewarmOptionChainResult struct {
Expiry string
// Listed counts distinct validated contracts returned, including warm entries.
Listed int
// Cached counts new inserts only.
Cached int
Dropped int
Elapsed time.Duration
Err error
}
// PrewarmOptionChain bulk-resolves an option chain by issuing one partial-
// is the technique TWS uses internally to populate a chain instantly:
// IBKR's reqContractDetails returns every listed strike × C/P for a given
// and sidesteps the IBKR per-account reqContractDetails throttle that
// semaphore (4) to avoid bursting the gateway. Failures are localised —
// one timed-out expiry does not fail the others; tradingClass disambiguates classes.
func (c *Connection) PrewarmOptionChain(
ctx context.Context,
symbol string,
expiries []string,
tradingClass string,
timeout time.Duration,
) []PrewarmOptionChainResult {
if timeout <= 0 {
timeout = 30 * time.Second
}
results := make([]PrewarmOptionChainResult, len(expiries))
if len(expiries) == 0 {
return results
}
sem := make(chan struct{}, 4)
var wg sync.WaitGroup
for i, exp := range expiries {
wg.Go(func() {
select {
case sem <- struct{}{}:
case <-ctx.Done():
results[i] = PrewarmOptionChainResult{Expiry: exp, Err: ctx.Err()}
return
}
defer func() { <-sem }()
start := time.Now()
counts, err := c.prewarmOneExpiry(ctx, symbol, exp, tradingClass, timeout)
results[i] = PrewarmOptionChainResult{
Expiry: exp,
Listed: counts.listed,
Cached: counts.cached,
Dropped: counts.dropped,
Elapsed: time.Since(start),
Err: err,
}
})
}
wg.Wait()
return results
}
type prewarmCounts struct{ listed, cached, dropped int }
// prewarmOneExpiry leaves strike and right empty so IBKR returns the whole expiry.
func (c *Connection) prewarmOneExpiry(ctx context.Context, symbol, expiry, tradingClass string, timeout time.Duration) (prewarmCounts, error) {
contract := Contract{Symbol: symbol, SecType: "OPT", Expiry: expiry, Exchange: "SMART", PrimaryExch: optionUnderlyingPrimaryExchangeHint(symbol), Currency: "USD", Multiplier: 100, TradingClass: tradingClass}
attempts := optionContractResolutionAttempts(contract)
labels := make([]string, 0, len(attempts))
var lastErr error
for _, att := range attempts {
if err := ctx.Err(); err != nil {
return prewarmCounts{}, err
}
start := time.Now()
counts, err := c.prewarmOneExpiryAttempt(ctx, att.Contract, timeout)
outcome := "empty"
if err != nil {
outcome = err.Error()
} else if counts.listed > 0 {
outcome = "complete"
}
labels = append(labels, fmt.Sprintf("%s[%s: %s]", att.Label, time.Since(start).Round(time.Millisecond), outcome))
// Partial results remain explicitly incomplete. Never claim a complete
// expiry merely because some entries were inserted before a timeout.
if counts.listed > 0 || counts.dropped > 0 {
return counts, err
}
if err != nil {
lastErr = err
}
}
if lastErr != nil {
return prewarmCounts{}, fmt.Errorf("prewarm %s %s class=%s route attempts %s: %w", symbol, expiry, tradingClass, strings.Join(labels, ","), lastErr)
}
return prewarmCounts{}, fmt.Errorf("prewarm %s %s class=%s returned zero contract details across route attempts %s", symbol, expiry, tradingClass, strings.Join(labels, ","))
}
func (c *Connection) prewarmOneExpiryAttempt(ctx context.Context, contract Contract, timeout time.Duration) (prewarmCounts, error) {
detailsCh := make(chan ContractDetailsLite, 16_384)
// One terminal outcome, retained even when it arrives during the send or
// while the consumer is flushing data. Duplicate ends cannot block input.
terminal := make(chan error, 1)
finish := func(err error) {
select {
case terminal <- err:
default:
}
}
var dropped atomic.Int32
serverVersion := c.serverVersion
reqID, epoch, err := c.reserveNextRequestIDForEpoch(c.BrokerSessionEpoch())
if err != nil {
return prewarmCounts{}, err
}
dataHandlerID := c.RegisterHandlerAtEpoch(msgContractData, func(fields []string, received uint64) {
if received != epoch {
return
}
if lite, ok := parseContractDetailsLite(fields, reqID, serverVersion); ok {
select {
case detailsCh <- *lite:
default:
dropped.Add(1)
}
}
})
endHandlerID := c.RegisterHandlerAtEpoch(msgContractDataEnd, func(fields []string, received uint64) {
if received != epoch || len(fields) < 3 {
return
}
if id, err := strconv.Atoi(fields[2]); err == nil && id == reqID {
finish(nil)
}
})
reject := func(id, code int) {
if id != reqID {
return
}
switch code {
case 200, 320, 321, 322, 354, 502, 503, 504:
finish(&ContractDetailsRequestError{Code: code})
}
}
errorHandlerID := c.RegisterHandlerAtEpoch(msgErrMsg, func(fields []string, received uint64) {
if received != epoch || len(fields) < 4 {
return
}
id, idErr := strconv.Atoi(fields[2])
code, codeErr := strconv.Atoi(fields[3])
if idErr == nil && codeErr == nil {
reject(id, code)
}
})
noticeHandlerID := c.RegisterHandlerAtEpoch(msgSystemNotification, func(fields []string, received uint64) {
if received != epoch || len(fields) < 2 {
return
}
if note, err := parseSystemNotificationPayload([]byte(fields[1])); err == nil {
reject(int(note.tickerID), note.code)
}
})
defer c.UnregisterHandler(msgContractData, dataHandlerID)
defer c.UnregisterHandler(msgContractDataEnd, endHandlerID)
defer c.UnregisterHandler(msgErrMsg, errorHandlerID)
defer c.UnregisterHandler(msgSystemNotification, noticeHandlerID)
if err := c.sendContractDetailsRequestForEpoch(ctx, contract, reqID, epoch); err != nil {
return prewarmCounts{}, fmt.Errorf("send reqContractDetails: %w", err)
}
timer := time.NewTimer(timeout)
defer timer.Stop()
var counts prewarmCounts
seen := map[string]bool{}
flush := func(d ContractDetailsLite) {
if !optionDetailMatchesRequest(d, contract) {
return
}
key := optionContractKey(contract.Symbol, d.TradingClass, contract.Expiry, d.Strike, d.Right)
d, err := normalizeCurrentOptionDetail(key, d)
if err != nil || seen[key] {
return
}
// A retired response cannot repopulate a successor's resolution cache.
c.inboundEpochMu.RLock()
defer c.inboundEpochMu.RUnlock()
if c.BrokerSessionEpoch() != epoch {
return
}
seen[key] = true
counts.listed++
c.optionContractMu.Lock()
defer c.optionContractMu.Unlock()
if existing, ok := c.optionContractCache[key]; ok && existing.ConID != 0 {
return
}
c.optionContractCache[key] = d
counts.cached++
}
drain := func() {
for {
select {
case d := <-detailsCh:
flush(d)
default:
return
}
}
}
result := func(err error) (prewarmCounts, error) {
drain()
counts.dropped = int(dropped.Load())
if c.BrokerSessionEpoch() != epoch {
err = fmt.Errorf("broker socket generation changed during prewarm")
}
if err == nil && counts.dropped > 0 {
err = fmt.Errorf("prewarm truncated after dropping %d contractData frames", counts.dropped)
}
return counts, err
}
for {
select {
case d := <-detailsCh:
flush(d)
case err := <-terminal:
return result(err)
case <-timer.C:
return result(fmt.Errorf("prewarm timeout after %s", timeout))
case <-ctx.Done():
return result(ctx.Err())
}
}
}
// CancelMarketData cancels the market-data subscription identified by reqID.
func (c *Connection) CancelMarketData(reqID int) error {
if reqID <= 0 || reqID > maxProtoInt32 {
return fmt.Errorf("market-data request ID must be a positive signed 32-bit integer")
}
if !c.IsConnected() {
return fmt.Errorf("not connected to IBKR")
}
return c.cancelMarketDataForEpoch(context.Background(), reqID, c.BrokerSessionEpoch())
}
func (c *Connection) cancelMarketDataForEpoch(ctx context.Context, reqID int, epoch uint64) error {
if reqID <= 0 || reqID > maxProtoInt32 {
return fmt.Errorf("market-data request ID must be a positive signed 32-bit integer")
}
msg := c.encodeMsg(cancelMktData, 1, reqID)
err := c.sendMessageWithTypeContextForEpoch(ctx, msg, RequestTypeMarketDataCancel, epoch, true)
if err == nil {
c.releaseMarketDataSlotAtEpoch(reqID, epoch)
}
return err
}
// parsePositionMultiSnapshotRow reads the legacy PositionMulti callback:
// version, request ID, account, contract, position, average cost, model code.
// Account/model conflicts and malformed quantities cannot certify an empty or
// smaller holding. The current maximum negotiated server version is 203.
func parsePositionMultiSnapshotRow(fields []string, account string) (*RawPosition, error) {
if len(fields) < 18 || fields[1] != "1" {
return nil, fmt.Errorf("malformed positions snapshot callback")
}
if !strings.EqualFold(strings.TrimSpace(fields[3]), account) || fields[17] != "" {
return nil, ErrAccountSummaryScopeConflict
}
conID, conErr := strconv.Atoi(fields[4])
quantity, qtyErr := strconv.ParseFloat(fields[15], 64)
cost, costErr := strconv.ParseFloat(fields[16], 64)
strike := 0.0
var strikeErr error
if fields[8] != "" {
strike, strikeErr = strconv.ParseFloat(fields[8], 64)
}
multiplier := 1
var multiplierErr error
if fields[10] != "" {
multiplier, multiplierErr = strconv.Atoi(fields[10])
}
if conErr != nil || conID <= 0 || qtyErr != nil || !finiteFloat(quantity) || costErr != nil || !finiteFloat(cost) ||
strikeErr != nil || !finiteFloat(strike) || multiplierErr != nil || multiplier <= 0 || fields[5] == "" || fields[6] == "" || fields[12] == "" {
return nil, fmt.Errorf("malformed positions snapshot callback")
}
return &RawPosition{Account: account, Position: quantity, AverageCost: cost, Contract: Contract{
ConID: conID, Symbol: fields[5], SecType: fields[6], Expiry: fields[7], Strike: strike,
Right: fields[9], Multiplier: multiplier, Exchange: fields[11], Currency: fields[12], LocalSymbol: fields[13], TradingClass: fields[14],
}}, nil
}
// RequestPositions requests current positions via the one-shot reqPositions
// wire path. Library-callable; the daemon prefers the streaming portfolio
// path through Connector.CachedPositions backed by RequestAccountUpdates.
func (c *Connection) RequestPositions() error {
if !c.IsConnected() {
return fmt.Errorf("not connected to IBKR")
}
// Begin an isolated generation. The streaming projection and its current
c.portfolioProjectionMu.Lock()
c.positionsSnapshot = make(map[string]*RawPosition)
c.positionsSnapshotResult = nil
c.positionsSnapshotActive = true
c.portfolioProjectionMu.Unlock()
// Clear the end channel to ensure we wait for new data
select {
case <-c.positionsEndChan:
default:
}
msg := c.encodeMsg(reqPositions, "1")
return c.sendMessage(msg)
}
// WaitForPositionsEnd waits for the matching msgPositionEnd frame after a
// RequestPositions call. Library-callable companion to RequestPositions
// (daemon uses the streaming path; see RequestPositions for details).
func (c *Connection) WaitForPositionsEnd(timeout time.Duration) error {
select {
case <-c.positionsEndChan:
return nil
case <-time.After(timeout):
return fmt.Errorf("timeout waiting for positions end")
}
}
func (c *Connection) completePositionsSnapshot() {
c.portfolioProjectionMu.Lock()
defer c.portfolioProjectionMu.Unlock()
if !c.positionsSnapshotActive {
return
}
c.positionsSnapshotResult = make(map[string]*RawPosition, len(c.positionsSnapshot))
maps.Copy(c.positionsSnapshotResult, c.positionsSnapshot)
c.positionsSnapshot = nil
c.positionsSnapshotActive = false
}
// summarySnapshot accumulates the account-summary rows for one
// reqAccountSummary request, keyed like the shared accountSummary map
// accountSummaryEnd for the request's reqID.
type summarySnapshot struct {
settlementObservation AccountSettlementObservation
values map[string]string
done chan struct{}
expectedAccount string
observedRows int
scopeConflict bool
diagnosticOnly bool
brokerErrorCode int
}
type summarySnapshotResult struct {
settlementObservation AccountSettlementObservation
values map[string]string
scopeConflict bool
}
// registerSummarySnapshot opens a per-request accumulation for reqID.
// Must run before the request hits the wire so no row can be missed.
func (c *Connection) registerSummarySnapshot(reqID int, expectedAccount string) {
c.accountMu.Lock()
defer c.accountMu.Unlock()
if c.summarySnapshots == nil {
c.summarySnapshots = make(map[int]*summarySnapshot)
}
c.summarySnapshots[reqID] = &summarySnapshot{
values: make(map[string]string),
done: make(chan struct{}),
expectedAccount: strings.TrimSpace(expectedAccount),
}
}
// dropSummarySnapshot removes the per-request accumulation for reqID and
// rows only touch the shared map.
func (c *Connection) dropSummarySnapshot(reqID int) summarySnapshotResult {
c.accountMu.Lock()
defer c.accountMu.Unlock()
snap := c.summarySnapshots[reqID]
delete(c.summarySnapshots, reqID)
if snap == nil {
return summarySnapshotResult{}
}
return summarySnapshotResult{values: snap.values, scopeConflict: snap.scopeConflict, settlementObservation: snap.settlementObservation}
}
// signalSummaryEnd closes the per-request done channel for the reqID
// carried by an accountSummaryEnd message ([msgID, version, reqID]).
func (c *Connection) signalSummaryEnd(fields []string) {
if len(fields) < 3 {
return
}
reqID, err := strconv.Atoi(strings.TrimSpace(fields[2]))
if err != nil {
return
}
c.accountMu.Lock()
defer c.accountMu.Unlock()
snap := c.summarySnapshots[reqID]
if snap == nil {
return
}
select {
case <-snap.done:
default:
close(snap.done)
}
}
// RequestAccountSummary starts an account-summary request for reqID. An empty
// tags string requests the package's default set of account values.
func (c *Connection) RequestAccountSummary(reqID int, tags string) error {
return c.RequestAccountSummaryForAccount(reqID, tags, c.GetAccountCode())
}
// RequestAccountSummaryForAccount starts one account-bound summary read. TWS
// still receives group "All" because account codes are not account-group
// names; every row must match expectedAccount before publication.
func (c *Connection) RequestAccountSummaryForAccount(reqID int, tags, expectedAccount string) error {
return c.requestAccountSummaryForAccountContext(context.Background(), reqID, tags, expectedAccount)
}
func (c *Connection) requestAccountSummaryForAccountContext(ctx context.Context, reqID int, tags, expectedAccount string) error {
if !c.IsConnected() {
return fmt.Errorf("not connected to IBKR")
}
expectedAccount = strings.TrimSpace(expectedAccount)
if !accountCodeConcrete(expectedAccount) {
return ErrAccountSummaryScopeConflict
}
// If no tags specified, request all important ones
if tags == "" {
tags = "NetLiquidation,BuyingPower,TotalCashValue,GrossPositionValue,UnrealizedPnL,RealizedPnL"
}
epoch := c.BrokerSessionEpoch()
c.cancelStrandedSummaries(ctx, epoch)
if err := c.summarySchedule.enterOrdinary(ctx, reqID, epoch); err != nil {
return err
}
if err := c.claimRequestID(reqID); err != nil {
c.summarySchedule.leaveOrdinary(reqID, epoch)
return err
}
c.registerSummarySnapshot(reqID, expectedAccount)
// Clear the legacy end channel to ensure we wait for new data
select {
case <-c.acctSummaryEndChan:
default:
}
// reqAccountSummary message:
// 3: group ("All" to get all accounts)
msg := c.encodeMsg(reqAccountSummary, "1", reqID, "All", tags)
if err := c.sendMessageWithTypeContextForEpochGuarded(ctx, msg, RequestTypeGeneral, epoch, true, nil); err != nil {
c.dropSummarySnapshot(reqID)
if SendDispositionOf(err) == SendDispositionDefinitelyUnsent {
c.summarySchedule.leaveOrdinary(reqID, epoch)
} else {
c.strandSummary(reqID, epoch)
}
return err
}
return nil
}
// WaitForAccountSummaryEnd waits until an account-summary request completes or
// timeout elapses.
func (c *Connection) WaitForAccountSummaryEnd(timeout time.Duration) error {
select {
case <-c.acctSummaryEndChan:
return nil
case <-time.After(timeout):
return fmt.Errorf("timeout waiting for account summary end")
}
}
// awaitAccountSummarySnapshot blocks until the gateway emits
// accountSummaryEnd for reqID (or timeout elapses) and returns only the
// accountSummary map is also fed by the streaming reqAccountUpdates
// subscription, so unrelated updates cannot overwrite this request snapshot.
func (c *Connection) awaitAccountSummarySnapshot(reqID int, timeout time.Duration) (map[string]string, AccountSettlementObservation, error) {
return c.awaitAccountSummarySnapshotContext(context.Background(), reqID, timeout)
}
func (c *Connection) awaitAccountSummarySnapshotContext(ctx context.Context, reqID int, timeout time.Duration) (map[string]string, AccountSettlementObservation, error) {
c.accountMu.RLock()
snap := c.summarySnapshots[reqID]
c.accountMu.RUnlock()
if snap == nil {
return nil, AccountSettlementObservation{}, fmt.Errorf("no account summary request registered for reqID %d", reqID)
}
select {
case <-snap.done:
result := c.dropSummarySnapshot(reqID)
if result.scopeConflict {
return nil, AccountSettlementObservation{}, ErrAccountSummaryScopeConflict
}
return result.values, result.settlementObservation, nil
case <-ctx.Done():
c.dropSummarySnapshot(reqID)
return nil, AccountSettlementObservation{}, ctx.Err()
case <-time.After(timeout):
c.dropSummarySnapshot(reqID)
return nil, AccountSettlementObservation{}, fmt.Errorf("timeout waiting for account summary end")
}
}
// CancelAccountSummary cancels the account-summary request identified by reqID.
func (c *Connection) CancelAccountSummary(reqID int) error {
if reqID <= 0 || reqID > maxProtoInt32 {
return fmt.Errorf("account-summary request ID must be a positive signed 32-bit integer")
}
if !c.IsConnected() {
return fmt.Errorf("not connected to IBKR")
}
epoch := c.BrokerSessionEpoch()
err := c.sendMessage(c.encodeMsg(cancelAccountSummary, "1", reqID))
if err == nil {
c.summarySchedule.leaveOrdinary(reqID, epoch)
}
return err
}
func (c *Connection) cancelAccountSummaryForEpoch(ctx context.Context, reqID int, epoch uint64, guard func() error) error {
msg := c.encodeMsg(cancelAccountSummary, "1", reqID)
err := c.sendMessageWithTypeContextForEpochGuarded(ctx, msg, RequestTypeGeneral, epoch, true, guard)
if err == nil {
c.summarySchedule.leaveOrdinary(reqID, epoch)
} else {
c.strandSummary(reqID, epoch)
}
return err
}
// strandSummary records a subscription that may be open at IBKR with no
// cancellation sent. Only the current session's are kept: a socket change
// ends them at IBKR and resets the schedule.
func (c *Connection) strandSummary(reqID int, epoch uint64) {
s := &c.summarySchedule
s.mu.Lock()
defer s.mu.Unlock()
if c.BrokerSessionEpoch() != epoch {
return
}
if s.ordinary == nil {
s.ordinary = make(map[int]uint64)
}
if s.stranded == nil {
s.stranded = make(map[int]uint64)
}
s.ordinary[reqID] = epoch
s.stranded[reqID] = epoch
}
// cancelStrandedSummaries re-sends this session's failed cancellations before
// a new subscription. IBKR allows a client two account-summary subscriptions;
// two stranded ones refuse every later read with code 322 until the socket
// ends. It stops at the first failure: the transport is still not accepting.
func (c *Connection) cancelStrandedSummaries(ctx context.Context, epoch uint64) {
for _, reqID := range c.summarySchedule.strandedFor(epoch) {
cancelCtx, cancel := context.WithTimeout(ctx, time.Second)
err := c.cancelAccountSummaryForEpoch(cancelCtx, reqID, epoch, nil)
cancel()
if err != nil {
return
}
}
}
// GetPositions returns a detached map containing the current position cache.
func (c *Connection) GetPositions() map[string]*RawPosition {
c.positionsMu.RLock()
defer c.positionsMu.RUnlock()
// Return a copy to prevent external modification
result := make(map[string]*RawPosition)
maps.Copy(result, c.positions)
return result
}
// GetPositionsSnapshot returns the most recent complete reqPositions result.
// It is isolated from reqAccountUpdates; nil means no completed snapshot.
func (c *Connection) GetPositionsSnapshot() map[string]*RawPosition {
c.portfolioProjectionMu.RLock()
defer c.portfolioProjectionMu.RUnlock()
if c.positionsSnapshotResult == nil {
return nil
}
result := make(map[string]*RawPosition, len(c.positionsSnapshotResult))
maps.Copy(result, c.positionsSnapshotResult)
return result
}
// GetPositionsWithPortfolioHealth captures the cached portfolio rows and the
// stream receipts under one lock order and returns detached values.
func (c *Connection) GetPositionsWithPortfolioHealth() (map[string]*RawPosition, PortfolioStreamHealth) {
c.portfolioProjectionMu.RLock()
defer c.portfolioProjectionMu.RUnlock()
c.positionsMu.RLock()
c.portfolioHealthMu.RLock()
result := make(map[string]*RawPosition, len(c.positions))
maps.Copy(result, c.positions)
health := c.portfolioHealth
c.portfolioHealthMu.RUnlock()
c.positionsMu.RUnlock()
return result, health
}
// PortfolioProjectionGeneration returns the current structural portfolio
func (c *Connection) PortfolioProjectionGeneration() uint64 {
if c == nil {
return 0
}
c.portfolioProjectionMu.RLock()
c.portfolioHealthMu.RLock()
generation := c.portfolioHealth.ProjectionGeneration
c.portfolioHealthMu.RUnlock()
c.portfolioProjectionMu.RUnlock()
return generation
}
// GetPosition returns the cached position for key, if present.
func (c *Connection) GetPosition(key string) (*RawPosition, bool) {
c.positionsMu.RLock()
defer c.positionsMu.RUnlock()
pos, exists := c.positions[key]
return pos, exists
}
// GetAccountCode returns the last known managed account code.
func (c *Connection) GetAccountCode() string {
c.accountMu.RLock()
defer c.accountMu.RUnlock()
return c.account
}
// GetAccountSummary returns a detached copy of the current account-summary
func (c *Connection) GetAccountSummary() map[string]string {
c.accountMu.RLock()
defer c.accountMu.RUnlock()
// Return a copy to prevent external modification
result := make(map[string]string)
maps.Copy(result, c.accountSummary)
return result
}
// GetAccountValue returns the cached account value for key, if present.
func (c *Connection) GetAccountValue(key string) (string, bool) {
c.accountMu.RLock()
defer c.accountMu.RUnlock()
value, exists := c.accountSummary[key]
return value, exists
}
// RequestAccountUpdates subscribes to streaming account and portfolio updates
// for account.
func (c *Connection) RequestAccountUpdates(account string) error {
if !c.IsConnected() {
return fmt.Errorf("not connected to IBKR")
}
// A multi-account login's managedAccounts value is a list, not a code.
// PortfolioStreamHealth.Account and leave every concrete-account check
bound := strings.TrimSpace(account)
if !accountCodeConcrete(bound) {
bound = strings.TrimSpace(c.GetAccountCode())
}
if !accountCodeConcrete(bound) {
bound = ""
}
c.resetPortfolioStreamHealth(bound, time.Now().UTC())
msg := c.encodeMsg(reqAcctData, "2", "1", account)
return c.sendMessage(msg)
}
// RequestCurrentTime asks the gateway for its current time as a heartbeat.
func (c *Connection) RequestCurrentTime() error {
if !c.IsConnected() {
return fmt.Errorf("not connected to IBKR")
}
msg := c.encodeMsg(reqCurrentTime, "1")
return c.rateLimiter.SubmitWithRetries(RequestTypeHeartbeat, func() error {
return c.withTransport(false, func() error {
lengthBytes := make([]byte, 4)
binary.BigEndian.PutUint32(lengthBytes, uint32(len(msg)))
if _, err := c.writer.Write(lengthBytes); err != nil {
return err
}
if _, err := c.writer.Write(msg); err != nil {
return err
}
return c.writer.Flush()
})
}, 0) // No retries: heartbeat failures should surface immediately
}
// pauseTransport prevents non-handshake writers from accessing the socket.
func (c *Connection) pauseTransport() {
if c.transportCond == nil {
return
}
c.transportMu.Lock()
c.transportPaused = true
c.transportMu.Unlock()
}
// beginOutboundSession invalidates the prior outbound socket generation and
// generation may be published only by activateOutboundSession.
func (c *Connection) beginOutboundSession() uint64 {
state := c.publishRevokedOutboundSession()
if c.transportCond == nil {
return state
}
c.transportMu.Lock()
c.transportPaused = true
c.transportCond.Broadcast() // senders of the revoked generation stop waiting
c.transportMu.Unlock()
return state
}
// activateOutboundSession publishes a successfully handshaken generation.
func (c *Connection) activateOutboundSession(state uint64) bool {
if c.transportCond == nil {
return state&1 != 0 && c.outboundSessionState.CompareAndSwap(state, state&^uint64(1))
}
c.transportMu.Lock()
defer c.transportMu.Unlock()
if state&1 == 0 || !c.outboundSessionState.CompareAndSwap(state, state&^uint64(1)) {
return false
}
c.transportPaused = false
c.transportCond.Broadcast()
return true
}
// invalidateOutboundSession revokes protected-write authority under the same
func (c *Connection) invalidateOutboundSession(pause bool) {
// Publish revocation before waiting for a writer that already owns
c.publishRevokedOutboundSession()
if c.transportCond == nil {
return
}
c.transportMu.Lock()
c.transportPaused = pause
// Paused or not, senders of the revoked generation stop waiting.
c.transportCond.Broadcast()
c.transportMu.Unlock()
}
func (c *Connection) publishRevokedOutboundSession() uint64 {
for {
old := c.outboundSessionState.Load()
nextGeneration := (old >> 1) + 1
next := (nextGeneration << 1) | 1
if c.outboundSessionState.CompareAndSwap(old, next) {
return next
}
}
}
// resumeTransport unblocks any goroutines waiting to send IBKR messages.
func (c *Connection) resumeTransport() {
if c.transportCond == nil {
return
}
c.transportMu.Lock()
if !c.transportPaused {
c.transportMu.Unlock()
return
}
c.transportPaused = false
c.transportCond.Broadcast()
c.transportMu.Unlock()
}
// withTransport provides exclusive, sequential access to the underlying writer.
func (c *Connection) withTransport(allowDuringPause bool, fn func() error) error {
if c.transportCond == nil {
return fn()
}
c.transportMu.Lock()
for c.transportPaused && !allowDuringPause {
c.transportCond.Wait()
}
defer c.transportMu.Unlock()
return fn()
}
// errOutboundSessionEnded refuses a send whose session ended while it waited
// behind a paused transport.
var errOutboundSessionEnded = errors.New("broker session ended while the send waited for the transport")
// withSessionTransport is withTransport for a send admitted while the
// outbound session generation was admitted's. A paused transport resumes
// only when this Connection reconnects, and the daemon redials on a new
// Connection, so a sender of an ended generation would wait forever: on
// 2026-10-27 one such history read held the breadth refresh open past the
// session loss. It fails unsent instead; its request id belonged to the ended
// session's namespace anyway. A pause within the admitted generation (a
// handshake not yet activated) is still waited out.
func (c *Connection) withSessionTransport(admitted uint64, fn func() error) error {
if c.transportCond == nil {
return fn()
}
c.transportMu.Lock()
defer c.transportMu.Unlock()
for c.transportPaused && c.outboundSessionState.Load()>>1 == admitted>>1 {
c.transportCond.Wait()
}
if c.transportPaused {
return errOutboundSessionEnded
}
return fn()
}
// RegisterHandler adds a handler for msgID and returns the identifier accepted
func (c *Connection) RegisterHandler(msgID int, handler func([]string)) uint64 {
if handler == nil {
return 0
}
c.handlersMu.Lock()
c.handlerSeq++
entry := handlerEntry{id: c.handlerSeq, fn: handler}
c.msgHandlers[msgID] = append(c.msgHandlers[msgID], entry)
c.handlersMu.Unlock()
return entry.id
}
// tapInboundFrames arms fn to see every current-session inbound frame,
// fields[0] its message id, before the frame's own handlers run, until
// release. It is for diagnostics only: fn must not block or change fields.
func (c *Connection) tapInboundFrames(fn func(fields []string, epoch uint64)) (release func()) {
id := c.RegisterHandlerAtEpoch(msgInboundTap, fn)
c.inboundTaps.Add(1)
return sync.OnceFunc(func() {
c.UnregisterHandler(msgInboundTap, id)
c.inboundTaps.Add(-1)
})
}
// RegisterHandlerAtEpoch adds a handler that receives the socket epoch that
// the Connection reader observed; register before the corresponding request.
func (c *Connection) RegisterHandlerAtEpoch(msgID int, handler func([]string, uint64)) uint64 {
if handler == nil {
return 0
}
c.handlersMu.Lock()
c.handlerSeq++
entry := handlerEntry{id: c.handlerSeq, fnAtEpoch: handler}
c.msgHandlers[msgID] = append(c.msgHandlers[msgID], entry)
c.handlersMu.Unlock()
return entry.id
}
// GetNextRequestID reserves and returns the next connection-local request ID.
// broker error can never be reinterpreted as a later order event. Zero means
// the signed 32-bit broker namespace is exhausted.
func (c *Connection) GetNextRequestID() int {
reqID, _, err := c.reserveNextRequestID()
if err != nil {
return 0
}
return reqID
}
func (c *Connection) reserveNextRequestID() (int, uint64, error) {
c.reqIDMu.Lock()
defer c.reqIDMu.Unlock()
return c.reserveNextRequestIDLocked()
}
func (c *Connection) reserveNextRequestIDForEpoch(expectedEpoch uint64) (int, uint64, error) {
c.reqIDMu.Lock()
defer c.reqIDMu.Unlock()
if epoch := c.brokerSessionEpoch.Load(); epoch != expectedEpoch {
return 0, epoch, fmt.Errorf("%w: broker socket generation changed before request ID reservation", ErrBrokerIDNamespaceConflict)
}
return c.reserveNextRequestIDLocked()
}
func (c *Connection) reserveNextRequestIDLocked() (int, uint64, error) {
epoch := c.brokerSessionEpoch.Load()
if !c.haveNextValidID || c.brokerIDExhausted {
return 0, epoch, fmt.Errorf("broker request/order id namespace unavailable")
}
reqID := max(c.reqIDSeq, c.nextOrderID)
if reqID <= 0 {
reqID = 1
}
if reqID > maxProtoInt32 {
c.brokerIDExhausted = true
return 0, epoch, fmt.Errorf("broker request/order id namespace unavailable")
}
if reqID == maxProtoInt32 {
c.brokerIDExhausted = true
} else {
c.reqIDSeq = reqID + 1
}
c.reservedRequestIDs[reqID] = struct{}{}
return reqID, epoch, nil
}
func (c *Connection) nextRequestID() (int, error) {
id, epoch, err := c.reserveNextRequestID()
if err != nil {
return 0, err
}
if _, err := c.claimRequestIDForEpoch(id, epoch); err != nil {
return 0, err
}
return id, nil
}
func (c *Connection) nextRequestIDForForwarding() (int, error) {
id, _, err := c.nextRequestIDForForwardingWithEpoch()
return id, err
}
func (c *Connection) nextRequestIDForForwardingWithEpoch() (int, uint64, error) {
return c.reserveNextRequestID()
}
// discardRequestIDReservation drops retry provenance without moving the
// frontier backward. It is used when an internally generated request ID never
// reaches the Connection claim/send boundary.
func (c *Connection) discardRequestIDReservation(id int) {
if c == nil || id <= 0 {
return
}
c.reqIDMu.Lock()
delete(c.reservedRequestIDs, id)
c.reqIDMu.Unlock()
}
func (c *Connection) discardOrderIDReservation(id int) {
if c == nil || id <= 0 {
return
}
c.reqIDMu.Lock()
delete(c.reservedOrderIDs, id)
c.reqIDMu.Unlock()
}
func (c *Connection) claimRequestID(id int) error {
_, err := c.claimRequestIDCurrentEpoch(id)
return err
}
func (c *Connection) claimRequestIDCurrentEpoch(id int) (uint64, error) {
if id <= 0 || id > maxProtoInt32 {
return 0, fmt.Errorf("%w: request ID is outside the signed 32-bit broker namespace", ErrBrokerIDNamespaceConflict)
}
c.reqIDMu.Lock()
defer c.reqIDMu.Unlock()
return c.claimRequestIDLocked(id, nil)
}
func (c *Connection) claimRequestIDForEpoch(id int, expectedEpoch uint64) (uint64, error) {
if id <= 0 || id > maxProtoInt32 {
return 0, fmt.Errorf("%w: request ID is outside the signed 32-bit broker namespace", ErrBrokerIDNamespaceConflict)
}
c.reqIDMu.Lock()
defer c.reqIDMu.Unlock()
return c.claimRequestIDLocked(id, &expectedEpoch)
}
func (c *Connection) claimRequestIDLocked(id int, expectedEpoch *uint64) (uint64, error) {
epoch := c.brokerSessionEpoch.Load()
if expectedEpoch != nil && epoch != *expectedEpoch {
return epoch, fmt.Errorf("%w: broker socket generation changed before request ID claim", ErrBrokerIDNamespaceConflict)
}
if !c.haveNextValidID {
return epoch, fmt.Errorf("%w: nextValidId has not established the current socket request namespace", ErrBrokerIDNamespaceConflict)
}
_, reserved := c.reservedRequestIDs[id]
if !reserved {
frontier := max(c.reqIDSeq, c.nextOrderID)
if c.brokerIDExhausted || id < frontier {
return epoch, fmt.Errorf("%w: explicit request ID is behind the consumed request/order frontier", ErrBrokerIDNamespaceConflict)
}
}
delete(c.reservedRequestIDs, id)
if id >= maxProtoInt32 {
c.brokerIDExhausted = true
return epoch, nil
}
next := id + 1
if c.reqIDSeq < next {
c.reqIDSeq = next
}
if c.nextOrderID < next {
c.nextOrderID = next
}
return epoch, nil
}
func (c *Connection) orderIDOwned(id int) bool {
if id <= 0 {
return false
}
c.ordersMu.RLock()
_, open := c.openOrders[id]
c.ordersMu.RUnlock()
return open || c.IsWhatIfOrderID(id)
}
func (c *Connection) claimOrderIDCurrentEpoch(id int, alreadyOwned bool) (uint64, error) {
if id <= 0 || id > maxProtoInt32 {
return 0, fmt.Errorf("%w: order ID is outside the signed 32-bit broker namespace", ErrBrokerIDNamespaceConflict)
}
c.reqIDMu.Lock()
defer c.reqIDMu.Unlock()
return c.claimOrderIDLocked(id, alreadyOwned, nil, nil, nil)
}
func (c *Connection) claimOrderIDForEpoch(id int, alreadyOwned bool, expectedEpoch uint64) (uint64, error) {
if id <= 0 || id > maxProtoInt32 {
return 0, fmt.Errorf("%w: order ID is outside the signed 32-bit broker namespace", ErrBrokerIDNamespaceConflict)
}
c.reqIDMu.Lock()
defer c.reqIDMu.Unlock()
return c.claimOrderIDLocked(id, alreadyOwned, &expectedEpoch, nil, nil)
}
// claimOrderIDLocked claims an explicit order ID on the current socket
// generation. Below the frontier it accepts an ID reserved for this send, one
// this process placed (alreadyOwned), or one the broker reported on this
// generation as an open order of this client whose report names change, the
// order the caller means to change (#120); without change a report never
// suffices, and a send that names change claims only through ownership or
// the report, never because the ID lies at or above the frontier. fresh, when
// not nil, reports whether the ID was claimed as a new order's (no change
// named; reserved for it, or at or above the frontier) rather than as an
// order this process placed or the broker reported.
func (c *Connection) claimOrderIDLocked(id int, alreadyOwned bool, expectedEpoch *uint64, change *OrderChangeIdentity, fresh *bool) (uint64, error) {
epoch := c.brokerSessionEpoch.Load()
if expectedEpoch != nil && epoch != *expectedEpoch {
return epoch, fmt.Errorf("%w: broker socket generation changed before order ID claim", ErrBrokerIDNamespaceConflict)
}
if !c.haveNextValidID {
return epoch, fmt.Errorf("%w: nextValidId has not established the current socket order namespace", ErrBrokerIDNamespaceConflict)
}
if _, requestReserved := c.reservedRequestIDs[id]; requestReserved {
return epoch, fmt.Errorf("%w: order ID is owned by a reserved read-only request", ErrBrokerIDNamespaceConflict)
}
_, reserved := c.reservedOrderIDs[id]
report, reported := c.reportedOwnOrderIDs[id]
reported = reported && report.matches(change)
ahead := !c.brokerIDExhausted && id >= max(c.reqIDSeq, c.nextOrderID)
switch {
case change != nil && !alreadyOwned && !reported:
return epoch, fmt.Errorf("%w: the order to change is neither this process's nor reported by the broker for this client on the current connection", ErrBrokerIDNamespaceConflict)
case !reserved && !alreadyOwned && !reported && !ahead:
return epoch, fmt.Errorf("%w: explicit order ID is behind the consumed request/order frontier", ErrBrokerIDNamespaceConflict)
}
if fresh != nil {
*fresh = change == nil && (reserved || ahead)
}
delete(c.reservedOrderIDs, id)
if id >= maxProtoInt32 {
c.brokerIDExhausted = true
return epoch, nil
}
next := id + 1
if c.reqIDSeq < next {
c.reqIDSeq = next
}
if c.nextOrderID < next {
c.nextOrderID = next
}
return epoch, nil
}
// claimOrderIDForForwardingAtEpoch validates a Connector-level explicit ID
// and leaves one exact reservation for the subsequent Connection place/WhatIf
// encoder.
func (c *Connection) claimOrderIDForForwardingAtEpoch(id int, alreadyOwned bool, expectedEpoch *uint64, change *OrderChangeIdentity) (uint64, error) {
epoch, _, err := c.claimOrderIDForForwardingAtEpochFresh(id, alreadyOwned, expectedEpoch, change)
return epoch, err
}
// claimOrderIDForForwardingAtEpochFresh is claimOrderIDForForwardingAtEpoch
// that also reports whether the ID was claimed as a new order's.
func (c *Connection) claimOrderIDForForwardingAtEpochFresh(id int, alreadyOwned bool, expectedEpoch *uint64, change *OrderChangeIdentity) (uint64, bool, error) {
if id <= 0 || id > maxProtoInt32 {
return 0, false, fmt.Errorf("%w: order ID is outside the signed 32-bit broker namespace", ErrBrokerIDNamespaceConflict)
}
c.reqIDMu.Lock()
defer c.reqIDMu.Unlock()
var fresh bool
epoch, err := c.claimOrderIDLocked(id, alreadyOwned, expectedEpoch, change, &fresh)
if err != nil {
return 0, false, err
}
c.reservedOrderIDs[id] = struct{}{}
return epoch, fresh, nil
}
func (c *Connection) reserveRequestID(allowed func(int) bool) (int, error) {
id, _, err := c.reserveRequestIDWithEpoch(allowed)
return id, err
}
func (c *Connection) reserveRequestIDWithEpoch(allowed func(int) bool) (int, uint64, error) {
const maxRequestIDReservations = 1024
for range maxRequestIDReservations {
candidate, epoch, err := c.reserveNextRequestID()
if err != nil {
return 0, epoch, err
}
if allowed != nil && !allowed(candidate) {
c.reqIDMu.Lock()
delete(c.reservedRequestIDs, candidate)
c.reqIDMu.Unlock()
continue
}
if _, err := c.claimRequestIDForEpoch(candidate, epoch); err != nil {
return 0, epoch, err
}
return candidate, epoch, nil
}
return 0, 0, fmt.Errorf("broker request id namespace unavailable")
}
// scanMessages is a split function for the scanner to handle IBKR messages
func (c *Connection) scanMessages(data []byte, atEOF bool) (advance int, token []byte, err error) {
if atEOF && len(data) == 0 {
return 0, nil, nil
}
// Need at least 4 bytes for the length
if len(data) < 4 {
return 0, nil, nil
}
// Read message length
msgLength := int(binary.BigEndian.Uint32(data[:4]))
totalLength := 4 + msgLength
// Check if we have the complete message
if len(data) < totalLength {
return 0, nil, nil
}
// Return the message (without length prefix)
return totalLength, data[4:totalLength], nil
}
package ibkr
import (
"context"
"errors"
"fmt"
"maps"
"math"
"math/big"
"os"
"path/filepath"
"slices"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/osauer/canary/v2/internal/logepisode"
"github.com/osauer/canary/v2/pkg/ibkr/internal/logging"
)
var connectorLogger = logging.Component("IBKR Connector")
var marketDataLogger = logging.Component("IBKR MarketData")
// OptionSubscriptionGenericTicks requests option volume, OI, IV, and history.
const OptionSubscriptionGenericTicks = "100,101,104,106"
// OptionOpenInterestGenericTick requests call and put open interest.
const OptionOpenInterestGenericTick = "101"
const (
// OptionModelDataTypeLive identifies live/frozen model tick 13.
OptionModelDataTypeLive = 1
// OptionModelDataTypeDelayed identifies delayed model tick 83.
OptionModelDataTypeDelayed = 3
)
// ErrSymbolInactive indicates IBKR has reported the contract is unavailable (e.g., delisted).
var ErrSymbolInactive = errors.New("symbol marked inactive")
// ErrContractDetailsTimeout indicates contract details did not arrive in time.
var ErrContractDetailsTimeout = errors.New("timeout waiting for contract details")
// ErrContractNoDefinition is IBKR's definitive missing-contract verdict.
var ErrContractNoDefinition = errors.New("no security definition for contract")
// ErrBrokerIDNamespaceConflict reports that an explicit broker order/WhatIf
// ID is still owned by an open read-only request. The broker-adjacent
// operation is refused before local indexing or wire send and may be retried.
var ErrBrokerIDNamespaceConflict = errors.New("broker id namespace conflict")
// Connector owns one broker connection and its in-memory subscriptions and caches.
type Connector struct {
// marketRules caches price rules by broker session and rule ID.
marketRulesMu sync.Mutex
marketRules map[marketRuleKey][]PriceIncrement
// A failed cancellation retains the read subscription until the session
// changes; repeated refreshes must not accumulate live broker streams.
positionsRefreshMu sync.Mutex
positionsRefreshUncancelled ConnectorSessionBinding
display displayChanges
name string
config *ConnectorConfig
conn *Connection
fetchContractDetails func(string, time.Duration) ([]ContractDetailsLite, error)
contractTimingHook func(string, time.Duration, bool)
resolveWSHContract func(context.Context, string, time.Duration) (*ContractDetailsLite, error)
resolveWSHExactContract func(context.Context, string, int, time.Duration) (*ContractDetailsLite, error)
wshNow func() time.Time
wshGateMu sync.Mutex
wshGate chan struct{}
wshMetadataConn *Connection
wshMetadataReadyAt time.Time
wshEarningsEventTag string
// Component state
running bool
lastError error
mu sync.RWMutex
ready bool // true after handlers registered and startup completes
// Market data subscriptions
subscriptions map[string]*Subscription
reqIDMap map[int]string // Maps request IDs to symbols or routed subscription keys
subMu sync.RWMutex
exactQuoteSeq atomic.Uint64
optionPending map[string]*optionSubscriptionFlight // protected by subMu
// Order management
openOrders map[string]*trackedOrder
brokerOrderIndex map[string]string // IB order ID -> internal order ID
// placedOrderIDs are the broker order IDs this Connector placed as new
// orders (PlacedOrderID); an ID it only changed is not among them.
placedOrderIDs map[int]struct{}
// orderIDHighWater is the bounded monotonic broker-ID reservation frontier.
orderIDHighWater int
orderMu sync.RWMutex
orderLifecycleMu sync.RWMutex
orderLifecycle []orderLifecycleHandlerEntry
// handlerRegistration guards the one-time base-handler installation on a
// concrete Connection. Installation happens before Connect starts the wire
// reader, so lifecycle frames never need a lossy/reordering startup queue.
handlerRegistrationMu sync.Mutex
handlerRegistrations map[*Connection]struct{}
// orderLifecycleGeneration advances for every non-WhatIf order lifecycle
// callback accepted by this Connector. Inventory captures compare this value
// so an intervening lifecycle receipt cannot authorize a false negative.
orderLifecycleGeneration atomic.Uint64
// evidenceBarrier linearizes socket/order receipts with structural reads.
evidenceBarrier sync.RWMutex
// publicationBarrier linearizes daemon connector publication against the
// final transport section of a protected broker operation. It is separate
// from evidenceBarrier and its read side is acquired only after pacing and
// transport admission, so a paused sender cannot deadlock unpublication.
publicationBarrier sync.RWMutex
// brokerIDNamespaceMu serializes FEE ownership with the shared broker-ID
// frontier; Connection.reqIDMu remains the allocation authority.
brokerIDNamespaceMu sync.Mutex
// whatIfBeforeBrokerIDClaim is a deterministic rollover seam. Production
// leaves it nil; tests pause before the broker-ID claim.
whatIfBeforeBrokerIDClaim func()
// Open-order snapshot plumbing is an epoch-bound single-flight because
// reqAllOpenOrders has no request ID, so timed-out flights remain poisoned.
requestAllOpenOrders func() error // test seam; nil uses the bound Connection
openOrderSnapshotMu sync.Mutex
openOrderSnapshot *openOrderSnapshotFlight
openOrderSnapshotPoison openOrderSnapshotBinding
openOrderSnapshotPoisonAt time.Time
openOrderSnapshotPoisonErr error
// openOrderSnapshotDone is the session of the last complete snapshot.
openOrderSnapshotDone openOrderSnapshotBinding
openOrderSnapshotDoneAt time.Time
openOrderSnapshotTimeout time.Duration
openOrderSnapshotBeforeSend func()
// executions are the in-flight reqExecutions answers keyed by request ID;
// execDetails that carry one of these IDs are a requested inventory, not a
// live fill, and are routed here instead of to the lifecycle handlers.
executionsMu sync.Mutex
executions map[int]*executionsFlight
// executionsExpired holds the request IDs whose flight ended without its
// execDetailsEnd, with the time it ended, so a late end is told apart
// from a message nobody asked for.
executionsExpired map[int]time.Time
// orderStatusLogSig dedupes the high-frequency order-status log line.
// IBKR re-sends unchanged orderStatus frames many times per second. Keep one
// signature per broker order ID and demote verbatim repeats to debug so
// INFO carries only genuine transitions. Purely a logging concern —
// the log path never widens the orderMu critical section.
orderStatusLogMu sync.Mutex
orderStatusLogSig map[string]string
// Lightweight contract details cache to improve routing during OOH sessions
contractMu sync.RWMutex
contractCache map[string]ContractDetailsLite
inactiveMu sync.RWMutex
inactiveSymbols map[string]inactiveSymbolState
inactiveCandidates map[string]inactiveCandidateState
// reviewedTerminal is the caller-owned set of stocks with reviewed
// terminal evidence (see SetReviewedTerminal). Unlike inactiveSymbols it
// survives connection loss and carries its reviewed evidence deadline.
reviewedTerminal map[string]ReviewedTerminalStock
// contractDetailsFlights coalesces identical unresolved contract requests.
// The broker sees one reqContractDetails and concurrent callers whose wait
// budgets remain open see the same terminal result; exact identities stay separate.
contractDetailsFlightMu sync.Mutex
contractDetailsFlights map[string]*contractDetailsFlight
// frontFutures retains completed identities only within a broker session.
// It also owns bounded acquisition when an individual caller stops waiting.
frontFutureMu sync.Mutex
frontFutures map[string]*frontFutureResolution
frontFutureActive int
// contractWarningState bounds repeated unresolved-contract warning lines.
contractWarningMu sync.Mutex
contractWarningState map[string]contractWarningState
contractWarningNow func() time.Time
// mktDataAbsent remembers terminal rejections without persisting entitlements.
absenceMu sync.Mutex
mktDataAbsent map[string]marketDataAbsence
absenceNow func() time.Time
// entitlementGapWarned records, per instrument (entitlementGapNoticeKey),
// when a terminal 354 first warned; repeats inside entitlementGapMemory
// log at INFO.
// Guarded by absenceMu.
entitlementGapWarned map[string]time.Time
// contractMisses rate-bounds re-resolution of symbols the broker answered
// "no security definition" for. Unlike inactiveSymbols this is not a
// verdict — only an escalating probe backoff — so it stays effective while
// a data-farm impairment vetoes inactive marking (the state in which an
// unresolvable held symbol was observed retrying at full poll rate).
contractMissMu sync.Mutex
contractMisses map[string]contractResolutionMiss
contractMissNow func() time.Time
// marketDataModeMu serializes connection-global reqMarketDataType changes.
marketDataModeMu sync.Mutex
// acctUpdatesMu guards the account-updates resubscribe throttle (see
// maybeResubscribeAccountUpdates) and the stream's current account.
acctUpdatesMu sync.Mutex
acctUpdatesLastAt time.Time
acctUpdatesAccount string
acctUpdatesNow func() time.Time
// pnlResubMu guards the daily-P&L resubscribe throttle.
pnlRepairMu sync.Mutex
pnlResubMu sync.Mutex
pnlResubLastAt time.Time
pnlResubNow func() time.Time
pnlSilenceLog logepisode.State
// backendConnMu guards backend-link health; a disconnected backend cannot
// carry a locally accepted broker operation. The counters make chronic
// link flapping (dozens of loss/restore cycles a night) one first-class
// status observation instead of a scattering of repeated warnings, and
// the episode state coalesces the log to one disturbance per summary —
// see backend_link.go.
backendConnMu sync.Mutex
backendConnDown bool
backendConnAt time.Time
backendConnLosses int
backendConnLossesInWindow int
backendConnLastOutage time.Duration
backendConnLongestOutage time.Duration
maintWindows []MaintenanceWindow
backendSessionOpen func(time.Time) bool
backendLogRequired func(time.Time, time.Time) bool
backendLossWarned bool
backendEpisodeWarned bool
epActive bool
epStart time.Time
epLastRestore time.Time
epLosses int
epLossesInWindow int
epLongest time.Duration
epTimer *time.Timer
// epGen invalidates an already-fired finalize timer that lost the mutex
// race against a new loss extending the episode.
epGen int
epGap time.Duration // test override; 0 means backendEpisodeGap
// mdReplayInFlight collapses concurrent code-1101 subscription replays.
mdReplayInFlight atomic.Bool
// Option IV tracking (by underlying symbol or per-contract key)
optMu sync.RWMutex
optIV map[string]float64 // last observed implied vol (fraction, e.g., 0.30)
optIVDataType map[string]int // model-tick source: 1=tick 13, 3=tick 83; absent for generic tick 24
optReqIDs map[int]string // option reqID -> underlying or option market-data key
optQuoteBid map[string]float64 // last observed option bid per underlying
optQuoteAsk map[string]float64 // last observed option ask per underlying
optPrevClose map[string]float64 // tick 9 on the option contract itself (NOT the underlying)
optGreeks map[string]Greeks // last observed model-computation greeks per option key
optUnderlyingPx map[string]float64 // model-computation underlying price per option key
// In-flight reqContractDetails requests keyed by reqID let terminal notices
// fail the pending wait instead of burning the caller's whole budget and
// reported a timeout, disguising the broker's definitive answer as a
// transient) and never reached inactive marking.
contractDetailsMu sync.Mutex
contractDetailsReqs map[int]*contractDetailsRequest
// Historical data requests (HMDS)
historicalMu sync.Mutex
historicalReqs map[int]*historicalRequest
historicalBackoff map[string]int
historicalExactFlights map[string]*historicalExactFlight
historicalRouteReqs map[int]chan error
historicalNow func() time.Time
// dataFarms records the latest notice per farm; status exposes unhealthy rows.
dataFarmMu sync.RWMutex
dataFarms map[string]DataFarmStatus
farmRecoveryAt time.Time
// farmLog dedupes farm transition lines with the daemon's other lanes.
farmLog atomic.Pointer[FarmTransitionLog]
// historicalStall notices a Gateway that holds history requests unanswered.
historicalStall historicalStall
// answers keeps the answer and timeout clocks behind AnswerPath.
answers answerClock
// pnl owns account and per-contract P&L subscriptions and is never nil.
pnl *pnlCache
}
// DataFarmStatus describes the latest notice observed for one IBKR data farm.
type DataFarmStatus struct {
Name string
Type string
Status string
Code int
Message string
AsOf time.Time
// impairedSince is when the current break began; repeated break notices
// keep it while AsOf follows the latest notice.
impairedSince time.Time
// warned marks a break that has lasted dataFarmBreakAttention and drawn
// its one WARN; its recovery then warns too, with the duration.
warned bool
}
// dataFarmBreakAttention is how long a data-farm break lasts before it is
// worth a WARN. IBKR's maintenance windows flap farms for seconds at a time,
// two lines per farm per flap (over forty on the weekend morning of
// 2026-10-04); a break that outlives this is the one that holds requests.
const dataFarmBreakAttention = 5 * time.Minute
// DataFarmStatuses returns a detached snapshot of tracked farm notices.
func (c *Connector) DataFarmStatuses() []DataFarmStatus {
if c == nil {
return nil
}
c.dataFarmMu.RLock()
out := make([]DataFarmStatus, 0, len(c.dataFarms))
for _, farm := range c.dataFarms {
out = append(out, farm)
}
c.dataFarmMu.RUnlock()
sort.SliceStable(out, func(i, j int) bool {
if out[i].Type != out[j].Type {
return out[i].Type < out[j].Type
}
return out[i].Name < out[j].Name
})
return out
}
// ConnectorConfig configures the single [Connection] owned by a [Connector].
type ConnectorConfig struct {
// Deprecated: ServiceName is retained for v2 source compatibility and has no effect.
ServiceName string
PreferredClientID int
BaseConfig *ConnectionConfig
// OpenOrderSnapshotTimeout bounds the wait for an open-order snapshot's
// openOrderEnd before the socket generation is poisoned; zero uses 15 s.
OpenOrderSnapshotTimeout time.Duration
}
// Subscription holds the latest values for one streaming market-data request.
type Subscription struct {
exactContract Contract
exactSession ConnectorSessionBinding
optionRisk *OptionRiskMeasurement
LastAt, BidAt, AskAt, MarkAt, CloseAt time.Time
Symbol string
// SessionEpoch is set for exact-session subscriptions. Zero identifies a
// legacy/shared subscription that cannot satisfy broker-write authority.
SessionEpoch uint64
// Right is the normalized option right for option-leg subscriptions.
Right string
ReqID int
Fields []string
LastPrice float64
Bid float64
Ask float64
// MarkPrice is tick 37, which may be the only price for some indices.
MarkPrice float64
BidSize int64
AskSize int64
Volume int64
VolumeObserved bool
VolumeAt time.Time
AvgVolume int64
// OpenInt is the option open interest at this contract: tick 27
// the opposite right, so only the tick matching Right is committed.
OpenInt int64
OpenIntObserved bool
// ShortableShares is wire tick 89 (a tickSize), delivered for the
// generic-tick-236 request; ShortableObserved distinguishes a real zero.
ShortableShares int64
ShortableObserved bool
// ShortableTickAt is when this process last received shortable-share tick 89.
ShortableTickAt time.Time
PrevClose float64
Open float64
High float64
Low float64
// Week-range highs/lows arrive via generic tick 165.
Week13Low float64
Week13High float64
Week26Low float64
Week26High float64
Week52Low float64
Week52High float64
// LastTradeTime is the Unix timestamp carried by tick-string type 45 or 88.
LastTradeTime time.Time
// LastTickAt is when this process last received a tick message from the
// gateway on this subscription. Unlike LastTime it is never seeded at
// subscribe time and never advanced by subscription bookkeeping, so a
// live receipt from subscription bookkeeping.
LastTickAt time.Time
// LastPriceTickAt is when this process last accepted a positive price tick
// from the gateway on this subscription. It is never seeded at subscribe
// volume, IV, or last-trade-time ticks. RTVolume advances it only when that
// broker value was struck; frozen data can arrive now with an older trade time.
LastPriceTickAt time.Time
// IV is the option implied volatility tick (generic tick 106), present
// only when requested, stored as a fraction such as 0.30.
IV float64
// bidYield, askYield and lastYield are a bond's yield ticks (bond.go);
// nil until the gateway sends one.
bidYield, askYield, lastYield *float64
// LastTime is the subscription re-request staleness clock.
LastTime time.Time
Observed bool // true once we receive any tick for this reqID
// RejectCh receives a [SubscriptionRejection] when the gateway returns
// 10197) — "the subscription will never produce ticks" semantics.
// Buffered one; the receipt producer never blocks on a full channel.
RejectCh chan SubscriptionRejection
// Option readers share one line until the final matching reader leaves.
optionReaders int
optionSession ConnectorSessionBinding
// replaySpec records the wire form of this subscription so the 1101
// backend-recovery path can re-issue it: IBKR code 1101 means the
// TWS<->IBKR link was restored with server-side subscriptions LOST,
// so the old reqID will not tick again. Nil for exact-session subscriptions.
replaySpec *mdReplaySpec
// replayedAfter10197 bounds competing-session recovery to one replay.
replayedAfter10197 bool
delayedFallback bool
delayedTicks bool
liveRetryTimer *time.Timer
liveRetryAt time.Time // when liveRetryTimer re-probes the live feed
previousQuote *Subscription
previousDataType int
// rejectedReqID records the reqID the gateway reported dead via a
// tears the ticker down itself, so a wire CancelMarketData for that
// exact reqID only draws error 300 "Can't find EId". Stored as the
// wireCancelNeeded.
rejectedReqID int
// fallbackRefusal is the 354 that moved this line to the delayed feed.
// MarketDataAbsences discloses it once the line serves a delayed price and
// no suppressing absence record names the key, as after a farm-impairment
// veto; it never gates a request.
fallbackRefusal *marketDataAbsence
}
// wireCancelNeeded reports whether sub still names a gateway-side
// subscription that needs a wire CancelMarketData on teardown. False when
// the gateway already killed this exact reqID via a terminal rejection —
// case, never skipped (2026-05-21 slot-leak lesson).
func wireCancelNeeded(sub *Subscription) bool {
return sub != nil && sub.ReqID != 0 && sub.ReqID != sub.rejectedReqID
}
// SubscriptionRejection records a terminal IBKR error for a market-data
// subscription. Message is untrusted broker text.
type SubscriptionRejection struct {
Code int
Message string
}
// terminalSubscriptionErrorCodes is the set of IBKR error codes that
// guarantee the subscription will never produce ticks. The error handler
// - 200 "No security definition has been found for the request"
func isTerminalSubscriptionError(code int) bool {
switch code {
case 200, 320, 321, 322, 354, 10197:
return true
}
return false
}
// marketDataAbsenceRetry bounds terminal-rejection suppression.
const marketDataAbsenceRetry = 30 * time.Minute
// entitlementGapMemory bounds how long a warned 354 keeps its repeat probes at
// INFO. A gap the account closes mid-day is honestly re-probed at the next
// retry window either way; this only decides the log level of the echo.
const entitlementGapMemory = 24 * time.Hour
// exactQuoteKeyMarker separates an exact-session quote's per-request sequence
// from its instrument key (SubscribeMarketDataWithContractForSession).
const exactQuoteKeyMarker = "|EXACT:"
// entitlementGapKey is the warned-gap memory's key for a subscription key,
// used for a request without a recorded contract and for gaps recorded before
// 2026-10-12 (see entitlementGapInstrumentKey for the live key). An
// exact-session quote's key carries a fresh sequence per request, so the
// memory drops it: every preview of one instrument then shares one gap. Only
// the log memory is keyed this way; the absence verdict stays per request, so
// a gap recorded here never blocks an order-preview quote.
func entitlementGapKey(key string) string {
base, _, _ := strings.Cut(key, exactQuoteKeyMarker)
return base
}
// entitlementGapInstrumentKey names the instrument a 354 refused, from the
// contract the request carried: symbol, security type and currency, e.g.
// "NDX IND USD" or "SPX OPT USD". The connection keeps a request's contract
// for its lifetime, so a notice that lands after its requester released the
// line still names it. Every option of one class shares the key, as they
// share the subscription the refusal is about. Empty without a symbol.
func entitlementGapInstrumentKey(alias reqAliasEntry) string {
symbol := strings.ToUpper(strings.TrimSpace(alias.symbol))
if symbol == "" {
return ""
}
secType := strings.ToUpper(strings.TrimSpace(alias.secType))
if secType == "" {
secType = "STK"
}
currency := strings.ToUpper(strings.TrimSpace(alias.currency))
if currency == "" {
currency = "USD"
}
return symbol + " " + secType + " " + currency
}
// inactiveMarkTTL bounds an inactive mark the same way marketDataAbsenceRetry
// Marks are in-memory only — a false mark formed while the gateway answered
// "no security definition" for everything (nightly-reset wedge, observed
const inactiveMarkTTL = 12 * time.Hour
type marketDataAbsence struct {
code int
message string
at time.Time
delayedAttemptAt time.Time
delayedAvailable bool
}
// MarketDataAbsenceError reports that a recent terminal entitlement rejection
// are local times; Message is untrusted broker text.
type MarketDataAbsenceError struct {
Key string
Code int
Message string
ObservedAt time.Time
RetryAt time.Time
FallbackDataType int
FallbackReceivedAt time.Time
}
// Error returns a concise description of the suppressed market-data request.
func (e *MarketDataAbsenceError) Error() string {
return fmt.Sprintf("market data for %s unavailable (IBKR %d at %s; retry after %s)",
e.Key, e.Code, e.ObservedAt.Format("15:04:05"), e.RetryAt.Format("15:04:05"))
}
func (c *Connector) absenceClock() time.Time {
if c.absenceNow != nil {
return c.absenceNow()
}
return time.Now()
}
// rememberMarketDataAbsence records a terminal entitlement rejection for a
// subscription key. Logged at Info once per window so the log shows one
// honest probe per key per marketDataAbsenceRetry instead of a churn loop.
func (c *Connector) rememberMarketDataAbsence(key string, code int, message string) {
now := c.absenceClock()
c.absenceMu.Lock()
if c.mktDataAbsent == nil {
c.mktDataAbsent = make(map[string]marketDataAbsence)
}
prev, had := c.mktDataAbsent[key]
c.mktDataAbsent[key] = marketDataAbsence{code: code, message: message, at: now}
c.absenceMu.Unlock()
if !had || now.Sub(prev.at) >= marketDataAbsenceRetry {
c.logInfo("Market data for %s rejected (code %d); suppressing resubscribes for %s", key, code, marketDataAbsenceRetry)
}
}
// rememberEntitlementGap records a warned 354 in the log memory under the key
// knownEntitlementGap judged its line by (entitlementGapNoticeKey). It is
// independent of the absence verdict: the line has warned either way.
func (c *Connector) rememberEntitlementGap(key string) {
if key == "" {
return
}
now := c.absenceClock()
c.absenceMu.Lock()
c.recordEntitlementGapLocked(key, now)
c.absenceMu.Unlock()
}
// recordEntitlementGapLocked starts a key's memory window unless one is
// running. Caller holds absenceMu.
func (c *Connector) recordEntitlementGapLocked(key string, now time.Time) {
key = entitlementGapKey(key)
if c.entitlementGapWarned == nil {
c.entitlementGapWarned = make(map[string]time.Time)
}
if first, ok := c.entitlementGapWarned[key]; !ok || now.Sub(first) >= entitlementGapMemory {
c.entitlementGapWarned[key] = now
}
}
// knownEntitlementGap reports whether a 354 for reqID repeats a gap this
// connector (or the one it inherited from) already warned about. The first
// rejection of a key keeps its warning; the wire echo of every later probe
// inside entitlementGapMemory is INFO.
//
// Owner decision (Oliver, 2026-10-08 07:15 CEST): a code-354 "market data not
// subscribed" notice warns once per subscription key per 24 h; repeats inside
// the window log at INFO, remembered across reconnects and daemon restarts.
// This replaces the 2026-10-03 rule that warned again after every daemon
// start. The memory is recorded even while a data farm is impaired: the farm
// veto gates only the absence verdict, never whether a line that warned is
// remembered.
//
// Owner instruction (Oliver, 2026-10-12 06:40 CEST): the one warning per 24 h
// must hold. It did not: the memory was keyed by the connector's subscription
// key, which exists only while the requester holds its line, and a 354 often
// lands after a short probe released it (borrow-inventory probes poll for
// 2.5 s), and option lines have no such key at all. Those lines warned and
// were never remembered, so every repeat warned again: 285 WARN lines with
// no repeat at INFO, and an empty recorded document. The memory now keys the
// instrument the request named (entitlementGapNoticeKey), whoever still holds
// the line.
func (c *Connector) knownEntitlementGap(reqID int, alias reqAliasEntry) bool {
key := c.entitlementGapNoticeKey(reqID, alias)
if key == "" {
return false
}
now := c.absenceClock()
c.absenceMu.Lock()
defer c.absenceMu.Unlock()
first, ok := c.entitlementGapWarned[key]
if !ok {
return false
}
if now.Sub(first) >= entitlementGapMemory {
delete(c.entitlementGapWarned, key)
return false
}
return true
}
// entitlementGapNoticeKey is the warned-gap memory's key for a 354 on reqID:
// the instrument the request named (entitlementGapInstrumentKey), else, for a
// request without a recorded contract, its subscription key with an
// exact-session sequence dropped (entitlementGapKey). The line's severity and
// the record use this one key, so a gap that warned is the gap remembered.
func (c *Connector) entitlementGapNoticeKey(reqID int, alias reqAliasEntry) string {
if key := entitlementGapInstrumentKey(alias); key != "" {
return key
}
return entitlementGapKey(c.subscriptionMissKeyForNotice(reqID, alias))
}
// backendLinkBroken is true from a 1100 or 2110 until the 1101/1102 restore:
// the backend latch, or the connectivity farm row that 2110 writes and only a
// connectivity notice clears (see recordDataFarmNotice).
func (c *Connector) backendLinkBroken() bool {
if down, _ := c.backendConnectivityDown(); down {
return true
}
c.dataFarmMu.RLock()
defer c.dataFarmMu.RUnlock()
for _, farm := range c.dataFarms {
if farm.Type == "connectivity" && farm.Status == "broken" {
return true
}
}
return false
}
// backendLinkUnsettled is true while the link is broken and for
// BackendRestoreSettle after its 1101/1102 restore.
func (c *Connector) backendLinkUnsettled() bool {
if c.backendLinkBroken() {
return true
}
down, at := c.backendConnectivityDown()
return !down && !at.IsZero() && c.absenceClock().Sub(at) < BackendRestoreSettle
}
// noticeLogContext is the connector's view for wire-notice severity.
func (c *Connector) noticeLogContext() noticeLogContext {
return noticeLogContext{backendLinkDown: c.backendLinkBroken, backendLinkUnsettled: c.backendLinkUnsettled, knownEntitlementGap: c.knownEntitlementGap}
}
// MarketDataMemory is the connector-independent part of the entitlement
// memory: terminal 354 absences still inside their retry window and the keys
// whose entitlement gap already warned. A successor connector inherits it so a
// reconnect neither re-probes a fresh gap nor warns about a known one again.
// It carries subscription and instrument keys and broker notice text, never
// account data.
type MarketDataMemory struct {
absences map[string]marketDataAbsence
gapWarned map[string]time.Time
}
// ExportMarketDataMemory snapshots the live entitlement memory for a successor.
func (c *Connector) ExportMarketDataMemory() MarketDataMemory {
if c == nil {
return MarketDataMemory{}
}
now := c.absenceClock()
c.absenceMu.Lock()
defer c.absenceMu.Unlock()
m := MarketDataMemory{absences: make(map[string]marketDataAbsence, len(c.mktDataAbsent)), gapWarned: make(map[string]time.Time, len(c.entitlementGapWarned))}
for key, entry := range c.mktDataAbsent {
if now.Sub(entry.at) < marketDataAbsenceRetry {
m.absences[key] = entry
}
}
for key, first := range c.entitlementGapWarned {
if now.Sub(first) < entitlementGapMemory {
m.gapWarned[key] = first
}
}
return m
}
// EntitlementGaps returns the subscription keys whose entitlement gap has
// warned and when each first did, for a daemon that keeps them across
// restarts. The keys name contracts, never accounts.
func (m MarketDataMemory) EntitlementGaps() map[string]time.Time {
return maps.Clone(m.gapWarned)
}
// WithEntitlementGaps returns m with recorded gaps added; a gap m already
// holds keeps its own time. Keys are normalised as the connector keys them,
// so a record holding one exact-session key per preview (written before
// 2026-10-08) merges into one gap per instrument at its earliest time. Lapsed
// gaps are dropped when a connector exports.
func (m MarketDataMemory) WithEntitlementGaps(gaps map[string]time.Time) MarketDataMemory {
merged := maps.Clone(m.gapWarned)
if merged == nil {
merged = make(map[string]time.Time, len(gaps))
}
added := make(map[string]time.Time, len(gaps))
for key, first := range gaps {
key = entitlementGapKey(key)
if _, held := merged[key]; held {
continue
}
if prev, ok := added[key]; !ok || first.Before(prev) {
added[key] = first
}
}
maps.Copy(merged, added)
m.gapWarned = merged
return m
}
// InheritMarketDataMemory seeds a connector before it starts. Entries the
// connector already holds win, so its own observations are never overwritten.
func (c *Connector) InheritMarketDataMemory(m MarketDataMemory) {
if c == nil || (len(m.absences) == 0 && len(m.gapWarned) == 0) {
return
}
c.absenceMu.Lock()
defer c.absenceMu.Unlock()
if c.mktDataAbsent == nil {
c.mktDataAbsent = make(map[string]marketDataAbsence, len(m.absences))
}
for key, entry := range m.absences {
if _, ok := c.mktDataAbsent[key]; !ok {
c.mktDataAbsent[key] = entry
}
}
if c.entitlementGapWarned == nil {
c.entitlementGapWarned = make(map[string]time.Time, len(m.gapWarned))
}
for key, first := range m.gapWarned {
if _, ok := c.entitlementGapWarned[key]; !ok {
c.entitlementGapWarned[key] = first
}
}
}
// marketDataAbsenceFor returns the active absence record for key, or nil.
func (c *Connector) marketDataAbsenceFor(key string) *MarketDataAbsenceError {
now := c.absenceClock()
c.absenceMu.Lock()
defer c.absenceMu.Unlock()
entry, ok := c.mktDataAbsent[key]
if !ok {
return nil
}
if now.Sub(entry.at) >= marketDataAbsenceRetry {
delete(c.mktDataAbsent, key)
return nil
}
report := marketDataAbsenceReport(key, entry)
return &report
}
// MarketDataAbsences snapshots every route key whose terminal entitlement
// rejection is still inside its retry window, ordered by key. Expired records
// are dropped on read exactly as marketDataAbsenceFor drops them, so an
// observation surface can never name a key the subscribe paths would already
// let through. A shared quote line that a 354 moved to the delayed feed is
// named for the same window even when a farm-impairment veto withheld the
// suppressing record, but only once it serves a delayed price: the vetoed 354
// is no entitlement verdict, so the entry discloses the served mode rather
// than a refusal or a gate. Message stays untrusted broker text; callers that
// classify must read Code.
func (c *Connector) MarketDataAbsences() []MarketDataAbsenceError {
if c == nil {
return nil
}
now := c.absenceClock()
c.absenceMu.Lock()
out := make([]MarketDataAbsenceError, 0, len(c.mktDataAbsent))
recorded := make(map[string]bool, len(c.mktDataAbsent))
for key, entry := range c.mktDataAbsent {
if now.Sub(entry.at) >= marketDataAbsenceRetry {
delete(c.mktDataAbsent, key)
continue
}
recorded[key] = true
out = append(out, marketDataAbsenceReport(key, entry))
}
c.absenceMu.Unlock()
c.subMu.RLock()
for i := range out {
out[i].FallbackDataType, out[i].FallbackReceivedAt = c.servedDelayedQuote(c.subscriptions[out[i].Key])
}
for key, sub := range c.subscriptions {
if sub == nil || sub.fallbackRefusal == nil || recorded[key] || now.Sub(sub.fallbackRefusal.at) >= marketDataAbsenceRetry {
continue
}
report := marketDataAbsenceReport(key, *sub.fallbackRefusal)
report.FallbackDataType, report.FallbackReceivedAt = c.servedDelayedQuote(sub)
if report.FallbackDataType != 0 {
out = append(out, report)
}
}
c.subMu.RUnlock()
sort.SliceStable(out, func(i, j int) bool { return out[i].Key < out[j].Key })
return out
}
// servedDelayedQuote reports the delayed feed type and price clock of the
// delayed price that sub, or the prior line it keeps showing during a live
// re-probe, has actually served. Both are zero when no delayed price has been
// served. The caller holds subMu.
func (c *Connector) servedDelayedQuote(sub *Subscription) (int, time.Time) {
if sub == nil {
return 0, time.Time{}
}
dt := c.subscriptionDataType(sub)
if sub.previousQuote != nil && sub.LastPriceTickAt.IsZero() {
dt, sub = sub.previousDataType, sub.previousQuote
}
if (dt == 3 || dt == 4) && !sub.LastPriceTickAt.IsZero() {
return dt, sub.LastPriceTickAt
}
return 0, time.Time{}
}
// marketDataAbsenceReport projects one in-window refusal for key onto the
// exported report shape; delayed-feed evidence is added by the caller.
func marketDataAbsenceReport(key string, entry marketDataAbsence) MarketDataAbsenceError {
return MarketDataAbsenceError{
Key: key,
Code: entry.code,
Message: entry.message,
ObservedAt: entry.at,
RetryAt: entry.at.Add(marketDataAbsenceRetry),
}
}
// Definition-miss probe backoff: first miss earns the floor, each repeat
// doubles it up to the cap. The floor keeps a nightly-reset wedge (gateway
// answering code 200 for everything, observed 2026-07-08) recoverable within
// a minute of the session healing, while the cap bounds a permanently
// unresolvable contract to two honest probes an hour instead of an unbounded
// retry loop (observed 2026-08-13: one delisted held symbol, 3,265 code-200
// warnings in a night because farm impairment vetoed the inactive mark).
const (
contractMissBackoffFloor = time.Minute
contractMissBackoffCap = 30 * time.Minute
)
type contractResolutionMiss struct {
at time.Time
backoff time.Duration
}
// ContractResolutionMissError reports that a recent broker "no security
// definition" answer is suppressing re-resolution of a symbol until RetryAt.
// It unwraps to ErrContractNoDefinition so existing classification holds.
type ContractResolutionMissError struct {
Symbol string
MissAt time.Time
RetryAt time.Time
}
// Error describes the suppressed resolution attempt and its retry boundary.
func (e *ContractResolutionMissError) Error() string {
return fmt.Sprintf("no security definition for %s (IBKR 200 at %s; retry after %s)",
e.Symbol, e.MissAt.Format("15:04:05"), e.RetryAt.Format("15:04:05"))
}
// Unwrap classifies the miss as the broker's definitive missing-contract
// verdict for errors.Is callers.
func (e *ContractResolutionMissError) Unwrap() error { return ErrContractNoDefinition }
func (c *Connector) contractMissClock() time.Time {
if c.contractMissNow != nil {
return c.contractMissNow()
}
return time.Now()
}
// contractResolutionMissFor returns the active miss record for symbol, or nil
// once the backoff has expired. Expired entries stay in the map (cleared only
// by a successful resolution) so the next recorded miss keeps escalating
// instead of restarting from the floor.
func (c *Connector) contractResolutionMissFor(symbol string) *ContractResolutionMissError {
now := c.contractMissClock()
c.contractMissMu.Lock()
defer c.contractMissMu.Unlock()
entry, ok := c.contractMisses[symbol]
if !ok || now.Sub(entry.at) >= entry.backoff {
return nil
}
return &ContractResolutionMissError{
Symbol: symbol,
MissAt: entry.at,
RetryAt: entry.at.Add(entry.backoff),
}
}
// recordContractResolutionMiss notes a definitive broker code-200 answer for
// symbol. Escalation applies even while a farm is impaired: unlike the 12-hour
// inactive mark (which impairment rightly vetoes), the worst false-miss cost
// here is one ≤30-minute probe delay that clears itself on the next success,
// while the flapping nights that keep farms impaired for hours are exactly
// when an unresolvable symbol otherwise retries unbounded.
func (c *Connector) recordContractResolutionMiss(symbol string) {
now := c.contractMissClock()
c.contractMissMu.Lock()
if c.contractMisses == nil {
c.contractMisses = make(map[string]contractResolutionMiss)
}
backoff := contractMissBackoffFloor
if prev, ok := c.contractMisses[symbol]; ok {
if now.Sub(prev.at) < prev.backoff {
// A concurrent in-flight request failing inside the same window
// is the same probe, not evidence for escalation.
c.contractMissMu.Unlock()
return
}
backoff = min(prev.backoff*2, contractMissBackoffCap)
}
c.contractMisses[symbol] = contractResolutionMiss{at: now, backoff: backoff}
c.contractMissMu.Unlock()
c.logInfo("Contract resolution for %s rejected (code 200); suppressing re-resolution for %s", symbol, backoff)
}
func (c *Connector) clearContractResolutionMiss(symbol string) {
c.contractMissMu.Lock()
delete(c.contractMisses, symbol)
c.contractMissMu.Unlock()
}
// farmRecoverySettleWindow keeps the impairment verdict standing after a
// farm reports OK. TWS flushes answers to requests queued during an outage
const farmRecoverySettleWindow = time.Minute
// BackendRestoreSettle is how long after a backend-link restore the answers
// TWS flushes for requests the loss cut still count as the outage's echoes.
// It reuses the farm recovery settle: the same flush, the same bound.
const BackendRestoreSettle = farmRecoverySettleWindow
func farmStatusImpaired(status string) bool {
return status == "disconnected" || status == "broken"
}
// HistoricalBarFarmOutageAnnounced reports whether a historical bar farm
// (an HMDS farm; fundfarm serves fundamentals, not bars) is still broken
// after its break drew the one WARN warnLongDataFarmBreaks gives a break
// that lasts. Bar requests it serves time out until it recovers, and the
// recovery warns too, so their requesters can join the announcement.
func (c *Connector) HistoricalBarFarmOutageAnnounced() bool {
if c == nil {
return false
}
c.dataFarmMu.RLock()
defer c.dataFarmMu.RUnlock()
for _, farm := range c.dataFarms {
if farm.Type == "historical" && strings.Contains(farm.Name, "hmds") && farm.warned && farmStatusImpaired(farm.Status) {
return true
}
}
return false
}
// marketDataFarmImpaired reports whether any market-data (or TWS-server
func (c *Connector) marketDataFarmImpaired() bool {
c.dataFarmMu.RLock()
defer c.dataFarmMu.RUnlock()
if !c.farmRecoveryAt.IsZero() && time.Since(c.farmRecoveryAt) < farmRecoverySettleWindow {
return true
}
for _, farm := range c.dataFarms {
switch farm.Type {
case "market", "connectivity", "security_definition", "historical":
if farmStatusImpaired(farm.Status) {
return true
}
}
}
return false
}
const (
contractHydrationWait = 2 * time.Second
contractHydrationPoll = 25 * time.Millisecond
contractHydrationGrace = 5 * time.Second
)
// HistoricalBar represents one OHLC bar returned by IBKR historical market
// broker-reported volume. Time is parsed best-effort and is zero when parsing
// fails, while Date always retains the original broker value.
type HistoricalBar struct {
Time time.Time
Date string
Open float64
High float64
Low float64
Close float64
Volume int64
Average float64
BarCount int
}
const minServerVerHistoricalDataEnd = 196
type historicalResult struct {
start string
end string
bars []HistoricalBar
err error
}
type historicalRequest struct {
sentAt time.Time
maxBars int
intraday bool
symbol string
result chan historicalResult
strictDaily bool
waitForEnd bool
requestOwnsNoticeCollision bool
connection *Connection
epoch uint64
requireEpoch bool
bufferedBars []HistoricalBar
bufferedErr error
}
type historicalExactFlight struct {
done chan struct{}
connection *Connection
epoch uint64
completedAt time.Time
expiresAt time.Time
bars []HistoricalBar
route Contract
err error
}
const (
historicalIdenticalRequestCooldown = 15 * time.Second
historicalFeeRateBackendTimeout = 45 * time.Second
historicalExactRouteBackendTimeout = 5 * time.Second
historicalExactRouteSuccessTTL = 30 * time.Minute
)
// ConnectorSessionBinding is an opaque, process-local identity for the
// but cannot manufacture broker authority from its contents.
type ConnectorSessionBinding struct {
connector *Connector
connection *Connection
epoch uint64
}
// HistoricalSessionBinding preserves the historical-data API name while all
// broker-adjacent cached receipts share the same socket-session identity.
type HistoricalSessionBinding = ConnectorSessionBinding
// Historical failure categories are connector-authored classifications. They
// intentionally contain no broker prose and let daemon callers map failures
const (
HistoricalFailureNotEntitled = "not_entitled"
HistoricalFailureNoData = "no_data"
HistoricalFailurePacing = "pacing"
HistoricalFailureGatewayUnavailable = "gateway_unavailable"
HistoricalFailureContractUnavailable = "contract_unavailable"
HistoricalFailureProtocolRejected = "protocol_rejected"
HistoricalFailureInvalidPayload = "invalid_payload"
)
// HistoricalRequestError reports a broker error from a historical-data
// and Message is untrusted broker text.
type HistoricalRequestError struct {
Code int
Message string
RetryAfter time.Duration
Category string
}
// Error returns the broker message when present, otherwise a code-based description.
func (e *HistoricalRequestError) Error() string {
if e.Message != "" {
return e.Message
}
if e.Category != "" {
return "historical data " + e.Category
}
return fmt.Sprintf("historical data error %d", e.Code)
}
// Is reports a code-200 history rejection as [ErrContractNoDefinition]: the
// broker cannot define the contract as described, the same verdict a
// contract-details miss carries, so callers classify both alike. The code
// alone decides, as the redacted form of this error keeps no message.
func (e *HistoricalRequestError) Is(target error) bool {
return target == ErrContractNoDefinition && (e.Code == 200 || historicalUnknownContract(e))
}
// historicalUnknownContract reports IBKR's code 162 "Unknown contract": the
// history service no longer knows the contract, the same verdict as a code
// 200 (WHLR, 2026-10-29, asked six times every five minutes as a route
// failure).
func historicalUnknownContract(e *HistoricalRequestError) bool {
return e != nil && e.Code == 162 && strings.Contains(strings.ToUpper(e.Message), "UNKNOWN CONTRACT")
}
// HistoricalDataValidationError reports a connector-authored validation
// failure. Reason is an allowlisted token and never includes broker payload.
type HistoricalDataValidationError struct {
Reason string
}
// Error returns a fixed connector-authored description and the allowlisted
// reason token; it never returns raw broker payload text.
func (e *HistoricalDataValidationError) Error() string {
if e == nil || e.Reason == "" {
return "historical data validation failed"
}
return "historical data validation failed: " + e.Reason
}
// NewConnector constructs a stopped Connector for one broker connection.
func NewConnector(config *ConnectorConfig) *Connector {
if config == nil {
config = &ConnectorConfig{}
} else {
configCopy := *config
config = &configCopy
}
if config.BaseConfig == nil {
config.BaseConfig = DefaultConfig()
} else {
baseConfigCopy := *config.BaseConfig
config.BaseConfig = &baseConfigCopy
}
if config.PreferredClientID == 0 {
config.PreferredClientID = config.BaseConfig.ClientID
}
if config.PreferredClientID == 0 {
config.PreferredClientID = 1
}
// Honour IBKR_PACKET_LOG_TEMPLATE if set and BaseConfig didn't already pin
// a packet log path. Template tokens: trailing path-separator means
// docgen:env IBKR_PACKET_LOG_TEMPLATE | Template path for raw IBKR wire-packet logs. Trailing `/` treats as directory; `%d` placeholder gets the gateway client ID. Unset disables wire logging.
if template := strings.TrimSpace(os.Getenv("IBKR_PACKET_LOG_TEMPLATE")); template != "" && config.BaseConfig.PacketLogPath == "" {
if strings.HasSuffix(template, string(os.PathSeparator)) {
template = filepath.Join(template, "ibkr_client_%d.log")
} else if !strings.Contains(template, "%d") {
template = template + "_%d.log"
}
config.BaseConfig.PacketLogPath = fmt.Sprintf(template, config.PreferredClientID)
}
connCfg := *config.BaseConfig
connCfg.ClientID = config.PreferredClientID
c := &Connector{
name: "IBKRConnector",
config: config,
conn: NewConnection(&connCfg),
subscriptions: make(map[string]*Subscription),
reqIDMap: make(map[int]string),
openOrders: make(map[string]*trackedOrder),
brokerOrderIndex: make(map[string]string),
orderStatusLogSig: make(map[string]string),
contractCache: make(map[string]ContractDetailsLite),
contractDetailsFlights: make(map[string]*contractDetailsFlight),
contractWarningState: make(map[string]contractWarningState),
optIV: make(map[string]float64),
optIVDataType: make(map[string]int),
optReqIDs: make(map[int]string),
optQuoteBid: make(map[string]float64),
optQuoteAsk: make(map[string]float64),
optPrevClose: make(map[string]float64),
optGreeks: make(map[string]Greeks),
optUnderlyingPx: make(map[string]float64),
contractDetailsReqs: make(map[int]*contractDetailsRequest),
historicalReqs: make(map[int]*historicalRequest),
historicalBackoff: make(map[string]int),
historicalExactFlights: make(map[string]*historicalExactFlight),
historicalRouteReqs: make(map[int]chan error),
pnl: newPnLCache(),
}
c.openOrderSnapshotTimeout = config.OpenOrderSnapshotTimeout
c.conn.evidenceBarrier = &c.evidenceBarrier
c.conn.publicationBarrier = &c.publicationBarrier
c.conn.displayChanged = c.display.notify
c.fetchContractDetails = c.FetchContractDetails
c.resolveWSHContract = c.resolveWSHStockContract
c.resolveWSHExactContract = c.resolveWSHExactStockContract
c.wshGate = make(chan struct{}, 1)
c.wshGate <- struct{}{}
return c
}
func (c *Connector) logInfo(format string, args ...any) {
connectorLogger.Infof("%s: "+format, append([]any{c.name}, args...)...)
}
func (c *Connector) logWarn(format string, args ...any) {
connectorLogger.Warnf("%s: "+format, append([]any{c.name}, args...)...)
}
func (c *Connector) logDebug(format string, args ...any) {
connectorLogger.Debugf("%s: "+format, append([]any{c.name}, args...)...)
}
func (c *Connector) recordContractTiming(symbol string, elapsed time.Duration, resolved bool) {
if symbol == "" || elapsed <= 0 {
return
}
if c.contractTimingHook != nil {
c.contractTimingHook(symbol, elapsed, resolved)
}
if c.conn != nil {
c.conn.observeContractTiming(symbol, elapsed, resolved)
}
}
func (c *Connector) inactiveReason(symbol string) (string, bool) {
c.inactiveMu.RLock()
if reason, reviewed := reviewedTerminalMatch(c.reviewedTerminal, symbol); reviewed {
c.inactiveMu.RUnlock()
return reason, true
}
state, ok := c.inactiveSymbols[symbol]
c.inactiveMu.RUnlock()
if !ok {
return "", false
}
// Lazy TTL expiry, mirroring the absence memory: an expired mark is
// deleted on read and the name earns a fresh probe; re-marking needs a
// fresh 2-in-10-min confirmation.
if time.Since(state.markedAt) > inactiveMarkTTL {
c.inactiveMu.Lock()
if cur, still := c.inactiveSymbols[symbol]; still && cur.markedAt.Equal(state.markedAt) {
delete(c.inactiveSymbols, symbol)
}
c.inactiveMu.Unlock()
return "", false
}
return state.reason, true
}
// InactiveReason reports an unexpired in-memory inactivity mark for symbol,
// or its membership in the reviewed terminal set (see
// [Connector.SetReviewedTerminal]). It performs no broker request. The boolean
// is false when no mark exists or the mark has expired; a heuristic mark's
// reason is untrusted broker text.
func (c *Connector) InactiveReason(symbol string) (string, bool) {
if symbol == "" {
return "", false
}
if reason, ok := c.inactiveReason(symbol); ok {
return reason, true
}
upper := strings.ToUpper(symbol)
if upper != symbol {
return c.inactiveReason(upper)
}
return "", false
}
// IsSymbolInactive reports whether symbol has an unexpired in-memory
// inactivity mark. It performs no broker request.
func (c *Connector) IsSymbolInactive(symbol string) bool {
_, inactive := c.InactiveReason(symbol)
return inactive
}
func (c *Connector) hasActiveContract(symbol string) bool {
symbol = strings.ToUpper(symbol)
c.contractMu.RLock()
detail, ok := c.contractCache[symbol]
c.contractMu.RUnlock()
return ok && detail.ConID != 0
}
func (c *Connector) clearInactiveCandidate(symbol string) {
c.inactiveMu.Lock()
if c.inactiveCandidates != nil {
delete(c.inactiveCandidates, strings.ToUpper(symbol))
}
c.inactiveMu.Unlock()
}
// inactiveConfirmations and inactiveCandidateWindow gate the in-memory
// inactive mark: a definition error must repeat within the window before a
// hiccup, contract-cache race) — observed 2026-06-11 on the currency-ledger
const (
inactiveConfirmations = 2
inactiveCandidateWindow = 10 * time.Minute
)
func joinPostActions(first, second func()) func() {
switch {
case first == nil:
return second
case second == nil:
return first
default:
return func() {
first()
second()
}
}
}
// registerInactiveCandidatePostAction performs only local state mutation and
// returns any broker-side subscription cleanup to its caller. Socket readers
func (c *Connector) registerInactiveCandidatePostAction(symbol, reason string) (bool, func()) {
if symbol == "" {
return false, nil
}
// Choke-point farm guard: both write paths (subscription notices AND
// historical failures) converge here. While any tracked farm is
// impaired the gateway's definition errors are a session verdict, not
// a contract verdict — counting them is how a nightly-reset wedge
// marked held names and VIX inactive (2026-07-08).
if c.marketDataFarmImpaired() {
return false, nil
}
symbol = strings.ToUpper(symbol)
upperReason := strings.ToUpper(reason)
definitionDead := strings.Contains(upperReason, "NO SECURITY DEFINITION") || strings.Contains(upperReason, "NO DATA")
// An actively cached contract vetoes non-definition reasons outright.
if c.hasActiveContract(symbol) && !definitionDead {
c.clearInactiveCandidate(symbol)
return false, nil
}
reason = strings.TrimSpace(reason)
now := time.Now()
c.inactiveMu.Lock()
if c.inactiveSymbols != nil {
if _, exists := c.inactiveSymbols[symbol]; exists {
c.inactiveMu.Unlock()
return true, nil
}
}
if c.inactiveCandidates == nil {
c.inactiveCandidates = make(map[string]inactiveCandidateState)
}
state := c.inactiveCandidates[symbol]
if !state.lastUpdated.IsZero() && now.Sub(state.lastUpdated) > inactiveCandidateWindow {
// Occurrences far apart are independent transients, not a
// confirmation.
state = inactiveCandidateState{}
}
state.count++
state.lastReason = reason
state.lastUpdated = now
shouldMark := state.count >= inactiveConfirmations
if shouldMark {
delete(c.inactiveCandidates, symbol)
} else {
c.inactiveCandidates[symbol] = state
}
c.inactiveMu.Unlock()
if shouldMark {
return true, c.markSymbolInactivePostAction(symbol, reason)
}
return false, nil
}
func (c *Connector) markSymbolInactivePostAction(symbol, reason string) func() {
if symbol == "" {
return nil
}
symbol = strings.ToUpper(symbol)
reason = strings.TrimSpace(reason)
if reason == "" {
reason = "inactive"
}
c.inactiveMu.Lock()
if c.inactiveSymbols == nil {
c.inactiveSymbols = make(map[string]inactiveSymbolState)
}
if c.inactiveCandidates != nil {
delete(c.inactiveCandidates, symbol)
}
if _, exists := c.inactiveSymbols[symbol]; exists {
c.inactiveMu.Unlock()
return nil
}
state := inactiveSymbolState{
reason: reason,
markedAt: time.Now(),
}
c.inactiveSymbols[symbol] = state
c.inactiveMu.Unlock()
post := c.detachSubscription(symbol)
c.logInfo("Suppressing market data for %s (inactive: %s)", symbol, reason)
return post
}
func (c *Connector) processSystemNoticeFrom(origin ConnectorSessionBinding, alias reqAliasEntry, note *systemNotification) (postBarrier func()) {
if note == nil {
return nil
}
func() {
c.publicationBarrier.RLock()
defer c.publicationBarrier.RUnlock()
c.evidenceBarrier.RLock()
defer c.evidenceBarrier.RUnlock()
if !c.SessionReceiptCurrent(origin) {
if note.tickerID > 0 {
c.notifyOrderErrorLifecycleUnderBarrier(origin, int(note.tickerID), note.code, note.message, note.advancedOrderRejectJSON)
}
return
}
// Backend-connectivity notices (1100/1101/1102) are session-global
// and must be tracked on every current-session path, including the
// historical-collision early returns below: during an outage TWS
defer func() {
if post := c.handleBackendConnectivityNotice(origin, note); post != nil {
postBarrier = joinPostActions(postBarrier, post)
}
}()
// Legacy sessions and already-consumed IDs may still surface delayed broker
// errors, so active exact-historical ownership wins before any durable order
if note.tickerID > 0 && c.failPendingExactHistoricalRoute(int(note.tickerID), note.code, note.message) {
c.recordDataFarmNotice(note.code, note.message, note.timestamp)
return
}
if note.tickerID > 0 && c.isKnownBrokerOrderID(int(note.tickerID)) &&
c.failNoticeCollisionHistorical(int(note.tickerID), note.code, note.message) {
c.recordDataFarmNotice(note.code, note.message, note.timestamp)
return
}
if note.tickerID > 0 {
c.notifyOrderErrorLifecycleUnderBarrier(origin, int(note.tickerID), note.code, note.message, note.advancedOrderRejectJSON)
}
c.recordDataFarmNotice(note.code, note.message, note.timestamp)
// msg-204 delivers order errors and request errors through the same id
// request-scoped recovery and inactive-candidate marking must not act
if note.tickerID > 0 && c.isKnownBrokerOrderID(int(note.tickerID)) {
return
}
// Request-scoped recovery must run before the alias-based inactive
// logic below: historical reqIDs never register an alias, so the
// live error path on current gateways — TWS API server ≥203 delivers
// frames, so handleIBKRError/handleErrorMessage never see them
postBarrier = c.recoverFromSystemNotice(origin, alias, note)
upperMsg := strings.ToUpper(note.message)
definitionDead := false
switch note.code {
case 200:
definitionDead = strings.Contains(upperMsg, "NO SECURITY DEFINITION")
case 162:
definitionDead = strings.Contains(upperMsg, "NO DATA")
case 366:
definitionDead = true
}
if !definitionDead {
return
}
// Record the inactive candidate under the connector's own subscription
// or contract-details key — exactly what the subscribe and resolve
// that no check-time key contains, so marks never suppressed anything.
key := c.resolutionKeyForNotice(int(note.tickerID), alias)
if key == "" {
c.logDebug("Ignoring definition error code %d for unowned or derivative request %s (%s): %s", note.code, alias.symbol, alias.localSymbol, note.message)
return
}
_, inactivePost := c.registerInactiveCandidatePostAction(key, note.message)
postBarrier = joinPostActions(postBarrier, inactivePost)
}()
return postBarrier
}
// historicalNoticeOwnsIDCollision is a closed code allowlist, never a broker
func historicalNoticeOwnsIDCollision(code int) bool {
switch code {
case 162, 200, 321, 354, 366,
502, 504, 1100, 1300,
10089, 10090, 10091, 10186, 10187:
return true
default:
return false
}
}
func (c *Connector) failNoticeCollisionHistorical(reqID, code int, message string) bool {
if !historicalNoticeOwnsIDCollision(code) {
return false
}
c.historicalMu.Lock()
request := c.historicalReqs[reqID]
requestOwns := request != nil && request.requestOwnsNoticeCollision
c.historicalMu.Unlock()
if !requestOwns {
return false
}
return c.failPendingHistorical(reqID, code, message)
}
func (c *Connector) failPendingExactHistoricalRoute(reqID, code int, message string) bool {
if !historicalNoticeOwnsIDCollision(code) {
return false
}
c.historicalMu.Lock()
failureCh := c.historicalRouteReqs[reqID]
if failureCh != nil {
delete(c.historicalRouteReqs, reqID)
}
c.historicalMu.Unlock()
if failureCh == nil {
return false
}
failureCh <- &HistoricalRequestError{Code: code, Message: message}
return true
}
// recoverFromSystemNotice drives the request-scoped recovery that the
// legacy msgErrMsg path (handleIBKRError / handleErrorMessage) promised
// but never receives on current gateways:
//
// - a pending historical request fails immediately instead of burning
// its timeout and then wire-cancelling a query the server already
// killed (the recurring error-366 source); the error — including 200
// "no security definition" — propagates to the caller;
// - in-flight market-data pollers get the RejectCh fast-abort;
// - terminal entitlement/definition rejections (200/354) release the
// rate-limiter slot, mark the exact reqID server-dead so teardown
// skips the futile wire cancel (the recurring error-300 source), and
// — for 354 — feed the absence memory so steady pollers stop
// re-requesting a name with no data entitlement (the recurring
// 354+2129 source);
// - 10197 keeps its force-delayed side effect and transparently replays
// the shared request IBKR killed so the first symbol is not lost.
//
// Deliberately NOT ported from handleIBKRError: refreshSubscription's
// blind alternate-routing resubscribe on 200/320/321/354 — re-requesting
// a terminally rejected subscription is exactly the churn loop this
// recovery exists to stop, and recovery-after-the-window is owned by the
// absence TTL.
func (c *Connector) recoverFromSystemNotice(origin ConnectorSessionBinding, alias reqAliasEntry, note *systemNotification) (postBarrier func()) {
if note.tickerID <= 0 {
return nil
}
reqID := int(note.tickerID)
code := note.code
if code == 354 {
// Whatever the request was, its line warned unless the gap is
// known; remember it before any requester-specific exit below.
c.rememberEntitlementGap(c.entitlementGapNoticeKey(reqID, alias))
}
if c.failPendingHistorical(reqID, code, note.message) {
return nil
}
// Contract-details requests hold no market-data slot and no subscription,
// so they exit here rather than falling through to the rejection and
// slot-release handling below.
if c.failPendingContractDetails(reqID, code, note.message) {
return nil
}
// A shared request rejected with 10197 gets one transparent replay after
// the connection enters delayed mode. Keep its RejectCh quiet until that
// attempt resolves so the original quote/gamma poll can receive the new
// reqID's delayed ticks. Every other terminal rejection still fast-aborts.
if code != 10197 && code != 354 {
c.pushSubscriptionRejection(reqID, code, note.message)
}
switch code {
case 200, 354:
c.markSubscriptionRejected(reqID)
if origin.connection != nil {
origin.connection.releaseMarketDataSlot(reqID)
}
if code == 200 && strings.Contains(strings.ToUpper(note.message), "NO SECURITY DEFINITION") {
if key := c.subscriptionMissKeyForNotice(reqID, alias); key != "" {
c.recordContractResolutionMiss(key)
}
}
case 10197:
firstTransition := origin.connection != nil && origin.connection.markCompetingLiveSession(strconv.Itoa(reqID))
postBarrier = func() {
var modeErr error
if firstTransition {
if err := origin.connection.setMarketDataTypeAtEpoch(3, origin.epoch); err != nil {
ibkrLogger.Errorf("[cid=%d] Failed to request delayed market data after 10197: %v", origin.connection.config.ClientID, err)
modeErr = err
} else {
ibkrLogger.Warnf("[cid=%d] Forced delayed market data after 10197 (%s)", origin.connection.config.ClientID, note.message)
}
}
if modeErr == nil && c.recoverSharedMarketDataAfter10197(origin, reqID, note.message) {
return
}
// Option/exact-session subscriptions cannot be transparently replayed;
// their bounded owners retain the retry decision. A failed mode switch
c.markSubscriptionRejected(reqID)
if origin.connection != nil {
origin.connection.releaseMarketDataSlotAtEpoch(reqID, origin.epoch)
}
c.pushSubscriptionRejection(reqID, code, note.message)
}
}
if code == 354 {
c.maybeRememberAbsenceForReqID(reqID, alias, code, note.message)
if recover := c.prepareDelayedQuoteRecovery(origin, reqID, note.message); recover != nil {
return recover
}
c.pushSubscriptionRejection(reqID, code, note.message)
}
return postBarrier
}
// contractDetailsRequest tracks one in-flight reqContractDetails so a system
// notice targeting its reqID can fail it immediately. resolutionKey is the
// inactive-mark key a definition rejection should count against, empty for
// derivative probes whose rejections are routine (option fan-outs request
// strike supersets; FX resolution quotes both pair directions).
type contractDetailsRequest struct {
resolutionKey string
fail chan error
sentAt time.Time
// keepText fails the request with a *ContractDetailsRejection carrying
// IBKR's code and text; only bond lookups ask for it.
keepText bool
}
// registerContractDetailsRequest arms reqID for notice-driven failure and
// returns the request plus a release func the caller must defer.
func (c *Connector) registerContractDetailsRequest(reqID int, resolutionKey string) (*contractDetailsRequest, func()) {
return c.armContractDetailsRequest(reqID, &contractDetailsRequest{resolutionKey: resolutionKey})
}
// registerBondContractDetailsRequest is registerContractDetailsRequest for a
// bond lookup, whose rejection keeps IBKR's code and text for the lookup's
// gap line.
func (c *Connector) registerBondContractDetailsRequest(reqID int) (*contractDetailsRequest, func()) {
return c.armContractDetailsRequest(reqID, &contractDetailsRequest{keepText: true})
}
func (c *Connector) armContractDetailsRequest(reqID int, req *contractDetailsRequest) (*contractDetailsRequest, func()) {
req.fail, req.sentAt = make(chan error, 1), time.Now()
c.contractDetailsMu.Lock()
c.contractDetailsReqs[reqID] = req
c.contractDetailsMu.Unlock()
return req, func() {
c.contractDetailsMu.Lock()
delete(c.contractDetailsReqs, reqID)
c.contractDetailsMu.Unlock()
}
}
// pendingContractDetailsKey returns the inactive-mark key a definition
func (c *Connector) pendingContractDetailsKey(reqID int) string {
c.contractDetailsMu.Lock()
req, ok := c.contractDetailsReqs[reqID]
c.contractDetailsMu.Unlock()
if !ok {
return ""
}
return req.resolutionKey
}
// failPendingContractDetails fails the contract-details request owning reqID,
// if any, so the caller returns on the broker's answer instead of burning its
// full budget and reporting a timeout. Informational codes leave the request
// running, matching failPendingHistorical. Returns true when reqID belonged to
// a pending contract-details request.
func (c *Connector) failPendingContractDetails(reqID, code int, message string) bool {
if code == 0 || code == -1 || (code >= 2100 && code < 2200) {
return false
}
c.contractDetailsMu.Lock()
req, ok := c.contractDetailsReqs[reqID]
c.contractDetailsMu.Unlock()
if !ok {
return false
}
err := fmt.Errorf("contract details request failed (IBKR %d)", code)
switch {
case req.keepText:
err = &ContractDetailsRejection{Code: code, Message: brokerNoticeLine(message)}
case code == 200 && strings.Contains(strings.ToUpper(message), "NO SECURITY DEFINITION"):
err = ErrContractNoDefinition
}
select {
case req.fail <- err:
default:
}
return true
}
// failPendingHistorical fails the historical request owning reqID, if
// any, mirroring handleIBKRError's histPending branch (162 keeps its
// pacing backoff, anything else resets it). Informational codes leave the
// request running. Returns true when reqID belonged to a pending
// historical request.
func (c *Connector) failPendingHistorical(reqID, code int, message string) bool {
if code == 0 || code == -1 || (code >= 2100 && code < 2200) {
return false
}
c.historicalMu.Lock()
hr, ok := c.historicalReqs[reqID]
c.historicalMu.Unlock()
if !ok {
return false
}
hErr := &HistoricalRequestError{Code: code, Message: message}
switch code {
case 162:
if hErr.Message == "" {
hErr.Message = "historical data pacing violation"
}
hErr.RetryAfter = c.nextHistoricalBackoff(hr.symbol)
case 321:
if hErr.Message == "" {
hErr.Message = "historical data request failed validation"
}
c.resetHistoricalBackoff(hr.symbol)
default:
c.resetHistoricalBackoff(hr.symbol)
}
c.failHistoricalRequest(reqID, hErr)
return true
}
// markSubscriptionRejected records that the gateway terminally killed
// notice for a stale reqID can never poison a live replacement
func (c *Connector) markSubscriptionRejected(reqID int) {
c.subMu.Lock()
defer c.subMu.Unlock()
key, ok := c.reqIDMap[reqID]
if !ok {
return
}
if sub, ok := c.subscriptions[key]; ok && sub != nil && sub.ReqID == reqID {
sub.rejectedReqID = reqID
}
}
// maybeRememberAbsenceForReqID feeds the market-data absence memory for a
// terminal 354, keyed by the connector's own subscription key (see
// subscriptionKeyForNotice) so the record key is identical to what the
// subscribe paths check. A rejection that is no absence verdict (a farm is
// impaired, or the delayed fallback drew it) forms none. The warned-gap log
// memory is recorded apart from it, for every 354 (recoverFromSystemNotice):
// withholding that record made every later probe of the gap warn again
// (2026-10-07, while an unrelated farm was down).
func (c *Connector) maybeRememberAbsenceForReqID(reqID int, alias reqAliasEntry, code int, message string) {
key := c.subscriptionMissKeyForNotice(reqID, alias)
if key == "" {
return
}
c.subMu.RLock()
sub := c.subscriptions[key]
delayed := sub != nil && sub.ReqID == reqID && sub.delayedFallback
c.subMu.RUnlock()
if !delayed && !c.marketDataFarmImpaired() {
c.rememberMarketDataAbsence(key, code, message)
}
}
// subscriptionKeyForNotice resolves the connector-owned subscription key a
// request-scoped notice may act on: reqIDMap[reqID], i.e. exactly what the
// subscribe paths check before re-requesting — bare symbol for
// SubscribeMarketData, route key for SubscribeMarketDataWithContract. Never
// alias.symbol: for option-IV subscriptions that is the underlying and a
// record there would blind the stock. Returns "" when the notice must not
// feed symbol-level memory: reqIDs the connector does not own
// (contract-details probes, snapshots, historical), option reqIDs and
// derivative aliases, and notices arriving while a market-data farm is
// impaired (a bounce-window error is not a verdict on the contract).
func (c *Connector) subscriptionKeyForNotice(reqID int, alias reqAliasEntry) string {
key := c.subscriptionMissKeyForNotice(reqID, alias)
if key == "" || c.marketDataFarmImpaired() {
return ""
}
return key
}
// subscriptionMissKeyForNotice resolves the subscription key a code-200
// definition rejection should feed into the definition-miss backoff. Same
// ownership and derivative exclusions as subscriptionKeyForNotice, but
// deliberately without the farm-impairment veto: the backoff is a probe-rate
// bound, not a contract verdict, and the flapping nights that keep farms
// impaired for hours are exactly when an unresolvable route otherwise
// re-requests at full poll rate.
func (c *Connector) subscriptionMissKeyForNotice(reqID int, alias reqAliasEntry) string {
if reqID <= 0 {
return ""
}
c.subMu.RLock()
key := c.reqIDMap[reqID]
c.subMu.RUnlock()
if key == "" {
return ""
}
c.optMu.RLock()
_, isOptionReq := c.optReqIDs[reqID]
c.optMu.RUnlock()
if isOptionReq {
return ""
}
switch strings.ToUpper(strings.TrimSpace(alias.secType)) {
case "OPT", "FOP", "WAR", "BAG":
return ""
}
return key
}
// resolutionKeyForNotice returns the inactive-mark key a definition rejection
// Contract-details rejections are the only evidence a name that can no longer
// be resolved ever produces — it never reaches a market-data subscription — so
func (c *Connector) resolutionKeyForNotice(reqID int, alias reqAliasEntry) string {
if key := c.subscriptionKeyForNotice(reqID, alias); key != "" {
return key
}
if reqID <= 0 || c.marketDataFarmImpaired() {
return ""
}
return c.pendingContractDetailsKey(reqID)
}
func (c *Connector) recordDataFarmNotice(code int, message string, asOf time.Time) {
farm, ok := dataFarmStatusFromNotice(code, message, asOf)
if !ok {
return
}
c.dataFarmMu.Lock()
if c.dataFarms == nil {
c.dataFarms = make(map[string]DataFarmStatus)
}
// A farm-level OK says nothing about the TWS<->IBKR link. This used to
// clear the connectivity mark on ANY ok notice, so a routine 2104
// ("market data farm connection is OK:usfarm") erased a 1100/2110 break
// — and because an impaired connectivity row short-circuits every farm
// type to unavailable in the status surface, that turned a real outage
// green. Only a connectivity notice writes the connectivity key now,
// which 1101/1102 already do through the generic path below: they carry
// type "connectivity" and the same forced "tws-server" name, so the
// overwrite and its impaired->ok stamp land there.
key := dataFarmKey(farm.Type, farm.Name)
prev, had := c.dataFarms[key]
if had && farmStatusImpaired(prev.Status) && farm.Status == "ok" {
c.farmRecoveryAt = time.Now()
}
if farmStatusImpaired(farm.Status) {
farm.impairedSince = farm.AsOf
if had && farmStatusImpaired(prev.Status) {
if !prev.impairedSince.IsZero() {
farm.impairedSince = prev.impairedSince
}
farm.warned = prev.warned
}
}
c.dataFarms[key] = farm
c.dataFarmMu.Unlock()
c.logDataFarmTransition(prev, had, farm)
c.warnLongDataFarmBreaks(asOf)
}
// FarmTransitionLog lets the connectors one process holds against the same
// TWS (the daemon's primary and bulk lanes, and each lane's successor after
// a reconnect) report a farm transition once. Every lane receives the same
// TWS-wide farm notices, so each line was written once per lane: two lanes
// doubled every break and recovery line on 2026-10-12. Each connector keeps
// its own farm rows; only the log line is shared. The zero value is ready.
//
// Owner instruction (Oliver, in chat, 2026-10-13 09:04 CEST): dependents of
// an owned outage log through the owner.
type FarmTransitionLog struct {
mu sync.Mutex
last map[string]string
}
const (
farmEventBreak = "break"
farmEventLong = "long"
farmEventRecover = "recover"
)
// claim reports whether this connector is the first to report event for the
// farm key since its last transition. A break is already told while the farm
// is reported broken or long-broken. A nil log claims everything.
func (l *FarmTransitionLog) claim(key, event string) bool {
if l == nil {
return true
}
l.mu.Lock()
defer l.mu.Unlock()
last := l.last[key]
if last == event || event == farmEventBreak && last == farmEventLong {
return false
}
if l.last == nil {
l.last = make(map[string]string)
}
l.last[key] = event
return true
}
// observeOK closes a reported break that recovered without a recovery line:
// a connectivity row cleared by 1101/1102, or a connector that first met the
// farm healthy. The next break is a new transition.
func (l *FarmTransitionLog) observeOK(key string) {
if l == nil {
return
}
l.mu.Lock()
defer l.mu.Unlock()
if last := l.last[key]; last == farmEventBreak || last == farmEventLong {
delete(l.last, key)
}
}
// SetFarmTransitionLog shares farm transition lines with the process's other
// connectors to the same TWS. Call before Start; nil logs every transition.
func (c *Connector) SetFarmTransitionLog(l *FarmTransitionLog) {
c.farmLog.Store(l)
}
// warnLongDataFarmBreaks gives each break that has outlived
// dataFarmBreakAttention its one WARN. Farm notices arrive on every flap of
// any farm and the scheduled-mode worker calls it each minute, so a lone
// long break is noticed within a minute in scheduled mode and on the next
// notice otherwise; status carries the live state in between.
func (c *Connector) warnLongDataFarmBreaks(now time.Time) {
var long []DataFarmStatus
c.dataFarmMu.Lock()
for key, farm := range c.dataFarms {
if farm.Type == "connectivity" || farm.warned || !farmStatusImpaired(farm.Status) {
continue
}
since := farm.impairedSince
if since.IsZero() {
since = farm.AsOf
}
if now.Sub(since) < dataFarmBreakAttention {
continue
}
farm.warned = true
c.dataFarms[key] = farm
long = append(long, farm)
}
c.dataFarmMu.Unlock()
for _, farm := range long {
if !c.farmLog.Load().claim(dataFarmKey(farm.Type, farm.Name), farmEventLong) {
continue
}
since := farm.impairedSince
if since.IsZero() {
since = farm.AsOf
}
c.logWarn("[cid=%d] IBKR %s has been %s for %s (code %d); requests it serves keep waiting", c.preferredClientID(), dataFarmLabel(farm), farm.Status, now.Sub(since).Round(time.Second), farm.Code)
}
}
func (c *Connector) preferredClientID() int {
if c.config != nil {
return c.config.PreferredClientID
}
return 0
}
func dataFarmLabel(farm DataFarmStatus) string {
return strings.ReplaceAll(farm.Type, "_", " ") + " data farm " + farm.Name
}
// logDataFarmTransition records farm breaks and recoveries at INFO, and warns
// once for the recovery of a break that lasted dataFarmBreakAttention (or had
// already warned for lasting it). Short flaps during IBKR's maintenance
// windows therefore leave no WARN; a farm that stops answering still leaves
// one WARN while broken (warnLongDataFarmBreaks) and one with the duration
// when it recovers. 1100/1101/1102 warn through the backend link tracker;
// 2110 is the one connectivity break that did not. A shared
// FarmTransitionLog writes each transition once across the process's lanes.
func (c *Connector) logDataFarmTransition(prev DataFarmStatus, had bool, farm DataFarmStatus) {
wasImpaired := had && farmStatusImpaired(prev.Status)
label := dataFarmLabel(farm)
cid := c.preferredClientID()
shared := c.farmLog.Load()
key := dataFarmKey(farm.Type, farm.Name)
switch {
case farm.Type == "connectivity":
if farm.Code == 2110 && !wasImpaired && shared.claim(key, farmEventBreak) {
c.logWarn("[cid=%d] TWS reports its connection to IBKR broken (code 2110)", cid)
} else if !farmStatusImpaired(farm.Status) {
shared.observeOK(key)
}
case farmStatusImpaired(farm.Status) && !wasImpaired:
if !shared.claim(key, farmEventBreak) {
return
}
c.logInfo("[cid=%d] IBKR %s is %s (code %d); requests it serves wait until it recovers", cid, label, farm.Status, farm.Code)
case wasImpaired && !farmStatusImpaired(farm.Status):
if !shared.claim(key, farmEventRecover) {
return
}
since := prev.impairedSince
if since.IsZero() {
since = prev.AsOf
}
outage := farm.AsOf.Sub(since)
if prev.warned || outage >= dataFarmBreakAttention {
c.logWarn("[cid=%d] IBKR %s recovered (%s, code %d) after %s", cid, label, farm.Status, farm.Code, outage.Round(time.Second))
} else {
c.logInfo("[cid=%d] IBKR %s recovered (%s, code %d) after %s", cid, label, farm.Status, farm.Code, outage.Round(time.Second))
}
case !farmStatusImpaired(farm.Status):
shared.observeOK(key)
}
}
func dataFarmStatusFromNotice(code int, message string, asOf time.Time) (DataFarmStatus, bool) {
farmType, ok := dataFarmTypeForCode(code, message)
if !ok {
return DataFarmStatus{}, false
}
status := dataFarmStatusForCode(code)
if status == "" {
return DataFarmStatus{}, false
}
name := dataFarmNameFromMessage(message)
// Connectivity notices name the TWS<->IBKR link, never a farm. 1102's
// become the name and mint a key that 1100's broken mark never matches.
if farmType == "connectivity" {
name = "tws-server"
} else if name == "" {
switch farmType {
case "security_definition":
name = "secdef"
default:
name = farmType
}
}
if asOf.IsZero() {
asOf = time.Now()
}
return DataFarmStatus{
Name: name,
Type: farmType,
Status: status,
Code: code,
Message: message,
AsOf: asOf,
}, true
}
func dataFarmTypeForCode(code int, message string) (string, bool) {
switch code {
case 2103, 2104, 2108, 2119:
return "market", true
case 2105, 2106, 2107:
return "historical", true
case 2157, 2158:
return "security_definition", true
case 1100, 1101, 1102, 2110:
// 1100/1101/1102 word the link as "between IBKR and Trader
// Workstation", so the "connectivity between tws" fallback below
// (2110's wording) never matches them; they must be code-mapped.
// before TWS flushed queued code=200 answers for pending
return "connectivity", true
}
msg := strings.ToLower(message)
switch {
case strings.Contains(msg, "hmds") || strings.Contains(msg, "historical data farm"):
return "historical", true
case strings.Contains(msg, "sec-def") || strings.Contains(msg, "security definition data farm"):
return "security_definition", true
case strings.Contains(msg, "market data farm"):
return "market", true
case strings.Contains(msg, "connectivity between tws"):
return "connectivity", true
default:
return "", false
}
}
func dataFarmStatusForCode(code int) string {
switch code {
case 1101, 1102, 2104, 2106, 2119, 2158:
return "ok"
case 2107, 2108:
return "inactive"
case 2103, 2105, 2157:
return "disconnected"
case 1100, 2110:
return "broken"
default:
return ""
}
}
func dataFarmNameFromMessage(message string) string {
if idx := strings.LastIndex(message, ":"); idx >= 0 && idx+1 < len(message) {
name := strings.TrimSpace(message[idx+1:])
name = strings.Trim(name, ".")
return name
}
return ""
}
func dataFarmKey(farmType, name string) string {
return strings.ToLower(strings.TrimSpace(farmType)) + "\x00" + strings.ToLower(strings.TrimSpace(name))
}
// mdReplaySpec captures the exact wire-request shape of a shared market-data
type mdReplaySpec struct {
contract Contract
genericTicks string
symbol string
primaryExch string
}
type mdReplayEntry struct {
key string
sub *Subscription
oldReqID int
spec mdReplaySpec
}
// handleBackendConnectivityNotice tracks the TWS<->IBKR backend link.
// 1100: link lost — refuse new order transmissions until a restore notice.
// 1102: restored with server-side state maintained — nothing to replay.
// 1101: restored with subscriptions LOST server-side — the gateway will never
// resume ticking the old request IDs, so the post action replays every live
// shared market-data subscription and force-rebuilds the account-updates and
// daily-P&L streams.
func (c *Connector) handleBackendConnectivityNotice(origin ConnectorSessionBinding, note *systemNotification) func() {
switch note.code {
case 1100:
c.setBackendConnectivityDown(true, note.timestamp)
return nil
case 1102:
c.recordBackendConnectivity(false, note.timestamp, note.code)
return nil
case 1101:
c.recordBackendConnectivity(false, note.timestamp, note.code)
return func() { go c.recoverFromBackendDataLoss(origin) }
default:
return nil
}
}
func (c *Connector) setBackendConnectivityDown(down bool, at time.Time) {
c.recordBackendConnectivity(down, at, 0)
}
func (c *Connector) recordBackendConnectivity(down bool, at time.Time, restoreCode int) {
defer c.display.notify()
if at.IsZero() {
at = time.Now()
}
c.backendConnMu.Lock()
changed := c.backendConnDown != down
if !changed {
// A repeated notice is the same episode; keep the transition instant
// so a later restore pairs against the episode's true start.
c.backendConnMu.Unlock()
return
}
gap := c.epGap
if gap <= 0 {
gap = backendEpisodeGap
}
if down {
c.backendConnLosses++
inWindow := maintenanceWindowsContain(c.maintWindows, at)
if inWindow {
c.backendConnLossesInWindow++
}
sessionOpen := c.backendSessionOpen != nil && c.backendSessionOpen(at)
c.epGen++
if c.epTimer != nil {
c.epTimer.Stop()
c.epTimer = nil
}
newEpisode := !c.epActive
if newEpisode {
c.epActive = true
c.backendEpisodeWarned = false
c.epStart = at
c.epLosses = 0
c.epLossesInWindow = 0
c.epLongest = 0
}
c.epLosses++
if inWindow {
c.epLossesInWindow++
}
episodeLosses := c.epLosses
quiet := c.backendLogRequired != nil && !c.backendLogRequired(at, at)
if c.backendLogRequired != nil {
sessionOpen = !quiet
}
c.backendLossWarned = !quiet && (sessionOpen || newEpisode)
c.backendEpisodeWarned = c.backendEpisodeWarned || c.backendLossWarned
c.backendConnDown = true
c.backendConnAt = at
c.backendConnMu.Unlock()
suffix := ""
if inWindow {
suffix += " (inside IBKR maintenance window)"
}
msg := "TWS lost connectivity to the IBKR backend (code 1100); waiting for a 1101/1102 restore notice" + suffix
switch {
case quiet:
c.logInfo("%s — outside configured gateway duty windows", msg)
case sessionOpen:
// A loss with the session open is an order-transmission hole —
// always loud, even mid-episode.
c.logWarn("%s — during open trading session", msg)
case newEpisode:
c.logWarn("%s", msg)
default:
c.logInfo("%s (episode loss %d)", msg, episodeLosses)
}
return
}
outageStart := c.backendConnAt
var outage time.Duration
if !c.backendConnAt.IsZero() {
outage = max(at.Sub(c.backendConnAt), 0)
c.backendConnLastOutage = outage
c.backendConnLongestOutage = max(c.backendConnLongestOutage, outage)
c.epLongest = max(c.epLongest, outage)
}
losses := c.backendConnLosses
firstOfEpisode := c.epActive && c.epLosses == 1
lossWarned := c.backendLossWarned
required := c.backendLogRequired == nil || c.backendLogRequired(outageStart, at)
if required {
c.backendEpisodeWarned = c.backendEpisodeWarned || outage > backendOutageAttention || firstOfEpisode
}
c.backendConnDown = false
c.backendConnAt = at
c.epLastRestore = at
if c.epActive {
gen := c.epGen
c.epTimer = time.AfterFunc(gap, func() { c.finalizeBackendEpisode(gen) })
}
c.backendConnMu.Unlock()
// Cancel echoes drawn by requests the dead link never registered were
// kept at debug; account for them once, on the bookend.
echoes := ""
if c.conn != nil {
if n := c.conn.takeSuppressedCancelEchoes(); n > 0 {
echoes = fmt.Sprintf("; %d cancel echoes (code 300) kept in debug logs", n)
}
if n := c.conn.takeSuppressedDefinitionMisses(); n > 0 {
echoes += fmt.Sprintf("; %d definition misses (code 200) kept in debug logs", n)
}
}
switch {
case !lossWarned && !required:
c.logInfo("TWS restored connectivity to the IBKR backend after %s (loss %d this session, restore_code=%d) — outside configured gateway duty windows%s", outage.Round(time.Second), losses, restoreCode, echoes)
case outage > backendOutageAttention:
// A blip heals in seconds; anything past the threshold was a real
// hole in availability and stays loud regardless of episode state.
c.logWarn("TWS restored connectivity to the IBKR backend after %s (loss %d this session, restore_code=%d) — outage exceeded %s%s", outage.Round(time.Second), losses, restoreCode, backendOutageAttention, echoes)
case firstOfEpisode || lossWarned:
// WARN, not INFO: this pairs the episode's opening loss warning and
// must survive the warn default log level.
c.logWarn("TWS restored connectivity to the IBKR backend after %s (loss %d this session, restore_code=%d)%s", outage.Round(time.Second), losses, restoreCode, echoes)
default:
c.logInfo("TWS restored connectivity to the IBKR backend after %s (loss %d this session, restore_code=%d)%s", outage.Round(time.Second), losses, restoreCode, echoes)
}
}
// BackendLinkReport summarizes the TWS-to-IBKR upstream link: the live latch
// plus loss/outage counters accumulated since the connector started.
// LossesInMaintenance counts the subset of Losses that began inside a
// configured broker maintenance window — expected, not incident evidence.
type BackendLinkReport struct {
Down bool
ChangedAt time.Time
Losses int
LossesInMaintenance int
LastOutage time.Duration
LongestOutage time.Duration
}
// BackendLink returns the current upstream-link latch and its session-scoped
// flap counters.
func (c *Connector) BackendLink() BackendLinkReport {
if c == nil {
return BackendLinkReport{}
}
c.backendConnMu.Lock()
defer c.backendConnMu.Unlock()
return BackendLinkReport{
Down: c.backendConnDown,
ChangedAt: c.backendConnAt,
Losses: c.backendConnLosses,
LossesInMaintenance: c.backendConnLossesInWindow,
LastOutage: c.backendConnLastOutage,
LongestOutage: c.backendConnLongestOutage,
}
}
func (c *Connector) backendConnectivityDown() (bool, time.Time) {
c.backendConnMu.Lock()
defer c.backendConnMu.Unlock()
return c.backendConnDown, c.backendConnAt
}
// recoverFromBackendDataLoss is the 1101 post action. Exact-session
// subscriptions (SessionEpoch != 0) are excluded: they are short-lived
// broker-write evidence bound to one request, and their owners fail and
// retry themselves. Runs off the read loop; the in-flight guard makes a
// duplicate 1101 a no-op instead of a double replay.
func (c *Connector) recoverFromBackendDataLoss(origin ConnectorSessionBinding) {
if !c.SessionCurrent(origin) {
return
}
if !c.mdReplayInFlight.CompareAndSwap(false, true) {
c.logDebug("1101 subscription replay already in flight; skipping duplicate")
return
}
defer c.mdReplayInFlight.Store(false)
replayed, dropped := c.replayMarketDataSubscriptions(origin)
if replayed > 0 || dropped > 0 {
c.logInfo("Replayed %d market-data subscriptions after 1101 data loss (%d dropped for demand re-subscribe)", replayed, dropped)
}
if !c.SessionCurrent(origin) {
return
}
c.acctUpdatesMu.Lock()
hadAcctStream := !c.acctUpdatesLastAt.IsZero()
c.acctUpdatesMu.Unlock()
if hadAcctStream {
_ = c.resubscribeAccountUpdatesForSession(origin)
}
c.forceResubscribeDailyPnLForSession(origin)
}
func (c *Connector) replayMarketDataSubscriptions(origin ConnectorSessionBinding) (replayed, dropped int) {
var entries []mdReplayEntry
c.subMu.RLock()
for key, sub := range c.subscriptions {
if sub == nil || sub.SessionEpoch != 0 || sub.ReqID == 0 || sub.replaySpec == nil {
continue
}
if sub.rejectedReqID == sub.ReqID {
// The gateway already tore this ticker down terminally; the
continue
}
entries = append(entries, mdReplayEntry{key: key, sub: sub, oldReqID: sub.ReqID, spec: *sub.replaySpec})
}
c.subMu.RUnlock()
for _, e := range entries {
if origin.connection == nil || !c.SessionCurrent(origin) {
// Socket bounced mid-replay; the successor session rebuilds
return replayed, dropped
}
// The backend has already dropped this ticker. Reuse the common replay
// path, retaining delayed mode and its observation clocks when applicable.
c.subMu.Lock()
if c.subscriptions[e.key] != e.sub || e.sub.ReqID != e.oldReqID {
c.subMu.Unlock()
continue
}
e.sub.rejectedReqID = e.oldReqID
mode := 0
if e.sub.delayedFallback {
mode = 4
}
c.subMu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
err := c.replaceSharedQuote(ctx, origin, e.key, e.sub, e.oldReqID, mode)
cancel()
if err != nil {
c.removeSharedQuoteRequest(e.key, e.sub, e.oldReqID)
dropped++
c.logWarn("Failed to replay market data for %s after 1101 (%v); dropped for demand re-subscribe", e.key, err)
continue
}
replayed++
}
return replayed, dropped
}
// recoverSharedMarketDataAfter10197 replays the one shared subscription that
// triggered IBKR's competing-session transition. Error 10197 kills the
// original reqID, while reqMarketDataType(3) applies only to later requests;
// without this replay the first symbol stays empty and only subsequent symbols
// replacement, while failure signals the original poller with typed 10197.
func (c *Connector) recoverSharedMarketDataAfter10197(origin ConnectorSessionBinding, reqID int, message string) bool {
entry, ok := c.sharedMarketDataReplayEntry(reqID)
if !ok {
return false
}
// The gateway has already killed oldReqID. Release its slot before
// teardown never sends the futile cancel that would draw error 300.
c.markSubscriptionRejected(entry.oldReqID)
if origin.connection != nil {
origin.connection.releaseMarketDataSlotAtEpoch(entry.oldReqID, origin.epoch)
}
contract, genericTicks, err := marketDataReplayRequest(entry.spec)
if err != nil || origin.connection == nil || !c.SessionCurrent(origin) {
c.pushSubscriptionRejection(entry.oldReqID, 10197, message)
return true
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
adopted := false
newReqID, requestErr := origin.connection.requestSharedMarketDataWithContractForEpoch(
ctx, contract, genericTicks, origin.epoch,
func(newReqID int) func() {
c.subMu.Lock()
current := c.subscriptions[entry.key]
if current == entry.sub && current.ReqID == entry.oldReqID {
delete(c.reqIDMap, entry.oldReqID)
c.reqIDMap[newReqID] = entry.key
resetSubscriptionObservations(current)
current.ReqID = newReqID
current.LastTime = time.Now()
adopted = true
}
c.subMu.Unlock()
return func() {
if !adopted {
return
}
c.subMu.Lock()
current := c.subscriptions[entry.key]
if current == entry.sub && current.ReqID == newReqID {
delete(c.reqIDMap, newReqID)
c.reqIDMap[entry.oldReqID] = entry.key
current.ReqID = entry.oldReqID
current.rejectedReqID = entry.oldReqID
}
c.subMu.Unlock()
}
},
)
if requestErr == nil && newReqID != 0 && adopted {
c.logInfo("Replayed market data for %s under delayed mode after IBKR 10197", entry.key)
return true
}
if requestErr == nil && newReqID != 0 {
_ = origin.connection.cancelMarketDataForEpoch(context.Background(), newReqID, origin.epoch)
}
c.pushSubscriptionRejection(entry.oldReqID, 10197, message)
c.logWarn("Failed to replay market data for %s after IBKR 10197; leaving it unavailable", entry.key)
return true
}
func (c *Connector) sharedMarketDataReplayEntry(reqID int) (mdReplayEntry, bool) {
if reqID <= 0 {
return mdReplayEntry{}, false
}
c.subMu.Lock()
defer c.subMu.Unlock()
key := c.reqIDMap[reqID]
sub := c.subscriptions[key]
if key == "" || sub == nil || sub.ReqID != reqID || sub.SessionEpoch != 0 || sub.replaySpec == nil || sub.replayedAfter10197 {
return mdReplayEntry{}, false
}
sub.replayedAfter10197 = true
return mdReplayEntry{key: key, sub: sub, oldReqID: reqID, spec: *sub.replaySpec}, true
}
func marketDataReplayRequest(spec mdReplaySpec) (Contract, string, error) {
if spec.symbol == "" {
if strings.TrimSpace(spec.contract.Symbol) == "" {
return Contract{}, "", errors.New("market-data replay contract has no symbol")
}
genericTicks := spec.genericTicks
if genericTicks == "" && !isBondMarketDataContract(spec.contract) {
genericTicks = sharedGenericTicks
}
return spec.contract, genericTicks, nil
}
symbol := strings.ToUpper(strings.TrimSpace(spec.symbol))
if symbol == "" {
return Contract{}, "", errors.New("market-data replay symbol is empty")
}
secType, exchange, currency, primaryExchange := classifySymbol(symbol)
if spec.primaryExch != "" {
primaryExchange = spec.primaryExch
}
localSymbol, tradingClass := contractDisplayHints(symbol, secType)
wireSymbol := dualClassWireSymbol(symbol)
if base, _, ok := FxPair(symbol); ok {
wireSymbol = base
}
contract := Contract{
Symbol: wireSymbol, SecType: secType, Exchange: exchange,
PrimaryExch: primaryExchange, Currency: currency,
LocalSymbol: localSymbol, TradingClass: tradingClass,
}
if contract.SecType == "STK" && spec.primaryExch == "" {
contract.PrimaryExch = ""
}
return contract, sharedGenericTicks, nil
}
type retiredMarketDataSubscription struct {
connection *Connection
epoch uint64
reqID int
cancel bool
}
func (retired retiredMarketDataSubscription) run() {
if retired.connection == nil || retired.reqID <= 0 {
return
}
if retired.cancel {
// Epoch-bound cancellation either writes on the originating socket or
// returns a definite stale refusal. It always releases only that epoch's
// slot, never a successor's reused reqID.
_ = retired.connection.cancelMarketDataForEpoch(context.Background(), retired.reqID, retired.epoch)
return
}
retired.connection.releaseMarketDataSlotAtEpoch(retired.reqID, retired.epoch)
}
func (c *Connector) detachSubscription(symbol string) func() {
if symbol == "" {
return nil
}
upper := strings.ToUpper(symbol)
// Lift the cancel target under the lock, then release before calling
c.subMu.Lock()
var cancelReqID, releaseReqID int
var subscriptionEpoch uint64
if sub, ok := c.subscriptions[upper]; ok {
subscriptionEpoch = sub.SessionEpoch
// Same wire-cancel exception as UnsubscribeMarketData: a reqID
// the gateway already reported dead only draws error 300 on
if wireCancelNeeded(sub) {
cancelReqID = sub.ReqID
} else {
releaseReqID = sub.ReqID
}
if sub.liveRetryTimer != nil {
sub.liveRetryTimer.Stop()
}
delete(c.subscriptions, upper)
}
for reqID, sym := range c.reqIDMap {
if strings.EqualFold(sym, upper) {
delete(c.reqIDMap, reqID)
}
}
conn := c.conn
epoch := uint64(0)
if conn != nil {
epoch = conn.BrokerSessionEpoch()
}
if subscriptionEpoch != 0 {
epoch = subscriptionEpoch
}
c.subMu.Unlock()
c.optMu.Lock()
for reqID, sym := range c.optReqIDs {
if strings.EqualFold(sym, upper) {
delete(c.optReqIDs, reqID)
}
}
c.optMu.Unlock()
retired := retiredMarketDataSubscription{connection: conn, epoch: epoch}
switch {
case cancelReqID != 0:
retired.reqID, retired.cancel = cancelReqID, true
case releaseReqID != 0:
retired.reqID = releaseReqID
default:
return nil
}
return retired.run
}
// SetMarketDataType requests the market-data mode for subsequent requests:
// It returns an error when no broker connection is active or the write fails.
func (c *Connector) SetMarketDataType(dataType int) error {
if c == nil {
return fmt.Errorf("IBKR connector not available")
}
c.marketDataModeMu.Lock()
defer c.marketDataModeMu.Unlock()
return c.setMarketDataType(dataType)
}
func (c *Connector) setMarketDataType(dataType int) error {
c.mu.RLock()
conn := c.conn
c.mu.RUnlock()
if conn == nil || !conn.IsConnected() {
return fmt.Errorf("IBKR connection not available")
}
if dataType == 1 && conn.HasCompetingLiveSession() {
connectorLogger.Warnf("%s: Live market data blocked by competing session; forcing delayed mode", c.name)
dataType = 3
}
return conn.SetMarketDataType(dataType)
}
// BeginDelayedMarketDataFallback temporarily changes subsequent market-data
// requests to IBKR delayed mode and force-refreshes symbol's rejected shared
// subscription. It is intentionally narrow: callers must have already
// observed a typed entitlement rejection and must release the returned lease
// IBKR returns live data even when type 3 was requested if the account is
// frozen-aware type-2 default unless this exact connection has since reported
func (c *Connector) BeginDelayedMarketDataFallback(ctx context.Context, symbol string) (func(), error) {
if c == nil {
return nil, fmt.Errorf("IBKR connector not available")
}
if ctx == nil {
ctx = context.Background()
}
symbol = strings.ToUpper(strings.TrimSpace(symbol))
if symbol == "" {
return nil, fmt.Errorf("market-data fallback symbol is required")
}
conn, release, err := c.beginDelayedMarketDataMode(ctx)
if err != nil {
return nil, err
}
// Code 354 is a verdict on the rejected request, not on the delayed
// request we are about to make. Rearm only that typed absence; every other
var rearmedAbsence *marketDataAbsence
c.absenceMu.Lock()
if absent, ok := c.mktDataAbsent[symbol]; ok && absent.code == 354 {
copy := absent
rearmedAbsence = ©
delete(c.mktDataAbsent, symbol)
}
c.absenceMu.Unlock()
restoreRearmedAbsence := func() {
if rearmedAbsence == nil {
return
}
c.absenceMu.Lock()
if _, replaced := c.mktDataAbsent[symbol]; !replaced {
c.mktDataAbsent[symbol] = *rearmedAbsence
}
c.absenceMu.Unlock()
}
if _, err := c.ensureMarketDataSubscription(ctx, symbol, nil, 0, true); err != nil {
restoreRearmedAbsence()
release()
return nil, fmt.Errorf("refresh %s under delayed market data: %w", symbol, err)
}
c.mu.RLock()
current := c.conn
c.mu.RUnlock()
if current != conn {
restoreRearmedAbsence()
release()
return nil, fmt.Errorf("IBKR connection changed during delayed market-data fallback")
}
return release, nil
}
// beginDelayedMarketDataMode switches the connection to IBKR delayed mode
// (type 3) for subsequent requests and holds the mode lock until release,
// which restores the frozen-aware default unless a competing live session
// has since been reported. The connection it switched is returned so the
// caller can notice a reconnect under the lease.
func (c *Connector) beginDelayedMarketDataMode(ctx context.Context) (*Connection, func(), error) {
if err := c.lockMarketDataMode(ctx); err != nil {
return nil, nil, err
}
c.mu.RLock()
conn := c.conn
ready := c.ready
c.mu.RUnlock()
if !ready || conn == nil || !conn.IsConnected() {
c.marketDataModeMu.Unlock()
return nil, nil, fmt.Errorf("IBKR connection not available")
}
if err := conn.SetMarketDataType(3); err != nil {
c.marketDataModeMu.Unlock()
return nil, nil, fmt.Errorf("request delayed market data: %w", err)
}
var once sync.Once
release := func() {
once.Do(func() {
c.mu.RLock()
current := c.conn
c.mu.RUnlock()
if current == conn {
if err := conn.restoreFrozenMarketDataTypeUnlessCompeting(); err != nil {
connectorLogger.Warnf("%s: Failed to restore frozen-aware market data after delayed fallback: %v", c.name, err)
}
}
c.marketDataModeMu.Unlock()
})
}
return conn, release, nil
}
// BeginDelayedHistoricalData puts the connection in IBKR delayed mode for a
// history read the broker refused for want of a market data permission: in
// delayed mode IBKR serves an exchange's bars to an account without its
// subscription, and a daily close is the same bar twenty minutes later. The
// caller reads under the lease and releases it; no quote subscription is
// touched (owner, 2026-10-31: no Xetra subscription, the trend fund's line
// reads delayed).
func (c *Connector) BeginDelayedHistoricalData(ctx context.Context) (func(), error) {
if c == nil {
return nil, fmt.Errorf("IBKR connector not available")
}
if ctx == nil {
ctx = context.Background()
}
_, release, err := c.beginDelayedMarketDataMode(ctx)
return release, err
}
func (c *Connector) lockMarketDataMode(ctx context.Context) error {
if err := ctx.Err(); err != nil {
return err
}
ticker := time.NewTicker(50 * time.Millisecond)
defer ticker.Stop()
for {
if c.marketDataModeMu.TryLock() {
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
}
}
}
// Start attaches lifecycle handlers and attempts to open the Connector's
// broker connection. It returns an error when already started. An initial
// connection failure leaves the Connector running but not ready and is exposed
// through [Connector.LastError], so that failure does not make Start fail.
func (c *Connector) Start(ctx context.Context) error {
c.mu.Lock()
if c.running {
c.mu.Unlock()
return fmt.Errorf("connector already running")
}
c.mu.Unlock()
// Connector start/stop narration and the degraded-connect lines log at
// the gateway is down, so at INFO/WARN this floods ibkr-daemon.log
// failure via LastError() rather than this log line.
c.logDebug("Starting IBKR connector (client_id: %d)", c.config.PreferredClientID)
c.attachConnectionHooks(c.conn)
if err := c.conn.Connect(ctx); err != nil {
c.logDebug("Failed to connect to IBKR: %v", err)
c.logDebug("Running in degraded mode without IBKR connection")
c.mu.Lock()
c.running = true
c.lastError = err
c.mu.Unlock()
return nil
}
c.mu.Lock()
c.running = true
c.lastError = nil
c.mu.Unlock()
c.logInfo("IBKR connector started successfully (client_id: %d)", c.config.PreferredClientID)
return nil
}
// LastError returns the most recent connector startup error that left the
// connector in degraded mode. Empty means either healthy or no concrete
// connector-level diagnosis is available.
func (c *Connector) LastError() string {
c.mu.RLock()
defer c.mu.RUnlock()
if c.lastError == nil {
return ""
}
return c.lastError.Error()
}
// SessionStartFailure reports why the latest connect attempt's API session
// did not start after the Gateway accepted the socket and answered the
// version handshake, and how many messages the Gateway sent after START_API.
// It is readable while Start is still retrying, and nil before such a
// failure and once a session starts.
func (c *Connector) SessionStartFailure() (messages int, err error) {
c.mu.RLock()
conn := c.conn
c.mu.RUnlock()
return conn.SessionStartFailure()
}
// LastConnectError returns the error behind LastError with its wrapping
// intact, so a caller can classify it — for example with errors.Is against
// ErrRejectedBeforeHandshake. It is nil when the connector is healthy or
// has no connector-level diagnosis.
func (c *Connector) LastConnectError() error {
c.mu.RLock()
defer c.mu.RUnlock()
return c.lastError
}
func (c *Connector) attachConnectionHooks(conn *Connection) {
c.ensureHandlersRegistered(conn)
conn.SetOnConnect(func() {
c.onConnectionEstablished(conn)
})
conn.SetOnDisconnect(func(err error) {
c.onConnectionLost(conn)
})
// If the connection is already established (e.g., reused after a hot
if conn.IsConnected() {
c.onConnectionEstablished(conn)
}
}
func (c *Connector) onConnectionEstablished(conn *Connection) {
defer c.display.notify()
c.resetWSHMetadataReadiness()
c.evidenceBarrier.RLock()
c.invalidateUnstampedConnectorObservations(conn)
c.mu.Lock()
c.conn = conn
c.ready = true
c.lastError = nil
c.mu.Unlock()
c.evidenceBarrier.RUnlock()
}
func (c *Connector) onConnectionLost(conn *Connection) {
defer c.display.notify()
c.resetWSHMetadataReadiness()
c.evidenceBarrier.RLock()
c.mu.Lock()
if c.conn == conn {
c.ready = false
}
c.mu.Unlock()
c.invalidateUnstampedConnectorObservations(conn)
c.evidenceBarrier.RUnlock()
c.endSessionHistory(conn)
// Keep every epoch-aware inbound hook on the retired Connection. Stop has
// a bounded drain, so a decoded late frame must still reach exact-session
if c.pnl != nil {
c.pnl.mu.Lock()
c.pnl.accountReqID = 0
c.pnl.accountAcct = ""
c.pnl.accountStartedAt = time.Time{}
c.pnl.account = AccountDailyPnL{}
c.pnl.positionReqIDs = make(map[int]int)
c.pnl.positionByReqID = make(map[int]int)
c.pnl.positionSnapshot = make(map[int]PositionDailyPnL)
c.pnl.mu.Unlock()
}
}
// invalidateUnstampedConnectorObservations drops in-memory broker facts whose
// order lifecycle/journal correlation maps so late retired-session receipts
// account-derived caches must be repopulated by the successor session.
func (c *Connector) invalidateUnstampedConnectorObservations(conn *Connection) {
if c == nil || conn == nil {
return
}
c.mu.RLock()
owned := c.conn == conn
c.mu.RUnlock()
if !owned {
return
}
c.subMu.Lock()
for _, sub := range c.subscriptions {
if sub.liveRetryTimer != nil {
sub.liveRetryTimer.Stop()
}
}
clear(c.subscriptions)
clear(c.reqIDMap)
c.subMu.Unlock()
c.backendConnMu.Lock()
c.backendConnDown = false
c.backendConnAt = time.Time{}
c.backendConnMu.Unlock()
c.contractMu.Lock()
clear(c.contractCache)
c.contractMu.Unlock()
// reviewedTerminal is caller-owned evidence, not a session observation,
// so it deliberately survives here.
c.inactiveMu.Lock()
clear(c.inactiveSymbols)
clear(c.inactiveCandidates)
c.inactiveMu.Unlock()
c.absenceMu.Lock()
clear(c.mktDataAbsent)
c.absenceMu.Unlock()
c.optMu.Lock()
clear(c.optIV)
clear(c.optIVDataType)
clear(c.optReqIDs)
clear(c.optQuoteBid)
clear(c.optQuoteAsk)
clear(c.optPrevClose)
clear(c.optGreeks)
clear(c.optUnderlyingPx)
c.optMu.Unlock()
c.dataFarmMu.Lock()
clear(c.dataFarms)
c.dataFarmMu.Unlock()
c.acctUpdatesMu.Lock()
c.acctUpdatesLastAt = time.Time{}
c.acctUpdatesAccount = ""
c.acctUpdatesMu.Unlock()
c.pnlResubMu.Lock()
c.pnlResubLastAt = time.Time{}
c.pnlResubMu.Unlock()
}
func (c *Connector) ensureHandlersRegistered(conn *Connection) {
if c == nil || conn == nil {
return
}
c.handlerRegistrationMu.Lock()
if c.handlerRegistrations == nil {
c.handlerRegistrations = make(map[*Connection]struct{})
}
if _, ok := c.handlerRegistrations[conn]; ok {
c.handlerRegistrationMu.Unlock()
return
}
// Keep the install mutex through the full fixed handler set. A concurrent
// attach must not observe the Connection as registered and start its reader
// while only a prefix of lifecycle handlers exists.
c.handlerRegistrations[conn] = struct{}{}
c.registerHandlers(conn)
c.handlerRegistrationMu.Unlock()
}
// MarketDataTypeForSymbol returns the latest gateway data-type notice for the
// symbol's active subscription: 1=live, 2=frozen, 3=delayed,
// 4=delayed-frozen, or 0 when the subscription or notice is absent.
func (c *Connector) MarketDataTypeForSymbol(symbol string) int {
c.subMu.RLock()
defer c.subMu.RUnlock()
sub := c.subscriptions[strings.ToUpper(symbol)]
if sub == nil || sub.ReqID == 0 {
return 0
}
if sub.previousQuote != nil && sub.LastPriceTickAt.IsZero() {
return sub.previousDataType
}
return c.subscriptionDataType(sub)
}
func (c *Connector) subscriptionDataType(sub *Subscription) int {
notice := 0
c.mu.RLock()
conn := c.conn
c.mu.RUnlock()
if conn != nil {
notice = conn.MarketDataType(sub.ReqID)
}
if sub.delayedTicks && notice != 3 && notice != 4 {
return 3
}
return notice
}
// ContractDetailsLite contains the routing, identity, schedule, and price-tick
// fields decoded from a broker contract-details response. Option-specific
type ContractDetailsLite struct {
ValidExchanges string
MarketRuleIDs string
ReqID int
Symbol string
SecType string
Expiry string
Strike float64
Right string
Exchange string
PrimaryExch string
Currency string
ConID int
UnderConID int
LocalSymbol string
TradingClass string
Multiplier int
Industry string
Category string
Subcategory string
StockType string
TimeZoneID string
TradingHours string
LiquidHours string
// MinTick is the venue's minimum price increment for the contract.
MinTick float64
}
// ResolvedOrderContract is an unambiguous broker contract-details identity
// captured on one exact Connector session. Contract always has a positive
// ConID; MinTick is zero only when the broker omitted it.
type ResolvedOrderContract struct {
ValidExchanges string
MarketRuleIDs string
UnderConID int
Industry string
Category string
StockType string
Contract Contract
MinTick float64
TimeZoneID string
// TradingHours and LiquidHours are the broker's hours lists in TimeZoneID;
// Add reads an option class's own close from them.
TradingHours string
LiquidHours string
}
// SessionWindows reads the contract's liquid hours, else its trading hours.
func (r ResolvedOrderContract) SessionWindows() ([]TradingWindow, bool) {
windows, _, ok := sessionWindows(r.TimeZoneID, r.LiquidHours, r.TradingHours)
return windows, ok
}
// ResolveOrderContractForSession resolves a symbol/option description to one
// exact positive-ConID identity. Epoch-aware handlers and an epoch-bound
func (c *Connector) ResolveOrderContractForSession(ctx context.Context, binding ConnectorSessionBinding, contract Contract, timeout time.Duration) (ResolvedOrderContract, error) {
if c == nil || !c.SessionCurrent(binding) {
return ResolvedOrderContract{}, fmt.Errorf("broker session changed before contract resolution")
}
contract = normalizeMarketDataContract(contract)
contract.Symbol = strings.ToUpper(strings.TrimSpace(contract.Symbol))
contract.SecType = strings.ToUpper(strings.TrimSpace(contract.SecType))
contract.Currency = strings.ToUpper(strings.TrimSpace(contract.Currency))
if contract.SecType == "ETF" {
// ETF is a daemon/user classification. IBKR contract-details and order
// wire identity use STK for exchange-traded funds.
contract.SecType = "STK"
}
if contract.Symbol == "" || contract.SecType == "" || contract.Currency == "" {
return ResolvedOrderContract{}, fmt.Errorf("contract symbol, secType, and currency are required")
}
if timeout <= 0 {
timeout = 5 * time.Second
}
resolveCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
conn := binding.connection
reqID, err := conn.reserveRequestID(nil)
if err != nil {
return ResolvedOrderContract{}, err
}
defer conn.discardRequestIDReservation(reqID)
detailsCh := make(chan ContractDetailsLite, 64)
doneCh := make(chan struct{}, 1)
overflowCh := make(chan struct{}, 1)
serverVersion := conn.serverVersion
dataHandlerID := conn.RegisterHandlerAtEpoch(msgContractData, func(fields []string, receiptEpoch uint64) {
if receiptEpoch != binding.epoch {
return
}
if detail, ok := parseContractDetailsLite(fields, reqID, serverVersion); ok {
select {
case detailsCh <- *detail:
default:
select {
case overflowCh <- struct{}{}:
default:
}
}
}
})
endHandlerID := conn.RegisterHandlerAtEpoch(msgContractDataEnd, func(fields []string, receiptEpoch uint64) {
if receiptEpoch != binding.epoch || len(fields) < 3 {
return
}
id, _ := strconv.Atoi(strings.TrimSpace(fields[2]))
if id == reqID {
select {
case doneCh <- struct{}{}:
default:
}
}
})
defer conn.UnregisterHandler(msgContractData, dataHandlerID)
defer conn.UnregisterHandler(msgContractDataEnd, endHandlerID)
// A rejection addressed to this reqID ends the wait with the broker's
// verdict; without it a code 200 burned the whole timeout and came back
// as a deadline nobody could tell from a slow gateway.
req, releaseReq := c.registerContractDetailsRequest(reqID, "")
defer releaseReq()
if err := conn.sendContractDetailsRequestForEpoch(resolveCtx, contract, reqID, binding.epoch); err != nil {
return ResolvedOrderContract{}, err
}
details := make([]ContractDetailsLite, 0, 2)
for {
select {
case detail := <-detailsCh:
details = append(details, detail)
case err := <-req.fail:
return ResolvedOrderContract{}, err
case <-overflowCh:
return ResolvedOrderContract{}, fmt.Errorf("contract details overflow")
case <-doneCh:
for {
select {
case detail := <-detailsCh:
details = append(details, detail)
case <-overflowCh:
return ResolvedOrderContract{}, fmt.Errorf("contract details overflow")
default:
resolved, err := exactOrderContract(contract, details)
if err != nil {
return ResolvedOrderContract{}, err
}
if !c.SessionCurrent(binding) {
return ResolvedOrderContract{}, fmt.Errorf("broker session changed during contract resolution")
}
return resolved, nil
}
}
case <-resolveCtx.Done():
return ResolvedOrderContract{}, resolveCtx.Err()
}
}
}
func exactOrderContract(request Contract, details []ContractDetailsLite) (ResolvedOrderContract, error) {
request.Symbol = strings.ToUpper(strings.TrimSpace(request.Symbol))
request.SecType = strings.ToUpper(strings.TrimSpace(request.SecType))
request.Currency = strings.ToUpper(strings.TrimSpace(request.Currency))
request.Exchange = strings.ToUpper(strings.TrimSpace(request.Exchange))
request.PrimaryExch = strings.ToUpper(strings.TrimSpace(request.PrimaryExch))
request.LocalSymbol = strings.TrimSpace(request.LocalSymbol)
request.TradingClass = strings.TrimSpace(request.TradingClass)
var selected *ContractDetailsLite
for i := range details {
detail := details[i]
detail.Symbol = strings.ToUpper(strings.TrimSpace(detail.Symbol))
detail.SecType = strings.ToUpper(strings.TrimSpace(detail.SecType))
detail.Currency = strings.ToUpper(strings.TrimSpace(detail.Currency))
detail.Exchange = strings.ToUpper(strings.TrimSpace(detail.Exchange))
detail.PrimaryExch = strings.ToUpper(strings.TrimSpace(detail.PrimaryExch))
detail.LocalSymbol = strings.TrimSpace(detail.LocalSymbol)
detail.TradingClass = strings.TrimSpace(detail.TradingClass)
detail.Expiry = strings.TrimSpace(detail.Expiry)
detail.Right = strings.ToUpper(strings.TrimSpace(detail.Right))
if detail.ConID <= 0 || detail.Symbol != request.Symbol || detail.Currency != request.Currency ||
(request.ConID > 0 && detail.ConID != request.ConID) || !orderContractSecTypeMatches(request.SecType, detail.SecType) ||
!exactOrderContractRouteMatches(request, detail) {
continue
}
if request.SecType == "OPT" && (detail.Expiry != strings.TrimSpace(request.Expiry) || detail.Right != strings.ToUpper(strings.TrimSpace(request.Right)) ||
!sameResolvedStrike(detail.Strike, request.Strike) || detail.Multiplier <= 0 || (request.Multiplier > 0 && detail.Multiplier != request.Multiplier)) {
continue
}
if request.LocalSymbol != "" && !strings.EqualFold(request.LocalSymbol, detail.LocalSymbol) {
continue
}
if request.TradingClass != "" && !strings.EqualFold(request.TradingClass, detail.TradingClass) {
continue
}
if selected != nil {
if selected.ConID != detail.ConID || !strings.EqualFold(selected.Exchange, detail.Exchange) ||
!strings.EqualFold(selected.PrimaryExch, detail.PrimaryExch) || !strings.EqualFold(selected.LocalSymbol, detail.LocalSymbol) ||
!strings.EqualFold(selected.TradingClass, detail.TradingClass) || selected.UnderConID != detail.UnderConID || selected.Multiplier != detail.Multiplier {
return ResolvedOrderContract{}, fmt.Errorf("contract details are ambiguous")
}
continue
}
copy := detail
selected = ©
}
if selected == nil {
return ResolvedOrderContract{}, fmt.Errorf("no exact positive-ConID contract match")
}
resolved := request
resolved.ConID = selected.ConID
// Preserve the caller's execution route. Contract-details Exchange is an
// identity filter above, not authority to silently reroute a SMART/direct
// order. Only fill it when the request truly omitted one.
if resolved.Exchange == "" && selected.Exchange != "" {
resolved.Exchange = selected.Exchange
}
// For options PrimaryExch is an underlying discovery hint and is commonly
if resolved.PrimaryExch == "" && selected.PrimaryExch != "" {
resolved.PrimaryExch = selected.PrimaryExch
}
resolved.SecType = selected.SecType
if selected.Multiplier > 0 {
resolved.Multiplier = selected.Multiplier
}
if selected.LocalSymbol != "" {
resolved.LocalSymbol = selected.LocalSymbol
}
if selected.TradingClass != "" {
resolved.TradingClass = selected.TradingClass
}
return ResolvedOrderContract{ValidExchanges: selected.ValidExchanges, MarketRuleIDs: selected.MarketRuleIDs, Contract: resolved, UnderConID: selected.UnderConID, MinTick: selected.MinTick, TimeZoneID: selected.TimeZoneID, TradingHours: selected.TradingHours, LiquidHours: selected.LiquidHours, Industry: selected.Industry, Category: selected.Category, StockType: selected.StockType}, nil
}
// exactOrderContractRouteMatches applies caller-supplied routing as an
// primary exchange must match one of the broker's route identity fields; this
// fields are concrete, both constraints must hold.
func exactOrderContractRouteMatches(request Contract, detail ContractDetailsLite) bool {
reqExchange := strings.ToUpper(strings.TrimSpace(request.Exchange))
reqPrimary := strings.ToUpper(strings.TrimSpace(request.PrimaryExch))
detailExchange := strings.ToUpper(strings.TrimSpace(detail.Exchange))
detailPrimary := strings.ToUpper(strings.TrimSpace(detail.PrimaryExch))
if reqExchange != "" && reqExchange != "SMART" && reqExchange != detailExchange {
return false
}
if orderContractSecTypeMatches("STK", request.SecType) && reqPrimary != "" && reqPrimary != "SMART" && reqPrimary != detailPrimary {
return false
}
return true
}
func orderContractSecTypeMatches(request, broker string) bool {
request = strings.ToUpper(strings.TrimSpace(request))
broker = strings.ToUpper(strings.TrimSpace(broker))
return request == broker || (request == "ETF" && broker == "STK") || (request == "STK" && broker == "ETF")
}
func sameResolvedStrike(a, b float64) bool {
return math.Abs(a-b) < 1e-9
}
// ContractDetailsFirst returns the first contract-details row the gateway
// failures are returned to the caller.
func (c *Connector) ContractDetailsFirst(ctx context.Context, contract Contract, timeout time.Duration) (*ContractDetailsLite, error) {
if !c.isConnected() {
return nil, fmt.Errorf("not connected to IBKR")
}
c.mu.RLock()
conn := c.conn
c.mu.RUnlock()
if conn == nil {
return nil, fmt.Errorf("no active connection")
}
return conn.fetchContractDetailFirst(ctx, contract, timeout)
}
func mergeContractDetailsLite(base, incoming ContractDetailsLite) ContractDetailsLite {
if base.Symbol == "" {
base.Symbol = incoming.Symbol
}
if base.ConID == 0 {
base.ConID = incoming.ConID
}
if base.Exchange == "" && incoming.Exchange != "" {
base.Exchange = incoming.Exchange
}
if base.PrimaryExch == "" && incoming.PrimaryExch != "" {
base.PrimaryExch = incoming.PrimaryExch
}
if base.Currency == "" && incoming.Currency != "" {
base.Currency = incoming.Currency
}
if base.LocalSymbol == "" && incoming.LocalSymbol != "" {
base.LocalSymbol = incoming.LocalSymbol
}
if base.TradingClass == "" && incoming.TradingClass != "" {
base.TradingClass = incoming.TradingClass
}
if base.Industry == "" && incoming.Industry != "" {
base.Industry = incoming.Industry
}
if base.Category == "" && incoming.Category != "" {
base.Category = incoming.Category
}
if base.Subcategory == "" && incoming.Subcategory != "" {
base.Subcategory = incoming.Subcategory
}
if base.StockType == "" && incoming.StockType != "" {
base.StockType = incoming.StockType
}
if base.TimeZoneID == "" && incoming.TimeZoneID != "" {
base.TimeZoneID = incoming.TimeZoneID
}
if base.TradingHours == "" && incoming.TradingHours != "" {
base.TradingHours = incoming.TradingHours
}
if base.LiquidHours == "" && incoming.LiquidHours != "" {
base.LiquidHours = incoming.LiquidHours
}
if base.MinTick == 0 && incoming.MinTick > 0 {
base.MinTick = incoming.MinTick
}
return base
}
type inactiveSymbolState struct {
reason string
markedAt time.Time
}
type inactiveCandidateState struct {
count int
lastReason string
lastUpdated time.Time
}
const contractWarningWindow = 5 * time.Minute
type contractWarningState struct {
lastEmitted time.Time
suppressed int
}
func (c *Connector) coalesceContractWarning(key string, window time.Duration, message string) (string, bool) {
if window <= 0 {
window = contractWarningWindow
}
now := time.Now()
if c.contractWarningNow != nil {
now = c.contractWarningNow()
}
c.contractWarningMu.Lock()
defer c.contractWarningMu.Unlock()
if c.contractWarningState == nil {
c.contractWarningState = make(map[string]contractWarningState)
}
state := c.contractWarningState[key]
if !state.lastEmitted.IsZero() && now.Sub(state.lastEmitted) < window {
state.suppressed++
c.contractWarningState[key] = state
return "", false
}
if state.suppressed > 0 {
message += fmt.Sprintf(" (%d identical warnings suppressed)", state.suppressed)
}
state.lastEmitted = now
state.suppressed = 0
c.contractWarningState[key] = state
return message, true
}
// MarketDataKeyForContract returns the normalized cache and subscription key
// upper-case symbol only when it has no positive ConID; routed or exact
// contracts join symbol, security type, exchange, primary exchange, currency,
// local symbol, and trading class with "|", followed by CONID for exact
func MarketDataKeyForContract(contract Contract) string {
symbol := strings.ToUpper(strings.TrimSpace(contract.Symbol))
if symbol == "" {
return ""
}
secType := strings.ToUpper(strings.TrimSpace(contract.SecType))
if secType == "" {
secType = "STK"
}
exchange := strings.ToUpper(strings.TrimSpace(contract.Exchange))
primary := strings.ToUpper(strings.TrimSpace(contract.PrimaryExch))
currency := strings.ToUpper(strings.TrimSpace(contract.Currency))
localSymbol := strings.ToUpper(strings.TrimSpace(contract.LocalSymbol))
tradingClass := strings.ToUpper(strings.TrimSpace(contract.TradingClass))
if secType == "STK" &&
contract.ConID <= 0 &&
exchange == "" &&
primary == "" &&
currency == "" &&
localSymbol == "" &&
tradingClass == "" {
return symbol
}
parts := []string{symbol, secType, exchange, primary, currency, localSymbol, tradingClass}
if contract.ConID > 0 {
parts = append(parts, "CONID:"+strconv.Itoa(contract.ConID))
}
return strings.Join(parts, "|")
}
// DefaultMarketDataKeyForSymbol returns the same normalized route key used by
// the symbol-only subscription path after applying package routing defaults.
// It returns an empty string for a blank symbol.
func DefaultMarketDataKeyForSymbol(symbol string) string {
upper := strings.ToUpper(strings.TrimSpace(symbol))
if upper == "" {
return ""
}
secType, exchange, currency, primary := classifySymbol(upper)
wireSymbol := dualClassWireSymbol(upper)
if base, _, ok := FxPair(upper); ok {
wireSymbol = base
}
return MarketDataKeyForContract(Contract{
Symbol: wireSymbol,
SecType: secType,
Exchange: exchange,
PrimaryExch: primary,
Currency: currency,
})
}
func normalizeMarketDataContract(contract Contract) Contract {
contract.Symbol = strings.ToUpper(strings.TrimSpace(contract.Symbol))
contract.SecType = strings.ToUpper(strings.TrimSpace(contract.SecType))
if contract.SecType == "" {
contract.SecType = "STK"
}
contract.Exchange = strings.ToUpper(strings.TrimSpace(contract.Exchange))
contract.PrimaryExch = strings.ToUpper(strings.TrimSpace(contract.PrimaryExch))
contract.Currency = strings.ToUpper(strings.TrimSpace(contract.Currency))
if contract.Currency == "" {
contract.Currency = "USD"
}
contract.LocalSymbol = strings.TrimSpace(contract.LocalSymbol)
contract.TradingClass = strings.TrimSpace(contract.TradingClass)
if contract.Exchange == "" {
contract.Exchange = "SMART"
}
return contract
}
func (c *Connector) applyContractDetail(detail ContractDetailsLite, contract *Contract) bool {
if detail.Exchange != "" {
contract.Exchange = detail.Exchange
}
if detail.PrimaryExch != "" {
contract.PrimaryExch = detail.PrimaryExch
}
if detail.ConID != 0 {
contract.ConID = detail.ConID
}
if detail.LocalSymbol != "" {
contract.LocalSymbol = detail.LocalSymbol
} else if detail.ConID != 0 {
connectorLogger.Debugf("Contract detail for %s (conID=%d) missing local symbol", detail.Symbol, detail.ConID)
}
if detail.TradingClass != "" {
contract.TradingClass = detail.TradingClass
}
return contract.ConID != 0
}
func normalizeEquityRouting(contract *Contract, fallbackPrimary string) {
if contract == nil || contract.SecType != "STK" {
return
}
if contract.PrimaryExch == "" {
contract.PrimaryExch = fallbackPrimary
}
if contract.PrimaryExch == "" && contract.Exchange != "" && !strings.EqualFold(contract.Exchange, "SMART") {
contract.PrimaryExch = contract.Exchange
}
if contract.PrimaryExch != "" && strings.EqualFold(contract.PrimaryExch, "SMART") {
contract.PrimaryExch = ""
}
if contract.PrimaryExch != "" {
if strings.EqualFold(contract.Exchange, contract.PrimaryExch) || contract.Exchange == "" {
contract.Exchange = "SMART"
}
}
}
func (c *Connector) prepareContract(symbol string, fetchTimeout time.Duration, asyncWarm bool) (Contract, bool) {
start := time.Now()
upper := strings.ToUpper(symbol)
secType, exchange, currency, primary := classifySymbol(upper)
localSymbol, tradingClass := contractDisplayHints(upper, secType)
// FX pairs split the user-supplied "USD.JPY" into Symbol=USD,
// Currency=JPY on the wire; the dotted/slash string itself is not a
// valid IBKR symbol field. Dual-class shares (BRK.B, BF.B) get
// translated to IBKR's space-form for the same reason — see
// dualClassWireSymbol.
wireSymbol := dualClassWireSymbol(upper)
if base, _, ok := FxPair(upper); ok {
wireSymbol = base
}
contract := Contract{
Symbol: wireSymbol,
SecType: secType,
Exchange: exchange,
PrimaryExch: primary,
Currency: currency,
LocalSymbol: localSymbol,
TradingClass: tradingClass,
}
if reason, inactive := c.inactiveReason(upper); inactive {
c.logDebug("Skipping contract hydration for inactive symbol %s (%s)", upper, reason)
return contract, false
}
var hasDetail bool
c.contractMu.RLock()
if detail, ok := c.contractCache[upper]; ok {
hasDetail = c.applyContractDetail(detail, &contract)
}
c.contractMu.RUnlock()
if !hasDetail && fetchTimeout > 0 && c.conn != nil && c.conn.IsConnected() {
fetch := c.fetchContractDetails
if fetch == nil {
fetch = c.FetchContractDetails
}
if details, err := fetch(upper, fetchTimeout); err == nil && len(details) > 0 {
detail := details[0]
c.contractMu.Lock()
c.contractCache[upper] = detail
c.contractMu.Unlock()
hasDetail = c.applyContractDetail(detail, &contract)
}
}
if !hasDetail && asyncWarm {
go c.asyncWarmContractDetails(upper, fetchTimeout)
}
elapsed := time.Since(start)
c.recordContractTiming(symbol, elapsed, hasDetail && contract.ConID != 0)
normalizeEquityRouting(&contract, primary)
return contract, hasDetail
}
func (c *Connector) waitForContractDetails(symbol string, base Contract, detailsReady bool) (Contract, bool) {
upper := strings.ToUpper(symbol)
if (detailsReady && base.ConID != 0) || base.Symbol == "" {
return base, detailsReady || base.ConID != 0
}
deadline := time.Now().Add(contractHydrationWait)
contract := base
for contract.ConID == 0 && time.Now().Before(deadline) {
time.Sleep(contractHydrationPoll)
c.contractMu.RLock()
detail, ok := c.contractCache[upper]
c.contractMu.RUnlock()
if !ok {
continue
}
contractCopy := contract
if c.applyContractDetail(detail, &contractCopy) && contractCopy.ConID != 0 {
normalizeEquityRouting(&contractCopy, contract.PrimaryExch)
return contractCopy, true
}
}
return contract, detailsReady || contract.ConID != 0
}
func (c *Connector) asyncWarmContractDetails(symbol string, timeout time.Duration) {
symbol = strings.ToUpper(symbol)
if _, inactive := c.inactiveReason(symbol); inactive {
return
}
if timeout <= 0 {
timeout = 2 * time.Second
}
if details, err := c.FetchContractDetails(symbol, timeout); err == nil && len(details) > 0 {
c.contractMu.Lock()
c.contractCache[symbol] = details[0]
c.contractMu.Unlock()
c.clearInactiveCandidate(symbol)
c.logInfo("Cached contract details for %s (PrimaryExch=%s)", symbol, details[0].PrimaryExch)
}
}
const (
minServerVerMdSizeMultiplier = 110
minServerVerAggGroup = 121
minServerVerUnderlyingInfo = 122
minServerVerMarketRules = 126
minServerVerRealExpiration = 134
minServerVerStockType = 152
minServerVerFractionalSize = 163
minServerVerSizeRules = 164
minServerVerFundDataFields = 179
minServerVerLastTradeDate = 182
minServerVerIneligibility = 186
)
// SeedContractDetails adds a caller-supplied contract to the Connector's
// in-memory cache when symbol is non-empty, detail has a non-zero ConID, and no
// resolved entry is already cached for that symbol. It never replaces a live
// resolved entry and performs no broker request. The result reports whether the
// seed was applied.
func (c *Connector) SeedContractDetails(symbol string, detail ContractDetailsLite) bool {
if symbol == "" || detail.ConID == 0 {
return false
}
key := strings.ToUpper(strings.TrimSpace(symbol))
c.contractMu.Lock()
defer c.contractMu.Unlock()
if existing, ok := c.contractCache[key]; ok && existing.ConID != 0 {
return false
}
c.contractCache[key] = detail
return true
}
// FetchContractDetails returns cached contract details for symbol when a
// the broker's completion marker. Identical in-flight requests are coalesced;
func (c *Connector) FetchContractDetails(symbol string, timeout time.Duration) ([]ContractDetailsLite, error) {
symbol = strings.ToUpper(strings.TrimSpace(symbol))
if symbol == "" {
return nil, fmt.Errorf("symbol is required")
}
if _, inactive := c.inactiveReason(symbol); inactive {
c.logDebug("Contract details fetch skipped for inactive symbol %s", symbol)
return nil, ErrSymbolInactive
}
if cached := c.cachedContractDetail(symbol); cached != nil && cached.ConID != 0 {
c.logDebug("Contract details fetch satisfied from cache symbol=%s conID=%d", symbol, cached.ConID)
return []ContractDetailsLite{*cached}, nil
}
if missErr := c.contractResolutionMissFor(symbol); missErr != nil {
c.logDebug("Contract details fetch suppressed for %s (%v)", symbol, missErr)
return nil, missErr
}
details, err := c.coalesceContractDetails("symbol\x00"+symbol, timeout, func(wireTimeout time.Duration, observe func([]ContractDetailsLite)) ([]ContractDetailsLite, error) {
return c.fetchContractDetailsSymbolWire(symbol, wireTimeout, observe)
})
switch {
case errors.Is(err, ErrContractNoDefinition):
c.recordContractResolutionMiss(symbol)
case err == nil && len(details) > 0:
c.clearContractResolutionMiss(symbol)
}
return details, err
}
func (c *Connector) fetchContractDetailsSymbolWire(symbol string, timeout time.Duration, observe func([]ContractDetailsLite)) ([]ContractDetailsLite, error) {
if !c.isConnected() {
return nil, fmt.Errorf("IBKR connection not available")
}
// Prepare contract using the same classification as market data.
// Dual-class shares (BRK.B, BF.B) translate to IBKR's space-form
// before going on the wire — see dualClassWireSymbol.
secType, exchange, currency, primary := classifySymbol(symbol)
wireSymbol := dualClassWireSymbol(symbol)
if base, _, ok := FxPair(symbol); ok {
wireSymbol = base
}
contract := Contract{Symbol: wireSymbol, SecType: secType, Exchange: exchange, Currency: currency}
if primary != "" {
contract.PrimaryExch = primary
}
detailsCh := make(chan ContractDetailsLite, 10)
// The end marker is buffered: it arrives right behind the last detail,
// while this waiter may still be handling that detail.
doneCh := make(chan struct{}, 1)
// stopCh releases the connection reader once nothing drains detailsCh.
stopCh := make(chan struct{})
serverVersion := c.conn.serverVersion
reqID, err := c.conn.nextRequestID()
if err != nil {
return nil, err
}
c.logDebug("Contract details fetch start reqID=%d symbol=%s secType=%s exch=%s primary=%s currency=%s", reqID, symbol, contract.SecType, contract.Exchange, contract.PrimaryExch, contract.Currency)
// Register temporary handlers
dataHandlerID := c.conn.RegisterHandler(msgContractData, func(fields []string) {
if lite, ok := parseContractDetailsLite(fields, reqID, serverVersion); ok {
select {
case detailsCh <- *lite:
case <-stopCh:
}
}
})
endHandlerID := c.conn.RegisterHandler(msgContractDataEnd, func(fields []string) {
if len(fields) < 3 {
return
}
rid, _ := strconv.Atoi(safeGet(fields, 2))
if rid == reqID {
select {
case doneCh <- struct{}{}:
default:
}
}
})
req, releaseReq := c.registerContractDetailsRequest(reqID, resolutionKeyForSecType(symbol, secType))
defer releaseReq()
if err := c.conn.sendContractDetailsRequest(contract, reqID); err != nil {
c.conn.UnregisterHandler(msgContractData, dataHandlerID)
c.conn.UnregisterHandler(msgContractDataEnd, endHandlerID)
close(stopCh)
return nil, err
}
// Wait for completion
var results []ContractDetailsLite
deadline := time.After(timeout)
for {
select {
case d := <-detailsCh:
results = append(results, d)
if observe != nil {
observe(results)
}
case <-doneCh:
c.conn.UnregisterHandler(msgContractData, dataHandlerID)
c.conn.UnregisterHandler(msgContractDataEnd, endHandlerID)
results = drainQueuedContractDetails(detailsCh, results)
close(stopCh)
if len(results) == 0 {
c.logDebug("Contract details fetch complete reqID=%d symbol=%s (0 rows)", reqID, symbol)
} else {
c.clearInactiveCandidate(symbol)
first := results[0]
// Populate the cache so callers that discard the
// another path may have raced to populate.
c.contractMu.Lock()
if existing, ok := c.contractCache[symbol]; !ok || existing.ConID == 0 {
c.contractCache[symbol] = first
}
c.contractMu.Unlock()
c.logDebug("Contract details fetch success reqID=%d symbol=%s count=%d conID=%d exch=%s primary=%s local=%s class=%s",
reqID, symbol, len(results), first.ConID, first.Exchange, first.PrimaryExch, first.LocalSymbol, first.TradingClass)
}
c.noteContractDetailsOutcome(nil, time.Now())
return results, nil
case err := <-req.fail:
c.conn.UnregisterHandler(msgContractData, dataHandlerID)
c.conn.UnregisterHandler(msgContractDataEnd, endHandlerID)
close(stopCh)
c.logDebug("Contract details fetch rejected reqID=%d symbol=%s received=%d: %v", reqID, symbol, len(results), err)
c.noteContractDetailsOutcome(err, time.Now())
return results, err
case <-deadline:
c.deferContractDetailsCleanup(symbol, reqID, detailsCh, doneCh, stopCh, dataHandlerID, endHandlerID)
c.logDebug("Contract details fetch timeout reqID=%d symbol=%s received=%d", reqID, symbol, len(results))
c.noteContractDetailsOutcome(ErrContractDetailsTimeout, time.Now())
return results, ErrContractDetailsTimeout
}
}
}
// resolutionKeyForSecType returns the key a definition rejection for this
// directions when only one is listed.
func resolutionKeyForSecType(key, secType string) string {
switch strings.ToUpper(strings.TrimSpace(secType)) {
case "OPT", "FOP", "WAR", "BAG", "CASH":
return ""
}
return key
}
func (c *Connector) fetchContractDetailsForContract(contract Contract, timeout time.Duration) ([]ContractDetailsLite, error) {
contract = normalizeMarketDataContract(contract)
if contract.Symbol == "" {
return nil, fmt.Errorf("contract symbol is required")
}
key := MarketDataKeyForContract(contract)
if _, inactive := c.inactiveReason(key); inactive {
c.logDebug("Contract details fetch skipped for inactive routed contract %s", key)
return nil, ErrSymbolInactive
}
if timeout <= 0 {
timeout = 12 * time.Second
}
flightKey := "contract\x00" + contractDetailsFlightKey(contract)
return c.coalesceContractDetails(flightKey, timeout, func(wireTimeout time.Duration, observe func([]ContractDetailsLite)) ([]ContractDetailsLite, error) {
return c.fetchContractDetailsForContractWire(contract, key, wireTimeout, observe)
})
}
func (c *Connector) fetchContractDetailsForContractWire(contract Contract, key string, timeout time.Duration, observe func([]ContractDetailsLite)) ([]ContractDetailsLite, error) {
if !c.isConnected() {
return nil, fmt.Errorf("IBKR connection not available")
}
lookup := contract
if lookup.SecType == "STK" && lookup.ConID == 0 && lookup.PrimaryExch != "" &&
(lookup.Exchange == "" || strings.EqualFold(lookup.Exchange, "SMART")) {
lookup.Exchange = lookup.PrimaryExch
}
detailsCh := make(chan ContractDetailsLite, 10)
// Buffered end marker and reader release, as in fetchContractDetailsSymbolWire.
doneCh := make(chan struct{}, 1)
stopCh := make(chan struct{})
defer close(stopCh)
serverVersion := c.conn.serverVersion
reqID, err := c.conn.nextRequestID()
if err != nil {
return nil, err
}
c.logDebug("Routed contract details fetch start reqID=%d key=%s secType=%s exch=%s primary=%s currency=%s",
reqID, key, lookup.SecType, lookup.Exchange, lookup.PrimaryExch, lookup.Currency)
dataHandlerID := c.conn.RegisterHandler(msgContractData, func(fields []string) {
if lite, ok := parseContractDetailsLite(fields, reqID, serverVersion); ok {
select {
case detailsCh <- *lite:
case <-stopCh:
}
}
})
endHandlerID := c.conn.RegisterHandler(msgContractDataEnd, func(fields []string) {
if len(fields) < 3 {
return
}
rid, _ := strconv.Atoi(safeGet(fields, 2))
if rid == reqID {
select {
case doneCh <- struct{}{}:
default:
}
}
})
req, releaseReq := c.registerContractDetailsRequest(reqID, resolutionKeyForSecType(key, contract.SecType))
defer releaseReq()
if err := c.conn.sendContractDetailsRequest(lookup, reqID); err != nil {
c.conn.UnregisterHandler(msgContractData, dataHandlerID)
c.conn.UnregisterHandler(msgContractDataEnd, endHandlerID)
return nil, err
}
var results []ContractDetailsLite
deadline := time.After(timeout)
for {
select {
case d := <-detailsCh:
results = append(results, d)
if observe != nil {
observe(results)
}
case <-doneCh:
c.conn.UnregisterHandler(msgContractData, dataHandlerID)
c.conn.UnregisterHandler(msgContractDataEnd, endHandlerID)
results = drainQueuedContractDetails(detailsCh, results)
if len(results) > 0 {
c.clearInactiveCandidate(key)
}
c.logDebug("Routed contract details fetch complete reqID=%d key=%s count=%d", reqID, key, len(results))
c.noteContractDetailsOutcome(nil, time.Now())
return results, nil
case err := <-req.fail:
c.conn.UnregisterHandler(msgContractData, dataHandlerID)
c.conn.UnregisterHandler(msgContractDataEnd, endHandlerID)
c.logDebug("Routed contract details fetch rejected reqID=%d key=%s received=%d: %v", reqID, key, len(results), err)
c.noteContractDetailsOutcome(err, time.Now())
return results, err
case <-deadline:
c.conn.UnregisterHandler(msgContractData, dataHandlerID)
c.conn.UnregisterHandler(msgContractDataEnd, endHandlerID)
c.logDebug("Routed contract details fetch timeout reqID=%d key=%s received=%d", reqID, key, len(results))
c.noteContractDetailsOutcome(ErrContractDetailsTimeout, time.Now())
return results, ErrContractDetailsTimeout
}
}
}
type contractDetailsFlight struct {
done chan struct{}
results []ContractDetailsLite
err error
waiters int
}
// contractDetailsSharedWireMinimum prevents a short-budget cache warmer from
// becoming the leader and ending the shared broker request before a concurrent
// still returns on its own deadline; the detached wire flight remains bounded.
const contractDetailsSharedWireMinimum = 30 * time.Second
// coalesceContractDetails shares one identical wire request. Followers retain
// their own wait budget and timing out never cancels the leader; the flight may
// still populate the contract cache or deliver a terminal broker answer to
// callers whose budgets remain open.
func (c *Connector) coalesceContractDetails(key string, timeout time.Duration, fetch func(time.Duration, func([]ContractDetailsLite)) ([]ContractDetailsLite, error)) ([]ContractDetailsLite, error) {
c.contractDetailsFlightMu.Lock()
if c.contractDetailsFlights == nil {
c.contractDetailsFlights = make(map[string]*contractDetailsFlight)
}
flight := c.contractDetailsFlights[key]
if flight != nil {
flight.waiters++
c.contractDetailsFlightMu.Unlock()
return c.waitForContractDetailsFlight(flight, timeout)
}
flight = &contractDetailsFlight{done: make(chan struct{}), waiters: 1}
c.contractDetailsFlights[key] = flight
c.contractDetailsFlightMu.Unlock()
wireTimeout := max(timeout, contractDetailsSharedWireMinimum)
go func() {
results, err := fetch(wireTimeout, func(partial []ContractDetailsLite) {
c.contractDetailsFlightMu.Lock()
flight.results = cloneContractDetails(partial)
c.contractDetailsFlightMu.Unlock()
})
c.contractDetailsFlightMu.Lock()
flight.results = cloneContractDetails(results)
flight.err = err
delete(c.contractDetailsFlights, key)
close(flight.done)
c.contractDetailsFlightMu.Unlock()
}()
return c.waitForContractDetailsFlight(flight, timeout)
}
func (c *Connector) waitForContractDetailsFlight(flight *contractDetailsFlight, timeout time.Duration) ([]ContractDetailsLite, error) {
select {
case <-flight.done:
return cloneContractDetails(flight.results), flight.err
default:
}
if timeout <= 0 {
return c.contractDetailsFlightPartial(flight), ErrContractDetailsTimeout
}
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case <-flight.done:
return cloneContractDetails(flight.results), flight.err
case <-timer.C:
return c.contractDetailsFlightPartial(flight), ErrContractDetailsTimeout
}
}
func (c *Connector) contractDetailsFlightPartial(flight *contractDetailsFlight) []ContractDetailsLite {
c.contractDetailsFlightMu.Lock()
defer c.contractDetailsFlightMu.Unlock()
return cloneContractDetails(flight.results)
}
func cloneContractDetails(in []ContractDetailsLite) []ContractDetailsLite {
if in == nil {
return nil
}
return append([]ContractDetailsLite(nil), in...)
}
func contractDetailsFlightKey(contract Contract) string {
contract = normalizeMarketDataContract(contract)
return strings.Join([]string{
strconv.Itoa(contract.ConID),
strings.ToUpper(strings.TrimSpace(contract.Symbol)),
strings.ToUpper(strings.TrimSpace(contract.SecType)),
strings.TrimSpace(contract.Expiry),
strconv.FormatFloat(contract.Strike, 'g', -1, 64),
strings.ToUpper(strings.TrimSpace(contract.Right)),
strconv.Itoa(contract.Multiplier),
strings.ToUpper(strings.TrimSpace(contract.Exchange)),
strings.ToUpper(strings.TrimSpace(contract.PrimaryExch)),
strings.ToUpper(strings.TrimSpace(contract.Currency)),
strings.ToUpper(strings.TrimSpace(contract.LocalSymbol)),
strings.ToUpper(strings.TrimSpace(contract.TradingClass)),
strings.ToUpper(strings.TrimSpace(contract.SecIDType)),
strings.TrimSpace(contract.SecID),
}, "\x00")
}
// contractDetailsLateGrace is how long the deferred cleanup goroutine
// Bumped from 3 s to 30 s after observing TWS gateways that respond to
// CBOE indices (VIX3M) and FX (USD.JPY) in the first hour after a TWS
// cold start. Under 3 s of grace those late frames were always lost,
// unresponsive gateway still surfaces the failure within one regime
const contractDetailsLateGrace = 30 * time.Second
// drainQueuedContractDetails appends details the reader queued before the end
// marker. A select that sees both channels ready may take the marker first.
func drainQueuedContractDetails(detailsCh <-chan ContractDetailsLite, results []ContractDetailsLite) []ContractDetailsLite {
for {
select {
case d := <-detailsCh:
results = append(results, d)
default:
return results
}
}
}
// deferContractDetailsCleanup takes over draining detailsCh after the caller's
// deadline and closes stopCh once nothing will drain it again.
func (c *Connector) deferContractDetailsCleanup(symbol string, reqID int, detailsCh <-chan ContractDetailsLite, doneCh <-chan struct{}, stopCh chan<- struct{}, dataHandlerID, endHandlerID uint64) {
go func() {
defer close(stopCh)
timer := time.NewTimer(contractDetailsLateGrace)
defer timer.Stop()
var cachedDetail *ContractDetailsLite
forLoop:
for {
select {
case detail := <-detailsCh:
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
timer.Reset(contractDetailsLateGrace)
if detail.Symbol != "" {
d := detail // copy
cachedDetail = &d
key := strings.ToUpper(detail.Symbol)
c.contractMu.Lock()
if existing, ok := c.contractCache[key]; !ok || existing.ConID == 0 {
c.contractCache[key] = detail
}
c.contractMu.Unlock()
}
case <-doneCh:
break forLoop
case <-timer.C:
break forLoop
}
}
c.conn.UnregisterHandler(msgContractData, dataHandlerID)
c.conn.UnregisterHandler(msgContractDataEnd, endHandlerID)
if cachedDetail != nil {
connectorLogger.Infof("[INFO] Contract details for %s arrived after timeout (reqID=%d, conID=%d)", symbol, reqID, cachedDetail.ConID)
}
for {
select {
case <-detailsCh:
default:
return
}
}
}()
}
func (c *Connector) ensureContractDetails(symbol string, timeout time.Duration) (*ContractDetailsLite, error) {
symbol = strings.ToUpper(symbol)
if _, inactive := c.inactiveReason(symbol); inactive {
return nil, ErrSymbolInactive
}
c.contractMu.RLock()
if cached, ok := c.contractCache[symbol]; ok && cached.ConID != 0 {
c.contractMu.RUnlock()
return &cached, nil
}
c.contractMu.RUnlock()
fetch := c.fetchContractDetails
if fetch == nil {
fetch = c.FetchContractDetails
}
details, err := fetch(symbol, timeout)
if err != nil {
return nil, err
}
if len(details) == 0 {
return nil, fmt.Errorf("contract details unavailable for %s", symbol)
}
primary := details[0]
c.contractMu.Lock()
c.contractCache[symbol] = primary
c.contractMu.Unlock()
return &primary, nil
}
func (c *Connector) cachedContractDetail(symbol string) *ContractDetailsLite {
symbol = strings.ToUpper(symbol)
c.contractMu.RLock()
defer c.contractMu.RUnlock()
if detail, ok := c.contractCache[symbol]; ok {
d := detail
return &d
}
return nil
}
func (c *Connector) awaitContractDetail(symbol string, wait time.Duration) *ContractDetailsLite {
return c.awaitContractDetailCtx(context.Background(), symbol, wait)
}
func (c *Connector) awaitContractDetailCtx(ctx context.Context, symbol string, wait time.Duration) *ContractDetailsLite {
if wait <= 0 {
return nil
}
timer := time.NewTimer(wait)
defer timer.Stop()
ticker := time.NewTicker(contractHydrationPoll)
defer ticker.Stop()
for {
if detail := c.cachedContractDetail(symbol); detail != nil && detail.ConID != 0 {
return detail
}
select {
case <-ctx.Done():
return nil
case <-timer.C:
return nil
case <-ticker.C:
}
}
}
func historicalTimeoutWithinContext(ctx context.Context, timeout time.Duration) (time.Duration, error) {
if err := ctx.Err(); err != nil {
return 0, err
}
if timeout <= 0 {
timeout = 45 * time.Second
}
if dl, ok := ctx.Deadline(); ok {
remaining := time.Until(dl)
if remaining <= 0 {
return 0, context.DeadlineExceeded
}
if remaining < timeout {
timeout = remaining
}
}
return timeout, nil
}
func safeGet(a []string, i int) string {
if i >= 0 && i < len(a) {
return a[i]
}
return ""
}
func parseIntSafe(s string) int {
if s == "" {
return 0
}
v, err := strconv.Atoi(strings.TrimSpace(s))
if err != nil {
return 0
}
return v
}
// normalizeWireLastTradeDate reduces a lastTradeDateOrContractMonth wire value
// to its leading date token. The gateway appends the settlement time and zone
// on whitespace or hyphen and keep only the first token, and everything
func normalizeWireLastTradeDate(raw string) string {
raw = strings.TrimSpace(raw)
if i := strings.IndexAny(raw, " -"); i >= 0 {
return raw[:i]
}
return raw
}
func parseContractDetailsLite(fields []string, expectedReqID int, serverVersion int) (*ContractDetailsLite, bool) {
if len(fields) <= 1 {
return nil, false
}
idx := 1
version := 8
if serverVersion < minServerVerSizeRules {
version = parseIntSafe(safeGet(fields, idx))
idx++
}
reqID := expectedReqID
if version >= 3 {
parsedReqID := parseIntSafe(safeGet(fields, idx))
idx++
if parsedReqID != 0 {
reqID = parsedReqID
}
}
if expectedReqID != 0 && reqID != expectedReqID {
return nil, false
}
symbol := strings.TrimSpace(safeGet(fields, idx))
idx++
secType := strings.TrimSpace(safeGet(fields, idx))
idx++
// Last trade date / contract month — for OPT this is the expiry YYYYMMDD.
expiry := normalizeWireLastTradeDate(safeGet(fields, idx))
idx++
if serverVersion >= minServerVerLastTradeDate {
idx++
}
// Strike and right.
strikeStr := safeGet(fields, idx)
idx++
right := strings.TrimSpace(safeGet(fields, idx))
idx++
strike := 0.0
if v, err := strconv.ParseFloat(strikeStr, 64); err == nil {
strike = v
}
exchange := strings.TrimSpace(safeGet(fields, idx))
idx++
currency := strings.TrimSpace(safeGet(fields, idx))
idx++
localSymbol := strings.TrimSpace(safeGet(fields, idx))
idx++
_ = safeGet(fields, idx) // market name
idx++
tradingClass := strings.TrimSpace(safeGet(fields, idx))
idx++
conID := parseIntSafe(safeGet(fields, idx))
idx++
minTick := 0.0
if v, err := strconv.ParseFloat(strings.TrimSpace(safeGet(fields, idx)), 64); err == nil && v > 0 {
minTick = v
}
idx++
if serverVersion >= minServerVerMdSizeMultiplier && serverVersion < minServerVerSizeRules {
idx++ // md size multiplier (deprecated)
}
multiplier := parseIntSafe(safeGet(fields, idx))
idx++
_ = safeGet(fields, idx) // order types
idx++
_ = safeGet(fields, idx) // valid exchanges
idx++
if version >= 2 {
idx++ // price magnifier
}
underConID := 0
if version >= 4 {
underConID = parseIntSafe(safeGet(fields, idx))
idx++
}
primaryExch := ""
if version >= 5 {
idx++ // long name
primaryExch = strings.TrimSpace(safeGet(fields, idx))
idx++
}
timeZoneID := ""
tradingHours := ""
liquidHours := ""
if version >= 6 {
idx++ // contractMonth
_ = safeGet(fields, idx) // industry: strict full-frame decoder owns classification
idx++
_ = safeGet(fields, idx) // category: strict full-frame decoder owns classification
idx++
_ = safeGet(fields, idx) // subcategory: strict full-frame decoder owns classification
idx++
timeZoneID = safeGet(fields, idx)
idx++
tradingHours = safeGet(fields, idx)
idx++
liquidHours = safeGet(fields, idx)
}
industry, category, subcategory, stockType := "", "", "", ""
validExchanges, marketRuleIDs := "", ""
if classification, ok := parseContractDetailsClassification(fields, serverVersion); ok {
validExchanges, marketRuleIDs = classification.validExchanges, classification.marketRuleIDs
industry = classification.industry
category = classification.category
subcategory = classification.subcategory
stockType = classification.stockType
}
return &ContractDetailsLite{
ValidExchanges: validExchanges, MarketRuleIDs: marketRuleIDs,
ReqID: reqID,
Symbol: symbol,
SecType: secType,
Expiry: expiry,
Strike: strike,
Right: right,
Exchange: exchange,
PrimaryExch: primaryExch,
Currency: currency,
ConID: conID,
UnderConID: underConID,
LocalSymbol: localSymbol,
TradingClass: tradingClass,
Multiplier: multiplier,
Industry: industry,
Category: category,
Subcategory: subcategory,
StockType: stockType,
TimeZoneID: timeZoneID,
TradingHours: tradingHours,
LiquidHours: liquidHours,
MinTick: minTick,
}, true
}
type contractDetailsClassification struct {
validExchanges string
marketRuleIDs string
industry string
category string
subcategory string
stockType string
}
// parseContractDetailsClassification follows the official contractData
// classification tuple is authoritative only when the entire versioned frame
// is present, numeric/count/boolean fields have their declared wire types, and
// still supply routing fields, but a partial or shifted frame can never supply
func parseContractDetailsClassification(fields []string, serverVersion int) (contractDetailsClassification, bool) {
cursor := contractDetailsWireCursor{fields: fields, ok: true}
messageID, ok := cursor.integer()
if !ok || messageID != msgContractData {
return contractDetailsClassification{}, false
}
version := 8
if serverVersion < minServerVerSizeRules {
version, ok = cursor.integer()
if !ok || version < 1 || version > 8 {
return contractDetailsClassification{}, false
}
}
if version >= 3 {
if _, ok = cursor.integer(); !ok { // reqID
return contractDetailsClassification{}, false
}
}
_ = cursor.string() // symbol
secType := cursor.string()
_ = cursor.string() // lastTradeDateOrContractMonth
if serverVersion >= minServerVerLastTradeDate {
_ = cursor.string() // lastTradeDate
}
if !cursor.number() { // strike
return contractDetailsClassification{}, false
}
_ = cursor.string() // right
_ = cursor.string() // exchange
_ = cursor.string() // currency
_ = cursor.string() // localSymbol
_ = cursor.string() // marketName
_ = cursor.string() // tradingClass
if _, ok = cursor.integer(); !ok { // conID
return contractDetailsClassification{}, false
}
if !cursor.number() { // minTick
return contractDetailsClassification{}, false
}
if serverVersion >= minServerVerMdSizeMultiplier && serverVersion < minServerVerSizeRules {
if _, ok = cursor.integer(); !ok { // deprecated mdSizeMultiplier
return contractDetailsClassification{}, false
}
}
_ = cursor.string() // multiplier is a protocol string
_ = cursor.string() // orderTypes
validExchanges := cursor.string() // aligned with marketRuleIDs
if version >= 2 {
if _, ok = cursor.integer(); !ok { // priceMagnifier
return contractDetailsClassification{}, false
}
}
if version >= 4 {
if _, ok = cursor.integer(); !ok { // underConID
return contractDetailsClassification{}, false
}
}
if version >= 5 {
_ = cursor.string() // longName
_ = cursor.string() // primaryExchange
}
classification := contractDetailsClassification{validExchanges: validExchanges}
if version >= 6 {
_ = cursor.string() // contractMonth
classification.industry = strings.TrimSpace(cursor.string())
classification.category = strings.TrimSpace(cursor.string())
classification.subcategory = strings.TrimSpace(cursor.string())
_ = cursor.string() // timeZoneID
_ = cursor.string() // tradingHours
_ = cursor.string() // liquidHours
}
if version >= 8 {
_ = cursor.string() // evRule
if !cursor.number() { // evMultiplier is double in current Java/C++ clients
return contractDetailsClassification{}, false
}
}
if version >= 7 && !cursor.stringPairs() { // secIdList
return contractDetailsClassification{}, false
}
if serverVersion >= minServerVerAggGroup {
if _, ok = cursor.integer(); !ok {
return contractDetailsClassification{}, false
}
}
if serverVersion >= minServerVerUnderlyingInfo {
_ = cursor.string() // underSymbol
_ = cursor.string() // underSecType
}
if serverVersion >= minServerVerMarketRules {
classification.marketRuleIDs = cursor.string()
}
if serverVersion >= minServerVerRealExpiration {
_ = cursor.string() // realExpirationDate
}
if serverVersion >= minServerVerStockType {
classification.stockType = strings.TrimSpace(cursor.string())
}
if serverVersion >= minServerVerFractionalSize && serverVersion < minServerVerSizeRules {
if !cursor.number() { // deprecated sizeMinTick
return contractDetailsClassification{}, false
}
}
if serverVersion >= minServerVerSizeRules {
for range 3 { // minSize, sizeIncrement, suggestedSizeIncrement
if !cursor.number() {
return contractDetailsClassification{}, false
}
}
}
if serverVersion >= minServerVerFundDataFields && strings.EqualFold(strings.TrimSpace(secType), "FUND") {
for range 7 { // name through management fee
_ = cursor.string()
}
for range 3 { // closed flags
if !cursor.boolean() {
return contractDetailsClassification{}, false
}
}
for range 7 { // amount through asset type
_ = cursor.string()
}
}
if serverVersion >= minServerVerIneligibility && !cursor.stringPairs() {
return contractDetailsClassification{}, false
}
if !cursor.complete() {
return contractDetailsClassification{}, false
}
return classification, true
}
type contractDetailsWireCursor struct {
fields []string
idx int
ok bool
}
func (c *contractDetailsWireCursor) string() string {
if !c.ok || c.idx >= len(c.fields) {
c.ok = false
return ""
}
value := c.fields[c.idx]
c.idx++
return value
}
func (c *contractDetailsWireCursor) integer() (int, bool) {
value := strings.TrimSpace(c.string())
if !c.ok {
return 0, false
}
if value == "" {
return 0, true
}
parsed, err := strconv.Atoi(value)
return parsed, err == nil
}
func (c *contractDetailsWireCursor) number() bool {
value := strings.TrimSpace(c.string())
if !c.ok || value == "" {
return c.ok
}
parsed, err := strconv.ParseFloat(value, 64)
return err == nil && !math.IsNaN(parsed) && !math.IsInf(parsed, 0)
}
func (c *contractDetailsWireCursor) boolean() bool {
value := strings.TrimSpace(c.string())
return c.ok && (value == "" || value == "0" || value == "1")
}
func (c *contractDetailsWireCursor) stringPairs() bool {
count, ok := c.integer()
if !ok || count < 0 || count > (len(c.fields)-c.idx)/2 {
c.ok = false
return false
}
for range count {
_ = c.string()
_ = c.string()
}
return c.ok
}
func (c *contractDetailsWireCursor) complete() bool {
if !c.ok || c.idx == len(c.fields) {
return c.ok
}
return c.idx+1 == len(c.fields) && c.fields[c.idx] == ""
}
// Stop marks the Connector stopped, cancels its P&L subscriptions, and closes
// the broker connection. It is idempotent. The Connector remains a valid value
func (c *Connector) Stop() error {
// Drop c.mu BEFORE calling into conn.Disconnect — that path fires the
// registered onDisconnect callback (attachConnectionHooks.func2), which
// calls back into onConnectionLost and tries to acquire c.mu. Holding
// mu across the callback would deadlock the shutdown path, hanging the
// daemon process after idle timeout / SIGTERM.
//
// Marking running=false before releasing the lock is the right boundary:
// any reentrant caller that re-checks c.running mid-shutdown sees a
// stopped connector and no-ops.
c.mu.Lock()
if !c.running {
c.mu.Unlock()
return nil
}
conn := c.conn
c.running = false
c.mu.Unlock()
// Debug, not Info: the daemon calls Stop on every reconnect-cycle teardown
c.logDebug("Stopping IBKR connector")
c.stopBackendEpisodeTimer()
// Cancel any live PnL subscriptions before the connection drops.
c.cancelAllPnL()
if conn != nil {
if err := conn.Disconnect(); err != nil {
c.logWarn("Error disconnecting: %v", err)
}
}
c.logDebug("IBKR connector stopped")
return nil
}
// sharedGenericTicks is the wire generic-tick set requested for shared
const sharedGenericTicks = "100,101,104,106,165,221,233,236"
// SubscribeMarketData ensures a symbol-keyed streaming subscription exists.
// Repeating the call for the same normalized symbol is a no-op, including from
// concurrent callers. ctx must be non-nil and bounds acquisition of a
// market-data slot. fields is retained as subscription metadata; the wire tick
// set is selected by the Connector. The slice is not copied and must not be
// mutated while subscribed. When disconnected, the method records a local
// subscription with no live request. Use [Connector.UnsubscribeMarketData] for
// cleanup.
func (c *Connector) SubscribeMarketData(ctx context.Context, symbol string, fields []string) error {
symbol = strings.ToUpper(symbol)
if reason, inactive := c.inactiveReason(symbol); inactive {
if reason == "" {
reason = "inactive"
}
c.logDebug("Skipping SubscribeMarketData for inactive symbol %s (%s)", symbol, reason)
return ErrSymbolInactive
}
defaultRouteKey := DefaultMarketDataKeyForSymbol(symbol)
if defaultRouteKey != "" && defaultRouteKey != symbol {
if reason, inactive := c.inactiveReason(defaultRouteKey); inactive {
if reason == "" {
reason = "inactive"
}
c.logDebug("Skipping SubscribeMarketData for inactive route %s (%s)", defaultRouteKey, reason)
return ErrSymbolInactive
}
}
// An unresolved symbol inside its definition-miss backoff must not fall
// through to the bare reqMktData path below: that wire request draws its
// own code-200 answer per call, which is the retry-loop shape the backoff
// exists to stop. A cached contract means the miss is stale — let it pass.
if !c.hasActiveContract(symbol) {
if missErr := c.contractResolutionMissFor(symbol); missErr != nil {
c.logDebug("Skipping SubscribeMarketData for %s (%v)", symbol, missErr)
return missErr
}
}
c.subMu.RLock()
if sub, exists := c.subscriptions[symbol]; exists {
c.subMu.RUnlock()
marketDataLogger.Debugf("%s: SubscribeMarketData(%s) is a no-op; existing subscription reqID=%d", c.name, symbol, sub.ReqID)
return nil
}
c.subMu.RUnlock()
spec := mdReplaySpec{symbol: symbol}
if c.conn != nil && c.conn.IsConnected() {
contract, ready := c.prepareContract(symbol, 2*time.Second, true)
contract, ready = c.waitForContractDetails(symbol, contract, ready)
if !ready && contract.ConID == 0 {
if missErr := c.contractResolutionMissFor(symbol); missErr != nil {
return missErr
}
}
if ready {
spec = mdReplaySpec{contract: contract, genericTicks: sharedGenericTicks}
} else {
spec.primaryExch = contract.PrimaryExch
}
}
return c.subscribeSharedQuote(ctx, symbol, fields, spec)
}
// SubscribeMarketDataWithContract ensures a streaming subscription exists for
// Repeating the same route is a no-op. ctx must be non-nil and bounds slot
// acquisition. fields is retained as metadata; the Connector selects the wire
// tick set. The slice is not copied and must not be mutated while subscribed.
func (c *Connector) SubscribeMarketDataWithContract(ctx context.Context, contract Contract, fields []string) (string, error) {
contract = normalizeMarketDataContract(contract)
contract = c.hydrateExplicitMarketDataContract(contract)
key := MarketDataKeyForContract(contract)
if key == "" {
return "", fmt.Errorf("contract symbol is required for market data")
}
if reason, inactive := c.inactiveReason(key); inactive {
if reason == "" {
reason = "inactive"
}
c.logDebug("Skipping routed SubscribeMarketData for %s (%s)", key, reason)
return key, ErrSymbolInactive
}
// A routed subscribe can carry a position-derived ConID the broker no
// longer resolves (delisting): the request itself then draws code 200 on
// every poll cycle with no contract-details fetch involved, so the
// definition-miss backoff must gate this path too.
if missErr := c.contractResolutionMissFor(key); missErr != nil {
c.logDebug("Skipping routed SubscribeMarketData for %s (%v)", key, missErr)
return key, missErr
}
ticks := sharedGenericTicks
if isBondMarketDataContract(contract) {
ticks = ""
}
return key, c.subscribeSharedQuote(ctx, key, fields, mdReplaySpec{contract: contract, genericTicks: ticks})
}
// SubscribeMarketDataWithContractForSession creates a short-lived,
// non-sharing subscription for one exact positive-ConID contract, or one
// socket satisfying broker-write evidence.
func (c *Connector) SubscribeMarketDataWithContractForSession(ctx context.Context, binding ConnectorSessionBinding, contract Contract, fields []string) (string, error) {
if c == nil || !c.SessionCurrent(binding) {
return "", fmt.Errorf("broker session changed before exact quote request")
}
contract = normalizeMarketDataContract(contract)
if contract.ConID <= 0 && !isExplicitSessionFXContract(contract) {
return "", fmt.Errorf("exact quote contract requires positive ConID or explicit CASH/IDEALPRO pair")
}
baseKey := MarketDataKeyForContract(contract)
if baseKey == "" {
return "", fmt.Errorf("contract symbol is required for market data")
}
key := baseKey + exactQuoteKeyMarker + strconv.FormatUint(c.exactQuoteSeq.Add(1), 10)
conn := binding.connection
cleanupPrepared := func(reqID int) {
c.subMu.Lock()
if sub := c.subscriptions[key]; sub != nil && sub.ReqID == reqID && sub.SessionEpoch == binding.epoch {
delete(c.subscriptions, key)
}
if c.reqIDMap[reqID] == key {
delete(c.reqIDMap, reqID)
}
c.subMu.Unlock()
}
wireContract := contract
normalizeResolvedOptionMarketDataContract(&wireContract)
genericTicks := OptionSubscriptionGenericTicks + ",165,221,233,236"
if isBondMarketDataContract(wireContract) {
// The stock and option tick lists are not defined for bonds.
genericTicks = ""
}
reqID, err := conn.requestMarketDataWithContractForEpoch(ctx, wireContract, genericTicks, false, false, binding.epoch, func(reqID int) func() {
c.subMu.Lock()
c.reqIDMap[reqID] = key
c.subscriptions[key] = &Subscription{
Symbol: key, ReqID: reqID, Fields: fields, LastTime: time.Now(), SessionEpoch: binding.epoch,
exactContract: wireContract, exactSession: binding,
RejectCh: make(chan SubscriptionRejection, 1),
}
c.subMu.Unlock()
return func() { cleanupPrepared(reqID) }
})
if err != nil {
return "", err
}
if !c.SessionCurrent(binding) {
cleanupPrepared(reqID)
binding.connection.releaseMarketDataSlotAtEpoch(reqID, binding.epoch)
return "", fmt.Errorf("broker session changed during exact quote request")
}
return key, nil
}
func isExplicitSessionFXContract(contract Contract) bool {
return contract.ConID == 0 &&
strings.EqualFold(strings.TrimSpace(contract.SecType), "CASH") &&
strings.EqualFold(strings.TrimSpace(contract.Exchange), "IDEALPRO") &&
strings.TrimSpace(contract.Symbol) != "" &&
strings.TrimSpace(contract.Currency) != "" &&
!strings.EqualFold(strings.TrimSpace(contract.Symbol), strings.TrimSpace(contract.Currency))
}
// UnsubscribeMarketDataForSession removes only the exact subscription created
// current. A retired cleanup can never cancel a successor subscription.
func (c *Connector) UnsubscribeMarketDataForSession(ctx context.Context, binding ConnectorSessionBinding, key string) error {
c.subMu.Lock()
sub := c.subscriptions[key]
if sub == nil || sub.SessionEpoch != binding.epoch {
c.subMu.Unlock()
return nil
}
delete(c.subscriptions, key)
if c.reqIDMap[sub.ReqID] == key {
delete(c.reqIDMap, sub.ReqID)
}
c.subMu.Unlock()
if !c.SessionCurrent(binding) {
binding.connection.releaseMarketDataSlotAtEpoch(sub.ReqID, binding.epoch)
return nil
}
return binding.connection.cancelMarketDataForEpoch(ctx, sub.ReqID, binding.epoch)
}
func (c *Connector) hydrateExplicitMarketDataContract(contract Contract) Contract {
if contract.ConID != 0 || contract.Symbol == "" {
return contract
}
detail := c.cachedContractDetail(contract.Symbol)
if detail == nil || detail.ConID == 0 {
return contract
}
candidate := contract
if !c.applyContractDetail(*detail, &candidate) {
return contract
}
normalizeEquityRouting(&candidate, contract.PrimaryExch)
if explicitContractRouteMatches(contract, candidate) {
return candidate
}
return contract
}
func explicitContractRouteMatches(requested, candidate Contract) bool {
if requested.Currency != "" && candidate.Currency != "" && !strings.EqualFold(requested.Currency, candidate.Currency) {
return false
}
reqExchange := strings.ToUpper(strings.TrimSpace(requested.Exchange))
reqPrimary := strings.ToUpper(strings.TrimSpace(requested.PrimaryExch))
candExchange := strings.ToUpper(strings.TrimSpace(candidate.Exchange))
candPrimary := strings.ToUpper(strings.TrimSpace(candidate.PrimaryExch))
if reqPrimary != "" {
return reqPrimary == candPrimary || reqPrimary == candExchange
}
if reqExchange == "" || reqExchange == "SMART" {
return true
}
return reqExchange == candExchange || reqExchange == candPrimary
}
// EnsureMarketDataSubscription creates a live symbol subscription or refreshes
// one whose last observed tick is at least staleAfter old. A staleAfter value
// of zero or less disables age-based refresh. The boolean reports whether a new
// wire request was sent. ctx must be non-nil and bounds market-data slot
// acquisition; unavailable, inactive, entitlement, and request failures are
// returned.
func (c *Connector) EnsureMarketDataSubscription(ctx context.Context, symbol string, fields []string, staleAfter time.Duration) (bool, error) {
return c.ensureMarketDataSubscription(ctx, symbol, fields, staleAfter, false)
}
// ensureMarketDataSubscription is the implementation behind the public
// before the replacement wire request, so the caller cannot mistake the prior
func (c *Connector) ensureMarketDataSubscription(ctx context.Context, symbol string, fields []string, staleAfter time.Duration, resetObservations bool) (bool, error) {
symbol = strings.ToUpper(symbol)
if reason, inactive := c.inactiveReason(symbol); inactive {
if reason == "" {
reason = "inactive"
}
c.logDebug("Skipping EnsureMarketDataSubscription for inactive symbol %s (%s)", symbol, reason)
return false, ErrSymbolInactive
}
if absErr := c.marketDataAbsenceFor(symbol); absErr != nil {
c.logDebug("Skipping EnsureMarketDataSubscription for %s (%v)", symbol, absErr)
return false, absErr
}
c.subMu.Lock()
defer c.subMu.Unlock()
// Helper to (re)request from IBKR, mapping reqID
request := func() (int, error) {
if !c.IsReady() {
return 0, fmt.Errorf("IBKR connection not ready")
}
contract, hasDetail := c.prepareContract(symbol, 2*time.Second, true)
contract, hasDetail = c.waitForContractDetails(symbol, contract, hasDetail)
if contract.SecType == "STK" && !hasDetail && contract.ConID == 0 {
contract.PrimaryExch = ""
}
hydrated := hasDetail || contract.ConID != 0
if !hydrated {
if late := c.awaitContractDetail(symbol, contractHydrationGrace); late != nil {
if c.applyContractDetail(*late, &contract) && contract.ConID != 0 {
hydrated = true
}
}
}
if !hydrated {
return 0, fmt.Errorf("contract details pending for %s", symbol)
}
var (
reqID int
err error
)
reqID, err = c.conn.RequestMarketDataWithContract(ctx, contract, "100,101,104,106,165,221,233,236", false, false)
if err != nil {
return 0, err
}
c.reqIDMap[reqID] = symbol
return reqID, nil
}
if sub, exists := c.subscriptions[symbol]; exists {
// Refresh if stale
if resetObservations || (staleAfter > 0 && time.Since(sub.LastTime) >= staleAfter) {
if sub.ReqID != 0 {
if conn := c.conn; conn != nil && conn.IsConnected() && wireCancelNeeded(sub) {
if err := conn.CancelMarketData(sub.ReqID); err != nil {
marketDataLogger.Warnf("%s: Failed to cancel stale market data for %s (ReqID: %d): %v", c.name, symbol, sub.ReqID, err)
}
} else if conn != nil {
// Server-side-rejected reqID or no live session: the wire
// cancel would only draw error 300, but slot accounting
// must stay in sync. The per-reqID release is idempotent
conn.releaseMarketDataSlot(sub.ReqID)
}
// Reset subscription metadata so the new request can cleanly
// re-register. The cancelled id leaves the map too: a 200 or
// 354 TWS had queued for it must not be read as the
// replacement's verdict.
delete(c.reqIDMap, sub.ReqID)
sub.ReqID = 0
sub.Observed = false
// Drain any stale rejection left by the previous reqID so
// the next poller doesn't fast-abort on it.
if sub.RejectCh != nil {
select {
case <-sub.RejectCh:
default:
}
} else {
sub.RejectCh = make(chan SubscriptionRejection, 1)
}
}
if resetObservations {
resetSubscriptionObservations(sub)
}
reqID, err := request()
if err != nil {
marketDataLogger.Warnf("%s: Failed to refresh market data for %s: %v", c.name, symbol, err)
return false, err
}
sub.ReqID = reqID
sub.LastTime = time.Now()
marketDataLogger.Debugf("%s: Refreshed market data subscription for %s (ReqID: %d)", c.name, symbol, reqID)
return true, nil
}
// Already subscribed and fresh enough
return false, nil
}
// No existing subscription: create and request
reqID := 0
if c.IsReady() {
if rid, err := request(); err == nil {
reqID = rid
} else {
marketDataLogger.Warnf("%s: Failed to request market data for %s: %v", c.name, symbol, err)
return false, err
}
} else {
return false, fmt.Errorf("IBKR connection not ready")
}
sub := &Subscription{
Symbol: symbol,
ReqID: reqID,
Fields: fields,
LastTime: time.Now(),
RejectCh: make(chan SubscriptionRejection, 1),
}
c.subscriptions[symbol] = sub
marketDataLogger.Debugf("%s: Subscribed to market data for %s (ReqID: %d)", c.name, symbol, reqID)
return true, nil
}
func resetSubscriptionObservations(sub *Subscription) {
if sub == nil {
return
}
sub.LastPrice = 0
sub.Bid = 0
sub.Ask = 0
sub.MarkPrice = 0
sub.BidSize = 0
sub.AskSize = 0
sub.Volume = 0
sub.VolumeObserved = false
sub.VolumeAt = time.Time{}
sub.AvgVolume = 0
sub.OpenInt = 0
sub.OpenIntObserved = false
sub.ShortableShares = 0
sub.ShortableObserved = false
sub.ShortableTickAt = time.Time{}
sub.PrevClose = 0
sub.Open = 0
sub.High = 0
sub.Low = 0
sub.Week13Low = 0
sub.Week13High = 0
sub.Week26Low = 0
sub.Week26High = 0
sub.Week52Low = 0
sub.Week52High = 0
sub.bidYield, sub.askYield, sub.lastYield = nil, nil, nil
sub.LastAt = time.Time{}
sub.BidAt = time.Time{}
sub.AskAt = time.Time{}
sub.MarkAt = time.Time{}
sub.CloseAt = time.Time{}
sub.LastTradeTime = time.Time{}
sub.LastTickAt = time.Time{}
sub.LastPriceTickAt = time.Time{}
sub.IV = 0
sub.LastTime = time.Time{}
sub.Observed = false
sub.delayedTicks = false
sub.rejectedReqID = 0
}
// UnsubscribeMarketData removes the normalized symbol or route key from the
// local subscription cache and best-effort cancels its live broker request. It
func (c *Connector) UnsubscribeMarketData(symbol string) error {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
return c.UnsubscribeMarketDataContext(ctx, symbol)
}
// UnsubscribeMarketDataContext removes a shared cache entry and best-effort
// cancels on its originating session. It does not hold the cache mutex during
// wire I/O. The caller owns the cancellation deadline.
func (c *Connector) UnsubscribeMarketDataContext(ctx context.Context, symbol string) error {
return c.unsubscribeMarketData(ctx, symbol, nil, nil)
}
// UnsubscribeSharedMarketDataForSession releases a shared line only if binding still
// names the cache generation. Retired callers cannot remove a successor line.
func (c *Connector) UnsubscribeSharedMarketDataForSession(ctx context.Context, binding ConnectorSessionBinding, symbol string) error {
return c.unsubscribeMarketData(ctx, symbol, &binding, nil)
}
func (c *Connector) unsubscribeMarketData(ctx context.Context, symbol string, binding *ConnectorSessionBinding, expected *Subscription) error {
symbol = strings.ToUpper(symbol)
c.mu.RLock()
conn := c.conn
c.mu.RUnlock()
var epoch uint64
if conn != nil {
epoch = conn.BrokerSessionEpoch()
}
c.subMu.Lock()
if binding != nil {
if !c.SessionCurrent(*binding) {
c.subMu.Unlock()
return nil
}
conn = binding.connection
epoch = binding.epoch
}
sub, exists := c.subscriptions[symbol]
if !exists || expected != nil && sub != expected {
c.subMu.Unlock()
return nil
}
if sub.optionReaders > 1 {
sub.optionReaders--
c.subMu.Unlock()
return nil
}
if sub.optionSession.connection != nil {
conn, epoch = sub.optionSession.connection, sub.optionSession.epoch
}
if sub.liveRetryTimer != nil {
sub.liveRetryTimer.Stop()
}
delete(c.subscriptions, symbol)
if c.reqIDMap[sub.ReqID] == symbol {
delete(c.reqIDMap, sub.ReqID)
}
reqID, wire := sub.ReqID, wireCancelNeeded(sub)
// Keep admission closed until the retired option route is removed. A
// replacement socket may reuse this request ID for the same option key.
c.optMu.Lock()
if c.optReqIDs[reqID] == symbol {
delete(c.optReqIDs, reqID)
}
c.optMu.Unlock()
c.subMu.Unlock()
if sub.optionSession.connection != nil && !c.SessionCurrent(sub.optionSession) {
conn.releaseMarketDataSlotAtEpoch(reqID, epoch)
return nil
}
if conn != nil && conn.IsConnected() && reqID != 0 {
if wire {
return conn.cancelMarketDataForEpoch(ctx, reqID, epoch)
}
conn.releaseMarketDataSlotAtEpoch(reqID, epoch)
}
return nil
}
// RawOrder contains the broker-wire fields accepted by Connector order-write
// responsible for supplying a broker-valid combination of order type, prices,
// quantity, time in force, account, and routing fields.
type RawOrder struct {
ParentID int
// AdaptivePriority requests IBKR Adaptive, never a plain-limit fallback.
AdaptivePriority string
OrderID int
ClientID int
PermID int
Action string // BUY or SELL
TotalQty int
OrderType string // MKT, LMT, STP, etc.
LmtPrice float64
LmtPriceSet bool
AuxPrice float64 // Stop price for stop orders
TrailStopPrice float64
TrailingPercent float64
LmtPriceOffset float64
TIF string // Time in force: DAY, GTC, IOC, etc.
TriggerMethod int // IBKR stop trigger method for stop/trailing orders
Account string
OrderRef string // Our internal order ID
OutsideRth bool // Allow execution outside regular trading hours
OpenClose string // O=open, C=close
// OcaGroup and OcaType place the order in an IBKR one-cancels-all
// group (Order.proto ocaGroup=27, ocaType=30). OcaType is 1 (cancel the
// others with block), 2 (reduce the others with block) or 3 (reduce
// without block); a group needs a type and a type needs a group.
OcaGroup string
OcaType int
// Changes names the open order an explicit-ID send changes (its
// permanent ID, account and reference); never sent on the wire. An ID
// this process did not place is claimed below the session's order-ID
// frontier only when the broker reported it on the bound socket
// generation under this identity.
Changes *OrderChangeIdentity
}
// SubmitOrder sends an unrestricted order through the active broker
// builds with the "trading" tag enable the wire path. A successful return means
// the frame was sent, not that the broker accepted or filled the order.
func (c *Connector) SubmitOrder(contract *Contract, order *RawOrder) error {
if !tradingEnabled {
return definitelyUnsent(ErrTradingDisabled)
}
binding, ok := c.CaptureSession()
if !ok {
return definitelyUnsent(fmt.Errorf("not connected to IBKR"))
}
return c.SubmitOrderForSession(binding, contract, order)
}
// SubmitOrderForSession sends an unrestricted order only on the exact
// Connector socket generation named by binding. The binding must have been
// allocator claim and again at the transport boundary before any wire write.
func (c *Connector) SubmitOrderForSession(binding ConnectorSessionBinding, contract *Contract, order *RawOrder) error {
return c.SubmitOrderForSessionGuarded(context.Background(), binding, contract, order, nil)
}
// SubmitOrderForSessionGuarded carries caller cancellation and a final
// authority guard to the exact socket write. guard runs under the connection
func (c *Connector) SubmitOrderForSessionGuarded(ctx context.Context, binding ConnectorSessionBinding, contract *Contract, order *RawOrder, guard func() error) error {
if !tradingEnabled {
return definitelyUnsent(ErrTradingDisabled)
}
return c.submitOrderForSession(ctx, binding, contract, order, nil, guard)
}
// SubmitPaperOrder validates gate against the configured connection and sends
// an order to a paper account. It is available in default builds without
// sent, not that the broker accepted or filled the order.
func (c *Connector) SubmitPaperOrder(gate PaperOrderGate, contract *Contract, order *RawOrder) error {
binding, ok := c.CaptureSession()
if !ok {
return definitelyUnsent(fmt.Errorf("not connected to IBKR"))
}
return c.SubmitPaperOrderForSession(binding, gate, contract, order)
}
// SubmitPaperOrderForSession validates gate and sends a paper order only on
// the exact Connector socket generation named by binding.
func (c *Connector) SubmitPaperOrderForSession(binding ConnectorSessionBinding, gate PaperOrderGate, contract *Contract, order *RawOrder) error {
return c.SubmitPaperOrderForSessionGuarded(context.Background(), binding, gate, contract, order, nil)
}
// SubmitPaperOrderForSessionGuarded is the paper-gated counterpart to
// SubmitOrderForSessionGuarded.
func (c *Connector) SubmitPaperOrderForSessionGuarded(ctx context.Context, binding ConnectorSessionBinding, gate PaperOrderGate, contract *Contract, order *RawOrder, guard func() error) error {
if err := gate.validate(); err != nil {
return definitelyUnsent(err)
}
return c.submitOrderForSession(ctx, binding, contract, order, &gate, guard)
}
func (c *Connector) submitOrderForSession(ctx context.Context, binding ConnectorSessionBinding, contract *Contract, order *RawOrder, paperGate *PaperOrderGate, guard func() error) error {
if c == nil {
return definitelyUnsent(fmt.Errorf("broker Connector is nil"))
}
if ctx == nil {
return definitelyUnsent(fmt.Errorf("broker order context is nil"))
}
if err := ctx.Err(); err != nil {
return definitelyUnsent(err)
}
if contract == nil {
return definitelyUnsent(fmt.Errorf("broker order contract is nil"))
}
if order == nil {
return definitelyUnsent(fmt.Errorf("broker order is nil"))
}
if !c.SessionCurrent(binding) {
return definitelyUnsent(fmt.Errorf("broker session binding is not current for this Connector"))
}
if down, at := c.backendConnectivityDown(); down {
return definitelyUnsent(fmt.Errorf("TWS reported IBKR backend connectivity lost at %s (code 1100) with no restore notice yet; refusing to transmit a broker order into a dead link", at.Format(time.RFC3339)))
}
conn := binding.connection
ibkrOrder := rawIBKROrder(contract, order)
// Bind the order id under the same coordination boundary used by the exact
// stale caller-supplied order is refused before local indexing or wire send.
var claimEpoch uint64
newOrder := true
c.brokerIDNamespaceMu.Lock()
if ibkrOrder.OrderID <= 0 {
var err error
ibkrOrder.OrderID, claimEpoch, err = c.nextDisjointOrderIDLockedForSession(binding)
if err != nil {
c.brokerIDNamespaceMu.Unlock()
return definitelyUnsent(err)
}
} else {
if c.feeRequestOwnsID(ibkrOrder.OrderID) {
c.brokerIDNamespaceMu.Unlock()
return definitelyUnsent(fmt.Errorf("%w: explicit order ID is owned by an active read-only request", ErrBrokerIDNamespaceConflict))
}
// Only an order this process placed is owned here: an ID it merely
// changed (an earlier process's or another app's order) proves its
// identity on every change (OrderChangeIdentity).
owned := c.PlacedOrderID(ibkrOrder.OrderID)
guard = conn.orderChangeWireGuard(ibkrOrder.OrderID, binding.epoch, order.Changes, owned, guard)
var err error
claimEpoch, newOrder, err = conn.claimOrderIDForForwardingAtEpochFresh(ibkrOrder.OrderID, owned, &binding.epoch, order.Changes)
if err != nil {
c.brokerIDNamespaceMu.Unlock()
return definitelyUnsent(err)
}
}
defer conn.discardOrderIDReservation(ibkrOrder.OrderID)
c.orderIDHighWater = max(c.orderIDHighWater, ibkrOrder.OrderID)
brokerID := strconv.Itoa(ibkrOrder.OrderID)
localID := strings.TrimSpace(order.OrderRef)
if localID == "" {
localID = brokerID
}
now := time.Now()
stopPrice := order.AuxPrice
if order.TrailStopPrice != 0 {
stopPrice = order.TrailStopPrice
}
coreOrder := &trackedOrder{
ID: localID,
BrokerID: brokerID,
Symbol: contract.Symbol,
Side: OrderSide(order.Action),
Quantity: float64(order.TotalQty),
OrderType: mapIBOrderType(order.OrderType),
LimitPrice: order.LmtPrice,
StopPrice: stopPrice,
TimeInForce: mapIBTimeInForce(order.TIF),
Status: OrderStatusPending,
CreatedAt: now,
UpdatedAt: now,
AllowOutsideRth: order.OutsideRth,
}
c.orderMu.Lock()
previousOrder, hadPreviousOrder := c.openOrders[localID]
previousIndex, hadPreviousIndex := c.brokerOrderIndex[brokerID]
c.openOrders[localID] = coreOrder
c.brokerOrderIndex[brokerID] = localID
c.orderMu.Unlock()
c.brokerIDNamespaceMu.Unlock()
// Place the order through the connection after local indexing so fast
// broker callbacks and errors can be correlated with the journal.
var err error
if paperGate != nil {
err = conn.placePaperOrderForEpochGuarded(ctx, *paperGate, ibkrOrder, claimEpoch, guard)
} else {
err = conn.placeOrderForEpochGuarded(ctx, ibkrOrder, claimEpoch, guard)
}
// Indexing a queued instruction is not placement authority. Retain it
// only after a successful or uncertain write, never a definite non-send.
if newOrder && (err == nil || SendDispositionOf(err) != SendDispositionDefinitelyUnsent) {
c.orderMu.Lock()
if c.placedOrderIDs == nil {
c.placedOrderIDs = make(map[int]struct{})
}
c.placedOrderIDs[ibkrOrder.OrderID] = struct{}{}
c.orderMu.Unlock()
}
if err != nil {
if SendDispositionOf(err) == SendDispositionDefinitelyUnsent {
c.orderMu.Lock()
if hadPreviousOrder {
c.openOrders[localID] = previousOrder
} else {
delete(c.openOrders, localID)
}
if hadPreviousIndex {
c.brokerOrderIndex[brokerID] = previousIndex
} else {
delete(c.brokerOrderIndex, brokerID)
}
c.orderMu.Unlock()
}
// A possibly written instruction retains its exact positive ID and local
// correlation so callbacks and a later safety cancel can still find it.
order.OrderID = ibkrOrder.OrderID
order.ClientID = ibkrOrder.ClientID
order.PermID = ibkrOrder.PermID
return fmt.Errorf("failed to place order: %w", err)
}
order.OrderID = ibkrOrder.OrderID
order.ClientID = ibkrOrder.ClientID
order.PermID = ibkrOrder.PermID
if newBrokerID := strconv.Itoa(ibkrOrder.OrderID); newBrokerID != brokerID {
c.orderMu.Lock()
delete(c.brokerOrderIndex, brokerID)
coreOrder.BrokerID = newBrokerID
c.brokerOrderIndex[newBrokerID] = localID
c.orderMu.Unlock()
}
c.logInfo("Order submitted: ID=%d, %s %s %d @ %.2f (TIF=%s, OutsideRth=%v)",
ibkrOrder.OrderID, order.Action, contract.Symbol, order.TotalQty,
order.LmtPrice, order.TIF, order.OutsideRth)
return nil
}
func rawIBKROrder(contract *Contract, order *RawOrder) *IBKROrder {
ibkrOrder := &IBKROrder{
ParentID: order.ParentID,
AdaptivePriority: order.AdaptivePriority,
OrderID: order.OrderID,
ClientID: order.ClientID,
PermID: order.PermID,
ConID: contract.ConID,
Symbol: contract.Symbol,
SecType: contract.SecType,
Expiry: contract.Expiry,
Strike: contract.Strike,
Right: contract.Right,
Multiplier: multiplierToString(contract.Multiplier),
Exchange: contract.Exchange,
PrimaryExch: contract.PrimaryExch,
Currency: contract.Currency,
LocalSymbol: contract.LocalSymbol,
TradingClass: contract.TradingClass,
ComboLegs: append([]ComboLeg(nil), contract.ComboLegs...),
BondRules: cloneBondOrderRules(contract.BondRules),
Action: order.Action,
TotalQty: order.TotalQty,
OrderType: order.OrderType,
LmtPrice: order.LmtPrice,
LmtPriceSet: order.LmtPriceSet || strings.EqualFold(order.OrderType, "LMT"),
AuxPrice: order.AuxPrice,
TrailStopPrice: order.TrailStopPrice,
TrailingPercent: order.TrailingPercent,
LmtPriceOffset: order.LmtPriceOffset,
TIF: order.TIF,
TriggerMethod: order.TriggerMethod,
OrderRef: order.OrderRef,
OutsideRth: order.OutsideRth,
OcaGroup: strings.TrimSpace(order.OcaGroup),
OcaType: order.OcaType,
Account: order.Account,
Transmit: true,
OpenClose: strings.ToUpper(strings.TrimSpace(order.OpenClose)),
Origin: 0,
}
if ibkrOrder.OpenClose == "" {
ibkrOrder.OpenClose = "O"
}
return ibkrOrder
}
// ReserveOrderID claims the next broker order ID without submitting an order.
func (c *Connector) ReserveOrderID() (int, error) {
if !tradingEnabled {
return 0, ErrTradingDisabled
}
binding, ok := c.CaptureSession()
if !ok {
return 0, fmt.Errorf("not connected to IBKR")
}
return c.ReserveOrderIDForSession(binding)
}
// ReserveOrderIDForSession claims the next broker order ID from the exact
func (c *Connector) ReserveOrderIDForSession(binding ConnectorSessionBinding) (int, error) {
if !tradingEnabled {
return 0, ErrTradingDisabled
}
if !c.SessionCurrent(binding) {
return 0, fmt.Errorf("broker session binding is not current for this Connector")
}
c.brokerIDNamespaceMu.Lock()
id, _, err := c.nextDisjointOrderIDLockedForSession(binding)
if err != nil {
c.brokerIDNamespaceMu.Unlock()
return 0, err
}
c.orderIDHighWater = max(c.orderIDHighWater, id)
c.brokerIDNamespaceMu.Unlock()
return id, nil
}
func (c *Connector) nextDisjointOrderIDLockedForSession(binding ConnectorSessionBinding) (int, uint64, error) {
for {
id, epoch, err := binding.connection.reserveNextOrderIDForEpoch(binding.epoch)
if err != nil {
return 0, epoch, err
}
if !c.feeRequestOwnsID(id) {
return id, epoch, nil
}
binding.connection.discardOrderIDReservation(id)
}
}
func (c *Connector) nextDisjointOrderIDLocked(conn *Connection) (int, uint64, error) {
for {
id, epoch, err := conn.reserveNextOrderID()
if err != nil {
return 0, epoch, err
}
if !c.feeRequestOwnsID(id) {
return id, epoch, nil
}
// The shared frontier remains consumed, but a skipped read-owned ID
// must not retain order-reservation provenance that could authorize a
// later explicit claim.
conn.discardOrderIDReservation(id)
}
}
func (c *Connector) feeRequestOwnsID(id int) bool {
if id <= 0 {
return false
}
c.historicalMu.Lock()
defer c.historicalMu.Unlock()
request := c.historicalReqs[id]
return c.historicalRouteReqs[id] != nil || (request != nil && request.requestOwnsNoticeCollision)
}
type orderLifecycleHandlerEntry struct {
legacy func(OrderLifecycleEvent)
receipt func(OrderLifecycleReceipt)
}
// RegisterOrderLifecycleHandler appends a compatibility callback for broker
// and must return quickly. A nil Connector or handler is ignored.
func (c *Connector) RegisterOrderLifecycleHandler(handler func(OrderLifecycleEvent)) {
if c == nil || handler == nil {
return
}
c.orderLifecycleMu.Lock()
c.orderLifecycle = append(c.orderLifecycle, orderLifecycleHandlerEntry{legacy: handler})
c.orderLifecycleMu.Unlock()
}
// RegisterOrderLifecycleReceiptHandler appends a callback that receives the
// exact socket-session receipt for every event.
func (c *Connector) RegisterOrderLifecycleReceiptHandler(handler func(OrderLifecycleReceipt)) {
if c == nil || handler == nil {
return
}
c.orderLifecycleMu.Lock()
c.orderLifecycle = append(c.orderLifecycle, orderLifecycleHandlerEntry{receipt: handler})
c.orderLifecycleMu.Unlock()
}
// OrderLifecycleGeneration returns the current connection-local order-event
// frontier without issuing a broker request. Zero means no accepted lifecycle
func (c *Connector) OrderLifecycleGeneration() uint64 {
if c == nil {
return 0
}
return c.orderLifecycleGeneration.Load()
}
// PortfolioProjectionGeneration returns the current structural portfolio
// frontier without issuing a broker request. It advances for scope,
// completeness, contract-set, or quantity changes, but not mark/PnL-only
// updates.
func (c *Connector) PortfolioProjectionGeneration() uint64 {
if c == nil {
return 0
}
c.mu.RLock()
conn := c.conn
c.mu.RUnlock()
if conn == nil {
return 0
}
return conn.PortfolioProjectionGeneration()
}
// PortfolioProjectionBinding is a caller-owned, exact-session snapshot of the
// structural portfolio projection and its typed stream receipt.
type PortfolioProjectionBinding struct {
Session ConnectorSessionBinding
Positions []*RawPosition
Health PortfolioStreamHealth
Generation uint64
}
// CapturePortfolioProjectionForSession snapshots positions, health, and the
// structural generation while portfolio/session mutations are excluded.
// False means binding is stale or the Connector is not ready.
func (c *Connector) CapturePortfolioProjectionForSession(binding ConnectorSessionBinding) (PortfolioProjectionBinding, bool) {
if c == nil {
return PortfolioProjectionBinding{}, false
}
c.publicationBarrier.RLock()
defer c.publicationBarrier.RUnlock()
c.evidenceBarrier.Lock()
defer c.evidenceBarrier.Unlock()
if !c.SessionCurrent(binding) {
return PortfolioProjectionBinding{}, false
}
positions, health := binding.connection.GetPositionsWithPortfolioHealth()
return PortfolioProjectionBinding{
Session: binding, Positions: c.filteredCachedPositions(positions),
Health: health, Generation: health.ProjectionGeneration,
}, true
}
// CapturePortfolioProjectionForBoundSession snapshots the structural
// portfolio authority without acquiring publicationBarrier or evidenceBarrier.
// It is only valid from a protected order wire guard while the transport owns
func (c *Connector) CapturePortfolioProjectionForBoundSession(binding ConnectorSessionBinding) (PortfolioProjectionBinding, bool) {
if c == nil || !c.SessionCurrent(binding) {
return PortfolioProjectionBinding{}, false
}
positions, health := binding.connection.GetPositionsWithPortfolioHealth()
if !c.SessionCurrent(binding) {
return PortfolioProjectionBinding{}, false
}
return PortfolioProjectionBinding{
Session: binding, Positions: c.filteredCachedPositions(positions),
Health: health, Generation: health.ProjectionGeneration,
}, true
}
// BrokerEvidenceBinding is a point-in-time identity for the Connector session,
// order callback frontier, and structural portfolio projection.
type BrokerEvidenceBinding struct {
Session ConnectorSessionBinding
OrderLifecycleGeneration uint64
PortfolioProjectionGeneration uint64
}
// CaptureBrokerEvidence returns one stable broker-evidence frontier. False
func (c *Connector) CaptureBrokerEvidence() (BrokerEvidenceBinding, bool) {
if c == nil {
return BrokerEvidenceBinding{}, false
}
c.publicationBarrier.RLock()
defer c.publicationBarrier.RUnlock()
c.evidenceBarrier.RLock()
defer c.evidenceBarrier.RUnlock()
session, ok := c.CaptureSession()
if !ok {
return BrokerEvidenceBinding{}, false
}
return BrokerEvidenceBinding{
Session: session, OrderLifecycleGeneration: c.OrderLifecycleGeneration(),
PortfolioProjectionGeneration: c.PortfolioProjectionGeneration(),
}, true
}
// WithStableBrokerEvidence executes commit while structural portfolio/session
// calling commit when binding is no longer exact.
func (c *Connector) WithStableBrokerEvidence(binding BrokerEvidenceBinding, commit func() bool) bool {
if c == nil || commit == nil {
return false
}
c.publicationBarrier.RLock()
defer c.publicationBarrier.RUnlock()
c.evidenceBarrier.Lock()
defer c.evidenceBarrier.Unlock()
if !c.SessionCurrent(binding.Session) || c.OrderLifecycleGeneration() != binding.OrderLifecycleGeneration ||
c.PortfolioProjectionGeneration() != binding.PortfolioProjectionGeneration {
return false
}
return commit()
}
// WithBrokerEvidenceMutation serializes an external owner-published identity
// change after all in-flight Connector evidence dispatch and exact-session
// broker operations have drained. It exists for daemon connector publication
// only; it does not authorize broker activity.
func (c *Connector) WithBrokerEvidenceMutation(change func()) {
if change == nil {
return
}
if c == nil {
change()
return
}
c.publicationBarrier.Lock()
defer c.publicationBarrier.Unlock()
c.evidenceBarrier.Lock()
defer c.evidenceBarrier.Unlock()
change()
}
// WithBoundBrokerSession admits operation only when binding names the current
// acquire the publication read side only for their final guarded write, where
// the exact Connection epoch remains the final pre-wire authority.
func (c *Connector) WithBoundBrokerSession(binding ConnectorSessionBinding, operation func() error) (bool, error) {
if c == nil || operation == nil {
return false, nil
}
if !c.SessionCurrent(binding) {
return false, nil
}
return true, operation()
}
func multiplierToString(mult int) string {
if mult <= 0 {
return ""
}
return strconv.Itoa(mult)
}
// CancelOrder sends a cancellation for broker orderID. Default builds return
// sent, not that the broker confirmed the order cancelled.
func (c *Connector) CancelOrder(orderID int) error {
if !tradingEnabled {
return definitelyUnsent(ErrTradingDisabled)
}
binding, ok := c.CaptureSession()
if !ok {
return definitelyUnsent(fmt.Errorf("not connected to IBKR"))
}
return c.CancelOrderForSession(binding, orderID)
}
// CancelOrderForSession sends a cancellation only on the exact Connector
func (c *Connector) CancelOrderForSession(binding ConnectorSessionBinding, orderID int) error {
return c.CancelOrderForSessionGuarded(context.Background(), binding, orderID, nil)
}
// CancelOrderForSessionGuarded carries caller cancellation and a final
// authority guard to the exact cancel frame.
func (c *Connector) CancelOrderForSessionGuarded(ctx context.Context, binding ConnectorSessionBinding, orderID int, guard func() error) error {
if !tradingEnabled {
return definitelyUnsent(ErrTradingDisabled)
}
if ctx == nil {
return definitelyUnsent(fmt.Errorf("broker cancel context is nil"))
}
if err := ctx.Err(); err != nil {
return definitelyUnsent(err)
}
if !c.SessionCurrent(binding) {
return definitelyUnsent(fmt.Errorf("broker session binding is not current for this Connector"))
}
err := binding.connection.cancelOrderForEpochGuarded(ctx, orderID, binding.epoch, guard)
if err != nil {
return fmt.Errorf("failed to cancel order: %w", err)
}
c.logInfo("Order cancel request sent for ID: %d", orderID)
return nil
}
// CancelPaperOrder validates gate against the configured connection and sends
// a cancellation for broker orderID in a paper account. A successful return
// means the frame was sent, not that the broker confirmed cancellation.
func (c *Connector) CancelPaperOrder(gate PaperOrderGate, orderID int) error {
binding, ok := c.CaptureSession()
if !ok {
return definitelyUnsent(fmt.Errorf("not connected to IBKR"))
}
return c.CancelPaperOrderForSession(binding, gate, orderID)
}
// CancelPaperOrderForSession validates gate and sends a paper cancellation
// only on the exact Connector socket generation named by binding.
func (c *Connector) CancelPaperOrderForSession(binding ConnectorSessionBinding, gate PaperOrderGate, orderID int) error {
return c.CancelPaperOrderForSessionGuarded(context.Background(), binding, gate, orderID, nil)
}
// CancelPaperOrderForSessionGuarded is the paper-gated counterpart to
// CancelOrderForSessionGuarded.
func (c *Connector) CancelPaperOrderForSessionGuarded(ctx context.Context, binding ConnectorSessionBinding, gate PaperOrderGate, orderID int, guard func() error) error {
if err := gate.validate(); err != nil {
return definitelyUnsent(err)
}
if ctx == nil {
return definitelyUnsent(fmt.Errorf("broker cancel context is nil"))
}
if err := ctx.Err(); err != nil {
return definitelyUnsent(err)
}
if !c.SessionCurrent(binding) {
return definitelyUnsent(fmt.Errorf("broker session binding is not current for this Connector"))
}
if err := binding.connection.cancelPaperOrderForEpochGuarded(ctx, gate, orderID, binding.epoch, guard); err != nil {
return fmt.Errorf("failed to cancel order: %w", err)
}
c.logInfo("Paper order cancel request sent for ID: %d", orderID)
return nil
}
func (c *Connector) seedContractCacheFromPositions(positions map[string]*RawPosition) {
if len(positions) == 0 {
return
}
hints := make(map[string]ContractDetailsLite, len(positions))
for _, pos := range positions {
if pos == nil {
continue
}
if isZeroValueStockPosition(pos) {
continue
}
contract := pos.Contract
if contract.ConID == 0 {
continue
}
// Only seed bare-symbol cache entries from stock positions. The
if !strings.EqualFold(contract.SecType, "STK") {
continue
}
symbol := strings.ToUpper(strings.TrimSpace(contract.Symbol))
if symbol == "" {
continue
}
detail := ContractDetailsLite{
Symbol: symbol,
Exchange: strings.TrimSpace(contract.Exchange),
PrimaryExch: strings.TrimSpace(contract.PrimaryExch),
ConID: contract.ConID,
LocalSymbol: strings.TrimSpace(contract.LocalSymbol),
TradingClass: strings.TrimSpace(contract.TradingClass),
}
if existing, ok := hints[symbol]; ok {
hints[symbol] = mergeContractDetailsLite(existing, detail)
} else {
hints[symbol] = detail
}
}
if len(hints) == 0 {
return
}
c.contractMu.Lock()
for symbol, hint := range hints {
if cached, ok := c.contractCache[symbol]; ok {
c.contractCache[symbol] = mergeContractDetailsLite(cached, hint)
} else {
c.contractCache[symbol] = hint
}
}
c.contractMu.Unlock()
}
// isConnected checks if we have an active IBKR connection. Reconnection on
// loss is the daemon's responsibility (server.triggerReconnect); the
// connector reports honest connectivity rather than masking it with retry
// state.
func (c *Connector) isConnected() bool {
c.mu.RLock()
conn := c.conn
c.mu.RUnlock()
if conn == nil {
return false
}
return conn.IsConnected()
}
// IsConnected reports whether the Connector currently has an active broker
func (c *Connector) IsConnected() bool { return c.isConnected() }
// UsingTLS reports the TLS mode the active session negotiated. False when
// disconnected or when a non-TLS handshake succeeded (possibly via fallback).
func (c *Connector) UsingTLS() bool {
c.mu.RLock()
conn := c.conn
c.mu.RUnlock()
if conn == nil {
return false
}
return conn.UsingTLS()
}
// IsReady reports whether the broker connection is established and the
// Connector's response handlers are registered.
func (c *Connector) IsReady() bool {
c.mu.RLock()
rd := c.ready
c.mu.RUnlock()
return rd && c.isConnected()
}
// CaptureSession returns the exact ready Connection object and socket epoch
// that may own a new broker-adjacent read. The token is process-local and is
// evidence for a later equality check, not durable readiness or authority.
func (c *Connector) CaptureSession() (ConnectorSessionBinding, bool) {
if c == nil {
return ConnectorSessionBinding{}, false
}
c.mu.RLock()
conn := c.conn
ready := c.ready
c.mu.RUnlock()
if !ready || conn == nil || !conn.IsConnected() {
return ConnectorSessionBinding{}, false
}
return ConnectorSessionBinding{connector: c, connection: conn, epoch: conn.BrokerSessionEpoch()}, true
}
// SessionCurrent reports whether binding still names this Connector's ready
// Connection and exact socket epoch.
func (c *Connector) SessionCurrent(binding ConnectorSessionBinding) bool {
if c == nil || binding.connector != c || binding.connection == nil {
return false
}
c.mu.RLock()
conn := c.conn
ready := c.ready
c.mu.RUnlock()
return ready && conn == binding.connection && conn.IsConnected() && conn.BrokerSessionEpoch() == binding.epoch
}
// SessionReceiptCurrent reports whether binding names the Connector's exact
// installed inbound socket generation. Connecting is accepted because
// startAPI synchronously processes a small frame burst before onConnect;
// disconnected/failed/reconnecting states are never current. This does not
// authorize outbound requests.
func (c *Connector) SessionReceiptCurrent(binding ConnectorSessionBinding) bool {
if c == nil || binding.connector != c || binding.connection == nil {
return false
}
c.mu.RLock()
conn := c.conn
c.mu.RUnlock()
if conn != binding.connection || conn.BrokerSessionEpoch() != binding.epoch {
return false
}
status := conn.Status()
return status == StatusConnecting || status == StatusConnected
}
// CaptureHistoricalSession is the historical-data compatibility spelling for
// [Connector.CaptureSession].
func (c *Connector) CaptureHistoricalSession() (HistoricalSessionBinding, bool) {
return c.CaptureSession()
}
// HistoricalSessionCurrent is the historical-data compatibility spelling for
// [Connector.SessionCurrent].
func (c *Connector) HistoricalSessionCurrent(binding HistoricalSessionBinding) bool {
return c.SessionCurrent(binding)
}
// ServerVersion returns the IBKR server protocol version reported by the
// gateway during the handshake. Returns 0 when no connection is established.
func (c *Connector) ServerVersion() int {
c.mu.RLock()
defer c.mu.RUnlock()
if c.conn == nil {
return 0
}
return c.conn.ServerVersion()
}
func (c *Connector) subscribeOption(ctx context.Context, underlying, tradingClass, expiryYMD string, strike float64, right string) (string, int, error) {
if !c.isConnected() {
return "", 0, ErrIBKRUnavailable
}
upperUnderlying := strings.ToUpper(underlying)
upperClass := strings.ToUpper(strings.TrimSpace(tradingClass))
if upperClass == "" {
upperClass = upperUnderlying
}
key := optionMarketDataKeyForClass(upperUnderlying, upperClass, expiryYMD, right, strike)
contract := Contract{
Symbol: upperUnderlying,
SecType: "OPT",
Exchange: "SMART",
PrimaryExch: optionUnderlyingPrimaryExchangeHint(upperUnderlying),
Currency: "USD",
Expiry: expiryYMD,
Strike: strike,
Right: strings.ToUpper(right),
Multiplier: 100,
TradingClass: upperClass,
}
binding, ready := c.CaptureSession()
if !ready {
return "", 0, ErrIBKRUnavailable
}
conn := binding.connection
if conn.applyCachedOptionContract(&contract) {
normalizeResolvedOptionMarketDataContract(&contract)
} else {
// Index options and other non-direct paths still resolve ConID before
// reqMktData. SPX/SPXW in particular needs this cache-backed path to
// preserve its classed contract identity.
if err := conn.resolveOptionContract(ctx, &contract, 5*time.Second); err != nil {
return "", 0, fmt.Errorf("resolve option %s %s %.2f%s: %w",
contract.Symbol, contract.Expiry, contract.Strike, contract.Right, err)
}
normalizeResolvedOptionMarketDataContract(&contract)
}
// Generic ticks mirror RequestOptionsMarketData: 100=opt volume,
// delivered for option contracts, so callers must not depend on
// Register the reqID before the wire send so ticks 27/28 cannot race
reqID, err := conn.requestMarketDataWithContractForEpoch(ctx, contract, OptionSubscriptionGenericTicks, false, false, binding.epoch, func(reqID int) func() {
subscriptionRight := strings.ToUpper(strings.TrimSpace(contract.Right))
if subscriptionRight != "" {
subscriptionRight = subscriptionRight[:1]
}
c.subMu.Lock()
c.reqIDMap[reqID] = key
c.subscriptions[key] = &Subscription{
Symbol: key,
Right: subscriptionRight,
ReqID: reqID,
LastTime: time.Now(),
RejectCh: make(chan SubscriptionRejection, 1),
optionReaders: 1, optionSession: binding, SessionEpoch: binding.epoch,
}
c.subMu.Unlock()
// Route option-computation ticks (msg 21, live types 10/11/13 and
c.optMu.Lock()
// A new wire request must prove its own observation. Cache values survive
delete(c.optIV, key)
delete(c.optIVDataType, key)
delete(c.optQuoteBid, key)
delete(c.optQuoteAsk, key)
delete(c.optPrevClose, key)
delete(c.optGreeks, key)
delete(c.optUnderlyingPx, key)
c.optReqIDs[reqID] = key
c.optMu.Unlock()
return func() {
c.removePreparedOptionSubscription(key, reqID)
}
})
if err != nil {
return "", 0, err
}
return key, reqID, nil
}
func (c *Connector) removePreparedOptionSubscription(key string, reqID int) {
c.subMu.Lock()
if c.reqIDMap[reqID] == key {
delete(c.reqIDMap, reqID)
}
if sub, ok := c.subscriptions[key]; ok && sub.ReqID == reqID {
delete(c.subscriptions, key)
}
c.subMu.Unlock()
c.optMu.Lock()
if c.optReqIDs[reqID] == key {
delete(c.optReqIDs, reqID)
}
c.optMu.Unlock()
}
// OptionMarketDataKey returns the normalized in-memory cache key used for an
// option line: the upper-cased underlying, the last six digits of expiryYMD
// (dashes ignored), the right, and the strike in its shortest decimal form.
// Whole-dollar strikes read as before ("100"); a fractional strike keeps its
// decimals ("102.5"), so it never shares a line with a whole-dollar neighbour.
func OptionMarketDataKey(underlying, expiryYMD, right string, strike float64) string {
upperUnderlying := strings.ToUpper(strings.TrimSpace(underlying))
upperRight := strings.ToUpper(strings.TrimSpace(right))
expiryKey := strings.ReplaceAll(strings.TrimSpace(expiryYMD), "-", "")
if len(expiryKey) > 6 {
expiryKey = expiryKey[len(expiryKey)-6:]
}
// Rounding to 1e-4 first absorbs binary noise in a strike parsed or
// computed from a decimal, so one listed strike always maps to one key.
return upperUnderlying + "_" + expiryKey + upperRight + strconv.FormatFloat(math.Round(strike*1e4)/1e4, 'f', -1, 64)
}
func optionMarketDataKeyForClass(underlying, tradingClass, expiryYMD, right string, strike float64) string {
base := OptionMarketDataKey(underlying, expiryYMD, right, strike)
upperUnderlying := strings.ToUpper(strings.TrimSpace(underlying))
upperClass := strings.ToUpper(strings.TrimSpace(tradingClass))
if upperClass == "" || upperClass == upperUnderlying {
return base
}
return upperUnderlying + "_" + upperClass + strings.TrimPrefix(base, upperUnderlying)
}
// PrewarmOptionChain resolves and caches option contracts for each expiry in a
// symbol and trading-class pair. It returns one result per expiry with cache
// counts, duration, and any error. The call returns nil when disconnected; ctx
// and timeout bound the underlying bulk requests. Later [Connector.SubscribeOption]
// calls can reuse the resolved contract identities.
func (c *Connector) PrewarmOptionChain(
ctx context.Context,
symbol string,
expiries []string,
tradingClass string,
timeout time.Duration,
) []PrewarmOptionChainResult {
if !c.isConnected() {
return nil
}
c.mu.RLock()
conn := c.conn
c.mu.RUnlock()
if conn == nil {
return nil
}
return conn.PrewarmOptionChain(ctx, symbol, expiries, tradingClass, timeout)
}
// RequestAccountUpdates starts the singleton streaming account and portfolio
// account value to resolve a concrete managed account from the connection.
// Aggregate values ("All", comma-separated managedAccounts lists) are not
// account codes — TWS rejects them with error 321 and the portfolio stream
// never starts. They are reduced to a concrete code (or to "", which TWS
// resolves itself for single-account logins) before hitting the wire.
func (c *Connector) RequestAccountUpdates(account string) error {
if !c.isConnected() {
return ErrIBKRUnavailable
}
if !accountCodeConcrete(account) {
account = c.conn.GetAccountCode()
}
if !accountCodeConcrete(account) {
account = firstConcreteAccountCode(account)
}
now := c.acctUpdatesClock()
c.acctUpdatesMu.Lock()
c.acctUpdatesLastAt = now
c.acctUpdatesAccount = account
c.acctUpdatesMu.Unlock()
return c.conn.RequestAccountUpdates(account)
}
// resubscribeAccountUpdates re-issues the subscription with the account the
// stream is already bound to. The self-heal paths must not pass "": on a
// multi-account login that resolves through the raw managedAccounts list to
// the operator's pinned account and makes the pinned account's own frames
func (c *Connector) resubscribeAccountUpdates() error {
c.acctUpdatesMu.Lock()
account := c.acctUpdatesAccount
c.acctUpdatesMu.Unlock()
return c.RequestAccountUpdates(account)
}
// acctUpdatesResubscribeThrottle bounds the dead-stream self-heal below to
// one resubscribe attempt per window, counted from the last subscribe.
const acctUpdatesResubscribeThrottle = 30 * time.Second
func (c *Connector) acctUpdatesClock() time.Time {
if c.acctUpdatesNow != nil {
return c.acctUpdatesNow()
}
return time.Now()
}
// accountUpdatesRepair names why a positions read asks for a fresh
// account-updates subscription.
type accountUpdatesRepair uint8
const (
// repairEmptyCache: the cache is empty although the account summary
// reports gross position value. The TWS account-updates stream
// occasionally fails to start after a rapid reconnect while quotes and
// the account summary flow normally; a flat account never triggers.
repairEmptyCache accountUpdatesRepair = iota
// repairScopeConflict: a rejected foreign-account frame latched the
// stream. Retained rows remain context only until a new, account-scoped
// subscription completes.
repairScopeConflict
// repairShortDownload: the end marker closed a generation that does not
// account for the stream's gross position value. A fresh download
// replaces the staged rows once TWS has loaded the whole account.
repairShortDownload
)
// maybeResubscribeAccountUpdates re-issues the account+portfolio stream
// subscription for an empty cache; see repairEmptyCache.
func (c *Connector) maybeResubscribeAccountUpdates() {
c.maybeResubscribeAccountUpdatesForReason(repairEmptyCache)
}
func (c *Connector) maybeResubscribeAccountUpdatesForReason(reason accountUpdatesRepair) {
if !c.isConnected() {
return
}
if reason == repairEmptyCache && !accountSummaryShowsPositions(c.conn.GetAccountSummary()) {
return
}
now := c.acctUpdatesClock()
c.acctUpdatesMu.Lock()
stale := now.Sub(c.acctUpdatesLastAt) >= acctUpdatesResubscribeThrottle
c.acctUpdatesMu.Unlock()
if !stale {
return
}
switch reason {
case repairScopeConflict:
ibkrLogger.Warnf("portfolio stream account scope conflicted; resubscribing account updates")
case repairShortDownload:
ibkrLogger.Warnf("portfolio download does not account for gross position value; resubscribing account updates")
default:
ibkrLogger.Warnf("positions cache empty while account summary shows gross position value; resubscribing account updates")
}
_ = c.resubscribeAccountUpdates()
}
// accountSummaryShowsPositions reports whether any GrossPositionValue
func accountSummaryShowsPositions(summary map[string]string) bool {
for key, raw := range summary {
if key != "GrossPositionValue" && !strings.HasPrefix(key, "GrossPositionValue_") {
continue
}
if v, err := strconv.ParseFloat(strings.TrimSpace(raw), 64); err == nil && v > 0 {
return true
}
}
return false
}
// CachedPositions returns the latest filtered portfolio cache without issuing
// pointers refer to cached rows and must be treated as read-only. Zero-quantity
func (c *Connector) CachedPositions() ([]*RawPosition, error) {
if !c.isConnected() {
return nil, nil
}
c.mu.RLock()
conn := c.conn
c.mu.RUnlock()
if conn == nil {
return nil, nil
}
ibkrPositions := conn.GetPositions()
result := c.filteredCachedPositions(ibkrPositions)
if len(result) == 0 {
// Self-heal a dead portfolio stream behind the read: consumers
// poll this path constantly, so a failed account-updates
c.maybeResubscribeAccountUpdates()
}
return result, nil
}
// CachedPositionsWithHealth returns the same read-only cached rows as
// heartbeat receipts. It performs no positions snapshot request; a typed
// account-scope conflict may trigger the throttled stream resubscribe behind
func (c *Connector) CachedPositionsWithHealth() ([]*RawPosition, PortfolioStreamHealth, error) {
if !c.isConnected() {
return nil, PortfolioStreamHealth{}, nil
}
c.mu.RLock()
conn := c.conn
c.mu.RUnlock()
if conn == nil {
return nil, PortfolioStreamHealth{}, nil
}
ibkrPositions, health := conn.GetPositionsWithPortfolioHealth()
result := c.filteredCachedPositions(ibkrPositions)
switch {
case !health.ScopeConflictAt.IsZero() || !health.InvalidPayloadAt.IsZero():
c.maybeResubscribeAccountUpdatesForReason(repairScopeConflict)
case !health.DownloadShortAt.IsZero():
c.maybeResubscribeAccountUpdatesForReason(repairShortDownload)
case len(result) == 0 && accountSummaryShowsPositions(conn.GetAccountSummary()):
c.maybeResubscribeAccountUpdates()
default:
return result, health, nil
}
// Rows and receipt must come from one capture: a download that completes
// between two reads would otherwise label the older rows current.
ibkrPositions, health = conn.GetPositionsWithPortfolioHealth()
return c.filteredCachedPositions(ibkrPositions), health, nil
}
func (c *Connector) filteredCachedPositions(ibkrPositions map[string]*RawPosition) []*RawPosition {
c.seedContractCacheFromPositions(ibkrPositions)
result := make([]*RawPosition, 0, len(ibkrPositions))
for _, pos := range ibkrPositions {
if pos == nil {
continue
}
if pos.Position == 0 {
continue
}
if pos.Contract.SecType == "STK" && pos.Contract.ConID == 0 {
continue
}
// Inactive marks never hide a held row: for a true delisting the
// row is zero-value and stays visible anyway, so a mark-based skip
// here fired almost exclusively on FALSE marks — silently hiding
// healthy holdings during gateway-wide degradation (2026-07-08).
// A possibly-stale price beats a vanished position.
result = append(result, pos)
}
return result
}
func isZeroValueStockPosition(pos *RawPosition) bool {
if pos == nil || !strings.EqualFold(pos.Contract.SecType, "STK") {
return false
}
if pos.Position == 0 {
return false
}
return pos.MarketPrice <= 0 && math.Abs(pos.MarketValue) < 1e-9
}
// RefreshPositions captures the ready broker session and requests a completed,
// isolated positions snapshot within timeout.
func (c *Connector) RefreshPositions(timeout time.Duration) ([]*RawPosition, error) {
binding, ok := c.CaptureSession()
if !ok {
return nil, ErrIBKRUnavailable
}
return c.RefreshPositionsForSession(context.Background(), binding, timeout)
}
// RefreshPositionsForSession uses a correlated positions subscription on the
// captured session. reqPositions has no request ID and cannot distinguish
// queued updates from a retired subscription from a fresh initial snapshot.
// The subscription is canceled after completion or timeout, and rows never
// update the streaming portfolio projection.
func (c *Connector) RefreshPositionsForSession(ctx context.Context, binding ConnectorSessionBinding, timeout time.Duration) (rows []*RawPosition, resultErr error) {
if ctx == nil || !c.SessionCurrent(binding) {
return nil, ErrIBKRUnavailable
}
if err := ctx.Err(); err != nil {
return nil, err
}
if timeout <= 0 {
return nil, fmt.Errorf("positions snapshot timeout must be positive")
}
if !c.positionsRefreshMu.TryLock() {
return nil, fmt.Errorf("another positions snapshot is in progress")
}
defer c.positionsRefreshMu.Unlock()
if c.positionsRefreshUncancelled == binding {
return nil, fmt.Errorf("prior positions subscription cancellation is unconfirmed")
}
conn := binding.connection
// The current negotiated maximum is 203. Position Multi remains legacy
// on these sessions; account protobuf begins at 207.
if version := conn.ServerVersion(); version < 103 || version >= 207 {
return nil, fmt.Errorf("positions snapshot protocol is unsupported")
}
expected := accountSummaryExpectedAccount(conn)
if !expected.valid() {
return nil, ErrAccountSummaryScopeConflict
}
guard := func() error {
if !c.SessionCurrent(binding) {
return ErrIBKRUnavailable
}
if !expected.equal(accountSummaryExpectedAccount(conn)) {
return ErrAccountSummaryScopeConflict
}
return nil
}
reqID, _, err := conn.reserveNextRequestIDForEpoch(binding.epoch)
if err != nil {
return nil, err
}
defer conn.discardRequestIDReservation(reqID)
if _, err := conn.claimRequestIDForEpoch(reqID, binding.epoch); err != nil {
return nil, err
}
var mu sync.Mutex
terminal := false
positions := make(map[int]*RawPosition)
done := make(chan error, 1)
finish := func(err error) {
if !terminal {
terminal = true
done <- err
}
}
rowHandler := conn.RegisterHandlerAtEpoch(msgPositionMulti, func(fields []string, epoch uint64) {
if epoch != binding.epoch || len(fields) < 3 || fields[2] != strconv.Itoa(reqID) {
return
}
mu.Lock()
defer mu.Unlock()
if terminal {
return
}
row, err := parsePositionMultiSnapshotRow(fields, string(expected))
if err != nil {
finish(err)
return
}
if row.Position == 0 {
delete(positions, row.Contract.ConID)
} else {
positions[row.Contract.ConID] = row
}
})
endHandler := conn.RegisterHandlerAtEpoch(msgPositionMultiEnd, func(fields []string, epoch uint64) {
if epoch != binding.epoch || len(fields) < 3 || fields[2] != strconv.Itoa(reqID) {
return
}
mu.Lock()
defer mu.Unlock()
if fields[1] != "1" || (len(fields) != 3 && (len(fields) != 4 || fields[3] != "")) {
finish(fmt.Errorf("malformed positions snapshot end"))
return
}
finish(nil)
})
defer conn.UnregisterHandler(msgPositionMulti, rowHandler)
defer conn.UnregisterHandler(msgPositionMultiEnd, endHandler)
sentPossible := false
defer func() {
mu.Lock()
terminal = true
mu.Unlock()
if !sentPossible {
return
}
// Cancellation needs its own bounded cleanup even when the reader's
// context expired. The caller's budget also bounds this cleanup.
cancelCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), timeout)
defer cancel()
err := conn.sendMessageWithTypeContextForEpochGuarded(cancelCtx, conn.encodeMsg(cancelPositionsMulti, 1, reqID), RequestTypeGeneral, binding.epoch, true, guard)
if err != nil {
c.positionsRefreshUncancelled = binding
rows, resultErr = nil, fmt.Errorf("cancel positions subscription: %w", err)
} else {
c.positionsRefreshUncancelled = ConnectorSessionBinding{}
}
if err := guard(); err != nil {
rows, resultErr = nil, err
}
}()
readCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
err = conn.sendMessageWithTypeContextForEpochGuarded(readCtx, conn.encodeMsg(reqPositionsMulti, 1, reqID, string(expected), ""), RequestTypeGeneral, binding.epoch, true, guard)
sentPossible = err == nil || SendDispositionOf(err) != SendDispositionDefinitelyUnsent
if err != nil {
return nil, err
}
select {
case err := <-done:
if err != nil {
return nil, err
}
case <-readCtx.Done():
return nil, fmt.Errorf("waiting for positions snapshot: %w", readCtx.Err())
}
if err := readCtx.Err(); err != nil {
return nil, err
}
if err := guard(); err != nil {
return nil, err
}
mu.Lock()
defer mu.Unlock()
rows = make([]*RawPosition, 0, len(positions))
for _, row := range positions {
rows = append(rows, row)
}
return rows, nil
}
// registerHandlers sets up message handlers for IBKR responses.
func (c *Connector) registerHandlers(conn *Connection) {
if conn == nil {
return
}
// Debug, not info: this fires once per Connection object, and the daemon
c.logDebug("Registering message handlers")
conn.setOpenOrderSnapshotObserver(func(msgID int, fields []string, epoch uint64) {
switch msgID {
case msgOpenOrder:
ev, ok := ParseOrderLifecycleEvent(fields)
if !ok || ev.WhatIf || ev.Type != OrderLifecycleEventOpenOrder || conn.IsWhatIfOrderID(ev.OrderID) || conn.modifyPreviewActive(ev.OrderID, epoch) {
return
}
c.collectOpenOrderSnapshotFrom(conn, epoch, ev)
case msgOpenOrderEnd:
c.finishOpenOrderSnapshotFrom(conn, epoch)
}
})
// Register tick price handler (msgID 1)
conn.RegisterHandler(1, func(fields []string) {
c.handleTickPrice(fields)
})
// Register tick size handler (msgID 2)
conn.RegisterHandler(2, func(fields []string) {
c.handleTickSize(fields)
})
// Register generic tick handler (msgID 45) for items like option IV (106)
conn.RegisterHandler(45, func(fields []string) {
c.handleTickGeneric(fields)
})
// Register string tick handler (msgID 46) for values such as last
conn.RegisterHandler(msgTickString, func(fields []string) {
c.handleTickString(fields)
})
// Register option computation handler (msgID 21) for greeks and model IV
conn.RegisterHandlerAtEpoch(msgTickOptionComputation, func(fields []string, epoch uint64) {
c.handleExactOptionRisk(ConnectorSessionBinding{connector: c, connection: conn, epoch: epoch}, fields)
c.handleOptionComputation(fields)
})
// Register historical data handler (msgID 17) for HMDS backfill
conn.RegisterHandler(msgHistoricalData, func(fields []string) {
c.handleHistoricalData(fields)
})
// Register historical data end handler (msgID 108) to finalize empty results
conn.RegisterHandler(msgHistoricalDataEnd, func(fields []string) {
c.handleHistoricalDataEnd(fields)
})
// Register the Connector-owned error handler separately so any returned
// outbound cleanup runs only after Connection releases its inbound epoch
// lease. Legacy externally registered handlers still use msgID 4 below.
conn.setErrorPostActionHandler(func(fields []string, epoch uint64) func() {
return c.handleIBKRErrorFrom(ConnectorSessionBinding{connector: c, connection: conn, epoch: epoch}, fields)
})
conn.RegisterHandler(msgErrMsg, func([]string) {})
// Register position handler (msgID 61)
conn.RegisterHandler(61, func(fields []string) {
c.handlePosition(fields)
})
// Register portfolio value handler (msgID 7)
conn.RegisterHandler(7, func(fields []string) {
c.handlePortfolioValue(fields)
})
// Register order lifecycle handlers (openOrder/orderStatus/execDetails).
conn.RegisterHandlerAtEpoch(msgOpenOrder, func(fields []string, epoch uint64) {
c.notifyOrderLifecycleFrom(conn, epoch, fields)
})
conn.setOrderStatusObserver(func(fields []string, epoch uint64, disposition previewStatusDisposition) {
c.notifyOrderLifecycleReceiptFrom(conn, epoch, fields, disposition)
})
conn.RegisterHandler(msgOrderStatus, func([]string) {})
conn.RegisterHandlerAtEpoch(msgExecDetails, func(fields []string, epoch uint64) {
c.notifyOrderLifecycleFrom(conn, epoch, fields)
})
conn.RegisterHandler(msgOpenOrderEnd, func(fields []string) {
// The epoch-aware Connection observer owns snapshot completion.
})
conn.RegisterHandler(msgExecDetailsEnd, func(fields []string) {
// A live flight's own handler completes it; this one names the
// ends that arrive after their flight gave up.
c.noteLateExecDetailsEnd(fields, time.Now())
})
// Register system notification handler (msgID 204) for farm status changes
conn.RegisterHandler(204, func(fields []string) {})
conn.SetSystemNoticeHandlerAtEpochWithPostAction(func(note *systemNotification, alias reqAliasEntry, epoch uint64) func() {
return c.processSystemNoticeFrom(ConnectorSessionBinding{connector: c, connection: conn, epoch: epoch}, alias, note)
})
conn.setNoticeLogContext(c.noticeLogContext())
// Daily P&L streams: msgPnL (94) for account-level, msgPnLSingle (95)
// for per-conId. Subscriptions are owned by Connector.SubscribeAccountPnL
// pnl cache for non-blocking reads by AccountDailyPnL / PositionDailyPnL.
conn.RegisterHandlerAtEpoch(msgPnL, func(fields []string, epoch uint64) {
c.receivePnLForSession(ConnectorSessionBinding{connector: c, connection: conn, epoch: epoch}, func() { c.handlePnL(fields) })
})
conn.RegisterHandlerAtEpoch(msgPnLSingle, func(fields []string, epoch uint64) {
c.receivePnLForSession(ConnectorSessionBinding{connector: c, connection: conn, epoch: epoch}, func() { c.handlePnLSingle(fields) })
})
}
// handleTickPrice processes tick price updates.
func (c *Connector) handleTickPrice(fields []string) {
if len(fields) < 4 {
return
}
// Format: [msgID, version, reqID, tickType, price, ...]
if len(fields) < 5 {
return
}
reqIDStr := fields[2]
tickTypeStr := fields[3]
priceStr := strings.TrimSpace(fields[4])
// Parse reqID with validation. strconv.Atoi is ~10× cheaper than
// fmt.Sscanf on this per-tick hot path (no reflection, no format
// machinery) — the field is a small ASCII integer.
reqID, err := strconv.Atoi(reqIDStr)
if err != nil || reqID == 0 {
marketDataLogger.Warnf("Invalid reqID in tick price: %q (error: %v)", reqIDStr, err)
return
}
// Find the symbol for this request ID
c.subMu.RLock()
symbol, exists := c.reqIDMap[reqID]
c.subMu.RUnlock()
// Parse tickType with validation
tickType, err := strconv.Atoi(tickTypeStr)
if err != nil {
if exists {
marketDataLogger.Warnf("Invalid tickType in tick price for reqID %d: %q (error: %v)", reqID, tickTypeStr, err)
}
return
}
// Handle empty price payload (IBKR sends blank string for stale ticks)
if priceStr == "" {
if exists {
c.subMu.Lock()
if sub, ok := c.subscriptions[symbol]; ok {
sub.LastTime = time.Now()
sub.LastTickAt = sub.LastTime
}
c.subMu.Unlock()
}
return
}
// Parse price with validation
price, err := strconv.ParseFloat(priceStr, 64)
if err != nil {
if exists {
marketDataLogger.Warnf("Invalid price in tick price for reqID %d: %q (error: %v)", reqID, priceStr, err)
}
return
}
if !exists {
// Unknown reqID - might be from previous session or automatic subscription
// ReqID 6 appears to be an automatic subscription from IBKR for account positions
if reqID != 6 {
marketDataLogger.Debugf("Received tick for unknown reqID %d", reqID)
}
return
}
// Log all market data for debugging with comprehensive tick type mapping
tickTypeName := "unknown"
isImportantTick := false
switch tickType {
case 1:
tickTypeName = "bid"
isImportantTick = true
case 2:
tickTypeName = "ask"
isImportantTick = true
case 4:
tickTypeName = "last"
isImportantTick = true
case 6:
tickTypeName = "high"
case 7:
tickTypeName = "low"
case 9:
tickTypeName = "close"
case 14:
tickTypeName = "open"
case 15:
tickTypeName = "low_13_weeks"
case 16:
tickTypeName = "high_13_weeks"
case 17:
tickTypeName = "low_26_weeks"
case 18:
tickTypeName = "high_26_weeks"
case 19:
tickTypeName = "low_52_weeks"
case 20:
tickTypeName = "high_52_weeks"
case 37:
tickTypeName = "mark_price"
isImportantTick = true
case 66:
tickTypeName = "delayed_bid"
isImportantTick = true
case 67:
tickTypeName = "delayed_ask"
isImportantTick = true
case 68:
tickTypeName = "delayed_last"
isImportantTick = true
case 72:
tickTypeName = "delayed_high"
case 73:
tickTypeName = "delayed_low"
case 75:
tickTypeName = "delayed_close"
case 76:
tickTypeName = "delayed_open"
case 221:
tickTypeName = "mark_price_slow"
case 225:
tickTypeName = "auction_data"
marketDataLogger.Infof("%s AUCTION DATA received (tick 225): %.2f", symbol, price)
case 232:
tickTypeName = "last_yield"
case 233:
tickTypeName = "rt_volume"
default:
tickTypeName = fmt.Sprintf("tick_%d", tickType)
}
// If this tick belongs to an option reqID, capture the option quote
c.optMu.RLock()
optSym, isOptionReq := c.optReqIDs[reqID]
c.optMu.RUnlock()
if isOptionReq {
c.subMu.Lock()
if sub, ok := c.subscriptions[optSym]; ok {
observedAt := time.Now()
sub.LastTime = observedAt
sub.LastTickAt = observedAt
if price > 0 {
sub.LastPriceTickAt = observedAt
stampDisplayPrice(sub, tickType, observedAt)
sub.Observed = true
switch tickType {
case 1, 66:
sub.Bid = price
case 2, 67:
sub.Ask = price
case 4, 68:
sub.LastPrice = price
case 9, 75:
sub.PrevClose = price
case 37:
sub.MarkPrice = price
}
}
}
c.subMu.Unlock()
if price > 0 {
c.optMu.Lock()
switch tickType {
case 1, 66:
c.optQuoteBid[optSym] = price
case 2, 67:
c.optQuoteAsk[optSym] = price
case 9, 75:
// Per-contract previous close (the option's own prior settle,
// NOT the underlying's). Used for option-level daily P&L
// attribution; without this, callers fall back to the
// underlying's prev close which is meaningless for an option.
c.optPrevClose[optSym] = price
}
c.optMu.Unlock()
}
return
}
// Log important ticks only if price > 0
if (isImportantTick || tickType == 225) && price > 0 {
marketDataLogger.Debugf("%s %s: %.2f", symbol, tickTypeName, price)
}
// Update subscription data based on tick type
c.subMu.Lock()
defer c.subMu.Unlock()
sub, exists := c.subscriptions[symbol]
if !exists || sub.ReqID != reqID {
return
}
// A bond's yield arrives as a price tick; it is never a price.
if isBondYieldTick(tickType) {
sub.recordBondYield(tickType, price)
sub.LastTime = time.Now()
sub.LastTickAt = sub.LastTime
return
}
// Validate price before updating: reject zero and negative prices to prevent
// overwriting valid prices with "no quote available" indicators from IBKR.
if price <= 0 || math.IsNaN(price) || math.IsInf(price, 0) {
// Update LastTime to show we received a tick, but don't update the price
sub.LastTime = time.Now()
sub.LastTickAt = sub.LastTime
return
}
// Statistics such as a 52-week high cannot establish a usable quote or
// finish a live probe. Retain the previous priced observation until then.
quotePrice := false
switch tickType {
case 1, 2, 4, 9, 37, 66, 67, 68, 75:
quotePrice = true
sub.Observed = true
sub.previousQuote = nil
}
if tickType >= 66 && tickType <= 76 {
sub.delayedTicks = true
}
if quotePrice && sub.delayedFallback {
c.recordDelayedQuoteAvailable(symbol)
}
if quotePrice && !sub.delayedTicks && c.subscriptionDataType(sub) == 1 {
sub.fallbackRefusal = nil
c.absenceMu.Lock()
delete(c.mktDataAbsent, symbol)
c.absenceMu.Unlock()
if sub.liveRetryTimer != nil {
sub.liveRetryTimer.Stop()
}
}
// Tick types: 1=bid, 2=ask, 4=last, 6=high, 7=low, 9=close, 14=open.
// Delayed subscriptions use 66/67/68/72/73/75/76 for the same fields.
// Close (9) is yesterday's regular-session close — the anchor for
// change-vs-prev-close. IBKR sends it automatically once per reqMktData,
// regardless of generic-tick flags.
//
// Tick types 15-20 (week-range highs/lows) only arrive when the
// streaming subscribe asked for generic tick 165 (Misc Stats). They are
// load-bearing for the scan-row 52w column; without them the column
// would be silently blank for symbols that didn't have an explicit
// historical-bars fetch.
switch tickType {
case 1, 66:
sub.Bid = price
case 2, 67:
sub.Ask = price
case 4, 68:
sub.LastPrice = price
case 6, 72:
sub.High = price
case 7, 73:
sub.Low = price
case 9, 75:
sub.PrevClose = price
case 14, 76:
sub.Open = price
case 15:
sub.Week13Low = price
case 16:
sub.Week13High = price
case 17:
sub.Week26Low = price
case 18:
sub.Week26High = price
case 19:
sub.Week52Low = price
case 20:
sub.Week52High = price
case 37:
// Mark price — see Subscription.MarkPrice. For indices this is
// often the only price tick the gateway sends.
sub.MarkPrice = price
}
observedAt := time.Now()
sub.LastTime = observedAt
sub.LastTickAt = observedAt
if quotePrice {
sub.LastPriceTickAt = observedAt
}
stampDisplayPrice(sub, tickType, observedAt)
}
// handleTickGeneric processes generic tick updates. The wire tick ids
// 106 delivers wire tick 24 (chain-averaged option implied vol of the
// underlying), and requesting generic tick 236 delivers wire tick 46
// (shortable difficulty level) plus wire tick 89 (shortable share count,
// request ids (106/236) here, which never appear as wire tick types, so
// Wire tick 46 is deliberately not handled: its 0–3 difficulty float
func (c *Connector) handleTickGeneric(fields []string) {
// Expected format: [msgID, version, reqID, tickType, value]
if len(fields) < 5 {
return
}
reqIDStr := fields[2]
tickTypeStr := fields[3]
valueStr := fields[4]
reqID, _ := strconv.Atoi(reqIDStr)
tickType, _ := strconv.Atoi(tickTypeStr)
val, _ := strconv.ParseFloat(valueStr, 64)
// Map reqID to underlying symbol
c.subMu.RLock()
symbol, exists := c.reqIDMap[reqID]
c.subMu.RUnlock()
if !exists {
return
}
switch {
case tickType == 24 && val > 0:
// 24 = Option Implied Volatility (averaged across the chain — the
iv := val
if iv > 1.5 { // normalize percent inputs
iv = iv / 100.0
}
c.optMu.Lock()
c.optIV[symbol] = iv
// Generic tick 24 is not a per-contract model-computation tick. Clear
delete(c.optIVDataType, symbol)
c.optMu.Unlock()
// Also write to the per-symbol subscription so MarketDataSnapshot sees
c.subMu.Lock()
if sub, ok := c.subscriptions[symbol]; ok {
sub.IV = iv
sub.LastTime = time.Now()
sub.LastTickAt = sub.LastTime
}
c.subMu.Unlock()
}
}
// handleOptionComputation processes IBKR option computation ticks (msgID 21),
// Wire format for IBKR server version ≥ MIN_SERVER_VER_PRICE_BASED_VOLATILITY
// and current TWS / IB Gateway builds report 200+, so callers always see
func (c *Connector) handleOptionComputation(fields []string) {
// New format has 12 fields (legacy had 12 too, but the meaning shifted).
// The trailing space in IBKR's wire-encoded frame can produce a 13th
// empty token after Split; accept ≥ 12.
if len(fields) < 12 {
return
}
reqID, err := strconv.Atoi(fields[1])
if err != nil {
return
}
tickType, err := strconv.Atoi(fields[2])
if err != nil {
return
}
parseFloat := func(s string) float64 {
v, err := strconv.ParseFloat(strings.TrimSpace(s), 64)
if err != nil {
return math.NaN()
}
return v
}
// fields[3] is tickAttrib (option computation flags); not consumed yet.
impliedVol := parseFloat(fields[4])
delta := parseFloat(fields[5])
optionPrice := parseFloat(fields[6])
gamma := parseFloat(fields[8])
vega := parseFloat(fields[9])
theta := parseFloat(fields[10])
underlyingPrice := parseFloat(fields[11])
c.optMu.Lock()
symbol, exists := c.optReqIDs[reqID]
if !exists {
c.optMu.Unlock()
return
}
switch tickType {
case 10, 80: // live/delayed bid computation
if optionPrice > 0 {
c.optQuoteBid[symbol] = optionPrice
}
case 11, 81: // live/delayed ask computation
if optionPrice > 0 {
c.optQuoteAsk[symbol] = optionPrice
}
case 13, 83: // live/delayed model computation — canonical source for greeks
if impliedVol > 0 {
if impliedVol > 1.5 {
impliedVol /= 100.0
}
c.optIV[symbol] = impliedVol
if tickType == 83 {
c.optIVDataType[symbol] = OptionModelDataTypeDelayed
} else {
c.optIVDataType[symbol] = OptionModelDataTypeLive
}
}
// IBKR sends a NaN/sentinel-tagged Greeks row when the model hasn't
// only commit Greeks once at least one component is sane — never
g, ok := c.optGreeks[symbol]
if !ok {
g = Greeks{}
}
if saneGreek(delta, 1.05) { // delta bounded by 1; tiny slack for binomial drift
g.Delta = delta
}
if saneGreek(gamma, 10) {
g.Gamma = gamma
}
if saneGreek(theta, 1e6) {
g.Theta = theta
}
if saneGreek(vega, 1e6) {
g.Vega = vega
}
if g != (Greeks{}) {
c.optGreeks[symbol] = g
}
if !math.IsNaN(underlyingPrice) && underlyingPrice > 0 && underlyingPrice < 1e9 {
c.optUnderlyingPx[symbol] = underlyingPrice
}
}
c.optMu.Unlock()
}
// saneGreek rejects NaN and IBKR's MaxFloat-style sentinel values that fire
func saneGreek(v, bound float64) bool {
if math.IsNaN(v) || math.IsInf(v, 0) {
return false
}
if math.Abs(v) > bound {
return false
}
return true
}
// SubscriptionRejectCh returns the terminal-rejection channel for a tracked
func (c *Connector) SubscriptionRejectCh(key string) <-chan SubscriptionRejection {
if key == "" {
return nil
}
c.subMu.RLock()
defer c.subMu.RUnlock()
sub, ok := c.subscriptions[strings.ToUpper(key)]
if !ok || sub == nil {
return nil
}
return sub.RejectCh
}
// pushSubscriptionRejection signals fast-abort to any in-flight poller
// watching this reqID's subscription. Non-blocking: the channel is
// buffered 1 and we drop on a full buffer so the error-handler goroutine
// never stalls. The drop is benign — the consumer's first read already
// carries the abort signal, and the specific code/message of subsequent
// rejections does not matter to the poller (every terminal code means
// "this subscription will never produce ticks").
//
// Looked up via reqIDMap (the same lookup handleIBKRError already does
// for recovery routing). Subscriptions created without a channel (test
// fixtures, scaffolding) silently no-op.
func (c *Connector) pushSubscriptionRejection(reqID, code int, message string) {
if reqID <= 0 || !isTerminalSubscriptionError(code) {
return
}
c.subMu.RLock()
var ch chan SubscriptionRejection
if sym, ok := c.reqIDMap[reqID]; ok {
if sub, ok := c.subscriptions[sym]; ok && sub != nil {
ch = sub.RejectCh
}
}
c.subMu.RUnlock()
if ch == nil {
return
}
select {
case ch <- SubscriptionRejection{Code: code, Message: message}:
default:
}
}
func (c *Connector) handleIBKRErrorFrom(origin ConnectorSessionBinding, fields []string) (postBarrier func()) {
if len(fields) < 4 {
return
}
// Parse reqID and code
reqID := 0
if len(fields) > 2 {
if v, err := strconv.Atoi(fields[2]); err == nil {
reqID = v
}
}
code := 0
if len(fields) > 3 {
if v, err := strconv.Atoi(fields[3]); err == nil {
code = v
}
}
rawMsg := ""
if len(fields) > 4 {
rawMsg = fields[4]
}
c.publicationBarrier.RLock()
defer c.publicationBarrier.RUnlock()
c.evidenceBarrier.RLock()
defer c.evidenceBarrier.RUnlock()
if !c.SessionReceiptCurrent(origin) {
c.notifyOrderErrorLifecycleUnderBarrier(origin, reqID, code, rawMsg, "")
return
}
if reqID > 0 && c.failPendingExactHistoricalRoute(reqID, code, rawMsg) {
c.recordDataFarmNotice(code, rawMsg, time.Now())
return
}
// Fast-abort signal for in-flight pollers. Sent first so a code-200
rejectionMsg := ""
if len(fields) > 4 {
rejectionMsg = fields[4]
}
c.pushSubscriptionRejection(reqID, code, rejectionMsg)
// Map to symbol if available (subscriptions or historical request)
symbol := ""
histPending := false
histOwnsNoticeCollision := false
if reqID > 0 {
c.subMu.RLock()
symbol = c.reqIDMap[reqID]
c.subMu.RUnlock()
if symbol == "" {
c.historicalMu.Lock()
if hr, ok := c.historicalReqs[reqID]; ok {
symbol = hr.symbol
histPending = true
histOwnsNoticeCollision = hr.requestOwnsNoticeCollision
}
c.historicalMu.Unlock()
}
if symbol == "" {
if alias, ok := c.conn.lookupReqAlias(reqID); ok && alias.symbol != "" {
symbol = alias.symbol
}
}
}
// The legacy msgErrMsg path shares the same multiplexed order/request-id
if !(histPending && histOwnsNoticeCollision && historicalNoticeOwnsIDCollision(code)) {
c.notifyOrderErrorLifecycleUnderBarrier(origin, reqID, code, rawMsg, "")
}
c.recordDataFarmNotice(code, rawMsg, time.Now())
upperMsg := strings.ToUpper(rawMsg)
upperSymbol := strings.ToUpper(symbol)
if symbol != "" && symbol != upperSymbol {
symbol = upperSymbol
}
parserMisalign := noticeSignalsParserMisalignment(code, rawMsg)
if parserMisalign {
context := c.parserContext(symbol)
if context != "" {
ibkrLogger.Errorf("[IBKR] Parser misalignment detected (code=%d reqID=%d symbol=%s): %s | frame=%s", code, reqID, symbol, rawMsg, context)
} else {
ibkrLogger.Errorf("[IBKR] Parser misalignment detected (code=%d reqID=%d symbol=%s): %s", code, reqID, symbol, rawMsg)
}
}
// If this error targets an outstanding historical request, fail it immediately
if histPending {
if code == 0 || code == -1 || (code >= 2100 && code < 2200) {
return // informational notices
}
msg := rawMsg
hErr := &HistoricalRequestError{Code: code, Message: msg}
switch code {
case 162:
if hErr.Message == "" {
hErr.Message = "historical data pacing violation"
}
hErr.RetryAfter = c.nextHistoricalBackoff(symbol)
case 321:
if hErr.Message == "" {
hErr.Message = "historical data request failed validation"
}
c.resetHistoricalBackoff(symbol)
default:
c.resetHistoricalBackoff(symbol)
}
c.failHistoricalRequest(reqID, hErr)
if symbol != "" {
if code == 366 || (code == 162 && strings.Contains(upperMsg, "NO DATA")) {
_, postBarrier = c.registerInactiveCandidatePostAction(symbol, rawMsg)
}
}
return
}
if code == 200 && symbol != "" {
if strings.Contains(upperMsg, "NO SECURITY DEFINITION HAS BEEN FOUND") {
marked, post := c.registerInactiveCandidatePostAction(symbol, rawMsg)
postBarrier = joinPostActions(postBarrier, post)
if marked {
return
}
}
}
switch code {
case 2108: // Market data farm disconnected
// Mark subs unobserved to force refresh path
c.subMu.Lock()
for _, sub := range c.subscriptions {
sub.Observed = false
}
c.subMu.Unlock()
case 2119, 2104, 200, 320, 321, 354:
// Never launch an unbound recovery request from an inbound socket
}
return postBarrier
}
func (c *Connector) parserContext(symbol string) string {
conn := c.conn
if conn == nil {
return ""
}
return conn.parserContext(symbol)
}
func (c *Connector) handleHistoricalData(fields []string) {
if len(fields) < 2 {
return
}
// For serverVersion >= 124, no version field in historical data messages
// (We require minimum serverVersion 124, so version field never present)
idx := 1
if idx >= len(fields) {
return
}
reqID, err := strconv.Atoi(fields[idx])
if err != nil {
return
}
idx++
req := c.getHistoricalRequest(reqID)
if req == nil {
return
}
if req.requireEpoch && !c.HistoricalSessionCurrent(HistoricalSessionBinding{connector: c, connection: req.connection, epoch: req.epoch}) {
c.failHistoricalRequest(reqID, &HistoricalRequestError{Category: HistoricalFailureGatewayUnavailable})
return
}
serverVersion := 0
if c.conn != nil {
serverVersion = c.conn.ServerVersion()
}
legacyFormat := false
if serverVersion > 0 {
legacyFormat = serverVersion < minServerVerHistoricalDataEnd
} else if idx < len(fields) {
// Auto-detect: if next field is non-numeric treat as legacy header
if _, err := strconv.Atoi(fields[idx]); err != nil {
legacyFormat = true
}
}
start := ""
end := ""
if legacyFormat {
start = safeField(fields, &idx)
end = safeField(fields, &idx)
}
count := 0
var countErr error
if idx < len(fields) {
if v, err := strconv.Atoi(fields[idx]); err == nil {
count = v
} else {
countErr = err
}
idx++
}
if req.strictDaily && (countErr != nil || count < 0) {
c.failHistoricalRequest(reqID, &HistoricalDataValidationError{Reason: "invalid_bar_count"})
return
}
if req.maxBars > 0 && count > req.maxBars {
c.failHistoricalRequest(reqID, &HistoricalDataValidationError{Reason: "series_size_limit"})
return
}
bars, parseErr := parseHistoricalBars(fields, &idx, count, req.strictDaily)
if parseErr != nil {
c.failHistoricalRequest(reqID, parseErr)
return
}
if req.strictDaily && !historicalPayloadConsumed(fields, idx) {
c.failHistoricalRequest(reqID, &HistoricalDataValidationError{Reason: "trailing_payload"})
return
}
result := historicalResult{
start: start,
end: end,
bars: bars,
}
if req.waitForEnd && !legacyFormat {
if err := c.bufferHistoricalResult(reqID, result); err != nil {
c.failHistoricalRequest(reqID, err)
}
return
}
c.completeHistoricalRequest(reqID, result)
}
// The wire decoder retains one final empty field for the protocol NUL delimiter.
// Accept that delimiter only; additional empty or nonempty fields are corruption.
func historicalPayloadConsumed(fields []string, idx int) bool {
return idx == len(fields) || (idx == len(fields)-1 && fields[idx] == "")
}
func parseHistoricalBars(fields []string, idx *int, count int, strictDaily bool) ([]HistoricalBar, error) {
// Each bar needs eight fields. Validate before trusting a wire count as an
// allocation size, including for legacy non-strict callers.
if count < 0 {
return nil, &HistoricalDataValidationError{Reason: "invalid_bar_count"}
}
if *idx < 0 || *idx > len(fields) || count > (len(fields)-*idx)/8 {
return nil, &HistoricalDataValidationError{Reason: "truncated_bar"}
}
bars := make([]HistoricalBar, 0, count)
for range count {
if *idx >= len(fields) {
if strictDaily {
return nil, &HistoricalDataValidationError{Reason: "truncated_bar"}
}
break
}
dateStr := fields[*idx]
*idx++
// Require six scalar fields (open, high, low, close, volume, average)
if *idx+6 >= len(fields) {
if strictDaily {
return nil, &HistoricalDataValidationError{Reason: "truncated_bar"}
}
break
}
values := [6]float64{}
for i := range values {
if strictDaily {
value, err := strconv.ParseFloat(strings.TrimSpace(fields[*idx]), 64)
if err != nil || math.IsNaN(value) || math.IsInf(value, 0) {
return nil, &HistoricalDataValidationError{Reason: "invalid_numeric"}
}
if i < 4 && value < 0 {
return nil, &HistoricalDataValidationError{Reason: "invalid_ohlc"}
}
values[i] = value
} else {
values[i] = parseFloat(fields[*idx])
}
*idx++
}
openVal, highVal, lowVal, closeVal := values[0], values[1], values[2], values[3]
if strictDaily && (highVal < lowVal || highVal < openVal || highVal < closeVal || lowVal > openVal || lowVal > closeVal) {
return nil, &HistoricalDataValidationError{Reason: "incoherent_ohlc"}
}
barCount := 0
if *idx < len(fields) {
if value, err := strconv.Atoi(fields[*idx]); err == nil {
barCount = value
} else if strictDaily {
return nil, &HistoricalDataValidationError{Reason: "invalid_bar_count"}
}
*idx++
}
barTime, timeErr := parseHistoricalTimestamp(dateStr)
if strictDaily && timeErr != nil {
return nil, &HistoricalDataValidationError{Reason: "invalid_daily_as_of"}
}
bars = append(bars, HistoricalBar{
Time: barTime,
Date: dateStr,
Open: openVal,
High: highVal,
Low: lowVal,
Close: closeVal,
Volume: int64(values[4]),
Average: values[5],
BarCount: barCount,
})
}
return bars, nil
}
func (c *Connector) handleHistoricalDataEnd(fields []string) {
// Accept modern unversioned and legacy versioned end receipts. The wire
// decoder retains the final delimiter; it is not an additional data field.
if len(fields) > 0 && fields[len(fields)-1] == "" {
fields = fields[:len(fields)-1]
}
idx := 1
switch len(fields) {
case 4: // message, request, start, end
case 5: // message, version, request, start, end
if fields[1] != "6" {
return
}
idx = 2
default:
return
}
reqID, err := strconv.Atoi(fields[idx])
if err != nil {
return
}
idx++
if req := c.getHistoricalRequest(reqID); req != nil && req.requireEpoch &&
!c.HistoricalSessionCurrent(HistoricalSessionBinding{connector: c, connection: req.connection, epoch: req.epoch}) {
c.failHistoricalRequest(reqID, &HistoricalRequestError{Category: HistoricalFailureGatewayUnavailable})
return
}
start := ""
if idx < len(fields) {
start = fields[idx]
idx++
}
end := ""
if idx < len(fields) {
end = fields[idx]
idx++
}
if req := c.getHistoricalRequest(reqID); req != nil && req.strictDaily && !historicalPayloadConsumed(fields, idx) {
c.failHistoricalRequest(reqID, &HistoricalDataValidationError{Reason: "trailing_payload"})
return
}
c.historicalMu.Lock()
req := c.historicalReqs[reqID]
result := historicalResult{start: start, end: end}
if req != nil {
result.bars = slices.Clone(req.bufferedBars)
result.err = req.bufferedErr
}
c.historicalMu.Unlock()
c.completeHistoricalRequest(reqID, result)
}
func safeField(fields []string, idx *int) string {
if *idx >= len(fields) {
return ""
}
val := fields[*idx]
*idx = *idx + 1
return val
}
func parseFloat(s string) float64 {
v, err := strconv.ParseFloat(s, 64)
if err != nil {
return 0
}
return v
}
func parseHistoricalTimestamp(raw string) (time.Time, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return time.Time{}, fmt.Errorf("empty historical timestamp")
}
normalized := strings.ReplaceAll(raw, " ", " ")
layouts := []string{
"20060102 15:04:05",
"20060102",
}
for _, layout := range layouts {
if ts, err := time.ParseInLocation(layout, normalized, time.UTC); err == nil {
return ts, nil
}
}
if epoch, err := strconv.ParseInt(normalized, 10, 64); err == nil && epoch > 0 {
return time.Unix(epoch, 0).UTC(), nil
}
return time.Time{}, fmt.Errorf("unable to parse historical timestamp: %s", raw)
}
func (c *Connector) getHistoricalRequest(reqID int) *historicalRequest {
c.historicalMu.Lock()
defer c.historicalMu.Unlock()
return c.historicalReqs[reqID]
}
type historicalRequestOptions struct {
endDateTime string
maxBars int
chartBarSize string
chartOutsideRTH bool
strictDaily bool
waitForEnd bool
requestOwnsNoticeCollision bool
formatDate int
session HistoricalSessionBinding
requireEpoch bool
}
func (c *Connector) createHistoricalRequestWithOptions(reqID int, symbol string, options historicalRequestOptions) *historicalRequest {
req := &historicalRequest{
sentAt: time.Now(),
intraday: options.chartBarSize != "" && options.chartBarSize != "1 day", symbol: symbol,
result: make(chan historicalResult, 1),
strictDaily: options.strictDaily,
maxBars: options.maxBars,
waitForEnd: options.waitForEnd,
requestOwnsNoticeCollision: options.requestOwnsNoticeCollision,
connection: options.session.connection,
epoch: options.session.epoch,
requireEpoch: options.requireEpoch,
}
c.historicalMu.Lock()
c.historicalReqs[reqID] = req
c.historicalMu.Unlock()
return req
}
func (c *Connector) bufferHistoricalResult(reqID int, res historicalResult) error {
c.historicalMu.Lock()
defer c.historicalMu.Unlock()
req := c.historicalReqs[reqID]
if req == nil {
return nil
}
if req.maxBars > 0 && len(res.bars) > req.maxBars-len(req.bufferedBars) {
return &HistoricalDataValidationError{Reason: "series_size_limit"}
}
if req.strictDaily {
barKey := func(bar HistoricalBar) string {
if req.intraday {
return bar.Time.UTC().Format(time.RFC3339Nano)
}
return bar.Time.UTC().Format("2006-01-02")
}
seen := make(map[string]struct{}, len(req.bufferedBars)+len(res.bars))
for _, bar := range req.bufferedBars {
seen[barKey(bar)] = struct{}{}
}
for _, bar := range res.bars {
date := barKey(bar)
if _, duplicate := seen[date]; duplicate {
return &HistoricalDataValidationError{Reason: "duplicate_session_date"}
}
seen[date] = struct{}{}
}
}
req.bufferedBars = append(req.bufferedBars, res.bars...)
if res.err != nil {
req.bufferedErr = res.err
}
return nil
}
func (c *Connector) completeHistoricalRequest(reqID int, res historicalResult) {
c.historicalMu.Lock()
req, ok := c.historicalReqs[reqID]
if ok {
delete(c.historicalReqs, reqID)
}
c.historicalMu.Unlock()
if !ok {
return
}
req.result <- res
close(req.result)
if len(res.bars) > 0 {
c.resetHistoricalBackoff(req.symbol)
}
}
func (c *Connector) failHistoricalRequest(reqID int, err error) {
c.completeHistoricalRequest(reqID, historicalResult{err: err})
}
// errHistorySessionEnded fails a history read whose broker session ended
// before the answer (see endSessionHistory).
var errHistorySessionEnded = fmt.Errorf("%w: the broker session ended before the history answer", ErrIBKRUnavailable)
// endSessionHistory fails every history read still waiting on conn, whose
// session has ended. TWS answers a request only on the session that carried
// it, so each read would otherwise wait out its own budget for an answer that
// cannot come: two minutes for a breadth read, which held the breadth refresh
// open until 23:47:00 after TWS's 23:45 restart (2026-10-01, 10-02, 10-06 and
// 10-07). The failure is neither an answer nor a timeout to the answer-path
// accounting. A read bound to another Connection is left alone.
func (c *Connector) endSessionHistory(conn *Connection) {
c.mu.RLock()
owned := c.conn == conn
c.mu.RUnlock()
if !owned {
return
}
c.historicalMu.Lock()
ended := make([]int, 0, len(c.historicalReqs))
for reqID, req := range c.historicalReqs {
if req.connection == nil || req.connection == conn {
ended = append(ended, reqID)
}
}
c.historicalMu.Unlock()
for _, reqID := range ended {
c.failHistoricalRequest(reqID, errHistorySessionEnded)
}
}
func (c *Connector) nextHistoricalBackoff(symbol string) time.Duration {
const base = 30 * time.Second
const maxDelay = 5 * time.Minute
c.historicalMu.Lock()
defer c.historicalMu.Unlock()
count := min(c.historicalBackoff[symbol]+1, 10)
c.historicalBackoff[symbol] = count
delay := min(base*time.Duration(1<<(count-1)), maxDelay)
return delay
}
func (c *Connector) resetHistoricalBackoff(symbol string) {
c.historicalMu.Lock()
delete(c.historicalBackoff, symbol)
c.historicalMu.Unlock()
}
func formatHistoricalDuration(lookbackDays int) string {
if lookbackDays <= 0 {
return "1 D"
}
if lookbackDays <= 365 {
return fmt.Sprintf("%d D", lookbackDays)
}
years := (lookbackDays + 364) / 365
if years == 1 {
return "1 Y"
}
return fmt.Sprintf("%d Y", years)
}
// FetchHistoricalDailyBars requests daily bars; cancellation best-effort cancels the wire request.
func (c *Connector) FetchHistoricalDailyBars(ctx context.Context, symbol string, lookbackDays int, timeout time.Duration) ([]HistoricalBar, error) {
return c.fetchHistoricalDailyBars(ctx, symbol, lookbackDays, timeout, "")
}
// FetchHistoricalDailyBarsWhatToShow requests daily bars using the normalized
// whatToShow value supplied by the caller, without feed fallback.
func (c *Connector) FetchHistoricalDailyBarsWhatToShow(ctx context.Context, symbol string, lookbackDays int, whatToShow string, timeout time.Duration) ([]HistoricalBar, error) {
cleanWhat, err := normalizeHistoricalWhatToShow(whatToShow)
if err != nil {
return nil, err
}
return c.fetchHistoricalDailyBars(ctx, symbol, lookbackDays, timeout, cleanWhat)
}
func (c *Connector) fetchHistoricalDailyBars(ctx context.Context, symbol string, lookbackDays int, timeout time.Duration, forceWhatToShow string) ([]HistoricalBar, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
if !c.IsReady() {
return nil, fmt.Errorf("IBKR connection not ready")
}
symbol = strings.ToUpper(symbol)
if _, inactive := c.inactiveReason(symbol); inactive {
return nil, ErrSymbolInactive
}
if lookbackDays <= 0 {
lookbackDays = 400
}
if timeout <= 0 {
timeout = 45 * time.Second
}
timeout, err := historicalTimeoutWithinContext(ctx, timeout)
if err != nil {
return nil, err
}
secType, exchange, currency, primary := classifySymbol(symbol)
// Dual-class shares use IBKR's space form to avoid code-200 rejection.
wireSymbol := dualClassWireSymbol(symbol)
if base, _, ok := FxPair(symbol); ok {
wireSymbol = base
}
baseContract := Contract{
Symbol: wireSymbol,
SecType: secType,
Exchange: exchange,
PrimaryExch: primary,
Currency: currency,
}
return c.fetchHistoricalDailyBarsWithBase(ctx, symbol, baseContract, primary, lookbackDays, timeout, true, forceWhatToShow)
}
// FetchHistoricalDailyBarsWithContract requests daily bars using the supplied route.
func (c *Connector) FetchHistoricalDailyBarsWithContract(ctx context.Context, contract Contract, lookbackDays int, timeout time.Duration) ([]HistoricalBar, error) {
return c.fetchHistoricalDailyBarsWithContract(ctx, contract, lookbackDays, timeout, "")
}
// FetchHistoricalDailyTradeBarsWithContract requests only TRADES daily bars
// using the supplied exact route. Unlike the general history helper, it never
// falls back to midpoint or adjusted-last data.
func (c *Connector) FetchHistoricalDailyTradeBarsWithContract(ctx context.Context, contract Contract, lookbackDays int, timeout time.Duration) ([]HistoricalBar, error) {
if contract.ConID == 0 {
return nil, fmt.Errorf("exact contract ID is required for TRADES history")
}
return c.fetchHistoricalDailyBarsWithContract(ctx, contract, lookbackDays, timeout, "TRADES")
}
// FetchHistoricalDailyFeeRates requests daily stock-borrow fee-rate bars for
// an exact broker contract. It pins FEE_RATE and requires a positive ConID; a
// missing route may be completed only by exact-ConID contract details. Identical
// concurrent requests share one result through IBKR's cooldown window.
func (c *Connector) FetchHistoricalDailyFeeRates(ctx context.Context, contract Contract, lookbackDays int, timeout time.Duration) ([]HistoricalBar, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
contract = normalizeExactHistoricalContract(contract)
if contract.ConID <= 0 {
return nil, &HistoricalDataValidationError{Reason: "missing_contract_id"}
}
if contract.Symbol == "" {
return nil, &HistoricalDataValidationError{Reason: "missing_symbol"}
}
if contract.SecType != "STK" {
return nil, &HistoricalDataValidationError{Reason: "unsupported_security_type"}
}
if contract.Currency == "" {
return nil, &HistoricalDataValidationError{Reason: "incomplete_exact_route"}
}
if !HistoricalFeeRateUSRouteSupported(contract, contract.Exchange != "") {
return nil, &HistoricalDataValidationError{Reason: "unsupported_market_calendar"}
}
if lookbackDays <= 0 {
lookbackDays = 7
}
if timeout <= 0 {
timeout = historicalFeeRateBackendTimeout
}
binding, ok := c.CaptureHistoricalSession()
if !ok {
return nil, &HistoricalRequestError{Category: HistoricalFailureGatewayUnavailable}
}
key := "fee-rate\x00" + historicalDailyFeeRateKey(contract, lookbackDays)
flight, leader := c.acquireHistoricalExactFlight(key, binding)
if leader {
go c.runHistoricalDailyFeeRateFlight(flight, binding, contract, lookbackDays)
}
waitCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
select {
case <-flight.done:
return slices.Clone(flight.bars), cloneSanitizedHistoricalError(flight.err)
case <-waitCtx.Done():
return nil, waitCtx.Err()
}
}
// ResolveExactHistoricalStockRoute completes a missing executable exchange
// only through an exact positive-ConID contract-details request. It rejects
// wrong, missing, or ambiguous details and never substitutes symbol identity.
func (c *Connector) ResolveExactHistoricalStockRoute(ctx context.Context, contract Contract, timeout time.Duration) (Contract, error) {
return c.resolveExactHistoricalStockRoute(ctx, contract, timeout)
}
func normalizeExactHistoricalContract(contract Contract) Contract {
contract.Symbol = strings.ToUpper(strings.TrimSpace(contract.Symbol))
contract.SecType = strings.ToUpper(strings.TrimSpace(contract.SecType))
contract.Exchange = strings.ToUpper(strings.TrimSpace(contract.Exchange))
contract.PrimaryExch = strings.ToUpper(strings.TrimSpace(contract.PrimaryExch))
contract.Currency = strings.ToUpper(strings.TrimSpace(contract.Currency))
contract.LocalSymbol = strings.TrimSpace(contract.LocalSymbol)
contract.TradingClass = strings.TrimSpace(contract.TradingClass)
contract.SecIDType = strings.TrimSpace(contract.SecIDType)
contract.SecID = strings.TrimSpace(contract.SecID)
return contract
}
var historicalFeeRateUSExchanges = map[string]struct{}{
"AMEX": {}, "ARCA": {}, "NASDAQ": {}, "NYSE": {}, "SMART": {},
}
// HistoricalFeeRateUSRouteSupported restricts FEE_RATE to the embedded U.S.
// cash-equity calendar and a closed set of exact IBKR stock routes.
func HistoricalFeeRateUSRouteSupported(contract Contract, requireExchange bool) bool {
contract = normalizeExactHistoricalContract(contract)
if contract.SecType != "STK" || contract.Currency != "USD" {
return false
}
if contract.PrimaryExch != "" {
if _, ok := historicalFeeRateUSExchanges[contract.PrimaryExch]; !ok {
return false
}
}
if contract.Exchange == "" {
return !requireExchange && contract.PrimaryExch != ""
}
_, ok := historicalFeeRateUSExchanges[contract.Exchange]
return ok
}
// sendExactHistoricalStockRouteRequest is the positive-ConID contract-details
// encoder used only by the FEE_RATE fallback. It checks the socket epoch before
// writer access so reconnect cannot redirect the request.
func (c *Connection) sendExactHistoricalStockRouteRequest(ctx context.Context, contract Contract, reqID int, epoch uint64) error {
c.registerReqAlias(ctx, reqID, contract)
fields := []any{
reqContractData,
8,
reqID,
contract.ConID,
contract.Symbol,
contract.SecType,
contract.Expiry,
"",
contract.Right,
"",
contract.Exchange,
contract.PrimaryExch,
contract.Currency,
contract.LocalSymbol,
contract.TradingClass,
0,
contract.SecIDType,
contract.SecID,
"",
}
return c.sendMessageWithTypeContextForEpoch(ctx, c.encodeMsg(fields...), RequestTypeGeneral, epoch, true)
}
// requestHistoricalDailyFeeRateForEpoch is the closed, epoch-bound FEE_RATE encoder.
func (c *Connection) requestHistoricalDailyFeeRateForEpoch(
ctx context.Context,
contract Contract,
lookbackDays int,
epoch uint64,
beforeSend func(int),
) (int, error) {
if !c.IsConnected() || c.BrokerSessionEpoch() != epoch {
return 0, &HistoricalRequestError{Category: HistoricalFailureGatewayUnavailable}
}
if err := c.requireServerVersion("RequestHistoricalData"); err != nil {
return 0, err
}
if contract.ConID <= 0 || !HistoricalFeeRateUSRouteSupported(contract, true) {
return 0, &HistoricalDataValidationError{Reason: "unsupported_market_calendar"}
}
reqID, err := c.reserveRequestID(nil)
if err != nil {
return 0, err
}
multiplier := ""
if contract.Multiplier != 0 {
multiplier = strconv.Itoa(contract.Multiplier)
}
fields := []any{
reqHistoricalData,
reqID,
contract.ConID,
contract.Symbol,
contract.SecType,
contract.Expiry,
"",
contract.Right,
multiplier,
contract.Exchange,
contract.PrimaryExch,
contract.Currency,
contract.LocalSymbol,
contract.TradingClass,
false,
}
if contract.SecIDType != "" || contract.SecID != "" {
fields = append(fields, contract.SecIDType, contract.SecID)
}
fields = append(fields,
"",
"1 day",
formatHistoricalDuration(lookbackDays),
true,
"FEE_RATE",
2,
false,
"",
)
if beforeSend != nil {
beforeSend(reqID)
}
if err := c.sendMessageWithTypeContextForEpoch(ctx, c.encodeMsg(fields...), RequestTypeHistorical, epoch, true); err != nil {
return 0, fmt.Errorf("failed to request historical FEE_RATE data: %w", err)
}
return reqID, nil
}
func (c *Connection) cancelHistoricalDataForEpoch(ctx context.Context, reqID int, epoch uint64) error {
if reqID <= 0 {
return nil
}
msg := c.encodeMsg(cancelHistoricalData, 1, reqID)
return c.sendMessageWithTypeContextForEpoch(ctx, msg, RequestTypeHistorical, epoch, true)
}
func historicalDailyFeeRateKey(contract Contract, lookbackDays int) string {
return strings.Join([]string{
strconv.Itoa(contract.ConID),
contract.Symbol,
contract.SecType,
contract.Expiry,
strconv.FormatFloat(contract.Strike, 'g', -1, 64),
contract.Right,
strconv.Itoa(contract.Multiplier),
contract.Exchange,
contract.PrimaryExch,
contract.Currency,
contract.LocalSymbol,
contract.TradingClass,
contract.SecIDType,
contract.SecID,
formatHistoricalDuration(lookbackDays),
"1 day",
"FEE_RATE",
"useRTH=1",
"formatDate=2",
}, "\x00")
}
func (c *Connector) historicalClock() time.Time {
if c.historicalNow != nil {
return c.historicalNow()
}
return time.Now()
}
func (c *Connector) acquireHistoricalExactFlight(key string, binding HistoricalSessionBinding) (*historicalExactFlight, bool) {
now := c.historicalClock()
c.historicalMu.Lock()
defer c.historicalMu.Unlock()
for candidateKey, candidate := range c.historicalExactFlights {
if !candidate.expiresAt.IsZero() && !now.Before(candidate.expiresAt) {
delete(c.historicalExactFlights, candidateKey)
}
}
if flight := c.historicalExactFlights[key]; flight != nil {
if flight.connection == binding.connection && flight.epoch == binding.epoch {
return flight, false
}
delete(c.historicalExactFlights, key)
}
flight := &historicalExactFlight{done: make(chan struct{}), connection: binding.connection, epoch: binding.epoch}
c.historicalExactFlights[key] = flight
return flight, true
}
func (c *Connector) runHistoricalDailyFeeRateFlight(flight *historicalExactFlight, binding HistoricalSessionBinding, contract Contract, lookbackDays int) {
flightCtx, cancel := context.WithTimeout(context.Background(), historicalFeeRateBackendTimeout)
defer cancel()
var err error
if contract.Exchange == "" {
contract, err = c.resolveExactHistoricalStockRoute(flightCtx, contract, historicalExactRouteBackendTimeout)
}
var bars []HistoricalBar
if err == nil && c.HistoricalSessionCurrent(binding) {
bars, err = c.fetchHistoricalWithContractOptions(
flightCtx,
contract.Symbol,
contract,
lookbackDays,
historicalFeeRateBackendTimeout,
"FEE_RATE",
historicalRequestOptions{
strictDaily: true, waitForEnd: true, requestOwnsNoticeCollision: true, formatDate: 2,
session: binding, requireEpoch: true,
},
)
} else if err == nil {
err = &HistoricalRequestError{Category: HistoricalFailureGatewayUnavailable}
}
if err == nil && len(bars) == 0 {
err = &HistoricalRequestError{Category: HistoricalFailureNoData}
}
if err == nil && !c.HistoricalSessionCurrent(binding) {
bars = nil
err = &HistoricalRequestError{Category: HistoricalFailureGatewayUnavailable}
}
if err != nil {
bars = nil
err = sanitizeExactHistoricalError(err)
}
c.historicalMu.Lock()
flight.bars = slices.Clone(bars)
flight.err = err
flight.completedAt = c.historicalClock()
flight.expiresAt = flight.completedAt.Add(historicalIdenticalRequestCooldown)
close(flight.done)
c.historicalMu.Unlock()
}
func (c *Connector) resolveExactHistoricalStockRoute(ctx context.Context, contract Contract, timeout time.Duration) (Contract, error) {
contract = normalizeExactHistoricalContract(contract)
if contract.ConID <= 0 || contract.Symbol == "" || contract.SecType != "STK" || contract.Currency == "" {
return Contract{}, &HistoricalRequestError{Category: HistoricalFailureContractUnavailable}
}
if !HistoricalFeeRateUSRouteSupported(contract, contract.Exchange != "") {
return Contract{}, &HistoricalDataValidationError{Reason: "unsupported_market_calendar"}
}
if contract.Exchange != "" {
return contract, nil
}
if timeout <= 0 {
timeout = historicalExactRouteBackendTimeout
}
binding, ok := c.CaptureHistoricalSession()
if !ok {
return Contract{}, &HistoricalRequestError{Category: HistoricalFailureGatewayUnavailable}
}
key := "fee-route\x00" + historicalDailyFeeRateKey(contract, 0)
flight, leader := c.acquireHistoricalExactFlight(key, binding)
if leader {
go c.runExactHistoricalStockRouteFlight(flight, binding, contract)
}
waitCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
select {
case <-flight.done:
return flight.route, cloneSanitizedHistoricalError(flight.err)
case <-waitCtx.Done():
return Contract{}, waitCtx.Err()
}
}
func (c *Connector) runExactHistoricalStockRouteFlight(flight *historicalExactFlight, binding HistoricalSessionBinding, contract Contract) {
ctx, cancel := context.WithTimeout(context.Background(), historicalExactRouteBackendTimeout)
defer cancel()
route, err := c.resolveExactHistoricalStockRouteWire(ctx, binding, contract)
if err == nil && !c.HistoricalSessionCurrent(binding) {
route = Contract{}
err = &HistoricalRequestError{Category: HistoricalFailureGatewayUnavailable}
}
if err != nil {
route = Contract{}
err = sanitizeExactHistoricalError(err)
}
now := c.historicalClock()
expiresAt := now.Add(historicalIdenticalRequestCooldown)
if err == nil {
expiresAt = now.Add(historicalExactRouteSuccessTTL)
}
c.historicalMu.Lock()
flight.route = route
flight.err = err
flight.completedAt = now
flight.expiresAt = expiresAt
close(flight.done)
c.historicalMu.Unlock()
}
func (c *Connector) resolveExactHistoricalStockRouteWire(ctx context.Context, binding HistoricalSessionBinding, contract Contract) (Contract, error) {
if !c.HistoricalSessionCurrent(binding) {
return Contract{}, &HistoricalRequestError{Category: HistoricalFailureGatewayUnavailable}
}
conn := binding.connection
reqID, err := conn.reserveRequestID(nil)
if err != nil {
return Contract{}, &HistoricalRequestError{Category: HistoricalFailureGatewayUnavailable}
}
detailsCh := make(chan ContractDetailsLite, 8)
doneCh := make(chan struct{}, 1)
failureCh := make(chan error, 1)
overflowCh := make(chan struct{}, 1)
serverVersion := conn.serverVersion
dataHandlerID := conn.RegisterHandler(msgContractData, func(fields []string) {
if conn.BrokerSessionEpoch() != binding.epoch {
return
}
if detail, ok := parseContractDetailsLite(fields, reqID, serverVersion); ok {
select {
case detailsCh <- *detail:
default:
select {
case overflowCh <- struct{}{}:
default:
}
}
}
})
endHandlerID := conn.RegisterHandler(msgContractDataEnd, func(fields []string) {
if conn.BrokerSessionEpoch() != binding.epoch {
return
}
if len(fields) < 3 {
return
}
id, _ := strconv.Atoi(fields[2])
if id == reqID {
select {
case doneCh <- struct{}{}:
default:
}
}
})
c.historicalMu.Lock()
c.historicalRouteReqs[reqID] = failureCh
c.historicalMu.Unlock()
defer func() {
conn.UnregisterHandler(msgContractData, dataHandlerID)
conn.UnregisterHandler(msgContractDataEnd, endHandlerID)
c.historicalMu.Lock()
delete(c.historicalRouteReqs, reqID)
c.historicalMu.Unlock()
}()
if !c.HistoricalSessionCurrent(binding) {
return Contract{}, &HistoricalRequestError{Category: HistoricalFailureGatewayUnavailable}
}
if err := conn.sendExactHistoricalStockRouteRequest(ctx, contract, reqID, binding.epoch); err != nil {
return Contract{}, &HistoricalRequestError{Category: HistoricalFailureGatewayUnavailable}
}
details := make([]ContractDetailsLite, 0, 1)
for {
select {
case detail := <-detailsCh:
details = append(details, detail)
case <-overflowCh:
return Contract{}, &HistoricalDataValidationError{Reason: "contract_details_overflow"}
case <-doneCh:
for {
select {
case detail := <-detailsCh:
details = append(details, detail)
case <-overflowCh:
return Contract{}, &HistoricalDataValidationError{Reason: "contract_details_overflow"}
default:
return exactHistoricalStockRoute(contract, details)
}
}
case routeErr := <-failureCh:
return Contract{}, routeErr
case <-ctx.Done():
return Contract{}, ctx.Err()
}
}
}
func exactHistoricalStockRoute(contract Contract, details []ContractDetailsLite) (Contract, error) {
var selected ContractDetailsLite
for _, detail := range details {
detail.Symbol = strings.ToUpper(strings.TrimSpace(detail.Symbol))
detail.SecType = strings.ToUpper(strings.TrimSpace(detail.SecType))
detail.Exchange = strings.ToUpper(strings.TrimSpace(detail.Exchange))
detail.PrimaryExch = strings.ToUpper(strings.TrimSpace(detail.PrimaryExch))
detail.Currency = strings.ToUpper(strings.TrimSpace(detail.Currency))
detail.LocalSymbol = strings.TrimSpace(detail.LocalSymbol)
detail.TradingClass = strings.TrimSpace(detail.TradingClass)
if detail.ConID != contract.ConID || detail.Symbol != contract.Symbol || detail.SecType != contract.SecType ||
detail.Currency != contract.Currency || detail.Exchange == "" ||
(contract.PrimaryExch != "" && detail.PrimaryExch != "" && detail.PrimaryExch != contract.PrimaryExch) ||
(contract.LocalSymbol != "" && detail.LocalSymbol != "" && detail.LocalSymbol != contract.LocalSymbol) ||
(contract.TradingClass != "" && detail.TradingClass != "" && detail.TradingClass != contract.TradingClass) {
return Contract{}, &HistoricalRequestError{Category: HistoricalFailureContractUnavailable}
}
if selected.ConID != 0 && (detail.Exchange != selected.Exchange || detail.PrimaryExch != selected.PrimaryExch ||
detail.LocalSymbol != selected.LocalSymbol || detail.TradingClass != selected.TradingClass) {
return Contract{}, &HistoricalRequestError{Category: HistoricalFailureContractUnavailable}
}
selected = detail
}
if selected.ConID == 0 {
return Contract{}, &HistoricalRequestError{Category: HistoricalFailureContractUnavailable}
}
resolved := contract
resolved.Exchange = selected.Exchange
if selected.PrimaryExch != "" {
resolved.PrimaryExch = selected.PrimaryExch
}
if resolved.LocalSymbol == "" {
resolved.LocalSymbol = selected.LocalSymbol
}
if resolved.TradingClass == "" {
resolved.TradingClass = selected.TradingClass
}
if !HistoricalFeeRateUSRouteSupported(resolved, true) {
return Contract{}, &HistoricalDataValidationError{Reason: "unsupported_market_calendar"}
}
return resolved, nil
}
func sanitizeExactHistoricalError(err error) error {
if err == nil {
return nil
}
if _, ok := errors.AsType[*HistoricalDataValidationError](err); ok {
return &HistoricalRequestError{Category: HistoricalFailureInvalidPayload}
}
if errors.Is(err, context.Canceled) {
return context.Canceled
}
if errors.Is(err, context.DeadlineExceeded) {
return context.DeadlineExceeded
}
if requestErr, ok := errors.AsType[*HistoricalRequestError](err); ok {
category := classifyHistoricalRequestFailure(requestErr)
return &HistoricalRequestError{
Code: requestErr.Code,
RetryAfter: requestErr.RetryAfter,
Category: category,
}
}
return &HistoricalRequestError{Category: HistoricalFailureGatewayUnavailable}
}
// HistoricalNotEntitled reports a historical-data refusal for want of a
// market data permission or subscription. It stands until the account's
// entitlements change, so retrying it only repeats the refusal.
func HistoricalNotEntitled(err error) bool {
requestErr, ok := errors.AsType[*HistoricalRequestError](err)
return ok && classifyHistoricalRequestFailure(requestErr) == HistoricalFailureNotEntitled
}
func classifyHistoricalRequestFailure(err *HistoricalRequestError) string {
if err == nil {
return HistoricalFailureProtocolRejected
}
switch err.Category {
case HistoricalFailureNotEntitled,
HistoricalFailureNoData,
HistoricalFailurePacing,
HistoricalFailureGatewayUnavailable,
HistoricalFailureContractUnavailable,
HistoricalFailureProtocolRejected,
HistoricalFailureInvalidPayload:
return err.Category
}
upperMessage := strings.ToUpper(err.Message)
switch err.Code {
case 354, 10089, 10090, 10091, 10186, 10187:
return HistoricalFailureNotEntitled
case 502, 504, 1100, 1300:
return HistoricalFailureGatewayUnavailable
case 200:
return HistoricalFailureContractUnavailable
case 321, 366:
return HistoricalFailureProtocolRejected
}
if err.Code == 162 {
switch {
case strings.Contains(upperMessage, "NO MARKET DATA PERMISSION"),
strings.Contains(upperMessage, "NOT SUBSCRIBED"),
strings.Contains(upperMessage, "MARKET DATA SUBSCRIPTION"),
strings.Contains(upperMessage, "PERMISSION TO USE"):
return HistoricalFailureNotEntitled
case strings.Contains(upperMessage, "NO DATA"):
return HistoricalFailureNoData
case strings.Contains(upperMessage, "PACING"):
return HistoricalFailurePacing
case historicalUnknownContract(err):
return HistoricalFailureContractUnavailable
}
}
return HistoricalFailureProtocolRejected
}
func cloneSanitizedHistoricalError(err error) error {
if err == nil {
return nil
}
if requestErr, ok := errors.AsType[*HistoricalRequestError](err); ok {
return &HistoricalRequestError{
Code: requestErr.Code,
RetryAfter: requestErr.RetryAfter,
Category: requestErr.Category,
}
}
if validation, ok := errors.AsType[*HistoricalDataValidationError](err); ok {
return &HistoricalDataValidationError{Reason: validation.Reason}
}
return err
}
func (c *Connector) fetchHistoricalDailyBarsWithContract(ctx context.Context, contract Contract, lookbackDays int, timeout time.Duration, forceWhatToShow string) ([]HistoricalBar, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
if !c.IsReady() {
return nil, fmt.Errorf("IBKR connection not ready")
}
contract = normalizeMarketDataContract(contract)
if contract.Symbol == "" {
return nil, fmt.Errorf("contract symbol is required")
}
symbol := strings.ToUpper(contract.Symbol)
if _, inactive := c.inactiveReason(MarketDataKeyForContract(contract)); inactive {
return nil, ErrSymbolInactive
}
if lookbackDays <= 0 {
lookbackDays = 400
}
if timeout <= 0 {
timeout = 45 * time.Second
}
timeout, err := historicalTimeoutWithinContext(ctx, timeout)
if err != nil {
return nil, err
}
fallbackPrimary := contract.PrimaryExch
if detail := c.cachedContractDetail(symbol); detail != nil && detail.ConID != 0 {
candidate := contract
if c.applyContractDetail(*detail, &candidate) {
normalizeEquityRouting(&candidate, fallbackPrimary)
if explicitContractRouteMatches(contract, candidate) {
contract = candidate
}
}
}
if contract.ConID == 0 {
// A routed resolution draws its own code 200 per read; the miss
// backoff that gates the symbol and subscription paths applies here
// too, keyed by the route.
routeKey := MarketDataKeyForContract(contract)
if missErr := c.contractResolutionMissFor(routeKey); missErr != nil {
c.logDebug("Routed contract details for %s suppressed (%v)", symbol, missErr)
return nil, missErr
}
resolveTimeout := min(timeout, 12*time.Second)
details, err := c.fetchContractDetailsForContract(contract, resolveTimeout)
if errors.Is(err, ErrContractNoDefinition) {
c.recordContractResolutionMiss(routeKey)
return nil, fmt.Errorf("contract details unresolved for %s: %w", symbol, err)
}
if len(details) > 0 {
for _, detail := range details {
candidate := contract
if !c.applyContractDetail(detail, &candidate) {
continue
}
normalizeEquityRouting(&candidate, fallbackPrimary)
if explicitContractRouteMatches(contract, candidate) {
contract = candidate
break
}
}
if contract.ConID == 0 {
c.logWarn("Routed contract details for %s returned no route match (exchange=%s primary=%s currency=%s)",
symbol, contract.Exchange, contract.PrimaryExch, contract.Currency)
}
} else if err != nil {
c.logDebug("Routed contract details for %s unavailable (%v)", symbol, err)
}
}
return c.fetchHistoricalDailyBarsWithBase(ctx, symbol, contract, fallbackPrimary, lookbackDays, timeout, false, forceWhatToShow)
}
func (c *Connector) fetchHistoricalDailyBarsWithBase(ctx context.Context, symbol string, baseContract Contract, primary string, lookbackDays int, timeout time.Duration, requireConID bool, forceWhatToShow string) ([]HistoricalBar, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
var err error
timeout, err = historicalTimeoutWithinContext(ctx, timeout)
if err != nil {
return nil, err
}
requestedContract := baseContract
// The identity this read inherits from the contract cache; a code 200
// against it can mean IBKR retired that conID (see refreshRetiredContract).
cachedConID := 0
if cached := c.cachedContractDetail(symbol); cached != nil {
cachedConID = cached.ConID
}
graceWindow := contractDetailsLateGrace
if timeout > 0 {
if half := timeout / 2; half > 0 && half < graceWindow {
graceWindow = half
}
}
// ensureContractDetails budget: 30 s. A historical-data fan-out
// wire alongside reqHistoricalData; even with the rate limiter's
// per-request dispatcher (no HoL blocking) IBKR can take several
// gateway was busy and the awaitContractDetail grace window
var resolveErr error
if baseContract.ConID == 0 && requireConID {
var fetchErr error
resolveTimeout := 30 * time.Second
if timeout > 0 {
resolveTimeout = min(resolveTimeout, timeout)
}
if detail, err := c.ensureContractDetails(symbol, resolveTimeout); err == nil && detail != nil {
candidate := baseContract
if c.applyContractDetail(*detail, &candidate) {
normalizeEquityRouting(&candidate, primary)
if requireConID || explicitContractRouteMatches(requestedContract, candidate) {
baseContract = candidate
}
}
} else {
fetchErr, resolveErr = err, err
late := c.awaitContractDetailCtx(ctx, symbol, graceWindow)
candidate := baseContract
if late != nil && c.applyContractDetail(*late, &candidate) {
normalizeEquityRouting(&candidate, primary)
if requireConID || explicitContractRouteMatches(requestedContract, candidate) {
baseContract = candidate
}
c.logInfo("Contract details for %s arrived during grace window (conID=%d)", symbol, late.ConID)
} else if fetchErr != nil {
c.logDebug("Contract details for %s unavailable (%v); using static classification hints only", symbol, fetchErr)
}
}
}
// The only WARN in this chain. A quote-history fallback runs the routed
if requireConID && baseContract.ConID == 0 {
if errors.Is(resolveErr, ErrContractNoDefinition) {
// The broker has answered. The definition-miss backoff owns the
// retry cadence and already logged the symbol once; a WARN per
// suppressed read would only repeat it.
c.logDebug("Historical data request skipped for %s: %v", symbol, resolveErr)
return nil, fmt.Errorf("contract details unresolved for %s: %w", symbol, resolveErr)
}
message := fmt.Sprintf("Historical data request aborted for %s: contract ID unresolved (exchange=%s primary=%s)", symbol, baseContract.Exchange, baseContract.PrimaryExch)
if summary, emit := c.coalesceContractWarning("historical_unresolved\x00"+symbol+"\x00"+baseContract.Exchange+"\x00"+baseContract.PrimaryExch, contractWarningWindow, message); emit {
c.logWarn("%s", summary)
}
return nil, fmt.Errorf("contract details unresolved for %s (exchange=%s primary=%s)", symbol, baseContract.Exchange, baseContract.PrimaryExch)
}
bars, err := c.fetchHistoricalDailyAttempts(ctx, symbol, baseContract, primary, lookbackDays, timeout, forceWhatToShow)
if err == nil || cachedConID == 0 || baseContract.ConID != cachedConID || !errors.Is(err, ErrContractNoDefinition) {
return bars, err
}
refreshed, ok := c.refreshRetiredContract(symbol, cachedConID, requestedContract, primary, requireConID, min(30*time.Second, timeout))
if !ok {
return bars, err
}
retryTimeout, timeoutErr := historicalTimeoutWithinContext(ctx, timeout)
if timeoutErr != nil {
return bars, err
}
return c.fetchHistoricalDailyAttempts(ctx, symbol, refreshed, primary, lookbackDays, retryTimeout, forceWhatToShow)
}
// refreshRetiredContract replaces a cached identity that drew a code 200.
// IBKR re-lists a security under a new conID after some corporate actions;
// the cache would otherwise keep the retired conID and draw the same
// rejection on every read, for good (OKE, conID 10794 to 921971937 in 2026).
// It reports false when the fresh lookup fails or names the rejected conID
// again, so the broker's verdict stands.
func (c *Connector) refreshRetiredContract(symbol string, rejectedConID int, requested Contract, primary string, requireConID bool, timeout time.Duration) (Contract, bool) {
c.contractMu.Lock()
if cached, ok := c.contractCache[symbol]; ok && cached.ConID == rejectedConID {
delete(c.contractCache, symbol)
}
c.contractMu.Unlock()
detail, err := c.ensureContractDetails(symbol, timeout)
if err != nil || detail == nil || detail.ConID == rejectedConID {
return Contract{}, false
}
candidate := requested
if !c.applyContractDetail(*detail, &candidate) {
return Contract{}, false
}
normalizeEquityRouting(&candidate, primary)
if !requireConID && !explicitContractRouteMatches(requested, candidate) {
return Contract{}, false
}
c.logWarn("Contract identity for %s changed (conID %d -> %d); replaced the cached contract", symbol, rejectedConID, detail.ConID)
return candidate, true
}
// fetchHistoricalDailyAttempts reads daily bars for a resolved contract,
// walking the whatToShow sequence and the primary-exchange route.
func (c *Connector) fetchHistoricalDailyAttempts(ctx context.Context, symbol string, baseContract Contract, primary string, lookbackDays int, timeout time.Duration, forceWhatToShow string) ([]HistoricalBar, error) {
type attempt struct {
contract Contract
whatToShow string
label string
}
var seq []string
if strings.TrimSpace(forceWhatToShow) != "" {
cleanWhat, err := normalizeHistoricalWhatToShow(forceWhatToShow)
if err != nil {
return nil, err
}
seq = []string{cleanWhat}
} else {
baseWhat := defaultHistoricalWhat(baseContract.SecType)
altWhat := alternateHistoricalWhat(baseWhat)
seq = historicalWhatSequence(baseContract.SecType, baseWhat, altWhat)
}
attempts := make([]attempt, 0, len(seq)*2)
seen := make(map[string]struct{})
appendAttempt := func(contract Contract, what string) {
if what == "" {
return
}
key := strings.ToUpper(contract.Exchange) + "|" + strings.ToUpper(what)
if _, ok := seen[key]; ok {
return
}
seen[key] = struct{}{}
attempts = append(attempts, attempt{
contract: contract,
whatToShow: what,
label: fmt.Sprintf("%s/%s", contract.Exchange, what),
})
}
for _, what := range seq {
appendAttempt(baseContract, what)
}
if primary != "" && strings.EqualFold(baseContract.Exchange, "SMART") {
altContract := baseContract
altContract.Exchange = primary
altContract.PrimaryExch = ""
for _, what := range seq {
appendAttempt(altContract, what)
}
}
var lastBars []HistoricalBar
var lastErr error
for idx, att := range attempts {
if err := ctx.Err(); err != nil {
return nil, err
}
bars, err := c.fetchHistoricalWithContract(ctx, symbol, att.contract, lookbackDays, timeout, att.whatToShow)
if err != nil {
if shouldRetryHistorical(err) && idx < len(attempts)-1 {
c.logWarn("Historical data attempt %s for %s failed (%v); retrying with alternate route", att.label, symbol, err)
lastErr = err
continue
}
return nil, err
}
if len(bars) > 0 {
if idx > 0 {
c.logInfo("Historical data for %s recovered via %s", symbol, att.label)
}
return bars, nil
}
c.logWarn("Historical data for %s returned no rows via %s", symbol, att.label)
lastBars = bars
}
if len(lastBars) == 0 {
if lastErr != nil {
return nil, lastErr
}
return nil, fmt.Errorf("historical data unavailable for %s", symbol)
}
return lastBars, nil
}
func defaultHistoricalWhat(secType string) string {
switch strings.ToUpper(secType) {
case "CMDTY", "CASH":
// CASH has no consolidated trade tape on IBKR; reqHistoricalData
return "MIDPOINT"
default:
// Indices publish their values as TRADES and quote no bid/ask, so
// MIDPOINT is only their alternate: asking it first costs a paced
// request that IBKR answers with 162.
return "TRADES"
}
}
func alternateHistoricalWhat(current string) string {
if strings.EqualFold(current, "TRADES") {
return "MIDPOINT"
}
if strings.EqualFold(current, "MIDPOINT") {
return "TRADES"
}
return current
}
func historicalWhatSequence(secType, baseWhat, altWhat string) []string {
seq := make([]string, 0, 5)
appendWhat := func(value string) {
if value == "" {
return
}
for _, existing := range seq {
if strings.EqualFold(existing, value) {
return
}
}
seq = append(seq, value)
}
appendWhat(baseWhat)
switch strings.ToUpper(strings.TrimSpace(secType)) {
case "STK":
appendWhat("ADJUSTED_LAST")
case "CASH":
// FX has no trade tape — don't bother probing TRADES;
// IBKR rejects with code 162 and the retry would just
return seq
}
if !strings.EqualFold(baseWhat, altWhat) {
appendWhat(altWhat)
}
return seq
}
func normalizeHistoricalWhatToShow(value string) (string, error) {
clean := strings.ToUpper(strings.TrimSpace(value))
switch clean {
case "TRADES", "MIDPOINT", "ADJUSTED_LAST":
return clean, nil
default:
return "", fmt.Errorf("unsupported historical whatToShow %q", value)
}
}
func shouldRetryHistorical(err error) bool {
if hErr, ok := errors.AsType[*HistoricalRequestError](err); ok {
switch hErr.Code {
case 162:
// Another route cannot find a contract the broker does not know.
return !historicalUnknownContract(hErr)
}
}
return false
}
func (c *Connector) fetchHistoricalWithContract(ctx context.Context, symbol string, contract Contract, lookbackDays int, timeout time.Duration, whatToShow string) ([]HistoricalBar, error) {
return c.fetchHistoricalWithContractOptions(ctx, symbol, contract, lookbackDays, timeout, whatToShow, historicalRequestOptions{})
}
func (c *Connector) fetchHistoricalWithContractOptions(ctx context.Context, symbol string, contract Contract, lookbackDays int, timeout time.Duration, whatToShow string, options historicalRequestOptions) ([]HistoricalBar, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
timeout, err := historicalTimeoutWithinContext(ctx, timeout)
if err != nil {
return nil, err
}
if contract.ConID == 0 {
c.logDebug("Skipping historical data request for %s: unresolved contract ID (exchange=%s primary=%s)", symbol, contract.Exchange, contract.PrimaryExch)
return nil, fmt.Errorf("contract ID unresolved for %s", symbol)
}
stallSession := options.session
if !options.requireEpoch {
c.mu.RLock()
conn := c.conn
c.mu.RUnlock()
if conn == nil {
return nil, ErrIBKRUnavailable
}
stallSession = HistoricalSessionBinding{connector: c, connection: conn, epoch: conn.BrokerSessionEpoch()}
}
if err := c.admitHistoricalRequest(stallSession, time.Now()); err != nil {
return nil, err
}
var req *historicalRequest
var registeredReqID int
duration := formatHistoricalDuration(lookbackDays)
formatDate := options.formatDate
if formatDate == 0 {
formatDate = 1
}
register := func(id int) {
registeredReqID = id
req = c.createHistoricalRequestWithOptions(id, symbol, options)
}
var reqID int
if options.requireEpoch {
if whatToShow != "FEE_RATE" || formatDate != 2 || !c.HistoricalSessionCurrent(options.session) {
return nil, &HistoricalRequestError{Category: HistoricalFailureGatewayUnavailable}
}
reqID, err = options.session.connection.requestHistoricalDailyFeeRateForEpoch(ctx, contract, lookbackDays, options.session.epoch, register)
} else {
// Connection's shared monotonic broker namespace is the sole allocator
// affects only delayed-notice routing; it must not create a second
barSize := "1 day"
if options.chartBarSize != "" {
barSize = options.chartBarSize
}
reqID, err = stallSession.connection.requestHistoricalDataWithIDGuard(ctx, contract, options.endDateTime, duration, barSize, whatToShow, !options.chartOutsideRTH, false, formatDate, false, nil, register)
}
if err != nil {
if registeredReqID != 0 {
c.failHistoricalRequest(registeredReqID, err)
}
return nil, err
}
if req == nil {
req = c.createHistoricalRequestWithOptions(reqID, symbol, options)
}
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case res := <-req.result:
if historicalAnswered(res.err) {
c.noteHistoricalAnswer(stallSession, time.Now())
}
if res.err != nil {
return nil, res.err
}
return res.bars, nil
case <-ctx.Done():
c.cancelHistoricalDataBestEffortWithOptions(reqID, options)
c.failHistoricalRequest(reqID, ctx.Err())
return nil, ctx.Err()
case <-timer.C:
c.cancelHistoricalDataBestEffortWithOptions(reqID, options)
c.noteHistoricalTimeout(stallSession, timeout, time.Now())
timeoutErr := fmt.Errorf("historical data timeout for %s after %s: %w", symbol, timeout, context.DeadlineExceeded)
c.failHistoricalRequest(reqID, timeoutErr)
return nil, timeoutErr
}
}
func (c *Connector) cancelHistoricalDataBestEffortWithOptions(reqID int, options historicalRequestOptions) {
if reqID == 0 {
return
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
var err error
if options.requireEpoch {
err = options.session.connection.cancelHistoricalDataForEpoch(ctx, reqID, options.session.epoch)
} else {
err = c.conn.CancelHistoricalData(ctx, reqID)
}
if err != nil {
c.logDebug("Historical cancel reqID=%d skipped/failed: %v", reqID, err)
}
}()
}
// OptionIV returns the last valid implied volatility as a fraction. False means
// no observation has been cached; the method performs no broker request.
func (c *Connector) OptionIV(symbol string) (float64, bool) {
c.optMu.RLock()
defer c.optMu.RUnlock()
v, ok := c.optIV[symbol]
return v, ok
}
// OptionIVWithDataType returns the last valid implied-volatility observation
// and model-tick source type. Callers requiring provenance must reject type zero.
func (c *Connector) OptionIVWithDataType(symbol string) (iv float64, dataType int, ok bool) {
c.optMu.RLock()
defer c.optMu.RUnlock()
iv, ok = c.optIV[symbol]
if !ok {
return 0, 0, false
}
return iv, c.optIVDataType[symbol], true
}
// OptionGreeks returns the last valid model-computation Greeks for an option key.
// False means no component has been observed and must not be treated as zero.
func (c *Connector) OptionGreeks(symbol string) (Greeks, bool) {
c.optMu.RLock()
defer c.optMu.RUnlock()
g, ok := c.optGreeks[symbol]
return g, ok
}
// OptionUnderlyingPrice returns the underlying price embedded in the latest
// option model-computation tick. False means no valid price has been observed.
func (c *Connector) OptionUnderlyingPrice(symbol string) (float64, bool) {
c.optMu.RLock()
defer c.optMu.RUnlock()
v, ok := c.optUnderlyingPx[symbol]
return v, ok
}
// OptionQuoteBidAsk returns the last observed bid and ask for an option key.
// It performs no broker request and does not itself determine freshness.
func (c *Connector) OptionQuoteBidAsk(symbol string) (bid, ask float64, ok bool) {
c.optMu.RLock()
defer c.optMu.RUnlock()
b, hasB := c.optQuoteBid[symbol]
a, hasA := c.optQuoteAsk[symbol]
if !hasB && !hasA {
return 0, 0, false
}
return b, a, true
}
// OptionPrevClose returns the option contract's previous regular-session close.
func (c *Connector) OptionPrevClose(symbol string) (float64, bool) {
c.optMu.RLock()
defer c.optMu.RUnlock()
v, ok := c.optPrevClose[symbol]
if !ok || v <= 0 {
return 0, false
}
return v, true
}
// CancelOptionIV best-effort cancels an option-IV request; unknown IDs are no-ops.
func (c *Connector) CancelOptionIV(reqID int) {
if reqID == 0 {
return
}
c.optMu.Lock()
_, isOption := c.optReqIDs[reqID]
delete(c.optReqIDs, reqID)
c.optMu.Unlock()
if !isOption {
return
}
c.subMu.Lock()
delete(c.reqIDMap, reqID)
conn := c.conn
c.subMu.Unlock()
if conn != nil && conn.IsConnected() {
if err := conn.CancelMarketData(reqID); err != nil {
marketDataLogger.Debugf("%s: CancelOptionIV(reqID=%d): %v", c.name, reqID, err)
}
}
}
// SubscribeOptionIV starts option market data keyed by normalized underlying.
func (c *Connector) SubscribeOptionIV(ctx context.Context, symbol string, expiry time.Time, strike float64, right string) (int, error) {
return c.subscribeOptionIV(ctx, symbol, expiry, strike, right, strings.ToUpper(symbol))
}
// SubscribeOptionIVKeyed starts option market data keyed by exact contract.
func (c *Connector) SubscribeOptionIVKeyed(ctx context.Context, symbol string, expiry time.Time, strike float64, right string) (int, string, error) {
expStr := expiry.UTC().Format("20060102")
key := OptionMarketDataKey(symbol, expStr, right, strike)
reqID, err := c.subscribeOptionIV(ctx, symbol, expiry, strike, right, key)
if err != nil {
return 0, "", err
}
return reqID, key, nil
}
func (c *Connector) subscribeOptionIV(ctx context.Context, symbol string, expiry time.Time, strike float64, right, routeKey string) (int, error) {
symbol = strings.ToUpper(symbol)
if routeKey == "" {
routeKey = symbol
}
if _, inactive := c.inactiveReason(symbol); inactive {
c.logDebug("Skipping option IV subscription for inactive symbol %s", symbol)
return 0, ErrSymbolInactive
}
c.mu.RLock()
conn := c.conn
c.mu.RUnlock()
if conn == nil || !conn.IsConnected() {
return 0, fmt.Errorf("IBKR connection not available")
}
// Format expiry as YYYYMMDD
expStr := expiry.UTC().Format("20060102")
reqID, err := conn.RequestOptionsMarketData(ctx, symbol, expStr, strike, strings.ToUpper(right))
if err != nil {
return 0, err
}
// Map reqID to the requested route key so we can attribute IV updates.
c.subMu.Lock()
c.reqIDMap[reqID] = routeKey
c.subMu.Unlock()
c.optMu.Lock()
c.optReqIDs[reqID] = routeKey
c.optMu.Unlock()
c.logInfo("Subscribed option IV for %s %s %.2f %s (ReqID: %d, key: %s)", symbol, expStr, strike, right, reqID, routeKey)
return reqID, nil
}
// handleTickSize processes tick size updates
func (c *Connector) handleTickSize(fields []string) {
if len(fields) < 4 {
return
}
// Format: [msgID, version, reqID, tickType, size]
if len(fields) < 5 {
return
}
reqIDStr := fields[2]
tickTypeStr := fields[3]
sizeStr := fields[4]
reqID, _ := strconv.Atoi(reqIDStr)
tickType, _ := strconv.Atoi(tickTypeStr)
size, ok := parseTickSize(c.ServerVersion(), tickType, sizeStr)
if !ok {
return
}
// Find the symbol for this request ID
c.subMu.RLock()
symbol, exists := c.reqIDMap[reqID]
c.subMu.RUnlock()
if !exists {
return
}
// Update subscription data based on tick type
c.subMu.Lock()
defer c.subMu.Unlock()
sub, exists := c.subscriptions[symbol]
if !exists {
return
}
// Mark observed on any size tick.
sub.Observed = true
observedAt := time.Now()
// IBKR tick types: 0=BID_SIZE, 3=ASK_SIZE, 8=VOLUME (cumulative day total).
// so only the tick matching Subscription.Right may commit OpenInt. On
switch tickType {
case 0, 69:
sub.BidSize = size
case 3, 70:
sub.AskSize = size
case 8, 74:
if size < 0 {
return
}
sub.Volume = size
sub.VolumeObserved = true
sub.VolumeAt = observedAt
case 21:
sub.AvgVolume = size
case 27:
if sub.Right == "C" {
sub.OpenInt = size
sub.OpenIntObserved = true
}
case 28:
if sub.Right == "P" {
sub.OpenInt = size
sub.OpenIntObserved = true
}
case 89:
// Shortable share count, delivered for the generic-tick-236
// request (TWS build 974+). Feeds the borrow-inventory market-
// never the tick-46 difficulty level, whose 0–3 float would read
sub.ShortableShares = size
sub.ShortableObserved = true
sub.ShortableTickAt = observedAt
}
sub.LastTime = observedAt
sub.LastTickAt = observedAt
}
// handleTickString processes IBKR tick-string updates. Tick type 45 carries
func (c *Connector) handleTickString(fields []string) {
if len(fields) < 5 {
return
}
reqID, err := strconv.Atoi(fields[2])
if err != nil {
return
}
tickType, err := strconv.Atoi(fields[3])
if err != nil {
return
}
value := strings.TrimSpace(fields[4])
if value == "" {
return
}
c.subMu.RLock()
symbol, exists := c.reqIDMap[reqID]
c.subMu.RUnlock()
if !exists {
return
}
c.subMu.Lock()
defer c.subMu.Unlock()
sub, ok := c.subscriptions[symbol]
if !ok || sub.ReqID != reqID {
return
}
priceObserved := false
switch tickType {
case 45, 88:
sec, err := strconv.ParseInt(value, 10, 64)
if err != nil || sec <= 0 {
return
}
sub.LastTradeTime = time.Unix(sec, 0)
case 233:
last, volume, ts, ok := parseRTVolumeTick(value, c.ServerVersion())
if !ok {
return
}
if last > 0 {
sub.LastPrice = last
priceObserved = true
}
if volume > 0 {
sub.Volume = volume
}
if !ts.IsZero() {
sub.LastTradeTime = ts
}
default:
return
}
observedAt := time.Now()
sub.LastTime = observedAt
sub.LastTickAt = observedAt
if priceObserved {
sub.LastPriceTickAt = observedAt
}
sub.Observed = true
}
func parseRTVolumeTick(value string, serverVersion int) (last float64, volume int64, ts time.Time, ok bool) {
parts := strings.Split(value, ";")
if len(parts) < 4 {
return 0, 0, time.Time{}, false
}
last, _ = strconv.ParseFloat(strings.TrimSpace(parts[0]), 64)
if v, parsed := parseTickSize(serverVersion, 8, strings.TrimSpace(parts[3])); parsed {
volume = v
}
rawTime := strings.TrimSpace(parts[2])
if rawTime != "" {
if n, err := strconv.ParseInt(rawTime, 10, 64); err == nil && n > 0 {
if n > 10_000_000_000 {
ts = time.UnixMilli(n)
} else {
ts = time.Unix(n, 0)
}
}
}
return last, volume, ts, last > 0 || volume > 0 || !ts.IsZero()
}
// parseTickSize reads the broker's decimal quantity without guessing a scale
// from its magnitude. Protocol 203 can report shares or lots according to the
// TWS setting; neither the server version nor a large value proves the unit.
// The existing integer read model truncates fractional quantities toward zero.
func parseTickSize(_, _ int, raw string) (int64, bool) {
raw = strings.TrimSpace(raw)
if raw == "" || len(raw) > 128 {
return 0, false
}
if size, err := strconv.ParseInt(raw, 10, 64); err == nil {
return size, size >= 0
}
// Enough precision for every accepted mantissa, including values near
// MaxInt64. Float64 conversion can round a valid decimal across that bound.
decimal, _, err := big.ParseFloat(raw, 10, 512, big.ToZero)
if err != nil || decimal.IsInf() || decimal.Sign() < 0 || decimal.Cmp(big.NewFloat(0x1p63)) >= 0 {
return 0, false
}
size, _ := decimal.Int64()
return size, true
}
// handlePosition processes position updates
func (c *Connector) handlePosition(fields []string) {
if len(fields) < 8 {
return
}
// Fields: version, account, symbol, secType, expiry, strike, right, multiplier, exchange, currency, localSymbol, tradingClass, position, avgCost
symbol := fields[2]
positionStr := fields[12]
avgCostStr := fields[13]
c.logDebug("Position update - Symbol: %s, Position: %s, AvgCost: %s",
symbol, positionStr, avgCostStr)
}
// handlePortfolioValue processes portfolio updates
func (c *Connector) handlePortfolioValue(fields []string) {
if len(fields) < 18 {
return
}
// Extract relevant fields
symbol := fields[1]
position := fields[6]
marketPrice := fields[7]
marketValue := fields[8]
avgCost := fields[9]
unrealizedPnL := fields[10]
realizedPnL := fields[11]
c.logDebug("Portfolio update - Symbol: %s, Position: %s, Price: %s, Value: %s, UnrealizedPnL: %s, RealizedPnL: %s, AvgCost: %s",
symbol, position, marketPrice, marketValue, unrealizedPnL, realizedPnL, avgCost)
}
// handleOrderStatus processes order status updates
func (c *Connector) handleOrderStatus(fields []string) {
if len(fields) < 3 {
return
}
start := 1
if len(fields) > 3 && isNumeric(fields[1]) && isNumeric(fields[2]) {
start = 2
}
if len(fields) <= start+3 {
return
}
orderID := ""
status := ""
filled := "0"
remaining := "0"
avgFillPrice := "0"
lastFillPrice := "0"
whyHeld := ""
if len(fields) > 1 && fields[1] == "protobuf" {
orderID = summaryFieldValue(fields, "orderId=")
status = summaryFieldValue(fields, "status=")
filled = summaryFieldValue(fields, "filled=")
remaining = summaryFieldValue(fields, "remaining=")
avgFillPrice = summaryFieldValue(fields, "avgFillPrice=")
lastFillPrice = summaryFieldValue(fields, "lastFillPrice=")
whyHeld = summaryFieldValue(fields, "whyHeld=")
} else {
orderID = fields[start]
status = fields[start+1]
filled = fields[start+2]
remaining = fields[start+3]
if len(fields) > start+4 {
avgFillPrice = fields[start+4]
}
// EDecoder: permId, parentId, lastFillPrice, clientId, whyHeld.
if len(fields) > start+7 {
lastFillPrice = fields[start+7]
}
if len(fields) > start+9 {
whyHeld = fields[start+9]
}
}
if orderID == "" || status == "" {
return
}
filledQty, _ := strconv.ParseFloat(filled, 64)
remainingQty, _ := strconv.ParseFloat(remaining, 64)
avgPx, _ := strconv.ParseFloat(avgFillPrice, 64)
lastPx, _ := strconv.ParseFloat(lastFillPrice, 64)
c.logOrderStatus(orderID, status, filledQty, remainingQty, avgPx)
c.orderMu.Lock()
internalID, ok := c.brokerOrderIndex[orderID]
if !ok {
// Fallback: try direct lookup using broker ID as key (some tests store that way)
internalID = orderID
}
order, exists := c.openOrders[internalID]
if !exists {
c.orderMu.Unlock()
return
}
order.BrokerID = orderID
order.FilledQty = filledQty
if avgPx > 0 {
order.FilledPrice = avgPx
} else if lastPx > 0 {
order.FilledPrice = lastPx
}
order.UpdatedAt = time.Now()
order.Status = mapIBOrderStatus(status, filledQty, remainingQty)
if whyHeld != "" {
order.Reason = whyHeld
}
if order.Status == OrderStatusFilled && order.FilledAt == nil {
now := time.Now()
order.FilledAt = &now
}
if order.Status == OrderStatusCancelled && order.CancelledAt == nil {
now := time.Now()
order.CancelledAt = &now
}
// Remove from open orders once terminal
terminal := isTerminalOrderStatus(order.Status)
if terminal {
delete(c.openOrders, internalID)
delete(c.brokerOrderIndex, orderID)
}
c.orderMu.Unlock()
// Drop the log-dedupe signature outside orderMu so a later frame or a
// reused broker id logs fresh at INFO instead of being swallowed as a
// repeat.
if terminal {
c.forgetOrderStatusLog(orderID)
}
}
// logOrderStatus emits the order-status line, demoting verbatim repeats to
// Debug. IBKR re-sends orderStatus frames for unchanged working orders many
// times per second, so only a change in status/filled/remaining reaches INFO;
func (c *Connector) logOrderStatus(orderID, status string, filled, remaining, avgPx float64) {
const format = "Order status - ID: %s, Status: %s, Filled: %.4f, Remaining: %.4f, AvgPrice: %.4f"
if c.orderStatusChanged(orderID, orderStatusLogSignature(status, filled, remaining)) {
c.logInfo(format, orderID, status, filled, remaining, avgPx)
return
}
c.logDebug(format, orderID, status, filled, remaining, avgPx)
}
// orderStatusLogSignature is the dedupe key for the order-status log line:
// only a change in one of these fields is worth an INFO line. avgFillPrice is
// omitted deliberately — it moves only alongside filled/remaining.
func orderStatusLogSignature(status string, filled, remaining float64) string {
return fmt.Sprintf("%s|%.4f|%.4f", status, filled, remaining)
}
// orderStatusChanged reports whether this order-status frame differs from the
// last one logged for orderID, recording the new signature when it does. True
// means the frame earns an INFO line; false means a verbatim repeat that
// belongs at Debug.
func (c *Connector) orderStatusChanged(orderID, sig string) bool {
c.orderStatusLogMu.Lock()
defer c.orderStatusLogMu.Unlock()
if c.orderStatusLogSig == nil {
c.orderStatusLogSig = make(map[string]string)
} else if c.orderStatusLogSig[orderID] == sig {
return false
}
c.orderStatusLogSig[orderID] = sig
return true
}
// forgetOrderStatusLog drops the dedupe signature for a terminal or removed
// order so its slot does not linger in the map and a later reused broker id
func (c *Connector) forgetOrderStatusLog(orderID string) {
c.orderStatusLogMu.Lock()
delete(c.orderStatusLogSig, orderID)
c.orderStatusLogMu.Unlock()
}
func (c *Connector) notifyOrderLifecycleFrom(conn *Connection, epoch uint64, fields []string) {
c.notifyOrderLifecycleReceiptFrom(conn, epoch, fields, previewStatusOrdinary)
}
func (c *Connector) notifyOrderLifecycleReceiptFrom(conn *Connection, epoch uint64, fields []string, disposition previewStatusDisposition) {
if disposition == previewStatusIgnore {
return
}
ev, ok := ParseOrderLifecycleEvent(fields)
if !ok {
return
}
if ev.WhatIf {
return
}
if ev.Type == OrderLifecycleEventExecDetails && ev.RequestID > 0 {
// An execDetails with a positive request ID answers reqExecutions. It
// is inventory for the flight that asked, never a live fill callback,
// so it must not reach the order journal.
c.deliverRequestedExecution(ev)
return
}
origin := ConnectorSessionBinding{connector: c, connection: conn, epoch: epoch}
c.publicationBarrier.RLock()
defer c.publicationBarrier.RUnlock()
c.evidenceBarrier.RLock()
defer c.evidenceBarrier.RUnlock()
current := c.SessionReceiptCurrent(origin)
if disposition == previewStatusUncertain {
c.dispatchOrderLifecycleReceiptUnderBarrier(origin, ev, current, true)
return
}
if current && ev.Type == OrderLifecycleEventStatus {
c.handleOrderStatus(fields)
}
c.dispatchOrderLifecycleUnderBarrier(origin, ev, current)
}
func (c *Connector) isWhatIfOrderID(orderID int) bool {
if c == nil || orderID <= 0 {
return false
}
c.mu.RLock()
conn := c.conn
c.mu.RUnlock()
return conn != nil && conn.IsWhatIfOrderID(orderID)
}
// ReportedOwnOrderForSession returns what the broker reported with this
// client's open order id on binding's socket generation (permanent ID,
// account, reference), and whether it reported it there; a binding to an
// earlier generation or another Connector sees no report. A caller changing
// an order it did not place checks the report names that order (the claim
// checks again under its lock, OrderChangeIdentity).
func (c *Connector) ReportedOwnOrderForSession(binding ConnectorSessionBinding, id int) (ReportedOwnOrder, bool) {
if c == nil || binding.connector != c || binding.connection == nil {
return ReportedOwnOrder{}, false
}
return binding.connection.reportedOwnOrderAtEpoch(id, binding.epoch)
}
// PlacedOrderID reports whether this Connector placed the broker order id
// as a new order, as opposed to an order an earlier process or another app
// placed and this one only changed. The set lives as long as the Connector.
func (c *Connector) PlacedOrderID(id int) bool {
if c == nil || id <= 0 {
return false
}
c.orderMu.RLock()
defer c.orderMu.RUnlock()
_, placed := c.placedOrderIDs[id]
return placed
}
// isKnownBrokerOrderID reports whether id names a broker order this
// connector owns: a journaled/open order or an in-flight WhatIf preview.
// Used to keep request-scoped notice recovery off order-scoped msg-204
// errors when the two integer id spaces collide.
func (c *Connector) isKnownBrokerOrderID(id int) bool {
if c == nil || id <= 0 {
return false
}
brokerID := strconv.Itoa(id)
c.orderMu.RLock()
_, indexed := c.brokerOrderIndex[brokerID]
_, direct := c.openOrders[brokerID]
c.orderMu.RUnlock()
return indexed || direct || c.isWhatIfOrderID(id)
}
func (c *Connector) notifyOrderErrorLifecycleUnderBarrier(origin ConnectorSessionBinding, orderID, code int, message, advancedRejectJSON string) {
if orderID <= 0 || code == 0 || orderWhatIfInformationalError(code) {
return
}
brokerID := strconv.Itoa(orderID)
c.orderMu.RLock()
_, indexed := c.brokerOrderIndex[brokerID]
_, direct := c.openOrders[brokerID]
c.orderMu.RUnlock()
if !indexed && !direct {
return
}
message = orderBrokerErrorMessage(code, message, advancedRejectJSON)
ev := OrderLifecycleEvent{
Type: OrderLifecycleEventError,
OrderID: orderID,
ErrorCode: code,
Status: orderBrokerErrorStatus(code),
Message: message,
}
c.dispatchOrderLifecycleUnderBarrier(origin, ev, c.SessionReceiptCurrent(origin))
}
func (c *Connector) dispatchOrderLifecycle(ev OrderLifecycleEvent) {
c.mu.RLock()
conn := c.conn
c.mu.RUnlock()
epoch := uint64(0)
if conn != nil {
epoch = conn.BrokerSessionEpoch()
}
c.dispatchOrderLifecycleFrom(ConnectorSessionBinding{connector: c, connection: conn, epoch: epoch}, ev)
}
func (c *Connector) dispatchOrderLifecycleFrom(origin ConnectorSessionBinding, ev OrderLifecycleEvent) {
c.publicationBarrier.RLock()
defer c.publicationBarrier.RUnlock()
c.evidenceBarrier.RLock()
defer c.evidenceBarrier.RUnlock()
c.dispatchOrderLifecycleUnderBarrier(origin, ev, c.SessionReceiptCurrent(origin))
}
func (c *Connector) dispatchOrderLifecycleUnderBarrier(origin ConnectorSessionBinding, ev OrderLifecycleEvent, current bool) {
c.dispatchOrderLifecycleReceiptUnderBarrier(origin, ev, current, false)
}
func (c *Connector) dispatchOrderLifecycleReceiptUnderBarrier(origin ConnectorSessionBinding, ev OrderLifecycleEvent, current, uncertain bool) {
c.orderLifecycleGeneration.Add(1)
c.orderLifecycleMu.RLock()
handlers := append([]orderLifecycleHandlerEntry(nil), c.orderLifecycle...)
c.orderLifecycleMu.RUnlock()
receipt := OrderLifecycleReceipt{Session: origin, Event: ev, Uncertain: uncertain}
for _, handler := range handlers {
if handler.receipt != nil {
handler.receipt(receipt)
} else if current && !uncertain && handler.legacy != nil {
handler.legacy(ev)
}
}
}
func orderBrokerErrorStatus(code int) string {
switch code {
case 103, 110, 201:
return "Rejected"
case 202:
return "Cancelled"
default:
return ""
}
}
func orderBrokerErrorMessage(code int, message, advancedRejectJSON string) string {
message = strings.TrimSpace(message)
if message == "" {
message = getErrorDescription(code)
}
if advancedRejectJSON = strings.TrimSpace(advancedRejectJSON); advancedRejectJSON != "" {
if message != "" {
message += "; "
}
message += "advanced_reject_json=" + advancedRejectJSON
}
if message == "" {
return fmt.Sprintf("broker error %d", code)
}
return fmt.Sprintf("broker error %d: %s", code, message)
}
// MarketDataSnapshot returns a detached point-in-time copy of all locally
// respective tick class arrives. None guarantees broker-source freshness.
func (c *Connector) MarketDataSnapshot() map[string]*MarketData {
c.subMu.RLock()
defer c.subMu.RUnlock()
data := make(map[string]*MarketData)
for symbol, sub := range c.subscriptions {
dataType := c.subscriptionDataType(sub)
if sub.previousQuote != nil && sub.LastPriceTickAt.IsZero() {
dataType = sub.previousDataType
sub = sub.previousQuote
}
data[symbol] = &MarketData{
FeedType: dataType,
Symbol: symbol,
Bid: sub.Bid,
Ask: sub.Ask,
Last: sub.LastPrice,
MarkPrice: sub.MarkPrice,
BidSize: int(sub.BidSize),
AskSize: int(sub.AskSize),
Volume: sub.Volume,
VolumeObserved: sub.VolumeObserved,
VolumeAt: sub.VolumeAt,
AvgVolume: sub.AvgVolume,
LastTickAt: sub.LastTickAt,
LastPriceTickAt: sub.LastPriceTickAt,
LastAt: sub.LastAt, BidAt: sub.BidAt, AskAt: sub.AskAt, MarkAt: sub.MarkAt, CloseAt: sub.CloseAt,
LastTradeTime: sub.LastTradeTime,
OpenInt: sub.OpenInt,
OpenIntObserved: sub.OpenIntObserved,
ShortableShares: sub.ShortableShares,
ShortableObserved: sub.ShortableObserved,
ShortableTickAt: sub.ShortableTickAt,
Close: sub.PrevClose,
Open: sub.Open,
High: sub.High,
Low: sub.Low,
Week13Low: sub.Week13Low,
Week13High: sub.Week13High,
Week26Low: sub.Week26Low,
Week26High: sub.Week26High,
Week52Low: sub.Week52Low,
Week52High: sub.Week52High,
IV: sub.IV,
BidYield: cloneYield(sub.bidYield),
AskYield: cloneYield(sub.askYield),
LastYield: cloneYield(sub.lastYield),
Timestamp: sub.LastTime,
}
}
return data
}
func isNumeric(value string) bool {
if value == "" {
return false
}
if _, err := strconv.ParseFloat(value, 64); err == nil {
return true
}
return false
}
func mapIBOrderType(orderType string) OrderType {
switch strings.ToUpper(orderType) {
case "MKT":
return OrderTypeMarket
case "LMT":
return OrderTypeLimit
case "STP":
return OrderTypeStop
case "STP LMT", "STPLMT":
return OrderTypeStopLimit
case "MOC":
return OrderTypeMOC
case "LOC":
return OrderTypeLOC
case "PEG MID", "PEGMID", "PEGMIDPT":
return OrderTypePegMid
default:
return OrderType(strings.ToUpper(orderType))
}
}
func mapIBTimeInForce(tif string) TimeInForce {
switch strings.ToUpper(tif) {
case "DAY":
return TimeInForceDay
case "GTC":
return TimeInForceGTC
case "IOC":
return TimeInForceIOC
case "FOK":
return TimeInForceFOK
case "GTD":
return TimeInForceGTD
case "OPG":
return TimeInForceOPG
default:
return TimeInForce(strings.ToUpper(tif))
}
}
func mapIBOrderStatus(status string, filled, remaining float64) OrderStatus {
s := strings.ToLower(status)
switch s {
case "pendingsubmit", "apipending":
return OrderStatusPending
case "presubmitted":
if filled > 0 && remaining > 0 {
return OrderStatusPartial
}
return OrderStatusSubmitted
case "submitted", "pendingcancel":
if filled > 0 && remaining > 0 {
return OrderStatusPartial
}
if remaining == 0 && filled > 0 {
return OrderStatusFilled
}
return OrderStatusSubmitted
case "partiallyfilled":
return OrderStatusPartial
case "filled":
return OrderStatusFilled
case "cancelled", "apicancelled":
return OrderStatusCancelled
case "inactive", "rejected", "error":
return OrderStatusRejected
case "expired":
return OrderStatusExpired
case "completed":
return OrderStatusFilled
default:
if remaining == 0 && filled > 0 {
return OrderStatusFilled
}
if filled > 0 && remaining > 0 {
return OrderStatusPartial
}
return OrderStatus(strings.ToUpper(status))
}
}
func isTerminalOrderStatus(status OrderStatus) bool {
switch status {
case OrderStatusFilled, OrderStatusCancelled, OrderStatusRejected, OrderStatusExpired:
return true
default:
return false
}
}
package ibkr
import (
"fmt"
"slices"
"strconv"
"strings"
"sync"
"time"
)
// optionExpiryFetch coalesces the per-exchange msg-75 frames that IBKR emits
// Two views are maintained side-by-side. The legacy `strikes` map dedupes
type optionExpiryFetch struct {
mu sync.Mutex
expirations map[string]struct{} // YYYYMMDD set, deduped across exchanges and classes
strikes map[string]map[float64]struct{} // YYYYMMDD -> set of strikes (legacy, any class)
classed map[string]map[string]map[float64]struct{} // YYYYMMDD -> tradingClass -> set of strikes
done chan struct{}
}
func newOptionExpiryFetch() *optionExpiryFetch {
return &optionExpiryFetch{
expirations: make(map[string]struct{}),
strikes: make(map[string]map[float64]struct{}),
classed: make(map[string]map[string]map[float64]struct{}),
done: make(chan struct{}),
}
}
// ExpiryClassedStrikes contains the sorted, deduplicated strike grid for one
// trading class on an expiry date. TradingClass preserves the broker's class
type ExpiryClassedStrikes struct {
TradingClass string `json:"trading_class"`
Strikes []float64 `json:"strikes"`
}
// fetchOptionExpiriesData runs one reqSecDefOptParams round trip and returns
func (c *Connector) fetchOptionExpiriesData(symbol string, timeout time.Duration) ([]string, map[string][]float64, map[string][]ExpiryClassedStrikes, error) {
if !c.IsReady() {
return nil, nil, nil, ErrIBKRUnavailable
}
symbol = strings.ToUpper(strings.TrimSpace(symbol))
if symbol == "" {
return nil, nil, nil, fmt.Errorf("FetchOptionExpiries: symbol required")
}
if _, inactive := c.inactiveReason(symbol); inactive {
return nil, nil, nil, ErrSymbolInactive
}
if timeout <= 0 {
timeout = 10 * time.Second
}
c.mu.RLock()
conn := c.conn
c.mu.RUnlock()
if conn == nil || !conn.IsConnected() {
return nil, nil, nil, ErrIBKRUnavailable
}
// Resolve the underlying conID via the existing contract cache. The chain
detail, err := c.ensureContractDetails(symbol, 5*time.Second)
if err != nil || detail == nil || detail.ConID == 0 {
// Fall back to the late-arrival grace window historical uses.
grace := contractDetailsLateGrace
if half := timeout / 2; half > 0 && half < grace {
grace = half
}
late := c.awaitContractDetail(symbol, grace)
if late == nil || late.ConID == 0 {
if err == nil {
err = fmt.Errorf("contract details unresolved for %s", symbol)
}
return nil, nil, nil, err
}
detail = late
}
secType, _, _, _ := classifySymbol(symbol)
if secType == "" {
secType = "STK"
}
fetch := newOptionExpiryFetch()
var (
registeredReqID int
dataHandlerID uint64
endHandlerID uint64
registeredHandlers bool
)
// Register handlers BEFORE the request goes on the wire, but key on the
// reqID so other in-flight reqSecDefOptParams calls (if any) don't fan in.
beforeSend := func(reqID int) {
registeredReqID = reqID
dataHandlerID = conn.RegisterHandler(msgSecurityDefinitionOptionalParameter, func(fields []string) {
c.handleSecDefOptParam(reqID, fetch, fields)
})
endHandlerID = conn.RegisterHandler(msgSecurityDefinitionOptionalParameterEnd, func(fields []string) {
c.handleSecDefOptParamEnd(reqID, fetch, fields)
})
registeredHandlers = true
}
_, err = conn.RequestSecDefOptParams(symbol, "", secType, detail.ConID, beforeSend)
if err != nil {
if registeredHandlers {
conn.UnregisterHandler(msgSecurityDefinitionOptionalParameter, dataHandlerID)
conn.UnregisterHandler(msgSecurityDefinitionOptionalParameterEnd, endHandlerID)
}
return nil, nil, nil, fmt.Errorf("reqSecDefOptParams: %w", err)
}
defer func() {
conn.UnregisterHandler(msgSecurityDefinitionOptionalParameter, dataHandlerID)
conn.UnregisterHandler(msgSecurityDefinitionOptionalParameterEnd, endHandlerID)
}()
_ = registeredReqID
timer := time.NewTimer(timeout)
defer timer.Stop()
// Wait for end marker or timeout. On timeout we still return whatever we
// observed across exchanges — partial data is more useful than nothing for
// the listing UX, and the IBKR-spec end marker is best-effort during
// degraded data-farm conditions.
timedOut := false
select {
case <-fetch.done:
case <-timer.C:
timedOut = true
}
expiries, strikes, classed := fetch.snapshot()
if len(expiries) == 0 && timedOut {
return nil, nil, nil, fmt.Errorf("option expiries timeout for %s after %s", symbol, timeout)
}
return expiries, strikes, classed, nil
}
// FetchOptionExpiries returns a newly allocated, sorted, deduplicated list of
// option expiry dates for symbol in YYYY-MM-DD form. A timeout of zero or less
// uses ten seconds. If the timeout expires after at least one response, the
// partial list is returned without an error; an empty timeout returns an error.
// An unavailable or inactive Connector returns the corresponding sentinel.
func (c *Connector) FetchOptionExpiries(symbol string, timeout time.Duration) ([]string, error) {
expiries, _, _, err := c.fetchOptionExpiriesData(symbol, timeout)
if err != nil {
return nil, err
}
return expiries, nil
}
// FetchOptionExpiryStrikes returns newly allocated, sorted, deduplicated strike
func (c *Connector) FetchOptionExpiryStrikes(symbol string, timeout time.Duration) (map[string][]float64, error) {
_, strikes, _, err := c.fetchOptionExpiriesData(symbol, timeout)
if err != nil {
return nil, err
}
return strikes, nil
}
// FetchOptionExpiryStrikesClassed returns newly allocated strike grids grouped
// first by YYYY-MM-DD expiry and then by broker trading class. Both class entries
func (c *Connector) FetchOptionExpiryStrikesClassed(symbol string, timeout time.Duration) (map[string][]ExpiryClassedStrikes, error) {
_, _, classed, err := c.fetchOptionExpiriesData(symbol, timeout)
if err != nil {
return nil, err
}
return classed, nil
}
// handleSecDefOptParam decodes one msg-75 frame and merges its expirations
// and strikes into the shared fetch state. Per the IBKR Python ibapi
// reference (decoder.processSecurityDefinitionOptionParameterMsg), the wire
// Note: there is no version field — msg 78 was added after IBKR moved to the
// BOTH the legacy `strikes` map (deduped across classes, for back-compat) and
func (c *Connector) handleSecDefOptParam(expectedReqID int, fetch *optionExpiryFetch, fields []string) {
if len(fields) < 7 {
return
}
// fields[0] = msgID
rid, err := strconv.Atoi(fields[1])
if err != nil || rid != expectedReqID {
return
}
// fields[2] = exchange (we keep it implicit — dedupe across all exchanges)
tradingClass := strings.TrimSpace(fields[4])
idx := 6
expCount, err := strconv.Atoi(fields[idx])
if err != nil || expCount < 0 {
return
}
idx++
if idx+expCount > len(fields) {
return
}
expirations := fields[idx : idx+expCount]
idx += expCount
if idx >= len(fields) {
return
}
strikeCount, err := strconv.Atoi(fields[idx])
if err != nil || strikeCount < 0 {
return
}
idx++
if idx+strikeCount > len(fields) {
return
}
strikeStrings := fields[idx : idx+strikeCount]
parsedStrikes := make([]float64, 0, strikeCount)
for _, s := range strikeStrings {
v, err := strconv.ParseFloat(s, 64)
if err != nil {
continue
}
parsedStrikes = append(parsedStrikes, v)
}
fetch.mu.Lock()
defer fetch.mu.Unlock()
for _, exp := range expirations {
exp = strings.TrimSpace(exp)
if exp == "" {
continue
}
fetch.expirations[exp] = struct{}{}
// Legacy: deduped across all classes — back-compat for SPY callers.
set, ok := fetch.strikes[exp]
if !ok {
set = make(map[float64]struct{})
fetch.strikes[exp] = set
}
for _, k := range parsedStrikes {
set[k] = struct{}{}
}
// Classed: keyed by tradingClass so SPX vs SPXW stay separated.
// Empty tradingClass (unexpected — IBKR always fills it in
// practice) buckets under "" rather than merging into a sibling.
byClass, ok := fetch.classed[exp]
if !ok {
byClass = make(map[string]map[float64]struct{})
fetch.classed[exp] = byClass
}
classSet, ok := byClass[tradingClass]
if !ok {
classSet = make(map[float64]struct{})
byClass[tradingClass] = classSet
}
for _, k := range parsedStrikes {
classSet[k] = struct{}{}
}
}
}
// handleSecDefOptParamEnd closes the fetch's done channel exactly once.
// IBKR sends one msg-76 per request; we tolerate a duplicate as a no-op.
func (c *Connector) handleSecDefOptParamEnd(expectedReqID int, fetch *optionExpiryFetch, fields []string) {
if len(fields) < 2 {
return
}
rid, err := strconv.Atoi(fields[1])
if err != nil || rid != expectedReqID {
return
}
fetch.mu.Lock()
defer fetch.mu.Unlock()
select {
case <-fetch.done:
return
default:
close(fetch.done)
}
}
// snapshot returns the deduped, normalised expiry list (YYYY-MM-DD,
// ascending), the per-expiry sorted strike list (legacy, merged across
func (f *optionExpiryFetch) snapshot() ([]string, map[string][]float64, map[string][]ExpiryClassedStrikes) {
f.mu.Lock()
defer f.mu.Unlock()
expiries := make([]string, 0, len(f.expirations))
for raw := range f.expirations {
if normalised, ok := normaliseExpiry8(raw); ok {
expiries = append(expiries, normalised)
}
}
slices.Sort(expiries)
strikes := make(map[string][]float64, len(f.strikes))
for raw, set := range f.strikes {
normalised, ok := normaliseExpiry8(raw)
if !ok {
continue
}
out := make([]float64, 0, len(set))
for k := range set {
out = append(out, k)
}
slices.Sort(out)
// Multiple raw expiries from different exchanges can normalise to the
// same key; merge instead of overwriting.
if existing, ok := strikes[normalised]; ok {
merged := append(existing, out...)
slices.Sort(merged)
strikes[normalised] = dedupeFloats(merged)
} else {
strikes[normalised] = out
}
}
classed := make(map[string][]ExpiryClassedStrikes, len(f.classed))
for raw, byClass := range f.classed {
normalised, ok := normaliseExpiry8(raw)
if !ok {
continue
}
// Stable class ordering so the gamma compute's two-pass prewarm
classNames := make([]string, 0, len(byClass))
for cls := range byClass {
classNames = append(classNames, cls)
}
slices.Sort(classNames)
for _, cls := range classNames {
set := byClass[cls]
out := make([]float64, 0, len(set))
for k := range set {
out = append(out, k)
}
slices.Sort(out)
classed[normalised] = append(classed[normalised], ExpiryClassedStrikes{
TradingClass: cls,
Strikes: out,
})
}
}
return expiries, strikes, classed
}
// normaliseExpiry8 converts IBKR's YYYYMMDD wire form into the YYYY-MM-DD
func normaliseExpiry8(raw string) (string, bool) {
if len(raw) != 8 {
return "", false
}
for i := range 8 {
if raw[i] < '0' || raw[i] > '9' {
return "", false
}
}
return raw[:4] + "-" + raw[4:6] + "-" + raw[6:], true
}
func dedupeFloats(in []float64) []float64 {
if len(in) <= 1 {
return in
}
out := in[:1]
for i := 1; i < len(in); i++ {
if in[i] != out[len(out)-1] {
out = append(out, in[i])
}
}
return out
}
package ibkr
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io/fs"
"maps"
"math"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
)
// ContractStore serializes snapshots of resolved contracts for reuse across
// Before [ContractStore.UseAuthority] succeeds, the store uses the legacy
// tests, not as runtime authority. After an authority is attached, every load
// legacy file.
type ContractStore struct {
dir string
mu sync.Mutex
authority ContractCacheAuthority
}
// ContractCacheAuthority stores the encoded contract-cache envelope for a
// [ContractStore]. SaveContractCache must publish payload and observedAt as one
// Once [ContractStore.UseAuthority] succeeds, the store never reads or writes
// its legacy JSON path. An authority that reports ok=false represents an empty
type ContractCacheAuthority interface {
LoadContractCache() (payload []byte, ok bool, err error)
SaveContractCache(payload []byte, observedAt time.Time) error
}
// NewContractStore returns a store whose legacy JSON codec is rooted at dir.
// It performs no I/O and creates dir only on the first legacy save. dir is
// ignored after [ContractStore.UseAuthority] succeeds.
func NewContractStore(dir string) *ContractStore {
return &ContractStore{dir: dir}
}
// UseAuthority validates authority's current payload and, on success, switches
// start. A nil store, nil authority, load failure, or invalid current envelope
// returns an error and leaves the existing backend unchanged. UseAuthority
// does not import or merge the legacy JSON file.
func (s *ContractStore) UseAuthority(authority ContractCacheAuthority) error {
if s == nil {
return errors.New("contract cache: nil store")
}
if authority == nil {
return errors.New("contract cache: nil authority")
}
raw, ok, err := authority.LoadContractCache()
if err != nil {
return fmt.Errorf("load contract authority: %w", err)
}
if ok {
if _, err := decodeContractCache(raw, true); err != nil {
return fmt.Errorf("validate contract authority: %w", err)
}
}
s.mu.Lock()
s.authority = authority
s.mu.Unlock()
return nil
}
// contractStoreVersion is the current encoded payload version. Legacy-file
// envelopes produce a cold-cache result. Authority payloads must match the
// current version exactly.
const contractStoreVersion = 3
// contractStoreFile is the filename inside ContractStore.dir.
const contractStoreFile = "contracts.json"
// contractCacheFile is the shared SQLite/legacy-codec payload shape.
type contractCacheFile struct {
Version int `json:"version"`
AsOf time.Time `json:"as_of"`
MembersHash string `json:"members_hash,omitempty"`
Contracts map[string]ContractDetailsLite `json:"contracts"`
Options map[string]ContractDetailsLite `json:"options,omitempty"`
}
// Load returns a caller-owned symbol-to-contract map and the membership hash
// missing payload, or newer legacy-file envelope returns (nil, "", nil) as a
// cold cache. Read and decode failures return an error. Older legacy envelopes
// [ContractStore.UseAuthority].
func (s *ContractStore) Load() (map[string]ContractDetailsLite, string, error) {
if s == nil {
return nil, "", nil
}
s.mu.Lock()
defer s.mu.Unlock()
f, ok, err := s.loadEnvelopeLocked()
if err != nil || !ok {
return nil, "", err
}
// Older legacy envelopes remain readable; newer ones produce a cold result
if f.Version > contractStoreVersion {
return nil, "", nil
}
return f.Contracts, f.MembersHash, nil
}
// LoadOptions returns a caller-owned option-key-to-contract map. Options whose
// snapshot without rewriting persistence. Legacy keys without a trading class
// are migrated in memory to an empty class segment; malformed legacy keys are
// skipped. A nil store, missing payload, newer legacy envelope, or a snapshot
// failures return an error. Calls are serialized with [ContractStore.Save].
func (s *ContractStore) LoadOptions() (map[string]ContractDetailsLite, error) {
if s == nil {
return map[string]ContractDetailsLite{}, nil
}
s.mu.Lock()
defer s.mu.Unlock()
f, ok, err := s.loadEnvelopeLocked()
if err != nil {
return nil, err
}
if !ok {
return map[string]ContractDetailsLite{}, nil
}
if f.Version > contractStoreVersion {
return map[string]ContractDetailsLite{}, nil
}
out := make(map[string]ContractDetailsLite, len(f.Options))
today := nyDateString(time.Now())
for k, v := range f.Options {
if v.Expiry != "" && v.Expiry < today {
continue
}
// Migrate the legacy key shape, which omitted the trading-class
if pipes := strings.Count(k, "|"); pipes == 3 {
parts := strings.SplitN(k, "|", 2)
if len(parts) == 2 {
k = parts[0] + "||" + parts[1]
}
} else if pipes != 4 {
continue
}
out[k] = v
}
return out, nil
}
func (s *ContractStore) loadEnvelopeLocked() (contractCacheFile, bool, error) {
var raw []byte
if s.authority != nil {
payload, ok, err := s.authority.LoadContractCache()
if err != nil || !ok {
return contractCacheFile{}, ok, err
}
raw = payload
} else {
payload, err := os.ReadFile(filepath.Join(s.dir, contractStoreFile))
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return contractCacheFile{}, false, nil
}
return contractCacheFile{}, false, fmt.Errorf("read contracts: %w", err)
}
raw = payload
}
f, err := decodeContractCache(raw, s.authority != nil)
if err != nil {
return contractCacheFile{}, false, err
}
return f, true, nil
}
func decodeContractCache(raw []byte, strictCurrent bool) (contractCacheFile, error) {
var f contractCacheFile
if err := json.Unmarshal(raw, &f); err != nil {
return contractCacheFile{}, fmt.Errorf("decode contracts: %w", err)
}
if !strictCurrent {
return f, nil
}
if f.Version != contractStoreVersion || f.AsOf.IsZero() || f.Contracts == nil {
return contractCacheFile{}, fmt.Errorf("invalid contract cache envelope: version=%d as_of=%s", f.Version, f.AsOf)
}
for key, detail := range f.Contracts {
if strings.TrimSpace(key) == "" || detail.ConID == 0 {
return contractCacheFile{}, fmt.Errorf("invalid contract cache row %q", key)
}
}
for key, detail := range f.Options {
normalized, err := normalizeCurrentOptionDetail(key, detail)
if err != nil {
return contractCacheFile{}, err
}
f.Options[key] = normalized
}
return f, nil
}
// normalizeCurrentOptionDetail makes the canonical cache key the source of
// truth for tuple fields omitted from the value. Malformed keys, zero ConIDs,
// and fields that contradict the key remain errors.
func normalizeCurrentOptionDetail(key string, detail ContractDetailsLite) (ContractDetailsLite, error) {
parts := strings.Split(key, "|")
if len(parts) != 5 {
return ContractDetailsLite{}, fmt.Errorf("invalid option contract cache row %q", key)
}
symbol := strings.ToUpper(strings.TrimSpace(parts[0]))
tradingClass := strings.ToUpper(strings.TrimSpace(parts[1]))
expiry := strings.TrimSpace(parts[2])
right := strings.ToUpper(strings.TrimSpace(parts[4]))
strike, err := strconv.ParseFloat(strings.TrimSpace(parts[3]), 64)
parsedExpiry, expiryErr := time.Parse("20060102", expiry)
if err != nil || expiryErr != nil || parsedExpiry.Format("20060102") != expiry || symbol == "" || tradingClass == "" || strike <= 0 || math.IsNaN(strike) || math.IsInf(strike, 0) || (right != "C" && right != "P") || detail.ConID <= 0 || optionContractKey(symbol, tradingClass, expiry, strike, right) != key {
return ContractDetailsLite{}, fmt.Errorf("invalid option contract cache row %q", key)
}
if detail.Symbol == "" {
detail.Symbol = symbol
} else if strings.ToUpper(strings.TrimSpace(detail.Symbol)) != symbol {
return ContractDetailsLite{}, fmt.Errorf("option contract cache row %q symbol contradicts key", key)
}
if detail.SecType == "" {
detail.SecType = "OPT"
} else if !strings.EqualFold(strings.TrimSpace(detail.SecType), "OPT") {
return ContractDetailsLite{}, fmt.Errorf("option contract cache row %q security type contradicts key", key)
}
if detail.TradingClass == "" {
detail.TradingClass = tradingClass
} else if tradingClass != "" && strings.ToUpper(strings.TrimSpace(detail.TradingClass)) != tradingClass {
return ContractDetailsLite{}, fmt.Errorf("option contract cache row %q trading class contradicts key", key)
}
if detail.Expiry == "" {
detail.Expiry = expiry
} else if strings.TrimSpace(detail.Expiry) != expiry {
return ContractDetailsLite{}, fmt.Errorf("option contract cache row %q expiry contradicts key", key)
}
if detail.Strike == 0 {
detail.Strike = strike
} else if detail.Strike != strike {
return ContractDetailsLite{}, fmt.Errorf("option contract cache row %q strike contradicts key", key)
}
if detail.Right == "" {
detail.Right = right
} else if strings.ToUpper(strings.TrimSpace(detail.Right)) != right {
return ContractDetailsLite{}, fmt.Errorf("option contract cache row %q right contradicts key", key)
}
return detail, nil
}
// nyDateString returns the New York trading-session date as YYYYMMDD.
// Falls back to UTC date if the zone fails to load — keeps the GC
func nyDateString(now time.Time) string {
if loc, err := time.LoadLocation("America/New_York"); err == nil {
return now.In(loc).Format("20060102")
}
return now.UTC().Format("20060102")
}
// Save publishes one filtered contract-cache snapshot. It copies its map
// inputs and does not mutate them. Contracts with a zero ConID and options
// expired before the current New York date are omitted. Every option key and
// value must form a valid, matching tuple; otherwise Save returns an error
// without publishing. membersHash may be empty when membership is not tracked.
//
// Calls on one store are serialized. An attached authority receives one
// encoded envelope; the legacy codec writes a temporary file and renames it
// over dir/contracts.json.
func (s *ContractStore) Save(contracts map[string]ContractDetailsLite, options map[string]ContractDetailsLite, membersHash string) error {
s.mu.Lock()
defer s.mu.Unlock()
return s.saveLocked(contracts, options, membersHash)
}
// SaveRetainingOptions replaces the underlying snapshot and merges option
// resolution hints into the last durable snapshot. A cleared or partially
// restored connection cannot erase nonexpired hints. Incoming tuples win;
// expired options are pruned. The load, merge and save are serialized together.
func (s *ContractStore) SaveRetainingOptions(contracts map[string]ContractDetailsLite, options map[string]ContractDetailsLite, membersHash string) error {
s.mu.Lock()
defer s.mu.Unlock()
previous, ok, err := s.loadEnvelopeLocked()
if err != nil {
return fmt.Errorf("load retained option contracts: %w", err)
}
merged := make(map[string]ContractDetailsLite, len(previous.Options)+len(options))
if ok && previous.Version == contractStoreVersion {
maps.Copy(merged, previous.Options)
}
maps.Copy(merged, options)
return s.saveLocked(contracts, merged, membersHash)
}
func (s *ContractStore) saveLocked(contracts map[string]ContractDetailsLite, options map[string]ContractDetailsLite, membersHash string) error {
filtered := make(map[string]ContractDetailsLite, len(contracts))
for sym, detail := range contracts {
if shouldPersistContract(detail) {
filtered[sym] = detail
}
}
// Validate and normalize every option before publication so a bad producer
// cannot poison durable state and defer the failure until the next restart.
filteredOptions := make(map[string]ContractDetailsLite, len(options))
today := nyDateString(time.Now())
for k, detail := range options {
normalized, err := normalizeCurrentOptionDetail(k, detail)
if err != nil {
return fmt.Errorf("refuse invalid option contract cache save: %w", err)
}
if normalized.Expiry < today {
continue
}
filteredOptions[k] = normalized
}
env := contractCacheFile{
Version: contractStoreVersion,
AsOf: time.Now().UTC(),
MembersHash: membersHash,
Contracts: filtered,
Options: filteredOptions,
}
if s.authority != nil {
payload, err := json.Marshal(env)
if err != nil {
return fmt.Errorf("encode contracts: %w", err)
}
return s.authority.SaveContractCache(payload, env.AsOf)
}
if err := os.MkdirAll(s.dir, 0o755); err != nil {
return fmt.Errorf("mkdir %s: %w", s.dir, err)
}
target := filepath.Join(s.dir, contractStoreFile)
tmp, err := os.CreateTemp(s.dir, contractStoreFile+".tmp.*")
if err != nil {
return fmt.Errorf("create temp: %w", err)
}
tmpPath := tmp.Name()
// On any error past this point, remove the orphaned temp file so
// we don't litter the cache dir.
defer func() {
if tmp != nil {
_ = tmp.Close()
}
_ = os.Remove(tmpPath)
}()
enc := json.NewEncoder(tmp)
enc.SetIndent("", " ")
if err := enc.Encode(env); err != nil {
return fmt.Errorf("encode contracts: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close temp: %w", err)
}
tmp = nil // signal defer to skip the second Close
if err := os.Rename(tmpPath, target); err != nil {
return fmt.Errorf("rename contracts: %w", err)
}
return nil
}
// shouldPersistContract reports whether a contract is resolved. The ordinary
// cache does not retain security type, so ConID is the only reliable filter.
func shouldPersistContract(d ContractDetailsLite) bool {
return d.ConID != 0
}
// MembersHash returns the first 16 lowercase hexadecimal characters of a
// SHA-256 hash of members. It trims and uppercases each element before sorting,
// so input order, case, and surrounding whitespace do not affect the result.
// Duplicate elements remain significant.
func MembersHash(members []string) string {
// Normalise BEFORE sorting so case/whitespace variants collapse to
// the same sort position. Sort-then-normalise would have left
// " BRK.b" sorting under leading-space, ahead of "AAPL", while the
// canonical "BRK.B" sorts after — different hash for the same set.
normalised := make([]string, len(members))
for i, m := range members {
normalised[i] = strings.ToUpper(strings.TrimSpace(m))
}
sort.Strings(normalised)
h := sha256.New()
for _, m := range normalised {
h.Write([]byte(m))
h.Write([]byte{0}) // delimiter to avoid "A"+"B" == "AB" collisions
}
return hex.EncodeToString(h.Sum(nil))[:16]
}
// DefaultContractStoreDir returns the root for the retired legacy JSON codec:
// $XDG_CACHE_HOME/ibkr when XDG_CACHE_HOME is set, otherwise
// $HOME/.cache/ibkr as resolved by [os.UserHomeDir]. It does not create the
// directory. Authority-backed stores do not use this path.
func DefaultContractStoreDir() (string, error) {
if v := os.Getenv("XDG_CACHE_HOME"); v != "" {
return filepath.Join(v, "ibkr"), nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("resolve user home: %w", err)
}
return filepath.Join(home, ".cache", "ibkr"), nil
}
// SnapshotContracts returns a caller-owned copy of the connector's resolved
func (c *Connector) SnapshotContracts() map[string]ContractDetailsLite {
c.contractMu.RLock()
defer c.contractMu.RUnlock()
out := make(map[string]ContractDetailsLite, len(c.contractCache))
for sym, detail := range c.contractCache {
if detail.ConID != 0 {
out[sym] = detail
}
}
return out
}
// SnapshotOptionContracts returns a caller-owned copy of the active
func (c *Connector) SnapshotOptionContracts() map[string]ContractDetailsLite {
c.mu.RLock()
conn := c.conn
c.mu.RUnlock()
if conn == nil {
return nil
}
conn.optionContractMu.RLock()
defer conn.optionContractMu.RUnlock()
out := make(map[string]ContractDetailsLite, len(conn.optionContractCache))
for key, detail := range conn.optionContractCache {
if detail.ConID != 0 {
out[key] = detail
}
}
return out
}
// IsOptionContractCached reports whether the active connection has a resolved
func (c *Connector) IsOptionContractCached(symbol, tradingClass, expiry string, strike float64, right string) bool {
c.mu.RLock()
conn := c.conn
c.mu.RUnlock()
if conn == nil {
return false
}
key := optionContractKey(symbol, tradingClass, expiry, strike, right)
conn.optionContractMu.RLock()
defer conn.optionContractMu.RUnlock()
entry, ok := conn.optionContractCache[key]
return ok && entry.ConID != 0
}
// SeedOptionContracts copies resolved entries into the active connection's
func (c *Connector) SeedOptionContracts(options map[string]ContractDetailsLite) int {
c.mu.RLock()
conn := c.conn
c.mu.RUnlock()
if conn == nil || len(options) == 0 {
return 0
}
conn.optionContractMu.Lock()
defer conn.optionContractMu.Unlock()
seeded := 0
for key, detail := range options {
if detail.ConID == 0 {
continue
}
if existing, ok := conn.optionContractCache[key]; ok && existing.ConID != 0 {
continue
}
conn.optionContractCache[key] = detail
seeded++
}
return seeded
}
package ibkr
import "context"
type definitionMissClassifiedKey struct{}
// WithDefinitionMissClassified marks the requests sent under ctx as ones whose
// caller records IBKR's "no security definition" answer itself. The wire echo
// of that answer is then INFO even for a request that carried a conID; without
// the mark a miss on a known conID stays a warning, because it is a stale
// identity on a position or chart. The lending-market worker needs it: it
// re-reads delisted names that IBKR's short-stock file still lists and parks
// each on that answer, so the first sighting of every name was a WARN (87 lines
// on 2026-10-06).
func WithDefinitionMissClassified(ctx context.Context) context.Context {
return context.WithValue(ctx, definitionMissClassifiedKey{}, true)
}
// DefinitionMissClassified reports whether ctx carries that mark.
func DefinitionMissClassified(ctx context.Context) bool {
if ctx == nil {
return false
}
marked, _ := ctx.Value(definitionMissClassifiedKey{}).(bool)
return marked
}
package ibkr
import (
"maps"
"sync"
"time"
)
// displayChanges broadcasts cache invalidation without blocking the wire reader.
type displayChanges struct {
mu sync.Mutex
changed chan struct{}
}
func (d *displayChanges) watch() <-chan struct{} {
d.mu.Lock()
defer d.mu.Unlock()
if d.changed == nil {
d.changed = make(chan struct{})
}
return d.changed
}
func (d *displayChanges) notify() {
d.mu.Lock()
defer d.mu.Unlock()
if d.changed != nil {
close(d.changed)
}
d.changed = make(chan struct{})
}
// DisplayChanges returns a broadcast invalidation channel. Capture it before
// DisplaySnapshot to avoid missing a change between reading and waiting. Closing
// the channel means reread, not that every intermediate tick has been retained.
func (c *Connector) DisplayChanges() <-chan struct{} { return c.display.watch() }
// DisplaySnapshot is a detached, cache-only display projection. Session and
// portfolio health gate use of its contents. Receive clocks do not prove exchange
// freshness. It performs no broker requests and grants no execution authority.
type DisplaySnapshot struct {
Session ConnectorSessionBinding
Account *RawAccountSummary
NetLiquidationAt time.Time
Positions []*RawPosition
Health PortfolioStreamHealth
Quotes map[string]*MarketData
DataTypes map[string]int
AccountPnL AccountDailyPnL
PnLAccount string
PositionPnL map[int]PositionDailyPnL
}
// CaptureDisplay snapshots caches within one inbound generation. False means
// the socket session is unavailable. Callers must also check portfolio scope,
// backend readiness, individual field clocks and current session before use.
func (c *Connector) CaptureDisplay() (DisplaySnapshot, bool) {
binding, ok := c.CaptureSession()
if !ok {
return DisplaySnapshot{}, false
}
out, ok := c.captureDisplayAccount(binding)
if !ok {
return DisplaySnapshot{}, false
}
out.Quotes = c.MarketDataSnapshot()
for key := range out.Quotes {
out.DataTypes[key] = out.Quotes[key].FeedType
}
if !c.SessionCurrent(binding) {
return DisplaySnapshot{}, false
}
return out, true
}
// The account/portfolio boundary must be released before taking subMu: some
// subscription paths hold subMu while waiting for a broker response.
func (c *Connector) captureDisplayAccount(binding ConnectorSessionBinding) (DisplaySnapshot, bool) {
c.publicationBarrier.RLock()
defer c.publicationBarrier.RUnlock()
binding.connection.inboundEpochMu.Lock()
defer binding.connection.inboundEpochMu.Unlock()
c.evidenceBarrier.Lock()
defer c.evidenceBarrier.Unlock()
if !c.SessionCurrent(binding) {
return DisplaySnapshot{}, false
}
conn := binding.connection
out := DisplaySnapshot{Session: binding, DataTypes: map[string]int{}}
positions, health := conn.GetPositionsWithPortfolioHealth()
out.Health = health
for _, p := range positions {
copy := *p
copy.Contract.ComboLegs = append([]ComboLeg(nil), p.Contract.ComboLegs...)
out.Positions = append(out.Positions, ©)
}
conn.accountMu.RLock()
if conn.displayAccount == out.Health.Account && accountCodeConcrete(conn.displayAccount) {
out.Account = parseAccountSummary(maps.Clone(conn.displayAccountValues), conn.displayAccount)
}
conn.accountMu.RUnlock()
if out.Account != nil {
// Legacy summary AsOf is a read time. Display never exposes that clock.
out.Account.AsOf = time.Time{}
_, currency, found := lookupAccountValue(out.Account.Raw, "NetLiquidation", out.Account.BaseCurrency)
if found {
key := "NetLiquidation"
if currency != "" && currency != "BASE" {
key += "_" + currency
}
conn.accountMu.RLock()
out.NetLiquidationAt = conn.accountValueTimes[key]
conn.accountMu.RUnlock()
}
}
c.pnl.mu.RLock()
out.AccountPnL = c.pnl.account
out.PnLAccount = c.pnl.accountAcct
out.PositionPnL = maps.Clone(c.pnl.positionSnapshot)
c.pnl.mu.RUnlock()
return out, true
}
func stampDisplayPrice(sub *Subscription, tick int, at time.Time) {
switch tick {
case 1, 66:
sub.BidAt = at
case 2, 67:
sub.AskAt = at
case 4, 68:
sub.LastAt = at
case 37:
sub.MarkAt = at
case 9, 75:
sub.CloseAt = at
}
}
package ibkr
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"sync"
"time"
)
// defaultExecutionsTimeout bounds one reqExecutions round trip. TWS answers
// a day's executions in well under a second; the generous bound covers the
// background pacing lane queueing behind interactive reads.
const defaultExecutionsTimeout = 15 * time.Second
// ExecutionsSnapshot is one answer to reqExecutions: the current day's
// executions TWS reports to this API client for the filtered account, as
// execDetails callbacks parsed into OrderLifecycleEvent (Type execDetails).
//
// Complete is true only when the broker's execDetailsEnd for this exact
// request ID arrived; a false Complete with rows means the deadline ended the
// collection and proves nothing about rows not seen. Which clients' executions
// appear is decided by TWS, not by this request: a non-master API client is
// documented to receive its own orders' executions, and the master client ID
// (TWS Global Configuration → API → Settings) receives every client's,
// including orders entered in TWS by hand. AsOf is the local UTC completion
// time, never a broker timestamp.
type ExecutionsSnapshot struct {
Complete bool
Executions []OrderLifecycleEvent
AsOf time.Time
RequestID int
}
// executionsFlight collects the execDetails that answer one request ID until
// its end marker or the caller's deadline.
type executionsFlight struct {
mu sync.Mutex
rows []OrderLifecycleEvent
end chan struct{}
done bool
}
func (f *executionsFlight) add(ev OrderLifecycleEvent) {
f.mu.Lock()
defer f.mu.Unlock()
if f.done {
return
}
f.rows = append(f.rows, ev)
}
func (f *executionsFlight) finish() {
f.mu.Lock()
defer f.mu.Unlock()
if f.done {
return
}
f.done = true
close(f.end)
}
func (f *executionsFlight) snapshot() []OrderLifecycleEvent {
f.mu.Lock()
defer f.mu.Unlock()
f.done = true
return append([]OrderLifecycleEvent(nil), f.rows...)
}
// ExecutionsFilter narrows reqExecutions beyond the account. LastNDays is
// the filter's lastNDays field, sent from server version 200 (TWS API 10.30)
// and ignored by older servers; zero leaves it unset, which the broker reads
// as the current day. What a given server returns for a set value is the
// server's decision; the snapshot's dates say what came back.
type ExecutionsFilter struct {
LastNDays int
}
// FetchExecutions sends reqExecutions with an account-only filter and waits
// for the same-request execDetailsEnd. ctx carries the pacing priority; a
// timeout of zero or less uses defaultExecutionsTimeout. The answer rows are
// routed to this flight by request ID and are never dispatched to the order
// lifecycle handlers, so a requested inventory cannot be mistaken for a live
// fill. A late answer after the deadline is dropped. This is a read request:
// nothing here places, changes or cancels an order.
func (c *Connector) FetchExecutions(ctx context.Context, account string, timeout time.Duration) (ExecutionsSnapshot, error) {
return c.FetchExecutionsFiltered(ctx, account, ExecutionsFilter{}, timeout)
}
// FetchExecutionsFiltered is FetchExecutions with an explicit filter.
func (c *Connector) FetchExecutionsFiltered(ctx context.Context, account string, filter ExecutionsFilter, timeout time.Duration) (ExecutionsSnapshot, error) {
now := time.Now().UTC()
if c == nil {
return ExecutionsSnapshot{AsOf: now}, fmt.Errorf("connector is nil")
}
if ctx == nil {
return ExecutionsSnapshot{AsOf: now}, fmt.Errorf("context is nil")
}
if !c.IsReady() {
return ExecutionsSnapshot{AsOf: now}, ErrIBKRUnavailable
}
if timeout <= 0 {
timeout = defaultExecutionsTimeout
}
c.mu.RLock()
conn := c.conn
c.mu.RUnlock()
if conn == nil {
return ExecutionsSnapshot{AsOf: now}, ErrIBKRUnavailable
}
reqID, err := conn.nextRequestID()
if err != nil {
return ExecutionsSnapshot{AsOf: now}, fmt.Errorf("request executions: %w", err)
}
flight := &executionsFlight{end: make(chan struct{})}
c.executionsMu.Lock()
if c.executions == nil {
c.executions = make(map[int]*executionsFlight)
}
c.executions[reqID] = flight
c.executionsMu.Unlock()
endHandlerID := conn.RegisterHandler(msgExecDetailsEnd, func(fields []string) {
if execDetailsEndRequestID(fields) == reqID {
flight.finish()
}
})
cleanupExpiring := func(expired bool) {
conn.UnregisterHandler(msgExecDetailsEnd, endHandlerID)
now := time.Now()
c.executionsMu.Lock()
delete(c.executions, reqID)
if expired {
c.expireExecutionsRequestLocked(reqID, now)
}
c.executionsMu.Unlock()
}
cleanup := func() { cleanupExpiring(false) }
// REQ_EXECUTIONS version 3: request ID, then the ExecutionFilter in wire
// order (clientId, acctCode, time, symbol, secType, exchange, side). A zero
// client ID and empty fields leave those dimensions unfiltered; only the
// account is pinned. The broker still decides which clients' executions
// this API client may see.
fields := []any{reqExecutions, 3, reqID, 0, strings.TrimSpace(account), "", "", "", "", ""}
if conn.serverVersion >= minServerVerParametrizedDaysOfExecutions {
// From server version 200 the filter ends with lastNDays (unset: an
// empty field, as the broker's own clients send it) and the count of
// specificDates (none). The gateway reads these two fields whatever
// the frame's length: a frame that stopped at side made it read the
// next request's bytes as them, so once a minute on the poll it
// answered error 320 ("Unable to parse field: 'Client Req Id' for
// input string: 'MART'") and the request after the poll was lost
// (2026-10-18, every build since the poll shipped).
var lastNDays any = ""
if filter.LastNDays > 0 {
lastNDays = filter.LastNDays
}
fields = append(fields, lastNDays, 0)
}
msg := conn.encodeMsg(fields...)
if err := conn.sendMessageWithTypeContext(ctx, msg, RequestTypeGeneral); err != nil {
cleanup()
return ExecutionsSnapshot{AsOf: time.Now().UTC(), RequestID: reqID}, fmt.Errorf("request executions: %w", err)
}
deadline := time.NewTimer(timeout)
defer deadline.Stop()
complete := false
var waitErr error
select {
case <-flight.end:
complete = true
case <-deadline.C:
case <-ctx.Done():
waitErr = ctx.Err()
}
// A deadline, not a caller's cancellation, leaves an end the broker may
// still send: remember it so a late one is named.
expired := !complete && (waitErr == nil || errors.Is(waitErr, context.DeadlineExceeded))
cleanupExpiring(expired)
return ExecutionsSnapshot{Complete: complete, Executions: flight.snapshot(), AsOf: time.Now().UTC(), RequestID: reqID}, waitErr
}
// executionsExpiredKeep bounds how long an expired request ID is remembered:
// a stalled Gateway has answered within minutes, and an end later than that
// is reported as unknown.
const executionsExpiredKeep = 10 * time.Minute
// expireExecutionsRequestLocked remembers a request whose flight reached its
// deadline without its execDetailsEnd and forgets those older than
// executionsExpiredKeep. The caller holds executionsMu.
func (c *Connector) expireExecutionsRequestLocked(reqID int, at time.Time) {
if c.executionsExpired == nil {
c.executionsExpired = make(map[int]time.Time)
}
for id, ended := range c.executionsExpired {
if at.Sub(ended) > executionsExpiredKeep {
delete(c.executionsExpired, id)
}
}
c.executionsExpired[reqID] = at
}
// noteLateExecDetailsEnd reports an execDetailsEnd that arrived after its
// flight's deadline. The rows were dropped and the next poll reads the day's
// executions again, so the line is evidence of a slow answer, not a lost
// fill. It warns while the logging schedule wants attention and is INFO
// otherwise; an end later than executionsExpiredKeep, or one no request
// waited for, stays in the debug log. On 2026-10-20 such an end, during
// IBKR's nightly reset, logged "Unhandled message ID 55" at WARN as if the
// message were unknown.
func (c *Connector) noteLateExecDetailsEnd(fields []string, now time.Time) {
reqID := execDetailsEndRequestID(fields)
c.executionsMu.Lock()
_, live := c.executions[reqID]
ended, late := c.executionsExpired[reqID]
if late {
delete(c.executionsExpired, reqID)
late = now.Sub(ended) <= executionsExpiredKeep
}
c.executionsMu.Unlock()
switch {
case live:
case late:
log := connectorLogger.Infof
if c.logRequiredAt(now) {
log = connectorLogger.Warnf
}
log("%s: IBKR answered executions request %d %s after its deadline; its rows were dropped and the next poll reads the day again", c.name, reqID, now.Sub(ended).Round(time.Millisecond))
default:
connectorLogger.Debugf("%s: execDetailsEnd for request %d, which no request is waiting for", c.name, reqID)
}
}
// deliverRequestedExecution hands an execDetails that answers reqExecutions
// to the flight that asked. A row for an unknown request ID is a late answer
// to a flight that already ended and is dropped.
func (c *Connector) deliverRequestedExecution(ev OrderLifecycleEvent) {
c.executionsMu.Lock()
flight := c.executions[ev.RequestID]
c.executionsMu.Unlock()
if flight == nil {
connectorLogger.Debugf("executions: dropping late execDetails for request %d (exec %s)", ev.RequestID, ev.ExecID)
return
}
flight.add(ev)
}
// execDetailsEndRequestID reads the request ID from an execDetailsEnd frame in
// either encoding: legacy [55, version, reqId] or the protobuf summary with a
// reqId= field. Zero means absent or unparsable.
func execDetailsEndRequestID(fields []string) int {
if len(fields) > 1 && fields[1] == "protobuf" {
return orderEventInt(summaryFieldValue(fields, "reqId="))
}
if len(fields) < 3 {
return 0
}
v, err := strconv.Atoi(strings.TrimSpace(fields[2]))
if err != nil {
return 0
}
return v
}
package ibkr
import (
"context"
"fmt"
"time"
)
const (
frontFutureCacheLimit = 64
frontFutureCacheAge = 6 * time.Hour
frontFutureRetry = 15 * time.Second
)
type frontFutureResolution struct {
binding ConnectorSessionBinding
done chan struct{}
contract Contract
err error
expires time.Time
}
// The connector owns these identity-only flights. A canceled/short caller
// leaves at most the existing bounded contract-details wire request running;
// its terminal success can serve the next snapshot without another broker trip.
func (c *Connector) frontFuture(ctx context.Context, contract Contract, now time.Time, wait time.Duration, fetch func(Contract, time.Duration) ([]ContractDetailsLite, error)) (Contract, error) {
return c.frontFutureAfter(ctx, contract, now, now, wait, fetch)
}
// FrontFutureAfter selects the nearest expiry strictly after the cutoff date.
// The observation time remains separate so a roll window cannot extend cache
// freshness or replace an ordinary front-contract lookup.
func (c *Connector) FrontFutureAfter(ctx context.Context, contract Contract, now, cutoff time.Time) (Contract, error) {
return c.frontFutureAfter(ctx, contract, now, cutoff, 3*time.Second, nil)
}
func (c *Connector) frontFutureAfter(ctx context.Context, contract Contract, now, cutoff time.Time, wait time.Duration, fetch func(Contract, time.Duration) ([]ContractDetailsLite, error)) (Contract, error) {
if err := ctx.Err(); err != nil {
return Contract{}, err
}
binding, ok := c.CaptureSession()
if !ok {
return Contract{}, fmt.Errorf("broker session unavailable")
}
contract = normalizeMarketDataContract(contract)
key := contractDetailsFlightKey(contract) + "\x00" + cutoff.UTC().Format("20060102")
c.frontFutureMu.Lock()
if c.frontFutures == nil {
c.frontFutures = make(map[string]*frontFutureResolution)
}
for k, entry := range c.frontFutures {
if entry.binding != binding || !entry.expires.IsZero() && !now.Before(entry.expires) {
delete(c.frontFutures, k)
}
}
entry := c.frontFutures[key]
if entry == nil {
if len(c.frontFutures) >= frontFutureCacheLimit || c.frontFutureActive >= frontFutureCacheLimit {
c.frontFutureMu.Unlock()
return Contract{}, fmt.Errorf("front future resolution capacity reached")
}
entry = &frontFutureResolution{binding: binding, done: make(chan struct{})}
c.frontFutures[key] = entry
c.frontFutureActive++
if fetch == nil {
fetch = func(contract Contract, timeout time.Duration) ([]ContractDetailsLite, error) {
return c.fetchFrontFutureDetails(binding, contract, timeout, func(budget time.Duration, observe func([]ContractDetailsLite)) ([]ContractDetailsLite, error) {
return c.fetchContractDetailsForContractWire(contract, MarketDataKeyForContract(contract), budget, observe)
})
}
}
go c.acquireFrontFuture(entry, contract, now, cutoff, fetch)
}
c.frontFutureMu.Unlock()
timer := time.NewTimer(wait)
defer timer.Stop()
select {
case <-ctx.Done():
return Contract{}, ctx.Err()
case <-timer.C:
return Contract{}, ErrContractDetailsTimeout
case <-entry.done:
}
if err := ctx.Err(); err != nil {
return Contract{}, err
}
if !c.SessionCurrent(binding) {
return Contract{}, fmt.Errorf("broker session changed")
}
return entry.contract, entry.err
}
func (c *Connector) fetchFrontFutureDetails(binding ConnectorSessionBinding, contract Contract, timeout time.Duration, wire func(time.Duration, func([]ContractDetailsLite)) ([]ContractDetailsLite, error)) ([]ContractDetailsLite, error) {
// Generic contract flights are not session keyed. Never join a request
// started before reconnect while establishing this session's identity.
key := fmt.Sprintf("front_future\x00%p\x00%d\x00%s", binding.connection, binding.epoch, contractDetailsFlightKey(contract))
return c.coalesceContractDetails(key, timeout, func(budget time.Duration, observe func([]ContractDetailsLite)) ([]ContractDetailsLite, error) {
if !c.SessionCurrent(binding) {
return nil, fmt.Errorf("broker session changed")
}
return wire(budget, observe)
})
}
func (c *Connector) acquireFrontFuture(entry *frontFutureResolution, contract Contract, now, cutoff time.Time, fetch func(Contract, time.Duration) ([]ContractDetailsLite, error)) {
started := time.Now()
details, err := fetch(contract, contractDetailsSharedWireMinimum)
elapsed := time.Since(started)
completed := now.Add(elapsed)
selectionTime := cutoff.Add(elapsed)
var resolved Contract
if err == nil {
resolved, err = selectFrontFuture(contract, details, selectionTime)
}
if !c.SessionCurrent(entry.binding) {
resolved, err = Contract{}, fmt.Errorf("broker session changed")
}
c.frontFutureMu.Lock()
defer c.frontFutureMu.Unlock()
c.frontFutureActive--
entry.contract, entry.err = resolved, err
entry.expires = completed.Add(frontFutureRetry)
if err == nil {
// Re-resolve on the next UTC date even if the six-hour lifetime spans
// midnight. The selection excludes contracts on their expiry date.
midnight := completed.Add(selectionTime.UTC().Truncate(24 * time.Hour).Add(24 * time.Hour).Sub(selectionTime))
entry.expires = completed.Add(frontFutureCacheAge)
if midnight.Before(entry.expires) {
entry.expires = midnight
}
}
close(entry.done)
}
package ibkr
import (
"errors"
"fmt"
"sync"
"time"
)
// ErrHistoricalServiceStalled reports a history request refused before the
// wire because the Gateway has stopped answering history on this session.
var ErrHistoricalServiceStalled = errors.New("the Gateway is not answering historical data requests")
// Stall thresholds, set by the owner on 2026-09-28.
const (
// historicalStallRequests unanswered requests spanning at least
// historicalStallSpan from the first one's send declare a stall.
historicalStallRequests = 3
historicalStallSpan = time.Minute
// historicalStallMinWait is the shortest wait that counts: a tighter
// caller budget proves nothing about the Gateway.
historicalStallMinWait = 10 * time.Second
// historicalStallProbeEvery admits one request while stalled.
historicalStallProbeEvery = 5 * time.Minute
)
// historicalStall tracks whether the Gateway still answers history. On
// 2026-09-28 the Mini's Gateway accepted every history request from 06:31
// and answered none; each cancel drew "query cancelled", so it held the
// queries. Every chart series kept sending its own doomed request, about 130
// an hour, and nothing said the Gateway needed a restart. Earlier episodes
// on this Mac's TWS ended only when TWS itself restarted.
type historicalStall struct {
mu sync.Mutex
session HistoricalSessionBinding
unanswered int
firstSent time.Time
since time.Time // zero unless stalled
probeAt time.Time
}
// resetForSessionLocked starts over on a new broker session: a reconnected
// Gateway earns fresh requests, and a stall still there is declared anew.
func (s *historicalStall) resetForSessionLocked(session HistoricalSessionBinding) {
if s.session == session {
return
}
s.session, s.unanswered, s.firstSent, s.since, s.probeAt = session, 0, time.Time{}, time.Time{}, time.Time{}
}
// A queued result can outlive teardown of the request that produced it.
// Compare the exact connection as well as its socket epoch before publishing
// that result into the current session's stall/admission state. Callers hold
// historicalStall.mu, so an old completion cannot replace a newer verdict.
func (c *Connector) historicalRequestSessionCurrent(session HistoricalSessionBinding) bool {
c.mu.RLock()
defer c.mu.RUnlock()
return session.connector == c && session.connection != nil && c.conn == session.connection && session.connection.BrokerSessionEpoch() == session.epoch
}
// admitHistoricalRequest refuses history while stalled, except for one probe
// per historicalStallProbeEvery.
func (c *Connector) admitHistoricalRequest(session HistoricalSessionBinding, now time.Time) error {
s := &c.historicalStall
s.mu.Lock()
defer s.mu.Unlock()
if !c.historicalRequestSessionCurrent(session) {
return errHistorySessionEnded
}
s.resetForSessionLocked(session)
if s.since.IsZero() {
return nil
}
if now.Sub(s.probeAt) >= historicalStallProbeEvery {
s.probeAt = now
return nil
}
return fmt.Errorf("%w since %s; next probe after %s", ErrHistoricalServiceStalled,
s.since.Format(time.TimeOnly), s.probeAt.Add(historicalStallProbeEvery).Format(time.TimeOnly))
}
// noteHistoricalAnswer records any broker answer to a history request: bars,
// an end marker, or a broker error such as a pacing refusal.
func (c *Connector) noteHistoricalAnswer(session HistoricalSessionBinding, now time.Time) {
s := &c.historicalStall
s.mu.Lock()
if !c.historicalRequestSessionCurrent(session) {
s.mu.Unlock()
return
}
s.resetForSessionLocked(session)
since := s.since
s.unanswered, s.firstSent, s.since, s.probeAt = 0, time.Time{}, time.Time{}, time.Time{}
s.mu.Unlock()
c.answers.noteAnswer(now)
if !since.IsZero() {
c.logWarn("IBKR Gateway answers historical data requests again after %s", now.Sub(since).Round(time.Second))
}
}
// noteHistoricalTimeout records a sent history request that waited out its
// budget without any answer.
func (c *Connector) noteHistoricalTimeout(session HistoricalSessionBinding, waited time.Duration, now time.Time) {
s := &c.historicalStall
s.mu.Lock()
if !c.historicalRequestSessionCurrent(session) {
s.mu.Unlock()
return
}
if waited < historicalStallMinWait {
s.mu.Unlock()
c.answers.noteTimeout(now)
return
}
s.resetForSessionLocked(session)
if s.unanswered == 0 {
s.firstSent = now.Add(-waited)
}
s.unanswered++
declare := s.since.IsZero() && s.unanswered >= historicalStallRequests && now.Sub(s.firstSent) >= historicalStallSpan
if declare {
s.since, s.probeAt = now, now
}
count, span := s.unanswered, now.Sub(s.firstSent)
s.mu.Unlock()
c.answers.noteTimeout(now)
if declare {
c.logWarn("IBKR Gateway has answered none of the last %d historical data requests over %s; holding further history requests except one probe every %s until it answers, and serving recorded history meanwhile (restarting the Gateway has cleared this before)",
count, span.Round(time.Second), historicalStallProbeEvery)
}
}
// HistoricalServiceStalled reports whether the Gateway has stopped answering
// history on the current broker session, and since when.
func (c *Connector) HistoricalServiceStalled() (time.Time, bool) {
if c == nil {
return time.Time{}, false
}
s := &c.historicalStall
s.mu.Lock()
defer s.mu.Unlock()
if !c.historicalRequestSessionCurrent(s.session) || s.since.IsZero() {
return time.Time{}, false
}
return s.since, true
}
// historicalAnswered reports whether a history result came from the broker:
// bars or an end marker (nil), a coded broker error, or a payload that failed
// validation. Connector-authored failures, such as a changed session, do not.
func historicalAnswered(err error) bool {
if err == nil {
return true
}
if hErr, ok := errors.AsType[*HistoricalRequestError](err); ok {
return hErr.Code != 0
}
_, ok := errors.AsType[*HistoricalDataValidationError](err)
return ok
}
// Package logging is a tiny slog-backed shim that preserves the call-site API
// the ibkr package was originally written against (Component(name).Debugf/Infof/...).
//
// The package writes through the standard library's log/slog so consumers can
// install any slog handler (text, JSON, custom) before the ibkr package starts
// emitting events. The default sink is a discard handler — pkg/ibkr stays
// silent until callers wire a handler via SetLogger(...). This is deliberate:
// daemonised consumers route stderr to /dev/null and a stderr-default would
// wedge the runtime on first emit (see cmd/canary/main.go::spawnDaemon).
// Pass nil to SetLogger to silence again.
package logging
import (
"context"
"fmt"
"io"
"log/slog"
"strings"
"sync/atomic"
)
// Level identifies the minimum severity accepted by the logging shim.
type Level int
const (
// LevelDebug enables debug and higher-severity messages.
LevelDebug Level = iota
// LevelInfo enables informational and higher-severity messages.
LevelInfo
// LevelWarn enables warning and error messages.
LevelWarn
// LevelError enables only error messages.
LevelError
)
var (
currentLevel atomic.Int32
logger atomic.Pointer[slog.Logger]
)
func init() {
currentLevel.Store(int32(LevelInfo))
logger.Store(discardLogger())
}
// Configure sets the active level filter ("debug"|"info"|"warn"|"error"); unknown
// values default to info. Does not touch the installed logger — callers that
// previously wired a sink via SetLogger keep it.
func Configure(level string) {
currentLevel.Store(int32(parseLevel(level)))
}
// SetLogger installs a custom slog.Logger. Pass nil to discard all output.
func SetLogger(l *slog.Logger) {
if l == nil {
l = discardLogger()
}
logger.Store(l)
}
func discardLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
// LevelEnabled reports whether the given level would produce output.
func LevelEnabled(l Level) bool {
return int32(l) >= currentLevel.Load()
}
func parseLevel(s string) Level {
switch strings.ToLower(strings.TrimSpace(s)) {
case "debug":
return LevelDebug
case "warn", "warning":
return LevelWarn
case "error":
return LevelError
default:
return LevelInfo
}
}
func emit(l Level, prefix, format string, args ...any) {
if !LevelEnabled(l) {
return
}
msg := fmt.Sprintf(format, args...)
lg := logger.Load()
if lg == nil {
return
}
attrs := []any{}
if prefix != "" {
attrs = append(attrs, slog.String("component", prefix))
}
switch l {
case LevelDebug:
lg.LogAttrs(context.Background(), slog.LevelDebug, msg, asAttrs(attrs)...)
case LevelInfo:
lg.LogAttrs(context.Background(), slog.LevelInfo, msg, asAttrs(attrs)...)
case LevelWarn:
lg.LogAttrs(context.Background(), slog.LevelWarn, msg, asAttrs(attrs)...)
case LevelError:
lg.LogAttrs(context.Background(), slog.LevelError, msg, asAttrs(attrs)...)
}
}
func asAttrs(in []any) []slog.Attr {
out := make([]slog.Attr, 0, len(in))
for _, v := range in {
if a, ok := v.(slog.Attr); ok {
out = append(out, a)
}
}
return out
}
// Entry tags log lines with a component name.
type Entry struct{ prefix string }
// Component returns a logger entry tagged with the component name.
func Component(name string) Entry { return Entry{prefix: name} }
// Debugf emits a formatted debug message when debug logging is enabled.
func (e Entry) Debugf(format string, args ...any) { emit(LevelDebug, e.prefix, format, args...) }
// Infof emits a formatted informational message when info logging is enabled.
func (e Entry) Infof(format string, args ...any) { emit(LevelInfo, e.prefix, format, args...) }
// Warnf emits a formatted warning message when warning logging is enabled.
func (e Entry) Warnf(format string, args ...any) { emit(LevelWarn, e.prefix, format, args...) }
// Errorf emits a formatted error message.
func (e Entry) Errorf(format string, args ...any) { emit(LevelError, e.prefix, format, args...) }
package ibkr
import (
"context"
"errors"
"fmt"
"slices"
"strings"
"time"
)
// Listings: every listing IBKR knows for one security, asked by ISIN, by
// contract id, or by symbol on named venues, for a read-only resolver
// (Canary's `canary core resolve`). Unlike the order and chart resolution
// it never picks one line: it asks every form, keeps every line that does
// not contradict the ISIN asked, and returns them all with the trace of
// each form's answer, so a caller shows the owner every candidate and the
// owner writes the contract id.
// ListingVenue is one exchange (and optional primary exchange) a symbol
// fallback is asked on.
type ListingVenue struct {
Exchange string
PrimaryExch string
}
// ListingRequest names a security: an ISIN, or a contract id, and symbol
// fallbacks asked on each venue. Currency, when set, is sent with every
// form. SecType defaults to STK (IBKR lists ETFs as STK).
type ListingRequest struct {
ISIN string
ConID int
Symbols []string
Venues []ListingVenue
Currency string
SecType string
}
// Listing is one contract IBKR answered: its identity, its venue, its long
// name and the ISIN its frame named (empty when the frame named none), and
// the request forms that found it.
type Listing struct {
ConID int
Symbol string
LocalSymbol string
SecType string
Exchange string
PrimaryExch string
Currency string
TradingClass string
LongName string
ISIN string
ValidExchanges string
MinTick float64
Forms []string
}
// FoundByISIN reports whether one of the request's by-ISIN forms answered
// the listing: IBKR listed it under the ISIN asked, whether or not its
// frame names an ISIN. A line found only by contract id or by symbol is
// not evidence of the ISIN.
func (l Listing) FoundByISIN() bool {
return slices.ContainsFunc(l.Forms, func(f string) bool { return strings.Contains(f, listingISINForm) })
}
// listingISINForm marks the by-ISIN forms' labels.
const listingISINForm = " by ISIN"
// ErrNoListing means no request form found a line for the request.
var ErrNoListing = errors.New("no listing for the request")
// forms are the reqContractDetails contracts for the request, in order: by
// contract id on each venue (or on its own when none is named); by ISIN on
// SMART and with no exchange; then each symbol on each venue.
func (r ListingRequest) forms() ([]bondWireForm, error) {
secType := strings.ToUpper(strings.TrimSpace(r.SecType))
if secType == "" {
secType = "STK"
}
ccy := strings.ToUpper(strings.TrimSpace(r.Currency))
if ccy != "" && !bondCurrencyCode(ccy) {
return nil, fmt.Errorf("listing request currency %q is not a three-letter code", r.Currency)
}
isin := strings.ToUpper(strings.TrimSpace(r.ISIN))
if isin != "" && !ValidISIN(isin) {
return nil, fmt.Errorf("listing request ISIN %q fails its check digit", r.ISIN)
}
venues := make([]ListingVenue, 0, len(r.Venues))
for _, v := range r.Venues {
venues = append(venues, ListingVenue{Exchange: strings.ToUpper(strings.TrimSpace(v.Exchange)), PrimaryExch: strings.ToUpper(strings.TrimSpace(v.PrimaryExch))})
}
venueLabel := func(v ListingVenue) string {
label := nonEmptyVenue(v.Exchange)
if v.PrimaryExch != "" {
label += " (primary " + v.PrimaryExch + ")"
}
return label
}
var forms []bondWireForm
if r.ConID > 0 {
if len(venues) == 0 {
venues = []ListingVenue{{}}
}
for _, v := range venues {
forms = append(forms, bondWireForm{label: fmt.Sprintf("%s contract id %d on %s", secType, r.ConID, venueLabel(v)),
contract: Contract{ConID: r.ConID, SecType: secType, Exchange: v.Exchange, PrimaryExch: v.PrimaryExch, Currency: ccy}})
}
}
if isin != "" {
forms = append(forms,
bondWireForm{label: secType + listingISINForm + " on SMART", contract: Contract{SecType: secType, Exchange: "SMART", Currency: ccy, SecIDType: BondIdentifierISIN, SecID: isin}},
bondWireForm{label: secType + listingISINForm + ", no exchange", contract: Contract{SecType: secType, Currency: ccy, SecIDType: BondIdentifierISIN, SecID: isin}})
}
for _, raw := range r.Symbols {
sym := strings.ToUpper(strings.TrimSpace(raw))
if sym == "" {
continue
}
for _, v := range venues {
forms = append(forms, bondWireForm{label: fmt.Sprintf("%s %s on %s", secType, sym, venueLabel(v)),
contract: Contract{Symbol: sym, SecType: secType, Exchange: v.Exchange, PrimaryExch: v.PrimaryExch, Currency: ccy}})
}
}
if len(forms) == 0 {
return nil, fmt.Errorf("listing request needs an ISIN, a contract id or a symbol on a venue")
}
return forms, nil
}
func nonEmptyVenue(exchange string) string {
if exchange == "" {
return "no exchange"
}
return exchange
}
// Listings asks the connected gateway every form of the request, one
// reqContractDetails each, and returns every listing found, by contract id
// in the order first seen. A line whose frame names another ISIN than the
// one asked is dropped (a symbol is free text and can name another
// security); a line naming none is kept with an empty ISIN. A form the
// gateway answers without a line, or rejects, is recorded and the next
// asked; a timeout or a changed session ends the lookup. No listing at all
// is ErrNoListing, with every answer in the trace.
func (c *Connector) Listings(ctx context.Context, request ListingRequest, timeout time.Duration) ([]Listing, *BondLookupTrace, error) {
forms, err := request.forms()
if err != nil {
return nil, nil, err
}
binding, ok := c.CaptureSession()
if !ok {
return nil, nil, ErrIBKRUnavailable
}
if timeout <= 0 {
timeout = 5 * time.Second
}
isin := strings.ToUpper(strings.TrimSpace(request.ISIN))
trace := &BondLookupTrace{Request: listingDescription(request), ServerVersion: binding.connection.serverVersion}
var out []Listing
index := map[int]int{}
for _, form := range forms {
lines, attempt, err := c.bondContractDetailsOnce(ctx, binding, form, timeout)
if err != nil {
attempt, miss := bondLookupMiss(attempt, err)
trace.Attempts = append(trace.Attempts, attempt)
if !miss {
return out, trace, err
}
continue
}
kept := 0
for _, d := range stampAsked(lines, form.contract) {
named := d.ISIN()
if isin != "" && named != "" && named != isin {
continue
}
kept++
if i, seen := index[d.ConID]; seen {
out[i].Forms = append(out[i].Forms, form.label)
if out[i].ISIN == "" {
out[i].ISIN = named
}
if out[i].LongName == "" {
out[i].LongName = d.LongName
}
if out[i].PrimaryExch == "" {
out[i].PrimaryExch = d.PrimaryExch
}
continue
}
index[d.ConID] = len(out)
out = append(out, Listing{ConID: d.ConID, Symbol: d.Symbol, LocalSymbol: d.LocalSymbol, SecType: d.SecType, Exchange: d.Exchange, PrimaryExch: d.PrimaryExch,
Currency: d.Currency, TradingClass: d.TradingClass, LongName: d.LongName, ISIN: named, ValidExchanges: d.ValidExchanges, MinTick: d.MinTick, Forms: []string{form.label}})
}
attempt.Lines = kept
if kept > 0 {
attempt.Outcome = BondAttemptLine
} else {
attempt.Outcome, attempt.Message = BondAttemptNoLine, fmt.Sprintf("the %d lines IBKR answered name another ISIN", len(lines))
}
trace.Attempts = append(trace.Attempts, attempt)
if !c.SessionCurrent(binding) {
return out, trace, fmt.Errorf("broker session changed during the listing lookup")
}
}
if len(out) == 0 {
return nil, trace, fmt.Errorf("%w: %s", ErrNoListing, trace.Request)
}
return out, trace, nil
}
func listingDescription(r ListingRequest) string {
var parts []string
if r.ISIN != "" {
parts = append(parts, "ISIN "+strings.ToUpper(strings.TrimSpace(r.ISIN)))
}
if r.ConID > 0 {
parts = append(parts, fmt.Sprintf("contract id %d", r.ConID))
}
if len(r.Symbols) > 0 {
parts = append(parts, "symbols "+strings.Join(r.Symbols, ", "))
}
if ccy := strings.ToUpper(strings.TrimSpace(r.Currency)); ccy != "" {
parts = append(parts, "in "+ccy)
}
return strings.Join(parts, " · ")
}
package ibkr
import (
"log/slog"
"github.com/osauer/canary/v2/pkg/ibkr/internal/logging"
)
// SetLogger installs a custom slog.Logger as the sink for all messages
// produced by the IBKR library. Pass nil to discard output entirely.
//
// Daemons and command-line tools should call this once at startup so library
// output funnels through the same handler the rest of the application uses.
func SetLogger(l *slog.Logger) { logging.SetLogger(l) }
// SetLogLevel adjusts the active level by string ("debug"|"info"|"warn"|"error").
// Unknown values default to "info".
func SetLogLevel(level string) { logging.Configure(level) }
package ibkr
import "time"
// MarketDataLoad is a read-only view of the connection's market-data
// pressure, for background readers that must yield to interactive work.
type MarketDataLoad struct {
// LinesHeld and LinesCapacity are the market-data subscription slots in
// use and the pool's size.
LinesHeld int
LinesCapacity int
// LastPacingError is when the broker last answered any request on this
// connection with a pacing error (code 100); zero when none was seen.
LastPacingError time.Time
}
// MarketDataLoad reports the current connection's line usage and pacing
// state. It sends nothing. False when no connection is attached.
func (c *Connector) MarketDataLoad() (MarketDataLoad, bool) {
if c == nil {
return MarketDataLoad{}, false
}
c.mu.RLock()
conn := c.conn
c.mu.RUnlock()
if conn == nil || conn.rateLimiter == nil {
return MarketDataLoad{}, false
}
rl := conn.rateLimiter
metrics := rl.GetMetrics()
return MarketDataLoad{
LinesHeld: rl.marketDataSubs.Count(),
LinesCapacity: cap(rl.marketDataSubs.ch),
LastPacingError: metrics.LastRateLimitError,
}, true
}
package ibkr
import (
"context"
"fmt"
"math"
"slices"
"strconv"
"strings"
"time"
)
type marketRuleKey struct {
epoch uint64
id int
}
// PriceIncrement is one broker-supplied price band for an exact route.
type PriceIncrement struct {
LowEdge float64 `json:"low_edge"`
Increment float64 `json:"increment"`
}
func routeMarketRule(r ResolvedOrderContract) (int, error) {
venues, ids := strings.Split(r.ValidExchanges, ","), strings.Split(r.MarketRuleIDs, ",")
if len(venues) != len(ids) {
return 0, fmt.Errorf("route price rules unavailable")
}
found := 0
for i, v := range venues {
if strings.TrimSpace(v) != r.Contract.Exchange {
continue
}
id, err := strconv.Atoi(strings.TrimSpace(ids[i]))
if err != nil || id <= 0 || found != 0 && found != id {
return 0, fmt.Errorf("route price rule ambiguous")
}
found = id
}
if found == 0 {
return 0, fmt.Errorf("route price rule unavailable")
}
return found, nil
}
func decodePriceIncrements(fields []string, id int) ([]PriceIncrement, error) {
invalid := fmt.Errorf("invalid market price rule")
if len(fields) < 3 || fields[0] != strconv.Itoa(msgMarketRule) || fields[1] != strconv.Itoa(id) {
return nil, invalid
}
n, err := strconv.Atoi(fields[2])
if err != nil || n < 1 || n > 128 {
return nil, invalid
}
expected := 3 + n*2
// The socket decoder retains the final NUL as one empty framing field.
// Accept that terminator only; extra empty or nonempty fields are invalid.
if len(fields) == expected+1 && fields[expected] == "" {
fields = fields[:expected]
}
if len(fields) != expected {
return nil, invalid
}
out := make([]PriceIncrement, 0, n)
for i := range n {
edge, e1 := strconv.ParseFloat(fields[3+2*i], 64)
inc, e2 := strconv.ParseFloat(fields[4+2*i], 64)
if e1 != nil || e2 != nil || math.IsNaN(edge) || math.IsInf(edge, 0) || edge < 0 || math.IsNaN(inc) || math.IsInf(inc, 0) || inc <= 0 || i == 0 && edge != 0 || i > 0 && edge <= out[i-1].LowEdge {
return nil, invalid
}
out = append(out, PriceIncrement{LowEdge: edge, Increment: inc})
}
return out, nil
}
// OrderPriceIncrements reads the route's complete price grid on the same
// broker session that resolved its contract. Missing rules never become .01.
func (c *Connector) OrderPriceIncrements(ctx context.Context, binding ConnectorSessionBinding, r ResolvedOrderContract) ([]PriceIncrement, error) {
if !c.SessionCurrent(binding) || r.Contract.ConID <= 0 {
return nil, fmt.Errorf("exact broker session required")
}
id, err := routeMarketRule(r)
if err != nil {
return nil, err
}
// Rules are static within a broker session; a reconnect starts afresh.
key := marketRuleKey{epoch: binding.epoch, id: id}
c.marketRulesMu.Lock()
cached, ok := c.marketRules[key]
c.marketRulesMu.Unlock()
if ok {
return slices.Clone(cached), nil
}
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
type result struct {
rows []PriceIncrement
err error
}
done := make(chan result, 1)
conn := binding.connection
handler := conn.RegisterHandlerAtEpoch(msgMarketRule, func(fields []string, epoch uint64) {
if epoch != binding.epoch || len(fields) < 2 || fields[1] != strconv.Itoa(id) {
return
}
rows, err := decodePriceIncrements(fields, id)
select {
case done <- result{rows, err}:
default:
}
})
defer conn.UnregisterHandler(msgMarketRule, handler)
// reqMarketRule(91) carries only the rule ID; the reply(93) is rule ID,
// count, then lowEdge/increment pairs (TWS socket API).
if err := conn.sendMessageWithTypeContextForEpoch(ctx, conn.encodeMsg(91, id), RequestTypeGeneral, binding.epoch, true); err != nil {
return nil, err
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case out := <-done:
if !c.SessionCurrent(binding) {
return nil, fmt.Errorf("broker session changed during price rule read")
}
if out.err == nil {
c.marketRulesMu.Lock()
if c.marketRules == nil {
c.marketRules = map[marketRuleKey][]PriceIncrement{}
}
c.marketRules[key] = slices.Clone(out.rows)
c.marketRulesMu.Unlock()
}
return out.rows, out.err
}
}
package ibkr
import (
"context"
"fmt"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
)
// MarketSnapshot is the result of one [Connector.FetchMarketSnapshot] request.
// Nil price pointers mean the broker did not provide that field. The one-shot
// request does not request generic ticks, so IV is nil and IVStatus is
// "unavailable". AsOf is the local completion time, not a broker tick timestamp.
type MarketSnapshot struct {
Symbol string
Bid *float64
Ask *float64
Last *float64
IV *float64
IVStatus string // "real" when populated from tick 106, "unavailable" otherwise.
AsOf time.Time
MarketDataType int // 1=live, 2=frozen, 3=delayed, 4=delayed-frozen, 0=unknown
}
const defaultMarketSnapshotTimeout = 5 * time.Second
// snapshotSession is the per-call state for a FetchMarketSnapshot
// invocation. Handlers receive a pointer to it via closure; this design
// also makes the handler methods directly testable without setting up a
// network connection.
type snapshotSession struct {
targetReqID atomic.Int64
snap *MarketSnapshot
gotEndCh chan struct{}
mu sync.Mutex
dataType int
}
func newSnapshotSession(symbol string) *snapshotSession {
s := &snapshotSession{
snap: &MarketSnapshot{
Symbol: symbol,
IVStatus: "unavailable",
},
gotEndCh: make(chan struct{}, 1),
}
s.targetReqID.Store(-1)
return s
}
// FetchMarketSnapshot requests a one-shot bid, ask, and last-price snapshot and
// waits for the broker's end marker. ctx must be non-nil. A timeout of zero or
// less uses five seconds. Context cancellation returns ctx.Err; timeout returns
// [context.DeadlineExceeded]. Both paths best-effort cancel the request and
// release its market-data slot. Inactive and unavailable connectors return
// [ErrSymbolInactive] and [ErrIBKRUnavailable] without sending a request.
func (c *Connector) FetchMarketSnapshot(ctx context.Context, symbol string, timeout time.Duration) (*MarketSnapshot, error) {
symbol = strings.ToUpper(strings.TrimSpace(symbol))
if symbol == "" {
return nil, fmt.Errorf("symbol is required")
}
if c.IsSymbolInactive(symbol) {
return nil, ErrSymbolInactive
}
if !c.IsReady() {
return nil, ErrIBKRUnavailable
}
if timeout <= 0 {
timeout = defaultMarketSnapshotTimeout
}
contract, ready := c.prepareContract(symbol, 2*time.Second, true)
contract, _ = c.waitForContractDetails(symbol, contract, ready)
session := newSnapshotSession(symbol)
priceHandID := c.conn.RegisterHandler(msgTickPrice, func(fields []string) {
c.handleSnapshotTickPrice(session, fields)
})
genericHandID := c.conn.RegisterHandler(msgTickGeneric, func(fields []string) {
c.handleSnapshotTickGeneric(session, fields)
})
dataTypeHandID := c.conn.RegisterHandler(msgMarketDataType, func(fields []string) {
c.handleSnapshotMarketDataType(session, fields)
})
endHandID := c.conn.RegisterHandler(msgTickSnapshotEnd, func(fields []string) {
c.handleSnapshotEnd(session, fields)
})
cleanup := func(reqID int) {
c.conn.UnregisterHandler(msgTickPrice, priceHandID)
c.conn.UnregisterHandler(msgTickGeneric, genericHandID)
c.conn.UnregisterHandler(msgMarketDataType, dataTypeHandID)
c.conn.UnregisterHandler(msgTickSnapshotEnd, endHandID)
if reqID > 0 && c.isConnected() {
if cancelErr := c.conn.CancelMarketData(reqID); cancelErr != nil {
connectorLogger.Debugf("CancelMarketData(reqID=%d) failed: %v", reqID, cancelErr)
}
}
}
// IBKR (server >= 100) rejects generic-tick lists when snapshot=true with
// error 321 ("Snapshot market data subscription is not applicable to
// generic ticks"). Snapshot requests therefore omit the IV/RTH ticks; IV
// stays unavailable in snapshot results, consistent with the no-fabrication
// invariant. Streaming subscribers (SubscribeMarketData) still get the
// generic-tick set since snapshot=false there.
reqID, err := c.conn.RequestMarketDataWithContract(ctx, contract, "", true, false)
if err != nil {
cleanup(0)
return nil, fmt.Errorf("request market snapshot: %w", err)
}
// Atomic guard: handlers compare each tick's reqID against this. Until
// Store is called, target == -1 and any inbound tick is dropped. The
// window between RequestMarketDataWithContract returning and Store is
// microseconds; on a real network IBKR cannot respond that quickly.
session.targetReqID.Store(int64(reqID))
deadline := time.NewTimer(timeout)
defer deadline.Stop()
select {
case <-session.gotEndCh:
cleanup(reqID)
case <-deadline.C:
cleanup(reqID)
return nil, context.DeadlineExceeded
case <-ctx.Done():
cleanup(reqID)
return nil, ctx.Err()
}
session.mu.Lock()
session.snap.MarketDataType = session.dataType
session.mu.Unlock()
session.snap.AsOf = time.Now().UTC()
return session.snap, nil
}
// handleSnapshotTickPrice parses a tick-price message and updates the session
// snapshot. Tick types: 1=bid, 2=ask, 4=last. Non-positive prices are dropped
// — IBKR emits 0/-1 for "no quote available", which is surfaced as nil rather
// than a confusing zero. Messages whose reqID does not match the session
// target are ignored.
func (c *Connector) handleSnapshotTickPrice(s *snapshotSession, fields []string) {
if len(fields) < 5 {
return
}
target := s.targetReqID.Load()
if target < 0 {
return
}
rid, err := strconv.Atoi(fields[2])
if err != nil || int64(rid) != target {
return
}
tickType, err := strconv.Atoi(fields[3])
if err != nil {
return
}
priceStr := strings.TrimSpace(fields[4])
if priceStr == "" {
return
}
price, err := strconv.ParseFloat(priceStr, 64)
if err != nil || price <= 0 {
return
}
s.mu.Lock()
defer s.mu.Unlock()
switch tickType {
case 1:
p := price
s.snap.Bid = &p
case 2:
p := price
s.snap.Ask = &p
case 4:
p := price
s.snap.Last = &p
}
}
// handleSnapshotTickGeneric parses generic-tick messages. Only tick 106
// (Option Implied Volatility) is captured — and only when positive. No
// fallback to historical vol; absence of tick 106 leaves IV nil and
// IVStatus="unavailable" per AGENTS.md.
func (c *Connector) handleSnapshotTickGeneric(s *snapshotSession, fields []string) {
if len(fields) < 5 {
return
}
target := s.targetReqID.Load()
if target < 0 {
return
}
rid, err := strconv.Atoi(fields[2])
if err != nil || int64(rid) != target {
return
}
tickType, err := strconv.Atoi(fields[3])
if err != nil {
return
}
if tickType != 106 {
return
}
val, err := strconv.ParseFloat(strings.TrimSpace(fields[4]), 64)
if err != nil || val <= 0 {
return
}
if val > 1.5 { // IBKR sometimes sends percent-form; normalize to fraction.
val = val / 100.0
}
s.mu.Lock()
defer s.mu.Unlock()
s.snap.IV = &val
s.snap.IVStatus = "real"
}
// handleSnapshotMarketDataType records the data-type notification IBKR sends
// alongside ticks (1=live, 2=frozen, 3=delayed, 4=delayed-frozen).
func (c *Connector) handleSnapshotMarketDataType(s *snapshotSession, fields []string) {
if len(fields) < 4 {
return
}
target := s.targetReqID.Load()
if target < 0 {
return
}
rid, err := strconv.Atoi(fields[2])
if err != nil || int64(rid) != target {
return
}
dt, err := strconv.Atoi(fields[3])
if err != nil {
return
}
s.mu.Lock()
s.dataType = dt
s.mu.Unlock()
}
// handleSnapshotEnd signals the FetchMarketSnapshot caller that IBKR has
// emitted tickSnapshotEnd for the current request. The channel send is
// non-blocking (buffered with capacity 1) so a duplicate end-message from
// IBKR cannot deadlock.
func (c *Connector) handleSnapshotEnd(s *snapshotSession, fields []string) {
if len(fields) < 3 {
return
}
target := s.targetReqID.Load()
if target < 0 {
return
}
rid, err := strconv.Atoi(fields[2])
if err != nil || int64(rid) != target {
return
}
select {
case s.gotEndCh <- struct{}{}:
default:
}
}
package ibkr
import (
"regexp"
"strconv"
"strings"
)
// A request frame the gateway reads shifted by one byte or one field puts the
// wrong value into every later slot: an exchange name loses its first byte
// and lands where a number was expected, and the gateway answers with a
// request-parse error that quotes what it saw — "MART" for SMART, "BOE" for
// CBOE, or a NumberFormatException naming a word. That echo, and only that
// echo, is the parser-misalignment signature. (Until 2026-10-04 any notice
// whose text merely contained those letters was flagged, so an ordinary
// "HMDS query returned no data: AAPL@SMART Trades" logged at ERROR.)
// parseFaultCodes are the gateway's "could not read or validate the request"
// answers; a shifted frame surfaces through one of them.
var parseFaultCodes = map[int]bool{320: true, 321: true, 322: true, 323: true}
// misalignmentVenues are routing venues whose first-byte-shifted spelling is
// the recognisable fragment. Fragments shorter than three letters, or equal
// to another venue's name, are not distinctive and are skipped.
var misalignmentVenues = []string{
"SMART", "CBOE", "CBOE2", "NASDAQ", "NASDAQOM", "NASDAQBX", "NYSE", "NYSENAT", "ARCA", "ARCAEDGE", "AMEX",
"ISLAND", "IDEALPRO", "GLOBEX", "NYMEX", "COMEX", "CBOT", "ECBOT", "ICEUS", "NYBOT", "IBIS", "IBIS2", "XETRA",
"SEHK", "TSE", "TSEJ", "BATS", "EDGEA", "EDGX", "PAXOS", "HKFE", "PHLX", "MIAX", "GEMINI", "MERCURY", "PEARL",
"EMERALD", "MEMX", "LTSE", "OVERNIGHT", "DRCTEDGE", "PINK", "OTCLNK", "VALUE", "FUNDSERV", "LSEETF", "EUREX",
"BVME", "SNFE", "TRADEWEB", "BONDDESK", "ICEEU", "ICEEUSOFT", "ENDEX", "MONEP", "MEFFRV", "IDEM", "OMS",
}
var shiftedVenueFragments = func() map[string]bool {
venues := map[string]bool{}
for _, v := range misalignmentVenues {
venues[v] = true
}
out := map[string]bool{}
for _, v := range misalignmentVenues {
fragment := v[1:]
if len(fragment) < 3 || venues[fragment] {
continue
}
out[fragment] = true
}
return out
}()
var (
noticeTokenPattern = regexp.MustCompile(`[A-Z0-9.]+`)
// "For input string: "X"" is Java's NumberFormatException detail; a
// non-numeric X at the gateway means text landed in a numeric slot.
numberFormatInput = regexp.MustCompile(`FOR INPUT STRING: "([^"]*)"`)
)
// noticeSignalsParserMisalignment reports whether a broker notice is the
// gateway describing a request frame it read misaligned. Only a parse-fault
// code or an explicit parse-exception text qualifies, and then only when the
// text carries a shifted venue fragment as a token of its own or a
// non-numeric value in a NumberFormatException. Ordinary notices that mention
// SMART or CBOE in passing are not misalignments.
func noticeSignalsParserMisalignment(code int, message string) bool {
upper := strings.ToUpper(message)
parseFault := parseFaultCodes[code] ||
strings.Contains(upper, "NUMBERFORMATEXCEPTION") ||
strings.Contains(upper, "UNABLE TO PARSE") ||
strings.Contains(upper, "ERROR READING REQUEST")
if !parseFault {
return false
}
if m := numberFormatInput.FindStringSubmatch(upper); m != nil {
if _, err := strconv.ParseFloat(strings.TrimSpace(m[1]), 64); err != nil {
return true
}
}
for _, token := range noticeTokenPattern.FindAllString(upper, -1) {
if shiftedVenueFragments[strings.Trim(token, ".")] {
return true
}
}
return false
}
package ibkr
import (
"context"
"errors"
"fmt"
"sync"
"time"
)
const defaultOpenOrderSnapshotProtocolTimeout = 15 * time.Second
// ErrOpenOrderSnapshotPoisoned means a reqAllOpenOrders request was sent but
// its uncorrelated openOrderEnd terminator was not proven on the same socket.
// No second request is safe on that socket generation because late callbacks
// could otherwise complete the wrong snapshot.
var ErrOpenOrderSnapshotPoisoned = errors.New("open-order snapshot socket generation poisoned")
// OpenOrderSnapshot is a one-shot read of the API-created open orders the
// gateway reports across client IDs. It does not bind or include manual TWS
// orders merely because Complete is true; those require the separate client-0
// open-order binding flow, which this request does not perform.
//
// Complete is true only when openOrderEnd arrived on the exact Connection
// socket epoch that sent reqAllOpenOrders. When Complete is false, Orders may
// contain callbacks collected before the caller or protocol deadline ended,
// but that proves nothing about absent orders. A request failure returns no
// collected orders. AsOf is the local UTC completion or failure time, not a
// broker timestamp. Session is the opaque Connection socket generation that
// sent the request, and Generation is the Connector order-lifecycle
// frontier at the exact same-epoch openOrderEnd; a change to either invalidates
// this receipt.
type OpenOrderSnapshot struct {
Complete bool // Complete reports whether same-epoch openOrderEnd arrived.
Orders []OrderLifecycleEvent // Orders contains collected openOrder events and may be empty.
AsOf time.Time // AsOf is the local UTC time at which the evidence completed.
Session ConnectorSessionBinding // Session identifies the exact Connection socket generation.
Generation uint64 // Generation is the order-event frontier captured at completion.
}
type openOrderSnapshotBinding struct {
conn *Connection
epoch uint64
}
func sameOpenOrderSnapshotBinding(a, b openOrderSnapshotBinding) bool {
return a.conn != nil && a.conn == b.conn && a.epoch == b.epoch
}
// openOrderSnapshotCollector accumulates openOrder callbacks between one
// reqAllOpenOrders request and its same-epoch openOrderEnd terminator.
type openOrderSnapshotCollector struct {
mu sync.Mutex
orders []OrderLifecycleEvent
end chan struct{}
closed bool
generation uint64
previewInvalid bool
}
func (col *openOrderSnapshotCollector) add(ev OrderLifecycleEvent) {
col.mu.Lock()
defer col.mu.Unlock()
if col.closed {
return
}
col.orders = append(col.orders, ev)
}
func (col *openOrderSnapshotCollector) finish(generation uint64) {
col.mu.Lock()
defer col.mu.Unlock()
if col.closed {
return
}
col.closed = true
col.generation = generation
close(col.end)
}
func (col *openOrderSnapshotCollector) snapshot() ([]OrderLifecycleEvent, uint64) {
col.mu.Lock()
defer col.mu.Unlock()
return append([]OrderLifecycleEvent{}, col.orders...), col.generation
}
type openOrderSnapshotFlight struct {
binding openOrderSnapshotBinding
collector *openOrderSnapshotCollector
done chan struct{}
result OpenOrderSnapshot
err error
recovery map[int]uint64
previewGeneration uint64
previewSafe bool
}
// RequestAllOpenOrders sends reqAllOpenOrders to the gateway. Results arrive
// asynchronously as openOrder and orderStatus callbacks followed by
// openOrderEnd; a nil error means only that the request frame was accepted for
// the socket write. This read request is available in both build modes.
func (c *Connection) RequestAllOpenOrders() error {
if !c.IsConnected() {
return fmt.Errorf("not connected to IBKR")
}
epoch, err := c.captureBrokerInstructionEpoch()
if err != nil {
return err
}
return c.requestAllOpenOrdersForEpoch(epoch)
}
func (c *Connection) requestAllOpenOrdersForEpoch(epoch uint64) error {
if !c.IsConnected() {
return fmt.Errorf("not connected to IBKR")
}
msg := c.encodeMsg(reqAllOpenOrders, "1")
return c.sendMessageWithTypeContextForEpoch(context.Background(), msg, RequestTypeOrder, epoch, true)
}
// SnapshotOpenOrders joins or starts one epoch-bound reqAllOpenOrders flight.
// Caller cancellation only stops that waiter; after the wire request begins,
// the collector remains installed until same-epoch openOrderEnd or the
// internal protocol deadline. This is required because the protocol has no
// request ID and a late terminator could otherwise bless a later flight.
//
// A canceled waiter issues no late request. A protocol timeout or uncertain
// send poisons only the exact Connection epoch; callers fail closed until a
// reconnect advances that epoch.
func (c *Connector) SnapshotOpenOrders(ctx context.Context) (OpenOrderSnapshot, error) {
if c == nil {
return OpenOrderSnapshot{}, fmt.Errorf("connector is nil")
}
if ctx == nil {
return OpenOrderSnapshot{}, fmt.Errorf("context is nil")
}
if err := ctx.Err(); err != nil {
return OpenOrderSnapshot{AsOf: time.Now().UTC()}, err
}
c.mu.RLock()
conn := c.conn
c.mu.RUnlock()
if conn == nil {
return OpenOrderSnapshot{}, fmt.Errorf("no active connection")
}
binding := openOrderSnapshotBinding{conn: conn, epoch: conn.BrokerSessionEpoch()}
c.openOrderSnapshotMu.Lock()
if sameOpenOrderSnapshotBinding(c.openOrderSnapshotPoison, binding) {
c.openOrderSnapshotMu.Unlock()
return OpenOrderSnapshot{AsOf: time.Now().UTC(), Session: ConnectorSessionBinding{connector: c, connection: binding.conn, epoch: binding.epoch}}, ErrOpenOrderSnapshotPoisoned
}
flight := c.openOrderSnapshot
if flight == nil || !sameOpenOrderSnapshotBinding(flight.binding, binding) {
flight = &openOrderSnapshotFlight{
binding: binding,
collector: &openOrderSnapshotCollector{end: make(chan struct{})},
done: make(chan struct{}),
}
c.openOrderSnapshot = flight
go c.runOpenOrderSnapshotFlight(flight)
}
c.openOrderSnapshotMu.Unlock()
select {
case <-flight.done:
return cloneOpenOrderSnapshot(flight.result), flight.err
case <-ctx.Done():
orders, generation := flight.collector.snapshot()
return OpenOrderSnapshot{
Complete: false,
Orders: orders,
AsOf: time.Now().UTC(),
Session: ConnectorSessionBinding{connector: c, connection: flight.binding.conn, epoch: flight.binding.epoch},
Generation: generation,
}, ctx.Err()
}
}
func (c *Connector) runOpenOrderSnapshotFlight(flight *openOrderSnapshotFlight) {
if beforeSend := c.openOrderSnapshotBeforeSend; beforeSend != nil {
beforeSend()
}
request := c.requestAllOpenOrders
if request == nil {
request = func() error { return flight.binding.conn.requestAllOpenOrdersForEpoch(flight.binding.epoch) }
}
flight.collector.mu.Lock()
flight.recovery, flight.previewGeneration, flight.previewSafe = flight.binding.conn.modifyStatusRecoveryTokens(flight.binding.epoch)
flight.collector.mu.Unlock()
if err := request(); err != nil {
c.completeOpenOrderSnapshotFlight(flight, OpenOrderSnapshot{AsOf: time.Now().UTC(), Session: ConnectorSessionBinding{connector: c, connection: flight.binding.conn, epoch: flight.binding.epoch}}, err, brokerSendMayHaveBeenWritten(err))
return
}
timeout := c.openOrderSnapshotTimeout
if timeout <= 0 {
timeout = defaultOpenOrderSnapshotProtocolTimeout
}
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case <-flight.collector.end:
orders, generation := flight.collector.snapshot()
flight.collector.mu.Lock()
invalid := flight.collector.previewInvalid
flight.collector.mu.Unlock()
var snapshotErr error
if invalid {
snapshotErr = fmt.Errorf("open-order snapshot overlapped a modify preview; refresh open orders")
}
c.completeOpenOrderSnapshotFlight(flight, OpenOrderSnapshot{
Complete: !invalid,
Orders: orders,
AsOf: time.Now().UTC(),
Session: ConnectorSessionBinding{connector: c, connection: flight.binding.conn, epoch: flight.binding.epoch},
Generation: generation,
}, snapshotErr, false)
case <-timer.C:
err := fmt.Errorf("%w: no same-epoch openOrderEnd before protocol deadline", ErrOpenOrderSnapshotPoisoned)
orders, generation := flight.collector.snapshot()
c.completeOpenOrderSnapshotFlight(flight, OpenOrderSnapshot{
Complete: false,
Orders: orders,
AsOf: time.Now().UTC(),
Session: ConnectorSessionBinding{connector: c, connection: flight.binding.conn, epoch: flight.binding.epoch},
Generation: generation,
}, err, true)
}
}
func (c *Connector) completeOpenOrderSnapshotFlight(flight *openOrderSnapshotFlight, result OpenOrderSnapshot, err error, poison bool) {
flight.result = cloneOpenOrderSnapshot(result)
flight.err = err
c.openOrderSnapshotMu.Lock()
if poison {
c.openOrderSnapshotPoison = flight.binding
c.openOrderSnapshotPoisonAt = time.Now()
c.openOrderSnapshotPoisonErr = err
if sameOpenOrderSnapshotBinding(c.openOrderSnapshotDone, flight.binding) {
// A poisoned generation has no current completion to report.
c.openOrderSnapshotDone, c.openOrderSnapshotDoneAt = openOrderSnapshotBinding{}, time.Time{}
}
} else if err == nil && result.Complete {
c.openOrderSnapshotDone = flight.binding
c.openOrderSnapshotDoneAt = result.AsOf
}
if c.openOrderSnapshot == flight {
c.openOrderSnapshot = nil
}
close(flight.done)
c.openOrderSnapshotMu.Unlock()
}
// OpenOrderSnapshotPoisoned reports whether the current session's open-order
// snapshot is poisoned, since when and why. Only a new socket generation
// clears it, so its owner redials the session (DropSession) to read open
// orders again.
func (c *Connector) OpenOrderSnapshotPoisoned() (since time.Time, cause error, poisoned bool) {
current, ok := c.currentOpenOrderSnapshotBinding()
if !ok {
return time.Time{}, nil, false
}
c.openOrderSnapshotMu.Lock()
defer c.openOrderSnapshotMu.Unlock()
if !sameOpenOrderSnapshotBinding(c.openOrderSnapshotPoison, current) {
return time.Time{}, nil, false
}
return c.openOrderSnapshotPoisonAt, c.openOrderSnapshotPoisonErr, true
}
// OpenOrderSnapshotCompletedAt returns when the current session last
// completed an open-order snapshot; zero when it has not.
func (c *Connector) OpenOrderSnapshotCompletedAt() time.Time {
current, ok := c.currentOpenOrderSnapshotBinding()
if !ok {
return time.Time{}
}
c.openOrderSnapshotMu.Lock()
defer c.openOrderSnapshotMu.Unlock()
if !sameOpenOrderSnapshotBinding(c.openOrderSnapshotDone, current) {
return time.Time{}
}
return c.openOrderSnapshotDoneAt
}
func (c *Connector) currentOpenOrderSnapshotBinding() (openOrderSnapshotBinding, bool) {
if c == nil {
return openOrderSnapshotBinding{}, false
}
c.mu.RLock()
conn := c.conn
c.mu.RUnlock()
if conn == nil {
return openOrderSnapshotBinding{}, false
}
return openOrderSnapshotBinding{conn: conn, epoch: conn.BrokerSessionEpoch()}, true
}
func cloneOpenOrderSnapshot(in OpenOrderSnapshot) OpenOrderSnapshot {
out := in
out.Orders = append([]OrderLifecycleEvent{}, in.Orders...)
return out
}
func (c *Connector) collectOpenOrderSnapshotFrom(conn *Connection, epoch uint64, ev OrderLifecycleEvent) {
c.openOrderSnapshotMu.Lock()
flight := c.openOrderSnapshot
if flight == nil || !sameOpenOrderSnapshotBinding(flight.binding, openOrderSnapshotBinding{conn: conn, epoch: epoch}) {
c.openOrderSnapshotMu.Unlock()
return
}
collector := flight.collector
c.openOrderSnapshotMu.Unlock()
collector.add(ev)
}
func (c *Connector) finishOpenOrderSnapshotFrom(conn *Connection, epoch uint64) {
c.openOrderSnapshotMu.Lock()
flight := c.openOrderSnapshot
if flight == nil || !sameOpenOrderSnapshotBinding(flight.binding, openOrderSnapshotBinding{conn: conn, epoch: epoch}) {
c.openOrderSnapshotMu.Unlock()
return
}
collector := flight.collector
c.openOrderSnapshotMu.Unlock()
collector.mu.Lock()
orders := append([]OrderLifecycleEvent(nil), collector.orders...)
recovery, generation, safe := flight.recovery, flight.previewGeneration, flight.previewSafe
collector.mu.Unlock()
valid := safe && conn.recoverModifyStatusFromSnapshot(epoch, recovery, generation, orders)
collector.mu.Lock()
collector.previewInvalid = !valid
collector.mu.Unlock()
collector.finish(c.OrderLifecycleGeneration())
}
package ibkr
import (
"context"
"fmt"
"strconv"
"strings"
)
// OptionExerciseRequest describes one exerciseOptions wire request. It is an
// instruction with position-changing side effects if IBKR accepts it, not a
// preview. The request does not itself carry paper-gate or application-level
// submit authority.
type OptionExerciseRequest struct {
// TickerID correlates broker callbacks. Connector.ExerciseOptions allocates
// one when it is zero; Connection.ExerciseOptions requires a positive value.
TickerID int
// Contract must be a non-nil OPT contract with symbol, expiry, positive
// strike, and a C or P right.
Contract *Contract
ExerciseAction int // ExerciseAction must be OptionExerciseActionExercise or OptionExerciseActionLapse.
ExerciseQuantity int // ExerciseQuantity is a positive number of option contracts.
Account string // Account is required and is trimmed before encoding.
Override int // Override is the broker exercise override flag and must be 0 or 1.
ManualOrderTime string // ManualOrderTime is sent only when the negotiated server version supports it.
}
const (
// OptionExerciseActionExercise asks IBKR to exercise the specified quantity.
OptionExerciseActionExercise = 1
// OptionExerciseActionLapse asks IBKR to let the specified quantity lapse.
OptionExerciseActionLapse = 2
)
// ExerciseOptions sends an option exercise or lapse instruction through the
// connector's active connection. A zero TickerID is replaced with a new request
// ID. The method checks ctx only before sending and does not wait for broker
// acknowledgement; a nil error means only that the frame was accepted for the
// socket write. Once the call reaches an active Connection, the default build
// returns [ErrTradingDisabled] before validation or transmission.
func (c *Connector) ExerciseOptions(ctx context.Context, req OptionExerciseRequest) error {
if ctx == nil {
return definitelyUnsent(fmt.Errorf("exerciseOptions context is nil"))
}
if err := ctx.Err(); err != nil {
return definitelyUnsent(err)
}
if !tradingEnabled {
return definitelyUnsent(ErrTradingDisabled)
}
binding, ok := c.CaptureSession()
if !ok {
return definitelyUnsent(fmt.Errorf("not connected to IBKR"))
}
return c.ExerciseOptionsForSession(ctx, binding, req)
}
// ExerciseOptionsForSession sends an option exercise or lapse instruction
// only on the exact Connector socket generation named by binding. A reconnect
// or disconnect before request-ID reservation or frame transmission is refused.
func (c *Connector) ExerciseOptionsForSession(ctx context.Context, binding ConnectorSessionBinding, req OptionExerciseRequest) error {
return c.ExerciseOptionsForSessionGuarded(ctx, binding, req, nil)
}
// ExerciseOptionsForSessionGuarded carries a final authority guard through
// pacing to the exact exercise/lapse socket write.
func (c *Connector) ExerciseOptionsForSessionGuarded(ctx context.Context, binding ConnectorSessionBinding, req OptionExerciseRequest, guard func() error) error {
if ctx == nil {
return definitelyUnsent(fmt.Errorf("exerciseOptions context is nil"))
}
if err := ctx.Err(); err != nil {
return definitelyUnsent(err)
}
if !tradingEnabled {
return definitelyUnsent(ErrTradingDisabled)
}
if !c.SessionCurrent(binding) {
return definitelyUnsent(fmt.Errorf("broker session binding is not current for this Connector"))
}
conn := binding.connection
if req.TickerID == 0 {
var err error
var epoch uint64
req.TickerID, epoch, err = conn.reserveNextRequestIDForEpoch(binding.epoch)
if err != nil {
return definitelyUnsent(err)
}
defer conn.discardRequestIDReservation(req.TickerID)
return conn.exerciseOptionsForEpochGuarded(ctx, req, &epoch, guard)
}
return conn.exerciseOptionsForEpochGuarded(ctx, req, &binding.epoch, guard)
}
// ExerciseOptions validates and sends an IBKR option exercise or lapse
// instruction. It can change a position if IBKR accepts it. A nil error means
// only that the frame was accepted for the socket write; the method does not
// wait for broker acknowledgement or finality. In the default build it returns
// [ErrTradingDisabled] before validation or transmission. The "trading" build
// tag enables this low-level wire method but does not grant submit authority.
func (c *Connection) ExerciseOptions(req OptionExerciseRequest) error {
if !tradingEnabled {
return definitelyUnsent(ErrTradingDisabled)
}
return c.exerciseOptionsForEpochGuarded(context.Background(), req, nil, nil)
}
func (c *Connection) exerciseOptionsForEpochGuarded(ctx context.Context, req OptionExerciseRequest, expectedEpoch *uint64, guard func() error) error {
if ctx == nil {
return definitelyUnsent(fmt.Errorf("exerciseOptions context is nil"))
}
if err := ctx.Err(); err != nil {
return definitelyUnsent(err)
}
if err := validateOptionExerciseRequest(req); err != nil {
return definitelyUnsent(err)
}
if c == nil {
return definitelyUnsent(fmt.Errorf("no active connection"))
}
if !c.IsConnected() {
return definitelyUnsent(fmt.Errorf("not connected to IBKR"))
}
var (
epoch uint64
err error
)
if expectedEpoch != nil {
epoch, err = c.claimRequestIDForEpoch(req.TickerID, *expectedEpoch)
} else {
epoch, err = c.claimRequestIDCurrentEpoch(req.TickerID)
}
if err != nil {
return definitelyUnsent(err)
}
return c.sendExerciseOptionsFrameGuarded(ctx, req, epoch, guard)
}
func (c *Connection) sendExerciseOptionsFrame(req OptionExerciseRequest, epoch uint64) error {
return c.sendExerciseOptionsFrameGuarded(context.Background(), req, epoch, nil)
}
func (c *Connection) sendExerciseOptionsFrameGuarded(ctx context.Context, req OptionExerciseRequest, epoch uint64, guard func() error) error {
msg, err := c.encodeExerciseOptionsMessage(req)
if err != nil {
return definitelyUnsent(err)
}
return c.sendMessageWithTypeContextForEpochGuarded(ctx, msg, RequestTypeOrder, epoch, true, guard)
}
func validateOptionExerciseRequest(req OptionExerciseRequest) error {
if req.Contract == nil {
return fmt.Errorf("exerciseOptions contract is required")
}
if req.TickerID <= 0 {
return fmt.Errorf("exerciseOptions ticker id must be positive")
}
if !strings.EqualFold(req.Contract.SecType, "OPT") {
return fmt.Errorf("exerciseOptions supports option contracts only")
}
if strings.TrimSpace(req.Contract.Symbol) == "" {
return fmt.Errorf("exerciseOptions contract symbol is required")
}
if strings.TrimSpace(req.Contract.Expiry) == "" {
return fmt.Errorf("exerciseOptions contract expiry is required")
}
if req.Contract.Strike <= 0 {
return fmt.Errorf("exerciseOptions contract strike is required")
}
right := strings.ToUpper(strings.TrimSpace(req.Contract.Right))
if right != "C" && right != "P" {
return fmt.Errorf("exerciseOptions contract right must be C or P")
}
switch req.ExerciseAction {
case OptionExerciseActionExercise, OptionExerciseActionLapse:
default:
return fmt.Errorf("exerciseOptions action must be 1 (exercise) or 2 (lapse)")
}
if req.ExerciseQuantity <= 0 {
return fmt.Errorf("exerciseOptions quantity must be positive")
}
if strings.TrimSpace(req.Account) == "" {
return fmt.Errorf("exerciseOptions account is required")
}
if req.Override != 0 && req.Override != 1 {
return fmt.Errorf("exerciseOptions override must be 0 or 1")
}
return nil
}
func (c *Connection) encodeExerciseOptionsMessage(req OptionExerciseRequest) ([]byte, error) {
if err := validateOptionExerciseRequest(req); err != nil {
return nil, err
}
contract := req.Contract
multiplier := ""
if contract.Multiplier != 0 {
multiplier = strconv.Itoa(contract.Multiplier)
}
fields := []any{
exerciseOptions,
2, // message version used by the documented exerciseOptions request shape
req.TickerID,
contract.ConID,
strings.ToUpper(strings.TrimSpace(contract.Symbol)),
"OPT",
strings.TrimSpace(contract.Expiry),
strconv.FormatFloat(contract.Strike, 'f', -1, 64),
strings.ToUpper(strings.TrimSpace(contract.Right)),
multiplier,
ifEmpty(contract.Exchange, "SMART"),
ifEmpty(contract.Currency, "USD"),
strings.TrimSpace(contract.LocalSymbol),
strings.TrimSpace(contract.TradingClass),
req.ExerciseAction,
req.ExerciseQuantity,
strings.TrimSpace(req.Account),
req.Override,
}
if c.serverVersion >= minServerVerManualOrderTime {
fields = append(fields, strings.TrimSpace(req.ManualOrderTime))
}
return c.encodeMsg(fields...), nil
}
package ibkr
import (
"strconv"
"strings"
"time"
)
// OptionRiskMeasurement is one atomic model-computation receipt from a
// non-sharing exact-contract subscription. Nil components are unavailable;
// a valid zero delta is observed. Prices and shared Greeks cannot freshen it.
type OptionRiskMeasurement struct {
Contract Contract
RequestID int
SessionEpoch uint64
RequestedAt time.Time
ReceivedAt time.Time
DataType int
IV *float64
Delta *float64
Underlying *float64
}
// Epoch reports the physical socket generation for receipt comparison. It
// cannot create a binding or establish that a session is still current.
func (b ConnectorSessionBinding) Epoch() uint64 { return b.epoch }
func (c *Connector) handleExactOptionRisk(origin ConnectorSessionBinding, fields []string) {
if len(fields) < 12 {
return
}
id, err := strconv.Atoi(fields[1])
if err != nil {
return
}
tick, err := strconv.Atoi(fields[2])
if err != nil || (tick != 13 && tick != 83) {
return
}
c.publicationBarrier.RLock()
defer c.publicationBarrier.RUnlock()
if !c.SessionCurrent(origin) {
return
}
c.subMu.Lock()
defer c.subMu.Unlock()
sub := c.subscriptions[c.reqIDMap[id]]
if sub == nil || sub.ReqID != id || sub.exactSession != origin || sub.exactContract.ConID <= 0 || sub.exactContract.SecType != "OPT" {
return
}
r := &OptionRiskMeasurement{Contract: sub.exactContract, RequestID: id, SessionEpoch: origin.epoch,
RequestedAt: sub.LastTime, ReceivedAt: time.Now().UTC(), DataType: origin.connection.MarketDataType(id)}
// A delayed tick is delayed even if a preceding notice said live. A
// missing notice is unknown, and frozen computation remains frozen.
if tick == 83 {
r.DataType = 3
}
if v, err := strconv.ParseFloat(strings.TrimSpace(fields[4]), 64); err == nil && v > 0 && v < 100 {
r.IV = &v
}
if v, err := strconv.ParseFloat(strings.TrimSpace(fields[5]), 64); err == nil && saneGreek(v, 1.05) {
r.Delta = &v
}
if v, err := strconv.ParseFloat(strings.TrimSpace(fields[11]), 64); err == nil && v > 0 && v < 1e9 {
r.Underlying = &v
}
// Replace the entire receipt, including missing values. Never assemble
// components from different computations.
sub.optionRisk = r
}
// OptionRiskForSession returns a caller-owned exact model receipt only while
// its subscription and physical connection remain current. It does no I/O.
func (c *Connector) OptionRiskForSession(binding ConnectorSessionBinding, key string) *OptionRiskMeasurement {
if c == nil {
return nil
}
c.publicationBarrier.RLock()
defer c.publicationBarrier.RUnlock()
if !c.SessionCurrent(binding) {
return nil
}
c.subMu.RLock()
defer c.subMu.RUnlock()
sub := c.subscriptions[key]
if sub == nil || sub.exactSession != binding || sub.optionRisk == nil {
return nil
}
out := *sub.optionRisk
if out.IV != nil {
out.IV = new(*out.IV)
}
if out.Delta != nil {
out.Delta = new(*out.Delta)
}
if out.Underlying != nil {
out.Underlying = new(*out.Underlying)
}
// A later non-live notice invalidates the receipt; a later live notice
// cannot upgrade a computation captured as delayed/frozen/unknown.
if current := binding.connection.MarketDataType(sub.ReqID); current != 1 {
out.DataType = current
}
return &out
}
package ibkr
import (
"context"
"strings"
"sync"
"time"
)
type optionSubscriptionFlight struct {
done chan struct{}
err error
}
// SubscribeOption holds a shared streaming line for one exact option. Readers
// of different trading classes must supply the class explicitly. Each success
// must be paired with UnsubscribeMarketData. Concurrent daemon readers should
// prefer HoldOption, whose release remains bound across session replacement.
func (c *Connector) SubscribeOption(ctx context.Context, underlying, tradingClass, expiryYMD string, strike float64, right string) (string, int, error) {
key, id, _, err := c.acquireOption(ctx, underlying, tradingClass, expiryYMD, strike, right)
return key, id, err
}
// HoldOption acquires one shared option reader and its idempotent release.
// The release cannot cancel a successor line even if a new socket reuses the
// same request ID. Each successful hold must be released by its caller.
func (c *Connector) HoldOption(ctx context.Context, underlying, tradingClass, expiryYMD string, strike float64, right string) (string, func() error, error) {
key, _, sub, err := c.acquireOption(ctx, underlying, tradingClass, expiryYMD, strike, right)
if err != nil {
return "", nil, err
}
var once sync.Once
var releaseErr error
return key, func() error {
once.Do(func() {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
releaseErr = c.unsubscribeMarketData(ctx, key, nil, sub)
})
return releaseErr
}, nil
}
func (c *Connector) acquireOption(ctx context.Context, underlying, tradingClass, expiryYMD string, strike float64, right string) (string, int, *Subscription, error) {
underlying = strings.ToUpper(strings.TrimSpace(underlying))
tradingClass = strings.ToUpper(strings.TrimSpace(tradingClass))
if tradingClass == "" {
tradingClass = underlying
}
key := optionMarketDataKeyForClass(underlying, tradingClass, expiryYMD, right, strike)
for {
if err := ctx.Err(); err != nil {
return "", 0, nil, err
}
c.subMu.Lock()
if pending := c.optionPending[key]; pending != nil {
c.subMu.Unlock()
select {
case <-ctx.Done():
return "", 0, nil, ctx.Err()
case <-pending.done:
if pending.err != nil {
return "", 0, nil, pending.err
}
continue
}
}
if sub := c.subscriptions[key]; sub != nil && sub.optionReaders > 0 && c.SessionCurrent(sub.optionSession) {
sub.optionReaders++
id := sub.ReqID
c.subMu.Unlock()
return key, id, sub, nil
}
if c.optionPending == nil {
c.optionPending = make(map[string]*optionSubscriptionFlight)
}
pending := &optionSubscriptionFlight{done: make(chan struct{})}
c.optionPending[key] = pending
c.subMu.Unlock()
// Contract resolution and slot admission happen once per key, without
// holding the subscription mutex. Other contracts continue independently.
actual, id, err := c.subscribeOption(ctx, underlying, tradingClass, expiryYMD, strike, right)
c.subMu.Lock()
sub := c.subscriptions[actual]
if err == nil && (sub == nil || sub.ReqID != id || !c.SessionCurrent(sub.optionSession)) {
err = ErrIBKRUnavailable
}
pending.err = err
delete(c.optionPending, key)
close(pending.done)
c.subMu.Unlock()
return actual, id, sub, err
}
}
package ibkr
import (
"strconv"
"strings"
)
const (
// OrderLifecycleEventOpenOrder identifies an openOrder callback.
OrderLifecycleEventOpenOrder = "openOrder"
// OrderLifecycleEventStatus identifies an orderStatus callback.
OrderLifecycleEventStatus = "orderStatus"
// OrderLifecycleEventExecDetails identifies an execDetails callback.
OrderLifecycleEventExecDetails = "execDetails"
// OrderLifecycleEventError identifies a correlated broker error synthesized by Connector.
OrderLifecycleEventError = "error"
)
// OrderLifecycleEvent is a typed subset of one broker order callback. Type
// identifies which fields are meaningful. Status remains the broker's text and
// no individual callback, socket write, or local mutation proves lifecycle
// finality; consumers must reconcile the callback sequence and broker truth.
//
// Quantities are in the broker's instrument units and prices are in the
// contract's quote currency. Numeric zero can be a real value or an absent or
// unparsable field because the wire callbacks do not preserve that distinction.
// Raw is a copied slice of untrusted wire fields and may be nil for synthesized
// error events.
type OrderLifecycleEvent struct {
ParentID *int // nil means the callback did not report a parent.
// ModifyTermsKnown is true only for decoded callbacks whose order fields
// were checked for terms the replacement encoder cannot preserve.
ModifyTermsKnown bool
ModifyUnsupported string
PrimaryExch string
OpenClose string
Deactivated *bool // Deactivated is explicit broker evidence; nil means not reported.
AlgoStrategy string
AdaptivePriority string
AlgoKnown bool
Type string // Type is one of the OrderLifecycleEvent constants.
OrderID int // OrderID is session-scoped; zero means absent.
PermID int // PermID is IBKR's permanent order ID; zero means absent.
ClientID int // ClientID identifies the TWS API client; zero is a valid client ID.
ClientIDPresent bool // ClientIDPresent distinguishes explicit client 0 from an omitted legacy field.
RequestID int // RequestID correlates execDetails requests; zero means absent.
Status string // Status is unnormalized broker state and may be empty.
ErrorCode int // ErrorCode is populated for synthesized error events.
Message string // Message is untrusted broker warning or error text.
Symbol string
SecType string
ConID int
Expiry string
Strike float64
Right string
Multiplier int
Exchange string
Currency string
LocalSymbol string
TradingClass string
Action string
TotalQuantity float64 // TotalQuantity is in shares or contracts.
OrderType string
LimitPrice float64 // LimitPrice is in quote-currency units.
AuxPrice float64 // AuxPrice is a stop price or trailing amount.
TrailingPercent float64 // TrailingPercent is the broker percentage value, not a fraction.
TrailStopPrice float64 // TrailStopPrice is in quote-currency units.
LmtPriceOffset float64 // LmtPriceOffset is in price units.
TIF string
TriggerMethod int
OutsideRth bool
WhatIf bool
Filled float64 // Filled is the callback's cumulative filled quantity.
Remaining float64 // Remaining is the callback's unfilled quantity.
AvgFillPrice float64 // AvgFillPrice is in quote-currency units.
LastFillPrice float64 // LastFillPrice is in quote-currency units.
WhyHeld string
MktCapPrice float64
ExecID string
ExecTime string
Account string
OcaGroup string // OcaGroup is the order's one-cancels-all group as the broker reports it; empty means none or not reported.
OcaType int // OcaType is that group's type (1 cancel with block, 2 reduce with block, 3 reduce without block); zero means none or not reported.
ExecutionSide string
Shares float64 // Shares is this execution's quantity in shares or contracts.
Price float64 // Price is this execution's price in quote-currency units.
CumQty float64 // CumQty is the execution's cumulative filled quantity.
OrderRef string
Raw []string
}
// OrderLifecycleReceipt binds one parsed broker lifecycle event to the exact
// Connection object and socket epoch that read its wire frame. Session is
// process-local evidence, not durable order authority.
type OrderLifecycleReceipt struct {
// Uncertain reports a status observed during a modify preview. Event is
// ambiguous preview/working-order evidence and must not be journaled as fact.
Uncertain bool
Session ConnectorSessionBinding
Event OrderLifecycleEvent
}
// ParseOrderLifecycleEvent parses openOrder, orderStatus, and execDetails wire
// callbacks. It accepts both legacy and summarized protobuf forms. Unknown or
// malformed messages return ok=false and a zero event; broker error events are
// correlated and synthesized separately by Connector. Parsed events are
// observations, not a finality decision.
func ParseOrderLifecycleEvent(fields []string) (ev OrderLifecycleEvent, ok bool) {
if len(fields) == 0 {
return OrderLifecycleEvent{}, false
}
msgID, err := strconv.Atoi(strings.TrimSpace(fields[0]))
if err != nil {
return OrderLifecycleEvent{}, false
}
switch msgID {
case msgOpenOrder:
return parseOpenOrderEvent(fields)
case msgOrderStatus:
return parseOrderStatusEvent(fields)
case msgExecDetails:
return parseExecDetailsEvent(fields)
default:
return OrderLifecycleEvent{}, false
}
}
func parseOpenOrderEvent(fields []string) (OrderLifecycleEvent, bool) {
if len(fields) > 1 && fields[1] == "protobuf" {
return parseOpenOrderProtoEvent(fields)
}
start := 1
if len(fields) > 2 && orderEventIntOK(fields[1]) {
start = 2
}
if len(fields) <= start+17 {
return OrderLifecycleEvent{}, false
}
statusIdx := start + 18
whatIf := false
if parseIBKRBool(orderEventField(fields, statusIdx)) && orderWhatIfStateStatus(orderEventField(fields, statusIdx+1)) {
whatIf = true
statusIdx++
}
ev := OrderLifecycleEvent{
Type: OrderLifecycleEventOpenOrder,
OrderID: orderEventInt(fields[start]),
ConID: orderEventInt(orderEventField(fields, start+1)),
PermID: orderEventInt(orderEventField(fields, start+19)),
Symbol: strings.ToUpper(strings.TrimSpace(orderEventField(fields, start+2))),
SecType: strings.ToUpper(strings.TrimSpace(orderEventField(fields, start+3))),
Expiry: strings.TrimSpace(orderEventField(fields, start+4)),
Strike: orderEventFloat(orderEventField(fields, start+5)),
Right: strings.ToUpper(strings.TrimSpace(orderEventField(fields, start+6))),
Multiplier: orderEventInt(orderEventField(fields, start+7)),
Exchange: strings.TrimSpace(orderEventField(fields, start+8)),
Currency: strings.ToUpper(strings.TrimSpace(orderEventField(fields, start+9))),
LocalSymbol: strings.TrimSpace(orderEventField(fields, start+10)),
TradingClass: strings.TrimSpace(orderEventField(fields, start+11)),
Action: strings.ToUpper(strings.TrimSpace(orderEventField(fields, start+12))),
TotalQuantity: orderEventFloat(orderEventField(fields, start+13)),
OrderType: strings.ToUpper(strings.TrimSpace(orderEventField(fields, start+14))),
LimitPrice: orderEventFloat(orderEventField(fields, start+15)),
AuxPrice: orderEventFloat(orderEventField(fields, start+16)),
TIF: strings.ToUpper(strings.TrimSpace(orderEventField(fields, start+17))),
WhatIf: whatIf,
Status: strings.TrimSpace(orderEventField(fields, statusIdx)),
Raw: append([]string{}, fields...),
}
return ev, ev.OrderID > 0
}
func parseOrderStatusEvent(fields []string) (OrderLifecycleEvent, bool) {
if len(fields) > 1 && fields[1] == "protobuf" {
return parseOrderStatusProtoEvent(fields)
}
start := 1
if len(fields) > 3 && orderEventIntOK(fields[1]) && orderEventIntOK(fields[2]) {
start = 2
}
if len(fields) <= start+3 {
return OrderLifecycleEvent{}, false
}
ev := OrderLifecycleEvent{
Type: OrderLifecycleEventStatus,
OrderID: orderEventInt(fields[start]),
Status: strings.TrimSpace(orderEventField(fields, start+1)),
Filled: orderEventFloat(orderEventField(fields, start+2)),
Remaining: orderEventFloat(orderEventField(fields, start+3)),
AvgFillPrice: orderEventFloat(orderEventField(fields, start+4)),
PermID: orderEventInt(orderEventField(fields, start+5)),
LastFillPrice: orderEventFloat(orderEventField(fields, start+7)),
ClientID: orderEventInt(orderEventField(fields, start+8)),
ClientIDPresent: len(fields) > start+8 && orderEventIntOK(orderEventField(fields, start+8)),
WhyHeld: strings.TrimSpace(orderEventField(fields, start+9)),
MktCapPrice: orderEventFloat(orderEventField(fields, start+10)),
Raw: append([]string{}, fields...),
}
return ev, ev.OrderID > 0 && ev.Status != ""
}
func parseExecDetailsEvent(fields []string) (OrderLifecycleEvent, bool) {
if len(fields) > 1 && fields[1] == "protobuf" {
return parseExecDetailsProtoEvent(fields)
}
start := 1
if len(fields) > 2 && orderEventIntOK(fields[1]) {
start = 2
}
if len(fields) <= start+19 {
return OrderLifecycleEvent{}, false
}
ev := OrderLifecycleEvent{
Type: OrderLifecycleEventExecDetails,
RequestID: orderEventInt(orderEventField(fields, start)),
OrderID: orderEventInt(orderEventField(fields, start+1)),
ConID: orderEventInt(orderEventField(fields, start+2)),
Symbol: strings.ToUpper(strings.TrimSpace(orderEventField(fields, start+3))),
SecType: strings.ToUpper(strings.TrimSpace(orderEventField(fields, start+4))),
Expiry: strings.TrimSpace(orderEventField(fields, start+5)),
Strike: orderEventFloat(orderEventField(fields, start+6)),
Right: strings.ToUpper(strings.TrimSpace(orderEventField(fields, start+7))),
Multiplier: orderEventInt(orderEventField(fields, start+8)),
Exchange: strings.TrimSpace(orderEventField(fields, start+9)),
Currency: strings.ToUpper(strings.TrimSpace(orderEventField(fields, start+10))),
LocalSymbol: strings.TrimSpace(orderEventField(fields, start+11)),
ExecID: strings.TrimSpace(orderEventField(fields, start+12)),
ExecTime: strings.TrimSpace(orderEventField(fields, start+13)),
Account: strings.TrimSpace(orderEventField(fields, start+14)),
ExecutionSide: strings.ToUpper(strings.TrimSpace(orderEventField(fields, start+16))),
Shares: orderEventFloat(orderEventField(fields, start+17)),
Price: orderEventFloat(orderEventField(fields, start+18)),
PermID: orderEventInt(orderEventField(fields, start+19)),
ClientID: orderEventInt(orderEventField(fields, start+20)),
ClientIDPresent: len(fields) > start+20 && orderEventIntOK(orderEventField(fields, start+20)),
CumQty: orderEventFloat(orderEventField(fields, start+22)),
AvgFillPrice: orderEventFloat(orderEventField(fields, start+23)),
OrderRef: strings.TrimSpace(orderEventField(fields, start+24)),
Raw: append([]string{}, fields...),
}
return ev, ev.ExecID != "" && (ev.OrderID > 0 || ev.PermID > 0 || ev.OrderRef != "")
}
func parseOpenOrderProtoEvent(fields []string) (OrderLifecycleEvent, bool) {
if summaryFieldValue(fields, "decode_error=") != "" {
return OrderLifecycleEvent{}, false
}
var deactivated *bool
if raw := summaryFieldValue(fields, "deactivated="); raw != "" {
v, err := strconv.ParseBool(raw)
if err != nil {
return OrderLifecycleEvent{}, false
}
deactivated = &v
}
clientID, clientIDPresent := orderEventSummaryInt(fields, "clientId=")
ev := OrderLifecycleEvent{
ParentID: orderEventParentID(fields),
ModifyTermsKnown: summaryFieldValue(fields, "modifyTermsKnown=") == "true",
ModifyUnsupported: summaryFieldValue(fields, "modifyUnsupported="),
PrimaryExch: summaryFieldValue(fields, "primaryExch="),
OpenClose: summaryFieldValue(fields, "openClose="),
Deactivated: deactivated,
AlgoKnown: true,
AlgoStrategy: summaryFieldValue(fields, "algoStrategy="),
AdaptivePriority: summaryFieldValue(fields, "adaptivePriority="),
Type: OrderLifecycleEventOpenOrder,
OrderID: orderEventInt(summaryFieldValue(fields, "orderId=")),
ConID: orderEventInt(summaryFieldValue(fields, "conId=")),
PermID: orderEventInt(summaryFieldValue(fields, "permId=")),
ClientID: clientID,
ClientIDPresent: clientIDPresent,
Symbol: strings.ToUpper(strings.TrimSpace(summaryFieldValue(fields, "symbol="))),
SecType: strings.ToUpper(strings.TrimSpace(summaryFieldValue(fields, "secType="))),
Expiry: strings.TrimSpace(summaryFieldValue(fields, "expiry=")),
Strike: orderEventFloat(summaryFieldValue(fields, "strike=")),
Right: strings.ToUpper(strings.TrimSpace(summaryFieldValue(fields, "right="))),
Multiplier: orderEventInt(summaryFieldValue(fields, "multiplier=")),
Exchange: strings.TrimSpace(summaryFieldValue(fields, "exchange=")),
Currency: strings.ToUpper(strings.TrimSpace(summaryFieldValue(fields, "currency="))),
LocalSymbol: strings.TrimSpace(summaryFieldValue(fields, "localSymbol=")),
TradingClass: strings.TrimSpace(summaryFieldValue(fields, "tradingClass=")),
Action: strings.ToUpper(strings.TrimSpace(summaryFieldValue(fields, "action="))),
TotalQuantity: orderEventFloat(summaryFieldValue(fields, "qty=")),
OrderType: strings.ToUpper(strings.TrimSpace(summaryFieldValue(fields, "orderType="))),
LimitPrice: orderEventFloat(summaryFieldValue(fields, "lmtPrice=")),
AuxPrice: orderEventFloat(summaryFieldValue(fields, "auxPrice=")),
TrailingPercent: orderEventFloat(summaryFieldValue(fields, "trailingPercent=")),
TrailStopPrice: orderEventFloat(summaryFieldValue(fields, "trailStopPrice=")),
LmtPriceOffset: orderEventFloat(summaryFieldValue(fields, "lmtPriceOffset=")),
TIF: strings.ToUpper(strings.TrimSpace(summaryFieldValue(fields, "tif="))),
TriggerMethod: orderEventInt(summaryFieldValue(fields, "triggerMethod=")),
OutsideRth: protoSummaryBool(fields, "outsideRth="),
WhatIf: protoSummaryBool(fields, "whatIf="),
Account: strings.TrimSpace(summaryFieldValue(fields, "account=")),
OrderRef: strings.TrimSpace(summaryFieldValue(fields, "orderRef=")),
OcaGroup: strings.TrimSpace(summaryFieldValue(fields, "ocaGroup=")),
OcaType: orderEventInt(summaryFieldValue(fields, "ocaType=")),
Status: strings.TrimSpace(summaryFieldValue(fields, "status=")),
Message: orderEventWarningMessage(fields),
Raw: append([]string{}, fields...),
}
return ev, ev.OrderID > 0
}
func orderEventWarningMessage(fields []string) string {
rejectReason := strings.TrimSpace(summaryFieldValue(fields, "rejectReason="))
warningText := strings.TrimSpace(summaryFieldValue(fields, "warningText="))
switch {
case rejectReason != "" && warningText != "":
return rejectReason + "; " + warningText
case rejectReason != "":
return rejectReason
default:
return warningText
}
}
func parseOrderStatusProtoEvent(fields []string) (OrderLifecycleEvent, bool) {
clientID, clientIDPresent := orderEventSummaryInt(fields, "clientId=")
ev := OrderLifecycleEvent{
ParentID: orderEventParentID(fields),
Type: OrderLifecycleEventStatus,
OrderID: orderEventInt(summaryFieldValue(fields, "orderId=")),
ConID: orderEventInt(summaryFieldValue(fields, "conId=")),
Status: strings.TrimSpace(summaryFieldValue(fields, "status=")),
Filled: orderEventFloat(summaryFieldValue(fields, "filled=")),
Remaining: orderEventFloat(summaryFieldValue(fields, "remaining=")),
AvgFillPrice: orderEventFloat(summaryFieldValue(fields, "avgFillPrice=")),
PermID: orderEventInt(summaryFieldValue(fields, "permId=")),
LastFillPrice: orderEventFloat(summaryFieldValue(fields, "lastFillPrice=")),
ClientID: clientID,
ClientIDPresent: clientIDPresent,
WhyHeld: strings.TrimSpace(summaryFieldValue(fields, "whyHeld=")),
MktCapPrice: orderEventFloat(summaryFieldValue(fields, "mktCapPrice=")),
Raw: append([]string{}, fields...),
}
return ev, ev.OrderID > 0 && ev.Status != ""
}
func parseExecDetailsProtoEvent(fields []string) (OrderLifecycleEvent, bool) {
clientID, clientIDPresent := orderEventSummaryInt(fields, "clientId=")
ev := OrderLifecycleEvent{
Type: OrderLifecycleEventExecDetails,
RequestID: orderEventInt(summaryFieldValue(fields, "reqId=")),
OrderID: orderEventInt(summaryFieldValue(fields, "orderId=")),
ConID: orderEventInt(summaryFieldValue(fields, "conId=")),
Symbol: strings.ToUpper(strings.TrimSpace(summaryFieldValue(fields, "symbol="))),
SecType: strings.ToUpper(strings.TrimSpace(summaryFieldValue(fields, "secType="))),
Expiry: strings.TrimSpace(summaryFieldValue(fields, "expiry=")),
Strike: orderEventFloat(summaryFieldValue(fields, "strike=")),
Right: strings.ToUpper(strings.TrimSpace(summaryFieldValue(fields, "right="))),
Multiplier: orderEventInt(summaryFieldValue(fields, "multiplier=")),
Exchange: strings.TrimSpace(summaryFieldValue(fields, "exchange=")),
Currency: strings.ToUpper(strings.TrimSpace(summaryFieldValue(fields, "currency="))),
LocalSymbol: strings.TrimSpace(summaryFieldValue(fields, "localSymbol=")),
ExecID: strings.TrimSpace(summaryFieldValue(fields, "execId=")),
ExecTime: strings.TrimSpace(summaryFieldValue(fields, "execTime=")),
Account: strings.TrimSpace(summaryFieldValue(fields, "account=")),
ExecutionSide: strings.ToUpper(strings.TrimSpace(summaryFieldValue(fields, "executionSide="))),
Shares: orderEventFloat(summaryFieldValue(fields, "shares=")),
Price: orderEventFloat(summaryFieldValue(fields, "price=")),
PermID: orderEventInt(summaryFieldValue(fields, "permId=")),
ClientID: clientID,
ClientIDPresent: clientIDPresent,
CumQty: orderEventFloat(summaryFieldValue(fields, "cumQty=")),
AvgFillPrice: orderEventFloat(summaryFieldValue(fields, "avgFillPrice=")),
OrderRef: strings.TrimSpace(summaryFieldValue(fields, "orderRef=")),
Raw: append([]string{}, fields...),
}
return ev, ev.ExecID != "" && (ev.OrderID > 0 || ev.PermID > 0 || ev.OrderRef != "")
}
func orderEventField(fields []string, idx int) string {
if idx < 0 || idx >= len(fields) {
return ""
}
return fields[idx]
}
func orderEventInt(s string) int {
v, _ := strconv.Atoi(strings.TrimSpace(s))
return v
}
func orderEventIntOK(s string) bool {
_, err := strconv.Atoi(strings.TrimSpace(s))
return err == nil
}
func orderEventSummaryInt(fields []string, prefix string) (int, bool) {
for _, field := range fields {
value, ok := strings.CutPrefix(field, prefix)
if !ok || !orderEventIntOK(value) {
continue
}
return orderEventInt(value), true
}
return 0, false
}
func orderEventFloat(s string) float64 {
v, _ := strconv.ParseFloat(strings.TrimSpace(s), 64)
return v
}
func orderEventParentID(fields []string) *int {
n, ok := orderEventSummaryInt(fields, "parentId=")
if !ok {
return nil
}
return &n
}
package ibkr
import (
"context"
"fmt"
"maps"
"strings"
)
type previewStatusDisposition uint8
const (
previewStatusOrdinary previewStatusDisposition = iota
previewStatusIgnore
previewStatusUncertain
)
func (c *Connection) setOrderStatusObserver(observer func([]string, uint64, previewStatusDisposition)) {
c.openOrderObserverMu.Lock()
c.orderStatusObserver = observer
c.openOrderObserverMu.Unlock()
}
func (c *Connection) dispatchOrderStatusObserver(fields []string, epoch uint64, disposition previewStatusDisposition) {
c.openOrderObserverMu.RLock()
observer := c.orderStatusObserver
c.openOrderObserverMu.RUnlock()
if observer != nil {
observer(fields, epoch, disposition)
}
}
// A status callback has no WhatIf discriminator. For a preview reusing a
// working order ID, retain uncertainty rather than guessing which it describes.
// All maps and generations below are protected by reqIDMu and scoped to epoch.
func (c *Connection) beginModifyPreview(id int, epoch uint64) error {
c.reqIDMu.Lock()
defer c.reqIDMu.Unlock()
if c.brokerSessionEpoch.Load() != epoch {
return fmt.Errorf("%w: broker session changed before modify preview", ErrBrokerIDNamespaceConflict)
}
if c.modifyPreviewIDs[id] || c.modifyStatusUncertain[id] != 0 {
return fmt.Errorf("%w: order preview is active or its status is uncertain; refresh open orders and preview again", ErrBrokerIDNamespaceConflict)
}
if c.modifyPreviewIDs == nil {
c.modifyPreviewIDs = make(map[int]bool)
}
c.modifyPreviewIDs[id] = true
c.modifyPreviewGeneration++
return nil
}
// Client zero has no inherited-order adoption. Recovery may use the exact
// identity supplied for an ID this Connector actually placed, without creating
// an own-order report or acquiring authority over a hand order.
func (c *Connection) rememberLocalPreviewIdentity(id int, epoch uint64, change *OrderChangeIdentity, locallyPlaced bool) {
if !locallyPlaced || change == nil || change.PermID <= 0 || strings.TrimSpace(change.Account) == "" || strings.TrimSpace(change.OrderRef) == "" {
return
}
c.reqIDMu.Lock()
defer c.reqIDMu.Unlock()
if c.brokerSessionEpoch.Load() != epoch || c.config == nil || c.config.ClientID != 0 {
return
}
if c.modifyLocalIdentity == nil {
c.modifyLocalIdentity = make(map[int]OrderChangeIdentity)
}
if _, exists := c.modifyLocalIdentity[id]; !exists {
c.modifyLocalIdentity[id] = *change
}
}
func clientZeroSnapshotNames(id int, want OrderChangeIdentity, orders []OrderLifecycleEvent) bool {
seen := false
for _, ev := range orders {
if ev.OrderID != id || !ev.ClientIDPresent || ev.ClientID != 0 || ev.WhatIf {
continue
}
if ev.PermID != want.PermID || !strings.EqualFold(strings.TrimSpace(ev.Account), strings.TrimSpace(want.Account)) || strings.TrimSpace(ev.OrderRef) != strings.TrimSpace(want.OrderRef) || (ev.Status != "Submitted" && ev.Status != "PreSubmitted") {
return false
}
seen = true
}
return seen
}
func (c *Connection) finishModifyPreview(id int, epoch uint64) bool {
c.reqIDMu.Lock()
defer c.reqIDMu.Unlock()
if c.brokerSessionEpoch.Load() != epoch {
return false
}
if c.modifyPreviewIDs[id] {
delete(c.modifyPreviewIDs, id)
c.modifyPreviewGeneration++
}
return c.modifyStatusUncertain[id] != 0
}
// Snapshot both markers under one lock interval. Completion clears the
// generic marker before the modify marker, so it cannot look like a new-order
// preview halfway through classification. No path holds whatIfOrdersMu while
// acquiring reqIDMu.
func (c *Connection) orderPreviewDisposition(id int, epoch uint64) previewStatusDisposition {
c.reqIDMu.Lock()
defer c.reqIDMu.Unlock()
if c.brokerSessionEpoch.Load() != epoch {
return previewStatusOrdinary
}
c.whatIfOrdersMu.Lock()
defer c.whatIfOrdersMu.Unlock()
if c.modifyPreviewIDs[id] {
return previewStatusUncertain
}
if _, ok := c.whatIfOrderIDs[id]; ok {
return previewStatusIgnore
}
return previewStatusOrdinary
}
func (c *Connection) modifyPreviewActive(id int, epoch uint64) bool {
c.reqIDMu.Lock()
defer c.reqIDMu.Unlock()
return c.brokerSessionEpoch.Load() == epoch && c.modifyPreviewIDs[id]
}
func (c *Connection) modifyStatusIsUncertain(id int, epoch uint64) bool {
c.reqIDMu.Lock()
defer c.reqIDMu.Unlock()
return c.brokerSessionEpoch.Load() == epoch && c.modifyStatusUncertain[id] != 0
}
// Recovery belongs to a request begun after the preview ended, never an older
// shared flight that may have filtered the working order as a preview echo.
func (c *Connection) modifyStatusRecoveryTokens(epoch uint64) (map[int]uint64, uint64, bool) {
c.reqIDMu.Lock()
defer c.reqIDMu.Unlock()
tokens := make(map[int]uint64)
valid := c.brokerSessionEpoch.Load() == epoch && len(c.modifyPreviewIDs) == 0
if valid {
maps.Copy(tokens, c.modifyStatusUncertain)
}
return tokens, c.modifyPreviewGeneration, valid
}
func (c *Connection) recoverModifyStatusFromSnapshot(epoch uint64, tokens map[int]uint64, generation uint64, orders []OrderLifecycleEvent) bool {
unlock := c.lockEvidenceChange()
defer unlock()
c.reqIDMu.Lock()
defer c.reqIDMu.Unlock()
if c.brokerSessionEpoch.Load() != epoch || len(c.modifyPreviewIDs) != 0 || c.modifyPreviewGeneration != generation {
return false
}
for _, ev := range orders {
serial := tokens[ev.OrderID]
if serial == 0 || c.modifyStatusUncertain[ev.OrderID] != serial || ev.WhatIf ||
!ev.ClientIDPresent || c.config == nil || ev.ClientID != c.config.ClientID ||
(ev.Status != "Submitted" && ev.Status != "PreSubmitted") {
continue
}
if c.config.ClientID == 0 {
want, known := c.modifyLocalIdentity[ev.OrderID]
if known && clientZeroSnapshotNames(ev.OrderID, want, orders) {
delete(c.modifyStatusUncertain, ev.OrderID)
}
continue
}
report, ok := c.reportedOwnOrderIDs[ev.OrderID]
if !ok || !report.matches(&OrderChangeIdentity{PermID: ev.PermID, Account: strings.TrimSpace(ev.Account), OrderRef: strings.TrimSpace(ev.OrderRef)}) {
continue
}
delete(c.modifyStatusUncertain, ev.OrderID)
}
return true
}
func (c *Connector) refreshAfterAmbiguousPreview(ctx context.Context, binding ConnectorSessionBinding) {
// Let a pre-window flight reach its own terminator; it cannot recover this
// order. Caller cancellation leaves the fence intact and does not poison a
// request merely because this waiter stopped waiting.
c.openOrderSnapshotMu.Lock()
old := c.openOrderSnapshot
c.openOrderSnapshotMu.Unlock()
if old != nil {
select {
case <-old.done:
case <-ctx.Done():
return
}
}
if !c.SessionCurrent(binding) || ctx.Err() != nil {
return
}
_, _ = c.SnapshotOpenOrders(ctx)
}
package ibkr
import (
"fmt"
"math"
"strconv"
"strings"
)
const (
protoOrderStatusMsgID = msgOrderStatus + protoBufMsgID
protoOpenOrderMsgID = msgOpenOrder + protoBufMsgID
protoExecDetailsMsgID = msgExecDetails + protoBufMsgID
protoOpenOrderEndMsgID = msgOpenOrderEnd + protoBufMsgID
protoExecDetailsEndMsgID = msgExecDetailsEnd + protoBufMsgID
)
type inboundOrderProtoSummary struct {
conID int
modifyTermsKnown bool
modifyUnsupported string
openClose string
deactivated *bool
algoStrategy string
adaptivePriority string
orderID int
requestID int
permID int
parentID int
parentIDKnown bool
clientID int
status string
symbol string
secType string
expiry string
strike float64
right string
multiplier string
exchange string
primaryExch string
currency string
localSymbol string
tradingClass string
action string
quantity string
orderType string
lmtPrice float64
auxPrice float64
trailingPercent float64
trailStopPrice float64
lmtPriceOffset float64
tif string
triggerMethod int
account string
orderRef string
ocaGroup string
ocaType int
outsideRth bool
whatIf bool
transmit bool
filled string
remaining string
avgFillPrice float64
lastFillPrice float64
whyHeld string
mktCapPrice float64
initMarginBefore float64
maintMarginBefore float64
equityWithLoanBefore float64
initMarginChange float64
maintMarginChange float64
equityWithLoanChange float64
initMarginAfter float64
maintMarginAfter float64
equityWithLoanAfter float64
commission float64
minCommission float64
maxCommission float64
commissionCurrency string
// stateSent marks the OrderState doubles (fields 2–13) IBKR sent: an
// absent one reads empty, never 0, so a WhatIf without a commission
// envelope is not mistaken for an explicit zero fee.
stateSent uint32
marginCurrency string
rejectReason string
warningText string
execID string
execTime string
execAccount string
executionExchange string
executionSide string
shares string
price float64
cumQty string
}
// stateDouble formats an OrderState double IBKR sent; one it did not send is
// empty, which the WhatIf reader reads as unknown.
func (s inboundOrderProtoSummary) stateDouble(field int, value float64) string {
if s.stateSent&(1<<field) == 0 {
return ""
}
return formatProtoSummaryFloat(value)
}
func summarizeInboundOrderProtoCallback(wireMsgID int, body []byte) ([]string, bool) {
switch wireMsgID {
case protoOpenOrderMsgID:
return summarizeOpenOrderProtoCallback(body), true
case protoOrderStatusMsgID:
return summarizeOrderStatusProtoCallback(body), true
case protoExecDetailsMsgID:
return summarizeExecDetailsProtoCallback(body), true
case protoOpenOrderEndMsgID:
return []string{strconv.Itoa(msgOpenOrderEnd), "protobuf", "wire_msg_id=" + strconv.Itoa(wireMsgID)}, true
case protoExecDetailsEndMsgID:
return summarizeExecDetailsEndProtoCallback(body), true
default:
return nil, false
}
}
func summarizeOpenOrderProtoCallback(body []byte) []string {
fields := inboundProtoBaseFields(msgOpenOrder, protoOpenOrderMsgID)
summary, err := parseOpenOrderProtoCallback(body)
if err != nil {
return append(fields, "decode_error="+err.Error())
}
fields = append(fields,
"orderId="+strconv.Itoa(summary.orderID),
"permId="+strconv.Itoa(summary.permID),
"clientId="+strconv.Itoa(summary.clientID),
)
fields = appendContractSummaryFields(fields, summary)
fields = appendOrderSummaryFields(fields, summary)
fields = append(fields,
"status="+summary.status,
"initMarginBefore="+summary.stateDouble(2, summary.initMarginBefore),
"maintMarginBefore="+summary.stateDouble(3, summary.maintMarginBefore),
"equityWithLoanBefore="+summary.stateDouble(4, summary.equityWithLoanBefore),
"initMarginChange="+summary.stateDouble(5, summary.initMarginChange),
"maintMarginChange="+summary.stateDouble(6, summary.maintMarginChange),
"equityWithLoanChange="+summary.stateDouble(7, summary.equityWithLoanChange),
"initMarginAfter="+summary.stateDouble(8, summary.initMarginAfter),
"maintMarginAfter="+summary.stateDouble(9, summary.maintMarginAfter),
"equityWithLoanAfter="+summary.stateDouble(10, summary.equityWithLoanAfter),
"commission="+summary.stateDouble(11, summary.commission),
"minCommission="+summary.stateDouble(12, summary.minCommission),
"maxCommission="+summary.stateDouble(13, summary.maxCommission),
"commissionCurrency="+summary.commissionCurrency,
"marginCurrency="+summary.marginCurrency,
"rejectReason="+summary.rejectReason,
"warningText="+summary.warningText,
)
return fields
}
func summarizeOrderStatusProtoCallback(body []byte) []string {
fields := inboundProtoBaseFields(msgOrderStatus, protoOrderStatusMsgID)
summary, err := parseOrderStatusProtoCallback(body)
if err != nil {
return append(fields, "decode_error="+err.Error())
}
if summary.parentIDKnown {
fields = append(fields, "parentId="+strconv.Itoa(summary.parentID))
}
return append(fields,
"orderId="+strconv.Itoa(summary.orderID),
"status="+summary.status,
"filled="+summary.filled,
"remaining="+summary.remaining,
"avgFillPrice="+formatProtoSummaryFloat(summary.avgFillPrice),
"permId="+strconv.Itoa(summary.permID),
"lastFillPrice="+formatProtoSummaryFloat(summary.lastFillPrice),
"clientId="+strconv.Itoa(summary.clientID),
"whyHeld="+summary.whyHeld,
"mktCapPrice="+formatProtoSummaryFloat(summary.mktCapPrice),
)
}
func summarizeExecDetailsProtoCallback(body []byte) []string {
fields := inboundProtoBaseFields(msgExecDetails, protoExecDetailsMsgID)
summary, err := parseExecDetailsProtoCallback(body)
if err != nil {
return append(fields, "decode_error="+err.Error())
}
fields = append(fields, "reqId="+strconv.Itoa(summary.requestID))
fields = appendContractSummaryFields(fields, summary)
return append(fields,
"orderId="+strconv.Itoa(summary.orderID),
"execId="+summary.execID,
"execTime="+summary.execTime,
"account="+summary.execAccount,
"executionExchange="+summary.executionExchange,
"executionSide="+summary.executionSide,
"shares="+summary.shares,
"price="+formatProtoSummaryFloat(summary.price),
"permId="+strconv.Itoa(summary.permID),
"clientId="+strconv.Itoa(summary.clientID),
"cumQty="+summary.cumQty,
"avgFillPrice="+formatProtoSummaryFloat(summary.avgFillPrice),
"orderRef="+summary.orderRef,
)
}
func inboundProtoBaseFields(baseMsgID, wireMsgID int) []string {
return []string{strconv.Itoa(baseMsgID), "protobuf", "wire_msg_id=" + strconv.Itoa(wireMsgID)}
}
func appendContractSummaryFields(fields []string, summary inboundOrderProtoSummary) []string {
return append(fields,
"conId="+strconv.Itoa(summary.conID),
"symbol="+summary.symbol,
"secType="+summary.secType,
"expiry="+summary.expiry,
"strike="+formatProtoSummaryFloat(summary.strike),
"right="+summary.right,
"multiplier="+summary.multiplier,
"exchange="+summary.exchange,
"primaryExch="+summary.primaryExch,
"currency="+summary.currency,
"localSymbol="+summary.localSymbol,
"tradingClass="+summary.tradingClass,
)
}
func appendOrderSummaryFields(fields []string, summary inboundOrderProtoSummary) []string {
if summary.parentIDKnown {
fields = append(fields, "parentId="+strconv.Itoa(summary.parentID))
}
if summary.deactivated != nil {
fields = append(fields, "deactivated="+strconv.FormatBool(*summary.deactivated))
}
return append(fields,
"modifyTermsKnown="+strconv.FormatBool(summary.modifyTermsKnown),
"modifyUnsupported="+summary.modifyUnsupported,
"openClose="+summary.openClose,
"algoStrategy="+summary.algoStrategy,
"adaptivePriority="+summary.adaptivePriority,
"action="+summary.action,
"qty="+summary.quantity,
"orderType="+summary.orderType,
"lmtPrice="+formatProtoSummaryFloat(summary.lmtPrice),
"auxPrice="+formatProtoSummaryFloat(summary.auxPrice),
"trailingPercent="+formatProtoSummaryFloat(summary.trailingPercent),
"trailStopPrice="+formatProtoSummaryFloat(summary.trailStopPrice),
"lmtPriceOffset="+formatProtoSummaryFloat(summary.lmtPriceOffset),
"tif="+summary.tif,
"triggerMethod="+strconv.Itoa(summary.triggerMethod),
"account="+summary.account,
"orderRef="+summary.orderRef,
"ocaGroup="+summary.ocaGroup,
"ocaType="+strconv.Itoa(summary.ocaType),
"outsideRth="+strconv.FormatBool(summary.outsideRth),
"whatIf="+strconv.FormatBool(summary.whatIf),
"transmit="+strconv.FormatBool(summary.transmit),
)
}
func parseOpenOrderProtoCallback(body []byte) (inboundOrderProtoSummary, error) {
var summary inboundOrderProtoSummary
err := forEachProtoField(body, func(fieldNumber, wireType int, value []byte) error {
switch fieldNumber {
case 1:
v, err := protoVarintValue(fieldNumber, wireType, value)
if err != nil {
return err
}
summary.orderID = int(v)
case 2:
return parseInboundContractProto(value, &summary)
case 3:
return parseInboundOrderProto(value, &summary)
case 4:
return parseInboundOrderStateProto(value, &summary)
}
return nil
})
if !summary.transmit && summary.modifyUnsupported == "" {
summary.modifyUnsupported = "order is not explicitly transmitted"
}
return summary, err
}
func parseOrderStatusProtoCallback(body []byte) (inboundOrderProtoSummary, error) {
var summary inboundOrderProtoSummary
err := forEachProtoField(body, func(fieldNumber, wireType int, value []byte) error {
switch fieldNumber {
case 1:
v, err := protoVarintValue(fieldNumber, wireType, value)
if err != nil {
return err
}
summary.orderID = int(v)
case 2:
summary.status = string(value)
case 3:
summary.filled = string(value)
case 4:
summary.remaining = string(value)
case 5:
v, err := protoFixed64Value(fieldNumber, wireType, value)
if err != nil {
return err
}
summary.avgFillPrice = math.Float64frombits(v)
case 6:
v, err := protoVarintValue(fieldNumber, wireType, value)
if err != nil {
return err
}
summary.permID = int(v)
case 7:
v, err := protoVarintValue(fieldNumber, wireType, value)
if err != nil {
return err
}
summary.parentID = int(v)
summary.parentIDKnown = true
case 8:
v, err := protoFixed64Value(fieldNumber, wireType, value)
if err != nil {
return err
}
summary.lastFillPrice = math.Float64frombits(v)
case 9:
v, err := protoVarintValue(fieldNumber, wireType, value)
if err != nil {
return err
}
summary.clientID = int(v)
case 10:
summary.whyHeld = string(value)
case 11:
v, err := protoFixed64Value(fieldNumber, wireType, value)
if err != nil {
return err
}
summary.mktCapPrice = math.Float64frombits(v)
}
return nil
})
return summary, err
}
func parseExecDetailsProtoCallback(body []byte) (inboundOrderProtoSummary, error) {
var summary inboundOrderProtoSummary
err := forEachProtoField(body, func(fieldNumber, wireType int, value []byte) error {
switch fieldNumber {
case 1:
v, err := protoVarintValue(fieldNumber, wireType, value)
if err != nil {
return err
}
summary.requestID = int(v)
case 2:
return parseInboundContractProto(value, &summary)
case 3:
return parseInboundExecutionProto(value, &summary)
}
return nil
})
return summary, err
}
func parseInboundContractProto(body []byte, summary *inboundOrderProtoSummary) error {
return forEachProtoField(body, func(fieldNumber, wireType int, value []byte) error {
switch fieldNumber {
case 1:
v, err := protoVarintValue(fieldNumber, wireType, value)
if err != nil {
return err
}
summary.conID = int(int32(v))
case 2:
summary.symbol = string(value)
case 3:
summary.secType = string(value)
case 4:
summary.expiry = string(value)
case 5:
v, err := protoFixed64Value(fieldNumber, wireType, value)
if err != nil {
return err
}
summary.strike = math.Float64frombits(v)
case 6:
summary.right = string(value)
case 7:
v, err := protoFixed64Value(fieldNumber, wireType, value)
if err != nil {
return err
}
summary.multiplier = formatProtoSummaryFloat(math.Float64frombits(v))
case 8:
summary.exchange = string(value)
case 9:
summary.primaryExch = string(value)
case 10:
summary.currency = string(value)
case 11:
summary.localSymbol = string(value)
case 12:
summary.tradingClass = string(value)
case 21:
summary.expiry = string(value)
}
return nil
})
}
func parseInboundOrderProto(body []byte, summary *inboundOrderProtoSummary) error {
summary.modifyTermsKnown = true
return forEachProtoField(body, func(fieldNumber, wireType int, value []byte) error {
// Refuse non-default attributes that our replacement encoder cannot
// round-trip. Never infer their absence from the lossy public summary.
if !twsModifySupportedField(fieldNumber) && protoOrderFieldHasValue(fieldNumber, wireType, value) && summary.modifyUnsupported == "" {
summary.modifyUnsupported = fmt.Sprintf("unsupported broker order field %d", fieldNumber)
}
switch fieldNumber {
case 61:
if wireType != protoWireBytes {
return fmt.Errorf("invalid algoStrategy wire type")
}
summary.algoStrategy = string(value)
case 62:
if wireType != protoWireBytes {
return fmt.Errorf("invalid algoParams wire type")
}
var key, param string
if err := forEachProtoField(value, func(f, w int, v []byte) error {
if w != protoWireBytes {
return fmt.Errorf("invalid algo parameter wire type")
}
if f == 1 {
key = string(v)
}
if f == 2 {
param = string(v)
}
return nil
}); err != nil {
return err
}
if key == "adaptivePriority" && summary.adaptivePriority == "" {
summary.adaptivePriority = param
} else {
summary.modifyUnsupported = "unsupported or duplicate algorithm parameter"
}
case 1:
v, err := protoVarintValue(fieldNumber, wireType, value)
if err != nil {
return err
}
summary.clientID = int(v)
case 2:
v, err := protoVarintValue(fieldNumber, wireType, value)
if err != nil {
return err
}
if summary.orderID == 0 {
summary.orderID = int(v)
}
case 3:
v, err := protoVarintValue(fieldNumber, wireType, value)
if err != nil {
return err
}
summary.permID = int(v)
case 4:
v, err := protoVarintValue(fieldNumber, wireType, value)
if err != nil {
return err
}
summary.parentID = int(v)
summary.parentIDKnown = true
case 5:
summary.action = string(value)
case 6:
summary.quantity = string(value)
case 8:
summary.orderType = string(value)
case 9:
v, err := protoFixed64Value(fieldNumber, wireType, value)
if err != nil {
return err
}
summary.lmtPrice = math.Float64frombits(v)
case 10:
v, err := protoFixed64Value(fieldNumber, wireType, value)
if err != nil {
return err
}
summary.auxPrice = math.Float64frombits(v)
case 11:
summary.tif = string(value)
case 12:
summary.account = string(value)
case 19:
v, err := protoVarintValue(fieldNumber, wireType, value)
if err != nil {
return err
}
summary.outsideRth = v != 0
case 22:
v, err := protoFixed64Value(fieldNumber, wireType, value)
if err != nil {
return err
}
summary.trailingPercent = math.Float64frombits(v)
case 23:
v, err := protoFixed64Value(fieldNumber, wireType, value)
if err != nil {
return err
}
summary.trailStopPrice = math.Float64frombits(v)
case 27:
summary.ocaGroup = string(value)
case 28:
summary.orderRef = string(value)
case 30:
v, err := protoVarintValue(fieldNumber, wireType, value)
if err != nil {
return err
}
summary.ocaType = int(v)
case 31:
v, err := protoVarintValue(fieldNumber, wireType, value)
if err != nil {
return err
}
summary.triggerMethod = int(v)
case 65:
v, err := protoVarintValue(fieldNumber, wireType, value)
if err != nil {
return err
}
summary.whatIf = v != 0
case 66:
v, err := protoVarintValue(fieldNumber, wireType, value)
if err != nil {
return err
}
summary.transmit = v != 0
case 68:
summary.openClose = string(value)
case 138: // IBKR Order.deactivate; absence is not false on older callbacks.
v, err := protoVarintValue(fieldNumber, wireType, value)
if err != nil || v > 1 {
return fmt.Errorf("invalid order deactivate field")
}
active := v == 1
summary.deactivated = &active
case 99:
v, err := protoFixed64Value(fieldNumber, wireType, value)
if err != nil {
return err
}
summary.lmtPriceOffset = math.Float64frombits(v)
}
return nil
})
}
// These fields are identity/receipt fields or are preserved by the DAY LMT
// replacement. All other meaningful terms require a dedicated round trip.
func twsModifySupportedField(field int) bool {
switch field {
case 1, 2, 3, 5, 6, 8, 9, 11, 12, 19, 27, 28, 30, 31, 61, 62, 66, 68, 138:
return true
}
return false
}
func protoOrderFieldHasValue(field, wire int, value []byte) bool {
switch wire {
case protoWireBytes:
return len(value) > 0
case protoWireVarint:
v, err := protoVarintValue(field, wire, value)
// IBKR's exempt-code default is -1; other integer unset fields use MAX_INT.
return err != nil || (v != 0 && v != math.MaxInt32 && !(field == 72 && int32(v) == -1))
case protoWireFixed64:
v, err := protoFixed64Value(field, wire, value)
f := math.Float64frombits(v)
return err != nil || (f != 0 && f != math.MaxFloat64)
default:
return true
}
}
func parseInboundOrderStateProto(body []byte, summary *inboundOrderProtoSummary) error {
return forEachProtoField(body, func(fieldNumber, wireType int, value []byte) error {
switch fieldNumber {
case 1:
summary.status = string(value)
case 2:
summary.stateSent |= 1 << 2
return setProtoDouble(fieldNumber, wireType, value, &summary.initMarginBefore)
case 3:
summary.stateSent |= 1 << 3
return setProtoDouble(fieldNumber, wireType, value, &summary.maintMarginBefore)
case 4:
summary.stateSent |= 1 << 4
return setProtoDouble(fieldNumber, wireType, value, &summary.equityWithLoanBefore)
case 5:
summary.stateSent |= 1 << 5
return setProtoDouble(fieldNumber, wireType, value, &summary.initMarginChange)
case 6:
summary.stateSent |= 1 << 6
return setProtoDouble(fieldNumber, wireType, value, &summary.maintMarginChange)
case 7:
summary.stateSent |= 1 << 7
return setProtoDouble(fieldNumber, wireType, value, &summary.equityWithLoanChange)
case 8:
summary.stateSent |= 1 << 8
return setProtoDouble(fieldNumber, wireType, value, &summary.initMarginAfter)
case 9:
summary.stateSent |= 1 << 9
return setProtoDouble(fieldNumber, wireType, value, &summary.maintMarginAfter)
case 10:
summary.stateSent |= 1 << 10
return setProtoDouble(fieldNumber, wireType, value, &summary.equityWithLoanAfter)
case 11:
summary.stateSent |= 1 << 11
return setProtoDouble(fieldNumber, wireType, value, &summary.commission)
case 12:
summary.stateSent |= 1 << 12
return setProtoDouble(fieldNumber, wireType, value, &summary.minCommission)
case 13:
summary.stateSent |= 1 << 13
return setProtoDouble(fieldNumber, wireType, value, &summary.maxCommission)
case 14:
summary.commissionCurrency = string(value)
case 15:
summary.marginCurrency = string(value)
case 26:
summary.rejectReason = string(value)
case 28:
summary.warningText = string(value)
}
return nil
})
}
func parseInboundExecutionProto(body []byte, summary *inboundOrderProtoSummary) error {
return forEachProtoField(body, func(fieldNumber, wireType int, value []byte) error {
switch fieldNumber {
case 1:
v, err := protoVarintValue(fieldNumber, wireType, value)
if err != nil {
return err
}
summary.orderID = int(v)
case 2:
summary.execID = string(value)
case 3:
summary.execTime = string(value)
case 4:
summary.execAccount = string(value)
case 5:
summary.executionExchange = string(value)
case 6:
summary.executionSide = string(value)
case 7:
summary.shares = string(value)
case 8:
return setProtoDouble(fieldNumber, wireType, value, &summary.price)
case 9:
v, err := protoVarintValue(fieldNumber, wireType, value)
if err != nil {
return err
}
summary.permID = int(v)
case 10:
v, err := protoVarintValue(fieldNumber, wireType, value)
if err != nil {
return err
}
summary.clientID = int(v)
case 12:
summary.cumQty = string(value)
case 13:
return setProtoDouble(fieldNumber, wireType, value, &summary.avgFillPrice)
case 14:
summary.orderRef = string(value)
}
return nil
})
}
func setProtoDouble(fieldNumber, wireType int, value []byte, dst *float64) error {
v, err := protoFixed64Value(fieldNumber, wireType, value)
if err != nil {
return err
}
*dst = math.Float64frombits(v)
return nil
}
func protoSummaryBool(fields []string, prefix string) bool {
return strings.EqualFold(summaryFieldValue(fields, prefix), "true")
}
// summarizeExecDetailsEndProtoCallback flattens ExecutionDetailsEnd (reqId
// only) into the same summary shape as the other order callbacks, so the
// executions request can correlate its terminator in either wire encoding.
func summarizeExecDetailsEndProtoCallback(body []byte) []string {
fields := inboundProtoBaseFields(msgExecDetailsEnd, protoExecDetailsEndMsgID)
requestID := 0
err := forEachProtoField(body, func(fieldNumber, wireType int, value []byte) error {
if fieldNumber != 1 {
return nil
}
v, err := protoVarintValue(fieldNumber, wireType, value)
if err != nil {
return err
}
requestID = int(v)
return nil
})
if err != nil {
return append(fields, "decode_error="+err.Error())
}
return append(fields, "reqId="+strconv.Itoa(requestID))
}
package ibkr
import (
"context"
"errors"
"fmt"
"math"
"slices"
"strconv"
"strings"
"sync"
)
const (
// OrderWhatIfStatusUnavailable means no broker decision was obtained.
OrderWhatIfStatusUnavailable = "unavailable"
// OrderWhatIfStatusAccepted means the matching callback was not classified as rejected.
// It is preview evidence, not order acceptance, submit authority, or a working order.
OrderWhatIfStatusAccepted = "accepted"
// OrderWhatIfStatusRejected means the broker callback or error rejected the preview.
OrderWhatIfStatusRejected = "rejected"
placeOrderFieldWhatIf = 100
)
// ErrOrderWhatIfUnavailable is the package sentinel for unavailable WhatIf
// evaluation. PreviewOrderWhatIf currently reports this condition through an
// [OrderWhatIfResult] with Status set to [OrderWhatIfStatusUnavailable] and a
// nil error; validation failures still return non-nil errors.
var ErrOrderWhatIfUnavailable = errors.New("order whatif unavailable")
// OrderWhatIfMargin is the broker's pre-trade margin and commission estimate
// returned on a WhatIf openOrder callback. Margin values are monetary amounts
// in Currency, while commission values use CommissionCurrency. A nil numeric
// pointer means the broker omitted the field or supplied an unusable sentinel;
// zero is a reported numeric value.
type OrderWhatIfMargin struct {
Currency string // Currency is the currency of the margin and equity values.
InitialMarginBefore *float64
InitialMarginAfter *float64
MaintenanceMarginBefore *float64
MaintenanceMarginAfter *float64
EquityWithLoanBefore *float64
EquityWithLoanAfter *float64
Commission *float64
MinCommission *float64
MaxCommission *float64
CommissionCurrency string // CommissionCurrency is the currency of commission values.
WarningText string // WarningText is untrusted broker text and does not authorize submission.
}
// OrderWhatIfResult is a broker WhatIf evaluation. Status is one of the
// OrderWhatIfStatus constants. OrderID correlates this preview only, Message and
// AdvancedRejectJSON are untrusted broker text, and nil margin pointers mean
// the corresponding estimates were absent. The result is neither submit
// authority nor an order receipt.
type OrderWhatIfResult struct {
// WorkingOrderUncertain means a status could describe the working order
// or its preview. Refresh/reconcile broker inventory before another review.
WorkingOrderUncertain bool
OrderID int // OrderID is the request's broker order ID; zero means unavailable before allocation.
Status string // Status is the package-level WhatIf classification.
BrokerStatus string // BrokerStatus is the unnormalized status from the matching callback.
Message string // Message explains rejection or unavailability and may contain broker text.
AdvancedRejectJSON string // AdvancedRejectJSON is opaque, untrusted broker rejection data.
Margin OrderWhatIfMargin
}
// PreviewOrderWhatIf sends a broker WhatIf order preview and waits for the
// matching openOrder or error callback. It is available in both build modes:
// the encoded request has WhatIf and Transmit set true for broker evaluation,
// but does not create a working order. Preview evidence never grants submit
// authority; unrestricted PlaceOrder and CancelOrder remain build-tag guarded.
//
// The method mutates order by applying defaults and IDs and by setting WhatIf
// and Transmit. Local validation or encoding failures are returned as errors.
// A disconnected connection, send failure, or ctx completion instead returns a
// result with Status [OrderWhatIfStatusUnavailable] and a nil error. Accepted
// and rejected results reflect callback classification, not order finality.
func (c *Connection) PreviewOrderWhatIf(ctx context.Context, order *IBKROrder) (OrderWhatIfResult, error) {
return c.previewOrderWhatIfForEpoch(ctx, order, nil, nil)
}
func (c *Connection) previewOrderWhatIfForEpoch(ctx context.Context, order *IBKROrder, expectedEpoch *uint64, guard func() error) (OrderWhatIfResult, error) {
if order == nil {
return OrderWhatIfResult{}, fmt.Errorf("order is nil")
}
if !c.IsConnected() {
return orderWhatIfUnavailableResult("not connected to IBKR"), nil
}
epoch, err := preparePlaceOrder(order, c, expectedEpoch)
if err != nil {
return OrderWhatIfResult{}, err
}
order.WhatIf = true
order.Transmit = true
c.markWhatIfOrderID(order.OrderID)
defer c.clearWhatIfOrderID(order.OrderID)
resultCh := make(chan OrderWhatIfResult, 1)
register := func(msgID int, handler func([]string)) uint64 {
if expectedEpoch == nil {
return c.RegisterHandler(msgID, handler)
}
return c.RegisterHandlerAtEpoch(msgID, func(fields []string, receiptEpoch uint64) {
if receiptEpoch == *expectedEpoch {
handler(fields)
}
})
}
errorHandlerID := register(msgErrMsg, func(fields []string) {
reqID, code, msg, ok := parseOrderWhatIfError(fields)
if !ok || reqID != order.OrderID || orderWhatIfInformationalError(code) {
return
}
sendOrderWhatIfResult(resultCh, OrderWhatIfResult{
OrderID: order.OrderID,
Status: OrderWhatIfStatusRejected,
BrokerStatus: "rejected",
Message: orderWhatIfErrorMessage(code, msg, ""),
})
})
systemNoticeHandlerID := register(msgSystemNotification, func(fields []string) {
reqID, code, msg, advancedRejectJSON, ok := parseOrderWhatIfSystemNotice(fields)
if !ok || reqID != order.OrderID || orderWhatIfInformationalError(code) {
return
}
sendOrderWhatIfResult(resultCh, OrderWhatIfResult{
OrderID: order.OrderID,
Status: OrderWhatIfStatusRejected,
BrokerStatus: "rejected",
Message: orderWhatIfErrorMessage(code, msg, advancedRejectJSON),
AdvancedRejectJSON: advancedRejectJSON,
})
})
openOrderHandlerID := register(msgOpenOrder, func(fields []string) {
result, ok := parseOrderWhatIfOpenOrder(fields, order.OrderID, c.serverVersion)
if ok {
if order.AdaptivePriority != "" && result.Status == OrderWhatIfStatusAccepted &&
(summaryFieldValue(fields, "algoStrategy=") != "Adaptive" || summaryFieldValue(fields, "adaptivePriority=") != order.AdaptivePriority) {
result = orderWhatIfUnavailableResult("broker WhatIf did not confirm the requested Adaptive priority; no plain-limit fallback")
result.OrderID = order.OrderID
}
sendOrderWhatIfResult(resultCh, result)
}
})
defer c.UnregisterHandler(msgErrMsg, errorHandlerID)
defer c.UnregisterHandler(msgSystemNotification, systemNoticeHandlerID)
defer c.UnregisterHandler(msgOpenOrder, openOrderHandlerID)
// Every frame that names the order is recorded, so a WhatIf that times out
// says what IBKR did send: a notice the handlers count as informational,
// or an openOrder they could not read (a bond WhatIf timed out live on
// 2026-10-07 with nothing in the log).
trace := &whatIfFrameTrace{}
releaseTap := c.tapInboundFrames(func(fields []string, receiptEpoch uint64) {
if expectedEpoch != nil && receiptEpoch != *expectedEpoch {
return
}
if line, ok := whatIfTapFrame(fields, order.OrderID); ok {
trace.add(line)
}
})
defer releaseTap()
if err := c.sendPlaceOrderFrameGuarded(ctx, order, epoch, guard); err != nil {
return orderWhatIfUnavailableResult(fmt.Sprintf("send broker WhatIf: %v", err)), nil
}
select {
case <-ctx.Done():
message := "timeout waiting for broker WhatIf response"
if seen := trace.snapshot(); len(seen) > 0 {
message += fmt.Sprintf(" (IBKR sent for order %d: %s)", order.OrderID, strings.Join(seen, "; "))
} else {
message += fmt.Sprintf(" (IBKR sent nothing naming order %d)", order.OrderID)
}
ibkrLogger.Warnf("WhatIf %s %s: %s", order.SecType, order.Symbol, message)
return orderWhatIfUnavailableResult(message), nil
case result := <-resultCh:
if expectedEpoch != nil && c.BrokerSessionEpoch() != *expectedEpoch {
return orderWhatIfUnavailableResult("broker session changed while waiting for WhatIf response"), nil
}
return result, nil
}
}
// PreviewOrderWhatIf sends a broker WhatIf preview for a connector-level
// contract/order pair. Nil inputs fail validation. It does not mutate order or
// add to Connector.openOrders because no working order should exist. Status and
// error behavior match [Connection.PreviewOrderWhatIf].
func (c *Connector) PreviewOrderWhatIf(ctx context.Context, contract *Contract, order *RawOrder) (OrderWhatIfResult, error) {
return c.previewOrderWhatIf(ctx, contract, order, 0, nil)
}
// PreviewOrderWhatIfForSession is PreviewOrderWhatIf constrained to the exact
// connector socket generation captured by binding.
func (c *Connector) PreviewOrderWhatIfForSession(ctx context.Context, binding ConnectorSessionBinding, contract *Contract, order *RawOrder) (OrderWhatIfResult, error) {
return c.previewOrderWhatIf(ctx, contract, order, 0, &binding)
}
// PreviewOrderWhatIfWithOrderID sends a broker WhatIf preview using a positive,
// caller-supplied broker order ID. This supports evaluating a replacement draft
// for a tracked order, but does not modify that order or grant authority to do
// so. Status and error behavior match [Connection.PreviewOrderWhatIf].
func (c *Connector) PreviewOrderWhatIfWithOrderID(ctx context.Context, contract *Contract, order *RawOrder, orderID int) (OrderWhatIfResult, error) {
if orderID <= 0 {
return OrderWhatIfResult{}, fmt.Errorf("order ID must be positive")
}
return c.previewOrderWhatIf(ctx, contract, order, orderID, nil)
}
// PreviewOrderWhatIfWithOrderIDForSession is
// PreviewOrderWhatIfWithOrderID constrained to the exact connector socket
// generation captured by binding.
func (c *Connector) PreviewOrderWhatIfWithOrderIDForSession(ctx context.Context, binding ConnectorSessionBinding, contract *Contract, order *RawOrder, orderID int) (OrderWhatIfResult, error) {
if orderID <= 0 {
return OrderWhatIfResult{}, fmt.Errorf("order ID must be positive")
}
return c.previewOrderWhatIf(ctx, contract, order, orderID, &binding)
}
func (c *Connector) previewOrderWhatIf(ctx context.Context, contract *Contract, order *RawOrder, orderID int, binding *ConnectorSessionBinding) (OrderWhatIfResult, error) {
if contract == nil {
return OrderWhatIfResult{}, fmt.Errorf("contract is nil")
}
if order == nil {
return OrderWhatIfResult{}, fmt.Errorf("order is nil")
}
if !c.isConnected() {
return orderWhatIfUnavailableResult("not connected to IBKR"), nil
}
c.mu.RLock()
conn := c.conn
c.mu.RUnlock()
if binding != nil {
if !c.SessionCurrent(*binding) {
return orderWhatIfUnavailableResult("broker session changed before WhatIf request"), nil
}
conn = binding.connection
}
if conn == nil {
return orderWhatIfUnavailableResult("no active connection"), nil
}
ibkrOrder := &IBKROrder{
ParentID: order.ParentID,
AdaptivePriority: order.AdaptivePriority,
ConID: contract.ConID,
Symbol: contract.Symbol,
SecType: contract.SecType,
Expiry: contract.Expiry,
Strike: contract.Strike,
Right: contract.Right,
Multiplier: multiplierToString(contract.Multiplier),
Exchange: contract.Exchange,
PrimaryExch: contract.PrimaryExch,
Currency: contract.Currency,
LocalSymbol: contract.LocalSymbol,
TradingClass: contract.TradingClass,
SecIDType: contract.SecIDType,
SecID: contract.SecID,
BondRules: cloneBondOrderRules(contract.BondRules),
ClientID: order.ClientID,
Action: order.Action,
TotalQty: order.TotalQty,
OrderType: order.OrderType,
LmtPrice: order.LmtPrice,
AuxPrice: order.AuxPrice,
TrailStopPrice: order.TrailStopPrice,
TrailingPercent: order.TrailingPercent,
LmtPriceOffset: order.LmtPriceOffset,
TIF: order.TIF,
TriggerMethod: order.TriggerMethod,
OrderRef: order.OrderRef,
OutsideRth: order.OutsideRth,
OcaGroup: strings.TrimSpace(order.OcaGroup),
OcaType: order.OcaType,
Account: order.Account,
Transmit: false,
WhatIf: true,
OpenClose: strings.ToUpper(strings.TrimSpace(order.OpenClose)),
Origin: 0,
}
if ibkrOrder.OpenClose == "" {
ibkrOrder.OpenClose = "O"
}
if orderID > 0 {
ibkrOrder.OrderID = orderID
}
conn.rememberLocalPreviewIdentity(ibkrOrder.OrderID, conn.BrokerSessionEpoch(), order.Changes, c.PlacedOrderID(ibkrOrder.OrderID))
if ibkrOrder.OrderID > 0 && conn.modifyStatusIsUncertain(ibkrOrder.OrderID, conn.BrokerSessionEpoch()) {
recoveryBinding := ConnectorSessionBinding{connector: c, connection: conn, epoch: conn.BrokerSessionEpoch()}
if binding != nil {
recoveryBinding = *binding
}
c.refreshAfterAmbiguousPreview(ctx, recoveryBinding)
result := orderWhatIfUnavailableResult("order status changed during its preview; refresh open orders and preview again")
result.WorkingOrderUncertain = true
return result, nil
}
if c.whatIfBeforeBrokerIDClaim != nil {
c.whatIfBeforeBrokerIDClaim()
}
var claimEpoch uint64
var guard func() error
c.brokerIDNamespaceMu.Lock()
if ibkrOrder.OrderID <= 0 {
var err error
if binding != nil {
ibkrOrder.OrderID, claimEpoch, err = c.nextDisjointOrderIDLockedForSession(*binding)
} else {
ibkrOrder.OrderID, claimEpoch, err = c.nextDisjointOrderIDLocked(conn)
}
if err != nil {
c.brokerIDNamespaceMu.Unlock()
return OrderWhatIfResult{}, err
}
} else {
if c.feeRequestOwnsID(ibkrOrder.OrderID) {
c.brokerIDNamespaceMu.Unlock()
return OrderWhatIfResult{}, fmt.Errorf("%w: explicit WhatIf order ID is owned by an active read-only request", ErrBrokerIDNamespaceConflict)
}
owned := c.PlacedOrderID(ibkrOrder.OrderID) || c.isWhatIfOrderID(ibkrOrder.OrderID)
var err error
if binding != nil {
claimEpoch, err = conn.claimOrderIDForForwardingAtEpoch(ibkrOrder.OrderID, owned, &binding.epoch, order.Changes)
} else {
claimEpoch, err = conn.claimOrderIDForForwardingAtEpoch(ibkrOrder.OrderID, owned, nil, order.Changes)
}
if err != nil {
c.brokerIDNamespaceMu.Unlock()
return OrderWhatIfResult{}, err
}
guard = conn.orderChangeWireGuard(ibkrOrder.OrderID, claimEpoch, order.Changes, c.PlacedOrderID(ibkrOrder.OrderID), nil)
}
defer conn.discardOrderIDReservation(ibkrOrder.OrderID)
modifyPreview := order.Changes != nil || c.PlacedOrderID(ibkrOrder.OrderID)
if modifyPreview {
if err := conn.beginModifyPreview(ibkrOrder.OrderID, claimEpoch); err != nil {
c.brokerIDNamespaceMu.Unlock()
return OrderWhatIfResult{}, err
}
}
c.orderIDHighWater = max(c.orderIDHighWater, ibkrOrder.OrderID)
// Mark under the shared boundary so a FEE allocation cannot race the
// interval before Connection.PreviewOrderWhatIf installs its handlers.
conn.markWhatIfOrderID(ibkrOrder.OrderID)
finished := false
uncertain := false
finish := func() {
if finished {
return
}
finished = true
if conn.BrokerSessionEpoch() == claimEpoch {
conn.clearWhatIfOrderID(ibkrOrder.OrderID)
}
if modifyPreview {
uncertain = conn.finishModifyPreview(ibkrOrder.OrderID, claimEpoch)
}
}
defer finish()
c.brokerIDNamespaceMu.Unlock()
result, err := conn.previewOrderWhatIfForEpoch(ctx, ibkrOrder, &claimEpoch, guard)
finish()
if uncertain {
c.refreshAfterAmbiguousPreview(ctx, ConnectorSessionBinding{connector: c, connection: conn, epoch: claimEpoch})
result := orderWhatIfUnavailableResult("order status changed during its preview; refresh open orders and preview again")
result.WorkingOrderUncertain = true
return result, nil
}
if err == nil && binding != nil && !c.SessionCurrent(*binding) {
return orderWhatIfUnavailableResult("broker session changed during WhatIf request"), nil
}
return result, err
}
func preparePlaceOrder(order *IBKROrder, c *Connection, expectedEpoch *uint64) (uint64, error) {
if err := ValidateOrder(order); err != nil {
return 0, err
}
stringFields := []struct {
name string
value string
}{
{"symbol", order.Symbol},
{"secType", order.SecType},
{"exchange", order.Exchange},
{"currency", order.Currency},
{"primary exchange", order.PrimaryExch},
{"local symbol", order.LocalSymbol},
{"trading class", order.TradingClass},
{"account", order.Account},
{"orderRef", order.OrderRef},
{"tif", order.TIF},
{"action", order.Action},
}
for _, field := range stringFields {
if err := ensureASCII(field.name, field.value); err != nil {
return 0, err
}
}
claimEpoch := expectedEpoch
if order.OrderID == 0 {
id, epoch, err := c.reserveNextOrderID()
if err != nil {
return 0, err
}
order.OrderID = id
claimEpoch = &epoch
}
var (
epoch uint64
err error
)
if claimEpoch != nil {
epoch, err = c.claimOrderIDForEpoch(order.OrderID, c.orderIDOwned(order.OrderID), *claimEpoch)
} else {
epoch, err = c.claimOrderIDCurrentEpoch(order.OrderID, c.orderIDOwned(order.OrderID))
}
if err != nil {
return 0, err
}
if order.ClientID == 0 {
order.ClientID = c.config.ClientID
}
if order.Account == "" {
order.Account = c.account
}
if order.Account == "" {
order.Account = c.config.Account
}
if order.OpenClose == "" {
order.OpenClose = "O"
}
return epoch, nil
}
func (c *Connection) sendPlaceOrderFrameGuarded(ctx context.Context, order *IBKROrder, epoch uint64, guard func() error) error {
if c.serverVersion >= minServerVerProtoBufPlaceOrder {
msg, err := encodePlaceOrderProtoFrame(order)
if err != nil {
return definitelyUnsent(err)
}
return c.sendMessageWithTypeContextForEpochGuarded(ctx, msg, RequestTypeOrder, epoch, true, guard)
}
if order.AdaptivePriority != "" {
return definitelyUnsent(fmt.Errorf("this broker protocol does not support Adaptive; no order sent"))
}
if order.LmtPriceOffset != 0 {
return definitelyUnsent(fmt.Errorf("legacy placeOrder encoder does not support lmtPriceOffset"))
}
fields := clonePlaceOrderFields()
assignPlaceOrderFields(fields, order)
interfaces := make([]any, len(fields))
interfaces[0] = placeOrder
for i := 1; i < len(fields); i++ {
interfaces[i] = fields[i]
}
msg := c.encodeMsg(interfaces...)
return c.sendMessageWithTypeContextForEpochGuarded(ctx, msg, RequestTypeOrder, epoch, true, guard)
}
func orderWhatIfUnavailableResult(message string) OrderWhatIfResult {
return OrderWhatIfResult{
Status: OrderWhatIfStatusUnavailable,
Message: strings.TrimSpace(message),
}
}
func sendOrderWhatIfResult(ch chan OrderWhatIfResult, result OrderWhatIfResult) {
select {
case ch <- result:
default:
}
}
func parseOrderWhatIfError(fields []string) (reqID, code int, message string, ok bool) {
if len(fields) < 4 {
return 0, 0, "", false
}
reqID, _ = strconv.Atoi(strings.TrimSpace(orderEventField(fields, 2)))
code, _ = strconv.Atoi(strings.TrimSpace(orderEventField(fields, 3)))
message = strings.TrimSpace(orderEventField(fields, 4))
return reqID, code, message, code != 0
}
func parseOrderWhatIfSystemNotice(fields []string) (reqID, code int, message, advancedRejectJSON string, ok bool) {
if len(fields) < 2 {
return 0, 0, "", "", false
}
note, err := parseSystemNotificationPayload([]byte(fields[1]))
if err != nil || note == nil || note.tickerID < 0 || note.code == 0 {
return 0, 0, "", "", false
}
return int(note.tickerID), note.code, strings.TrimSpace(note.message), strings.TrimSpace(note.advancedOrderRejectJSON), true
}
// whatIfFrameTrace keeps the first frames that name one WhatIf order.
type whatIfFrameTrace struct {
mu sync.Mutex
lines []string
}
const whatIfFrameTraceMax = 8
func (t *whatIfFrameTrace) add(line string) {
t.mu.Lock()
defer t.mu.Unlock()
if len(t.lines) < whatIfFrameTraceMax {
t.lines = append(t.lines, line)
}
}
func (t *whatIfFrameTrace) snapshot() []string {
t.mu.Lock()
defer t.mu.Unlock()
return slices.Clone(t.lines)
}
// whatIfTapFrame describes a frame that names orderID: an error or notice
// with IBKR's code and text, an openOrder with its WhatIf flag and status
// when it is a protobuf summary, or any other message by id and field count.
// ok is false for a frame about another order.
func whatIfTapFrame(fields []string, orderID int) (string, bool) {
msgID, err := strconv.Atoi(strings.TrimSpace(safeGet(fields, 0)))
if err != nil || orderID <= 0 {
return "", false
}
switch msgID {
case msgSystemNotification:
note, err := parseSystemNotificationPayload([]byte(safeGet(fields, 1)))
if err != nil || note.tickerID != int64(orderID) {
return "", false
}
return fmt.Sprintf("notice %d: %s", note.code, brokerNoticeLine(note.message)), true
case msgErrMsg:
reqID, code, msg, ok := parseOrderWhatIfError(fields)
if !ok || reqID != orderID {
return "", false
}
return fmt.Sprintf("error %d: %s", code, brokerNoticeLine(msg)), true
}
id := strconv.Itoa(orderID)
if strings.TrimSpace(safeGet(fields, 1)) != id && strings.TrimSpace(safeGet(fields, 2)) != id && !slices.Contains(fields, "orderId="+id) {
return "", false
}
line := fmt.Sprintf("msg %d, %d fields", msgID, len(fields))
if safeGet(fields, 1) == "protobuf" {
line += fmt.Sprintf(" (whatIf=%s, status=%s, symbol=%s)", wireValue(summaryFieldValue(fields, "whatIf=")),
wireValue(summaryFieldValue(fields, "status=")), wireValue(summaryFieldValue(fields, "symbol=")))
}
return line, true
}
func orderWhatIfInformationalError(code int) bool {
switch code {
case 2104, 2106, 2107, 2119, 2158, 2169:
return true
}
return code >= 2100 && code < 2200
}
func orderWhatIfErrorMessage(code int, message, advancedRejectJSON string) string {
message = strings.TrimSpace(message)
if message == "" {
message = fmt.Sprintf("broker rejected WhatIf request with error code %d", code)
} else {
message = fmt.Sprintf("broker rejected WhatIf request with error code %d: %s", code, message)
}
if advancedRejectJSON = strings.TrimSpace(advancedRejectJSON); advancedRejectJSON != "" {
message += " advanced_reject_json=" + advancedRejectJSON
}
return message
}
func parseOrderWhatIfOpenOrder(fields []string, orderID, serverVersion int) (OrderWhatIfResult, bool) {
if len(fields) == 0 || strings.TrimSpace(fields[0]) != strconv.Itoa(msgOpenOrder) {
return OrderWhatIfResult{}, false
}
if len(fields) > 1 && fields[1] == "protobuf" {
return parseOrderWhatIfOpenOrderProto(fields, orderID)
}
// Servers below MIN_SERVER_VER_ORDER_CONTAINER send a version field before
// the order id; later ones start with the order id, which is numeric like
// a version, so the server version decides where the id is (as EDecoder).
start := 1
if serverVersion < minServerVerOrderContainer {
start = 2
}
if orderEventInt(orderEventField(fields, start)) != orderID {
return OrderWhatIfResult{}, false
}
idx := findOrderWhatIfStateIndex(fields)
if idx < 0 {
return OrderWhatIfResult{}, false
}
brokerStatus := strings.TrimSpace(orderEventField(fields, idx+1))
margin, rejectReason := parseOrderWhatIfMargin(fields, idx+2, serverVersion)
status := OrderWhatIfStatusAccepted
message := ""
if orderWhatIfRejectedStatus(brokerStatus) || rejectReason != "" {
status = OrderWhatIfStatusRejected
message = rejectReason
if message == "" {
message = fmt.Sprintf("broker WhatIf status is %s", brokerStatus)
}
}
return OrderWhatIfResult{
OrderID: orderID,
Status: status,
BrokerStatus: brokerStatus,
Message: message,
Margin: margin,
}, true
}
func parseOrderWhatIfOpenOrderProto(fields []string, orderID int) (OrderWhatIfResult, bool) {
if orderEventInt(summaryFieldValue(fields, "orderId=")) != orderID {
return OrderWhatIfResult{}, false
}
if !protoSummaryBool(fields, "whatIf=") {
return OrderWhatIfResult{}, false
}
brokerStatus := strings.TrimSpace(summaryFieldValue(fields, "status="))
if brokerStatus == "" {
return OrderWhatIfResult{}, false
}
rejectReason := strings.TrimSpace(summaryFieldValue(fields, "rejectReason="))
margin := OrderWhatIfMargin{
InitialMarginBefore: parseIBKRFloatPtr(summaryFieldValue(fields, "initMarginBefore=")),
MaintenanceMarginBefore: parseIBKRFloatPtr(summaryFieldValue(fields, "maintMarginBefore=")),
EquityWithLoanBefore: parseIBKRFloatPtr(summaryFieldValue(fields, "equityWithLoanBefore=")),
InitialMarginAfter: parseIBKRFloatPtr(summaryFieldValue(fields, "initMarginAfter=")),
MaintenanceMarginAfter: parseIBKRFloatPtr(summaryFieldValue(fields, "maintMarginAfter=")),
EquityWithLoanAfter: parseIBKRFloatPtr(summaryFieldValue(fields, "equityWithLoanAfter=")),
Commission: parseIBKRFloatPtr(summaryFieldValue(fields, "commission=")),
MinCommission: parseIBKRFloatPtr(summaryFieldValue(fields, "minCommission=")),
MaxCommission: parseIBKRFloatPtr(summaryFieldValue(fields, "maxCommission=")),
CommissionCurrency: strings.TrimSpace(summaryFieldValue(fields, "commissionCurrency=")),
Currency: strings.TrimSpace(summaryFieldValue(fields, "marginCurrency=")),
WarningText: strings.TrimSpace(summaryFieldValue(fields, "warningText=")),
}
status := OrderWhatIfStatusAccepted
message := ""
if orderWhatIfRejectedStatus(brokerStatus) || rejectReason != "" {
status = OrderWhatIfStatusRejected
message = rejectReason
if message == "" {
message = fmt.Sprintf("broker WhatIf status is %s", brokerStatus)
}
}
return OrderWhatIfResult{
OrderID: orderID,
Status: status,
BrokerStatus: brokerStatus,
Message: message,
Margin: margin,
}, true
}
func findOrderWhatIfStateIndex(fields []string) int {
for i := 0; i+1 < len(fields); i++ {
if !parseIBKRBool(fields[i]) {
continue
}
if orderWhatIfStateStatus(fields[i+1]) {
return i
}
}
return -1
}
func orderWhatIfStateStatus(status string) bool {
switch strings.ToLower(strings.TrimSpace(status)) {
case "pending", "pendingsubmit", "apisent", "apipending", "presubmitted", "submitted", "inactive", "cancelled", "apicancelled", "rejected":
return true
default:
return false
}
}
func orderWhatIfRejectedStatus(status string) bool {
switch strings.ToLower(strings.TrimSpace(status)) {
case "inactive", "cancelled", "apicancelled", "rejected":
return true
default:
return false
}
}
func parseOrderWhatIfMargin(fields []string, idx, serverVersion int) (OrderWhatIfMargin, string) {
var margin OrderWhatIfMargin
if serverVersion >= 142 {
margin.InitialMarginBefore = parseIBKRFloatPtr(orderEventField(fields, idx))
margin.MaintenanceMarginBefore = parseIBKRFloatPtr(orderEventField(fields, idx+1))
margin.EquityWithLoanBefore = parseIBKRFloatPtr(orderEventField(fields, idx+2))
idx += 6 // skip before/change; the public shape exposes before/after.
}
margin.InitialMarginAfter = parseIBKRFloatPtr(orderEventField(fields, idx))
margin.MaintenanceMarginAfter = parseIBKRFloatPtr(orderEventField(fields, idx+1))
margin.EquityWithLoanAfter = parseIBKRFloatPtr(orderEventField(fields, idx+2))
margin.Commission = parseIBKRFloatPtr(orderEventField(fields, idx+3))
margin.MinCommission = parseIBKRFloatPtr(orderEventField(fields, idx+4))
margin.MaxCommission = parseIBKRFloatPtr(orderEventField(fields, idx+5))
margin.CommissionCurrency = strings.TrimSpace(orderEventField(fields, idx+6))
idx += 7
rejectReason := ""
if serverVersion >= 195 {
margin.Currency = strings.TrimSpace(orderEventField(fields, idx))
idx += 11 // currency + outside-RTH fields + suggestedSize
rejectReason = strings.TrimSpace(orderEventField(fields, idx))
idx++
allocationsCount := orderEventInt(orderEventField(fields, idx))
idx++
if allocationsCount > 0 {
idx += allocationsCount * 7
}
}
margin.WarningText = strings.TrimSpace(orderEventField(fields, idx))
return margin, rejectReason
}
func parseIBKRFloatPtr(raw string) *float64 {
s := strings.TrimSpace(strings.ReplaceAll(raw, ",", ""))
if s == "" || strings.EqualFold(s, "N/A") || strings.EqualFold(s, "NaN") {
return nil
}
value, err := strconv.ParseFloat(s, 64)
if err != nil || math.IsInf(value, 0) || math.IsNaN(value) || value == math.MaxFloat64 {
return nil
}
return &value
}
func parseIBKRBool(raw string) bool {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "1", "true", "yes":
return true
default:
return false
}
}
package ibkr
import (
"fmt"
"math"
"strings"
"time"
)
// IBKROrder is the mutable, low-level order and contract representation used by
// Connection order-write and WhatIf methods. It is a wire request and local
// observation, not proof that IBKR accepted, filled, cancelled, or finalized an
// order.
//
// Prices use the contract's quote currency and quantities use the broker's
// instrument units (shares for stock and contracts for options). Most optional
// numeric fields use zero to mean unspecified. Validation and sending may fill
// OrderID, ClientID, Account, OpenClose, and TIF and may update WhatIf,
// Transmit, Status, Remaining, and timestamp fields in place.
type IBKROrder struct {
// AdaptivePriority requests IBKR Adaptive, never a plain-limit fallback.
AdaptivePriority string
OrderID int // OrderID is the session-scoped broker order ID; zero requests allocation.
ClientID int // ClientID is the TWS API client ID; zero uses the connection's configured ID.
PermID int // PermID is IBKR's permanent order ID; zero means not observed.
Account string // Account is the target broker account; empty uses connection account data.
// Contract details
Symbol string
SecType string
ConID int
Exchange string
Currency string
Expiry string
Strike float64 // Strike is in the contract's quote currency; zero means unspecified.
Right string
Multiplier string // Multiplier is the broker's decimal string, such as "100" for many options.
PrimaryExch string
LocalSymbol string
TradingClass string
SecIDType string
SecID string
// BondRules is a BOND line's size and price grid; ValidateOrder refuses a
// BOND order without it or off it.
BondRules *BondOrderRules
// Order details
Action string // Action must be BUY or SELL.
TotalQty int // TotalQty is a positive number of shares or contracts.
OrderType string // MKT, LMT, STP, etc.
LmtPrice float64 // LmtPrice is in quote-currency units; zero means unspecified.
// LmtPriceSet distinguishes an explicit zero combo price from an omitted
// price. Single-contract orders continue to require a positive price.
LmtPriceSet bool
AuxPrice float64 // AuxPrice is a stop price or trailing amount; zero means unspecified.
ComboLegs []ComboLeg
// Time in force
TIF string // TIF is the broker time-in-force code; empty defaults to DAY.
OcaGroup string
OcaType int
OrderRef string // Our reference
Transmit bool // Transmit is set true by write and WhatIf helpers before encoding.
WhatIf bool // WhatIf is forced true for previews and false for order submission.
OpenClose string // OpenClose is O or C; connector helpers default an empty value to O.
Origin int
ParentID int
BlockOrder bool
SweepToFill bool
DisplaySize int
TriggerMethod int
OutsideRth bool // OutsideRth asks IBKR to permit execution outside regular trading hours.
Hidden bool
// State
Status string // Status is locally assigned or broker-observed and is not finality by itself.
Filled int // Filled is the observed filled quantity; zero may mean none or not observed.
Remaining int // Remaining is the observed open quantity; zero may mean none or not observed.
AvgFillPrice float64 // AvgFillPrice is in quote-currency units; zero may mean unavailable.
LastFillPrice float64 // LastFillPrice is in quote-currency units; zero may mean unavailable.
// Timestamps
CreatedTime time.Time // CreatedTime is local process time; IsZero reports unknown.
SubmittedTime time.Time // SubmittedTime is local socket-write time; IsZero reports unknown.
FilledTime *time.Time // FilledTime is nil when no fill time is recorded locally.
CancelledTime *time.Time // CancelledTime is nil when no cancellation time is recorded locally.
// Error tracking
LastError string
WhyHeld string
// Misc optional parameters
GoodAfterTime string
GoodTillDate string
Rule80A string
SettlingFirm string
AllOrNone bool
MinQty int
PercentOffset float64
ETradeOnly bool
FirmQuoteOnly bool
NbboPriceCap float64
AuctionStrategy int
StartingPrice float64
StockRefPrice float64
Delta float64
StockRangeLower float64
StockRangeUpper float64
OverridePercentageConstraints bool
Volatility float64
VolatilityType int
DeltaNeutralOrderType string
DeltaNeutralAuxPrice float64
DeltaNeutralConID int
DeltaNeutralSettlingFirm string
DeltaNeutralClearingAccount string
DeltaNeutralClearingIntent string
DeltaNeutralOpenClose string
DeltaNeutralShortSale bool
DeltaNeutralShortSaleSlot int
DeltaNeutralDesignatedLocation string
ContinuousUpdate int
ReferencePriceType int
TrailStopPrice float64 // TrailStopPrice is the initial stop price in quote-currency units.
TrailingPercent float64 // TrailingPercent is the broker percentage value, not a fraction.
LmtPriceOffset float64 // LmtPriceOffset is the TRAIL LIMIT offset in price units.
BasisPoints float64
BasisPointsType int
ScaleInitLevelSize int
ScaleSubsLevelSize int
ScalePriceIncrement float64
ScalePriceAdjustValue float64
ScalePriceAdjustInterval int
ScaleProfitOffset float64
ScaleAutoReset bool
ScaleInitPosition int
ScaleInitFillQty int
ScaleRandomPercent bool
HedgeType string
HedgeParam string
OptOutSmartRouting bool
ClearingAccount string
ClearingIntent string
NotHeld bool
ModelCode string
ShortSaleSlot int
DesignatedLocation string
ExemptCode int
DiscretionaryAmt float64
FaGroup string
FaMethod string
FaPercentage string
FaProfile string
}
// ValidateOrder performs local, basic validation of order. It requires a
// non-nil order, symbol, positive quantity, BUY or SELL action, and an order
// type, and validates the price combinations used by LMT, STP, STP LMT, TRAIL,
// and TRAIL LIMIT orders. If TIF is empty, ValidateOrder mutates it to "DAY".
//
// A nil result does not mean the broker will accept the order and does not
// provide submit authority. Connection state, account and contract details,
// encoder support, application policy, and broker eligibility are checked
// elsewhere.
func ValidateOrder(order *IBKROrder) error {
if order == nil {
return fmt.Errorf("order is nil")
}
if err := validateAdaptiveOrder(order); err != nil {
return err
}
// A bill or bond goes by contract id alone: IBKR's bond contract details
// carry no symbol, and any symbol sent with the contract id must match
// IBKR's own (error 478 otherwise).
if order.Symbol == "" && !(IsBillOrBond(order.SecType) && order.ConID > 0) {
return fmt.Errorf("symbol is required")
}
if order.TotalQty <= 0 {
return fmt.Errorf("quantity must be positive")
}
if order.Action != "BUY" && order.Action != "SELL" {
return fmt.Errorf("action must be BUY or SELL")
}
if order.OrderType == "" {
return fmt.Errorf("order type is required")
}
if IsBillOrBond(order.SecType) {
if err := validateBondOrder(order); err != nil {
return err
}
}
// Validate limit price for limit orders
if order.OrderType == "LMT" {
if strings.EqualFold(order.SecType, "BAG") {
if !order.LmtPriceSet || math.IsNaN(order.LmtPrice) || math.IsInf(order.LmtPrice, 0) {
return fmt.Errorf("finite explicit limit price required for combo limit orders")
}
} else if order.LmtPrice <= 0 {
return fmt.Errorf("limit price required for limit orders")
}
}
// Validate stop price for stop orders
if (order.OrderType == "STP" || order.OrderType == "STP LMT") && order.AuxPrice <= 0 {
return fmt.Errorf("stop price required for stop orders")
}
if order.OrderType == "TRAIL" || order.OrderType == "TRAIL LIMIT" {
if order.TrailStopPrice <= 0 {
return fmt.Errorf("trail stop price required for trailing stop orders")
}
hasAmount := order.AuxPrice > 0
hasPercent := order.TrailingPercent > 0
if hasAmount == hasPercent {
return fmt.Errorf("trailing stop orders require exactly one of aux price or trailing percent")
}
if order.OrderType == "TRAIL LIMIT" && order.LmtPriceOffset <= 0 {
return fmt.Errorf("limit price offset required for trailing stop limit orders")
}
if order.OrderType == "TRAIL" && order.LmtPriceOffset != 0 {
return fmt.Errorf("limit price offset is only supported for trailing stop limit orders")
}
if order.LmtPrice != 0 {
return fmt.Errorf("limit price must not be set on trailing stop orders; use limit price offset for TRAIL LIMIT")
}
}
// Default TIF if not specified
if order.TIF == "" {
order.TIF = "DAY"
}
return nil
}
// validateAdaptiveOrder limits this extension to the owner's accepted stock
// order shape. Unknown priorities never become IBKR or application defaults.
func validateAdaptiveOrder(order *IBKROrder) error {
if order.AdaptivePriority == "" {
return nil
}
switch order.AdaptivePriority {
case "Patient", "Normal", "Urgent":
default:
return fmt.Errorf("unsupported Adaptive priority %q", order.AdaptivePriority)
}
if (order.SecType != "STK" && order.SecType != "ETF") || order.OrderType != "LMT" || order.TIF != "DAY" || order.Exchange != "SMART" || order.OutsideRth || order.LmtPrice <= 0 || math.IsNaN(order.LmtPrice) || math.IsInf(order.LmtPrice, 0) {
return fmt.Errorf("the Adaptive algorithm requires a SMART stock LMT DAY order with a positive limit during regular hours")
}
return nil
}
package ibkr
import (
"encoding/hex"
"fmt"
"os"
"sync"
"time"
)
// PacketLogger captures raw IBKR frames for offline inspection. It is intended
// for narrow debugging sessions (e.g. verifying wire encoding) and is disabled
// by default to avoid noisy disk writes on production paths.
type PacketLogger interface {
// Outbound logs a fully framed payload (length-prefix excluded) that is about
// to be written to the socket.
Outbound(label string, payload []byte)
}
// HexPacketLogger writes outbound frames to a local file in a human-friendly
// hex representation. Each line contains:
//
// <timestamp> <label> <byte-length> <hex>
type HexPacketLogger struct {
mu sync.Mutex
out *os.File
path string
}
// NewHexPacketLogger creates a packet logger that appends to the given path.
// The caller is responsible for closing the returned logger when finished.
func NewHexPacketLogger(path string) (*HexPacketLogger, error) {
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
if err != nil {
return nil, fmt.Errorf("open packet log %q: %w", path, err)
}
return &HexPacketLogger{out: f, path: path}, nil
}
// Close releases the underlying file handle.
func (l *HexPacketLogger) Close() error {
l.mu.Lock()
defer l.mu.Unlock()
if l.out == nil {
return nil
}
err := l.out.Close()
l.out = nil
return err
}
// Outbound logs a payload if the logger is still active.
func (l *HexPacketLogger) Outbound(label string, payload []byte) {
l.mu.Lock()
defer l.mu.Unlock()
if l.out == nil {
return
}
ts := time.Now().UTC().Format(time.RFC3339Nano)
line := fmt.Sprintf("%s %s %d %s\n", ts, label, len(payload), hex.EncodeToString(payload))
_, _ = l.out.WriteString(line)
}
package ibkr
import (
"fmt"
"net"
"strconv"
"strings"
)
const paperOrderMode = "paper"
// PaperOrderGate identifies the paper account and connection coordinates that
// the narrow paper-order wrappers validate before writing a frame. Mode must be
// "paper", Account must name a concrete DU account, ClientID must be positive,
// and Endpoint or Host and Port must identify the configured connection.
//
// A valid PaperOrderGate is caller-supplied evidence, not authorization to
// submit or cancel an order, proof of the connected broker session's identity,
// or a broker acknowledgement. Callers remain responsible for preview, policy,
// freeze, journaling, and reconciliation controls.
type PaperOrderGate struct {
Mode string // Mode must equal "paper", ignoring case and surrounding space.
Account string // Account must be a non-aggregate account whose name starts with DU.
// Endpoint may contain host:port. When it parses successfully, it takes
// precedence over Host and Port during validation.
Endpoint string
Host string // Host is required when Endpoint does not supply one.
Port int // Port is required when Endpoint does not supply one; zero means absent.
ClientID int // ClientID must be positive and match a nonzero configured client ID.
}
func (g PaperOrderGate) validateConnection(c *Connection) error {
if err := g.validate(); err != nil {
return err
}
if c == nil {
return fmt.Errorf("paper order gate: connection is nil")
}
if strings.TrimSpace(c.config.Account) != "" &&
!strings.EqualFold(strings.TrimSpace(c.config.Account), strings.TrimSpace(g.Account)) {
return fmt.Errorf("paper order gate account %q does not match connection account %q", g.Account, c.config.Account)
}
// Endpoint takes precedence over Host and Port, as validate() reads it;
// an endpoint-only gate must be checked against the connection too.
host, port := g.endpointParts()
host = strings.TrimSpace(host)
if host == "" {
host = strings.TrimSpace(c.config.Host)
}
if port == 0 {
port = c.config.Port
}
if strings.TrimSpace(c.config.Host) != "" && !strings.EqualFold(strings.TrimSpace(c.config.Host), host) {
return fmt.Errorf("paper order gate host %q does not match connection host %q", host, c.config.Host)
}
if c.config.Port != 0 && port != c.config.Port {
return fmt.Errorf("paper order gate port %d does not match connection port %d", port, c.config.Port)
}
if c.config.ClientID != 0 && g.ClientID != c.config.ClientID {
return fmt.Errorf("paper order gate client ID %d does not match connection client ID %d", g.ClientID, c.config.ClientID)
}
return nil
}
func (g PaperOrderGate) validate() error {
if !strings.EqualFold(strings.TrimSpace(g.Mode), paperOrderMode) {
return fmt.Errorf("paper order gate requires mode=paper")
}
account := strings.TrimSpace(g.Account)
if account == "" {
return fmt.Errorf("paper order gate requires account")
}
if strings.EqualFold(account, "All") {
return fmt.Errorf("paper order gate rejects aggregate account All")
}
host, port := g.endpointParts()
if host == "" {
return fmt.Errorf("paper order gate requires endpoint host")
}
if port == 0 {
return fmt.Errorf("paper order gate requires endpoint port")
}
if g.ClientID <= 0 {
return fmt.Errorf("paper order gate requires positive client ID")
}
if !paperAccount(account) {
return fmt.Errorf("paper order gate requires a concrete DU paper account")
}
return nil
}
func (g PaperOrderGate) endpointParts() (string, int) {
host := strings.TrimSpace(g.Host)
port := g.Port
endpoint := strings.TrimSpace(g.Endpoint)
if endpoint == "" {
return host, port
}
if h, p, err := net.SplitHostPort(endpoint); err == nil {
host = h
if parsed, parseErr := strconv.Atoi(p); parseErr == nil {
port = parsed
}
return host, port
}
if host == "" {
host = endpoint
}
return host, port
}
func paperAccount(account string) bool {
return strings.HasPrefix(strings.ToUpper(strings.TrimSpace(account)), "DU")
}
package ibkr
import (
"encoding/binary"
"fmt"
"math"
"strconv"
"strings"
)
const (
protoWireVarint = 0
protoWireFixed64 = 1
protoWireBytes = 2
protoPlaceOrderMsgID = placeOrder + protoBufMsgID
minProtoInt32 = -1 << 31
maxProtoInt32 = 1<<31 - 1
)
func encodePlaceOrderProtoFrame(order *IBKROrder) ([]byte, error) {
body, err := encodePlaceOrderProtoBody(order)
if err != nil {
return nil, err
}
msg := make([]byte, 0, 4+len(body))
msg = binary.BigEndian.AppendUint32(msg, uint32(protoPlaceOrderMsgID))
msg = append(msg, body...)
return msg, nil
}
func encodePlaceOrderProtoBody(order *IBKROrder) ([]byte, error) {
if err := validatePlaceOrderProtoSupported(order); err != nil {
return nil, err
}
contract, err := encodePlaceOrderContractProto(order)
if err != nil {
return nil, err
}
orderMsg, err := encodePlaceOrderOrderProto(order)
if err != nil {
return nil, err
}
var body []byte
body = protoAppendInt32(body, 1, int32(order.OrderID))
body = protoAppendMessage(body, 2, contract)
body = protoAppendMessage(body, 3, orderMsg)
body = protoAppendMessage(body, 4, nil)
return body, nil
}
func encodePlaceOrderContractProto(order *IBKROrder) ([]byte, error) {
var msg []byte
msg = protoAppendInt32(msg, 1, int32(order.ConID))
msg = protoAppendString(msg, 2, order.Symbol)
msg = protoAppendString(msg, 3, strings.ToUpper(order.SecType))
msg = protoAppendString(msg, 4, order.Expiry)
if order.Strike != 0 {
msg = protoAppendDouble(msg, 5, order.Strike)
}
msg = protoAppendString(msg, 6, strings.ToUpper(order.Right))
msg = protoAppendString(msg, 7, order.Multiplier)
msg = protoAppendString(msg, 8, order.Exchange)
msg = protoAppendString(msg, 9, order.PrimaryExch)
msg = protoAppendString(msg, 10, order.Currency)
msg = protoAppendString(msg, 11, order.LocalSymbol)
msg = protoAppendString(msg, 12, order.TradingClass)
msg = protoAppendString(msg, 13, order.SecIDType)
msg = protoAppendString(msg, 14, order.SecID)
for _, leg := range order.ComboLegs {
msg = protoAppendMessage(msg, 20, encodeComboLegProto(leg))
}
return msg, nil
}
func encodeComboLegProto(leg ComboLeg) []byte {
var msg []byte
msg = protoAppendInt32(msg, 1, int32(leg.ConID))
msg = protoAppendInt32(msg, 2, int32(leg.Ratio))
msg = protoAppendString(msg, 3, strings.ToUpper(strings.TrimSpace(leg.Action)))
msg = protoAppendString(msg, 4, strings.ToUpper(strings.TrimSpace(leg.Exchange)))
if leg.OpenClose != 0 {
msg = protoAppendInt32(msg, 5, int32(leg.OpenClose))
}
return msg
}
func encodePlaceOrderOrderProto(order *IBKROrder) ([]byte, error) {
var msg []byte
msg = protoAppendInt32(msg, 1, int32(order.ClientID))
msg = protoAppendInt64(msg, 3, int64(order.PermID))
msg = protoAppendInt32(msg, 4, int32(order.ParentID))
msg = protoAppendString(msg, 5, strings.ToUpper(order.Action))
msg = protoAppendString(msg, 6, strconv.Itoa(order.TotalQty))
msg = protoAppendInt32(msg, 7, int32(order.DisplaySize))
msg = protoAppendString(msg, 8, strings.ToUpper(order.OrderType))
if order.LmtPrice != 0 || order.LmtPriceSet {
msg = protoAppendDouble(msg, 9, order.LmtPrice)
}
if order.AuxPrice != 0 {
msg = protoAppendDouble(msg, 10, order.AuxPrice)
}
msg = protoAppendString(msg, 11, strings.ToUpper(order.TIF))
msg = protoAppendString(msg, 12, order.Account)
if order.OutsideRth {
msg = protoAppendBool(msg, 19, true)
}
// IBKR Order.proto: ocaGroup=27, ocaType=30 (one-cancels-all).
msg = protoAppendString(msg, 27, order.OcaGroup)
msg = protoAppendString(msg, 28, order.OrderRef)
msg = protoAppendInt32(msg, 30, int32(order.OcaType))
msg = protoAppendInt32(msg, 31, int32(order.TriggerMethod))
if order.TrailingPercent != 0 {
msg = protoAppendDouble(msg, 22, order.TrailingPercent)
}
if order.TrailStopPrice != 0 {
msg = protoAppendDouble(msg, 23, order.TrailStopPrice)
}
msg = protoAppendInt32(msg, 43, int32(order.DeltaNeutralConID))
msg = protoAppendInt32(msg, 46, int32(order.DeltaNeutralShortSaleSlot))
// IBKR Order.proto: algoStrategy=61, algoParams=62 (string map).
if order.AdaptivePriority != "" {
msg = protoAppendString(msg, 61, "Adaptive")
entry := protoAppendString(nil, 1, "adaptivePriority")
entry = protoAppendString(entry, 2, order.AdaptivePriority)
msg = protoAppendMessage(msg, 62, entry)
}
if order.WhatIf {
msg = protoAppendBool(msg, 65, true)
}
if order.Transmit {
msg = protoAppendBool(msg, 66, true)
}
msg = protoAppendString(msg, 68, order.OpenClose)
msg = protoAppendInt32(msg, 69, int32(order.Origin))
msg = protoAppendInt32(msg, 70, int32(order.ShortSaleSlot))
msg = protoAppendInt32(msg, 72, -1)
msg = protoAppendDouble(msg, 76, order.DiscretionaryAmt)
msg = protoAppendInt32(msg, 88, 0)
msg = protoAppendDouble(msg, 89, 0)
msg = protoAppendDouble(msg, 91, 0)
if order.LmtPriceOffset != 0 {
msg = protoAppendDouble(msg, 99, order.LmtPriceOffset)
}
msg = protoAppendInt32(msg, 98, 0)
msg = protoAppendMessage(msg, 105, nil)
return msg, nil
}
// OCA types IBKR accepts (Order.ocaType): when a member fills, wholly or in
// part, the group's remaining orders are cancelled with block, reduced with
// block, or reduced without block. "With block" routes one member at a
// time, so two members can never fill at once (overfill protection).
const (
OcaTypeCancelWithBlock = 1
OcaTypeReduceWithBlock = 2
OcaTypeReduceNonBlock = 3
)
// validatePlaceOrderOCA holds an order to a complete one-cancels-all
// membership or none: a group names a type and a type names a group.
func validatePlaceOrderOCA(order *IBKROrder) error {
group := strings.TrimSpace(order.OcaGroup)
switch {
case group == "" && order.OcaType == 0:
return nil
case group == "":
return fmt.Errorf("protobuf placeOrder ocaType %d needs an ocaGroup", order.OcaType)
case order.OcaType < OcaTypeCancelWithBlock || order.OcaType > OcaTypeReduceNonBlock:
return unsupportedPlaceOrderProtoValue("ocaType", strconv.Itoa(order.OcaType), "1, 2 or 3 with an ocaGroup")
}
return nil
}
func validatePlaceOrderProtoSupported(order *IBKROrder) error {
if order == nil {
return fmt.Errorf("order is nil")
}
if err := validateAdaptiveOrder(order); err != nil {
return err
}
for _, field := range []struct {
name string
value int
}{
{"orderID", order.OrderID},
{"clientID", order.ClientID},
{"conID", order.ConID},
{"totalQty", order.TotalQty},
} {
if err := validateProtoInt32(field.name, field.value); err != nil {
return err
}
}
secType := strings.ToUpper(order.SecType)
if secType != "STK" && secType != "ETF" && secType != "OPT" && secType != "BAG" && secType != "CASH" && secType != "FUT" && !IsBillOrBond(secType) {
return unsupportedPlaceOrderProtoValue("secType", order.SecType, "STK/ETF/OPT/BAG/BILL/BOND/CASH/FUT only")
}
orderType := strings.ToUpper(strings.TrimSpace(order.OrderType))
if orderType != "LMT" && orderType != "TRAIL" && orderType != "TRAIL LIMIT" && orderType != "STP" {
return unsupportedPlaceOrderProtoValue("orderType", order.OrderType, "LMT/TRAIL/TRAIL LIMIT only")
}
if orderType == "STP" && (secType != "STK" || order.ParentID <= 0 || order.Action != "SELL" || order.TIF != "GTC" || order.TriggerMethod != 2 || order.OutsideRth || (!order.Transmit && !order.WhatIf)) {
return fmt.Errorf("fixed stop is supported only as an attached regular-session stock child")
}
if order.ParentID != 0 && orderType != "STP" {
return unsupportedPlaceOrderProtoField("parentID")
}
if IsBillOrBond(secType) {
// LMT DAY on a contract id, quantity and price on the line's grid.
if orderType != "LMT" {
return unsupportedPlaceOrderProtoValue("orderType", order.OrderType, "LMT only for "+secType)
}
if tif := strings.ToUpper(strings.TrimSpace(order.TIF)); tif != "DAY" {
return unsupportedPlaceOrderProtoValue("tif", order.TIF, "DAY only for "+secType)
}
if err := validateBondOrder(order); err != nil {
return fmt.Errorf("protobuf placeOrder %s: %w", secType, err)
}
}
if secType == "FUT" {
// A future (the index hedge and its permission check): LMT DAY in
// regular hours on its exact contract, expiry and multiplier.
if orderType != "LMT" {
return unsupportedPlaceOrderProtoValue("orderType", order.OrderType, "LMT only for FUT")
}
if tif := strings.ToUpper(strings.TrimSpace(order.TIF)); tif != "DAY" {
return unsupportedPlaceOrderProtoValue("tif", order.TIF, "DAY only for FUT")
}
if order.OutsideRth {
return unsupportedPlaceOrderProtoField("outsideRth")
}
if order.ConID <= 0 || strings.TrimSpace(order.Expiry) == "" || strings.TrimSpace(order.Multiplier) == "" || strings.TrimSpace(order.Exchange) == "" {
return fmt.Errorf("protobuf placeOrder FUT requires conID, expiry, multiplier and exchange")
}
if order.Right != "" || order.Strike != 0 || len(order.ComboLegs) != 0 {
return fmt.Errorf("protobuf placeOrder FUT takes no right, strike or combo legs")
}
}
if secType == "CASH" {
// A currency conversion: LMT DAY on IDEALPRO in the regular order
// shape, nothing else.
if orderType != "LMT" {
return unsupportedPlaceOrderProtoValue("orderType", order.OrderType, "LMT only for CASH")
}
if tif := strings.ToUpper(strings.TrimSpace(order.TIF)); tif != "DAY" {
return unsupportedPlaceOrderProtoValue("tif", order.TIF, "DAY only for CASH")
}
if !strings.EqualFold(strings.TrimSpace(order.Exchange), "IDEALPRO") {
return unsupportedPlaceOrderProtoValue("exchange", order.Exchange, "IDEALPRO only for CASH")
}
if order.OutsideRth {
return unsupportedPlaceOrderProtoField("outsideRth")
}
}
if err := validatePlaceOrderTriggerMethod(orderType, order.TriggerMethod); err != nil {
return err
}
tif := strings.ToUpper(order.TIF)
gtcTrail := tif == "GTC" && (orderType == "TRAIL" || orderType == "TRAIL LIMIT" || orderType == "STP")
if tif != "DAY" && !gtcTrail {
return unsupportedPlaceOrderProtoValue("tif", order.TIF, "DAY, or GTC for TRAIL/TRAIL LIMIT")
}
if err := validateProtoOrderTypePrices(orderType, order); err != nil {
return err
}
if secType == "BAG" {
if err := validateComboLegs(order.ComboLegs); err != nil {
return err
}
for _, field := range []struct {
name string
value string
}{
{"expiry", order.Expiry},
{"right", order.Right},
{"multiplier", order.Multiplier},
} {
if field.value != "" {
return unsupportedPlaceOrderProtoField(field.name)
}
}
if order.Strike != 0 {
return unsupportedPlaceOrderProtoField("strike")
}
} else if secType == "FUT" {
// Validated above.
} else if secType == "OPT" {
if strings.TrimSpace(order.Expiry) == "" {
return fmt.Errorf("protobuf placeOrder OPT requires expiry")
}
right := strings.ToUpper(strings.TrimSpace(order.Right))
if right != "C" && right != "P" {
return unsupportedPlaceOrderProtoValue("right", order.Right, "C/P only")
}
if order.Strike <= 0 {
return fmt.Errorf("protobuf placeOrder OPT requires positive strike")
}
if strings.TrimSpace(order.Multiplier) == "" {
return fmt.Errorf("protobuf placeOrder OPT requires multiplier")
}
} else {
if len(order.ComboLegs) != 0 {
return unsupportedPlaceOrderProtoField("comboLegs")
}
for _, field := range []struct {
name string
value string
}{
{"expiry", order.Expiry},
{"right", order.Right},
{"multiplier", order.Multiplier},
} {
if field.value != "" {
return unsupportedPlaceOrderProtoField(field.name)
}
}
if order.Strike != 0 {
return unsupportedPlaceOrderProtoField("strike")
}
}
if err := validatePlaceOrderOCA(order); err != nil {
return err
}
for _, field := range []struct {
name string
value string
}{
{"goodAfterTime", order.GoodAfterTime},
{"goodTillDate", order.GoodTillDate},
{"faGroup", order.FaGroup},
{"faMethod", order.FaMethod},
{"faPercentage", order.FaPercentage},
{"faProfile", order.FaProfile},
{"modelCode", order.ModelCode},
{"designatedLocation", order.DesignatedLocation},
{"rule80A", order.Rule80A},
{"settlingFirm", order.SettlingFirm},
{"deltaNeutralOrderType", order.DeltaNeutralOrderType},
{"deltaNeutralSettlingFirm", order.DeltaNeutralSettlingFirm},
{"deltaNeutralClearingAccount", order.DeltaNeutralClearingAccount},
{"deltaNeutralClearingIntent", order.DeltaNeutralClearingIntent},
{"deltaNeutralOpenClose", order.DeltaNeutralOpenClose},
{"deltaNeutralDesignatedLocation", order.DeltaNeutralDesignatedLocation},
{"hedgeType", order.HedgeType},
{"hedgeParam", order.HedgeParam},
{"clearingAccount", order.ClearingAccount},
{"clearingIntent", order.ClearingIntent},
} {
if field.value != "" {
return unsupportedPlaceOrderProtoField(field.name)
}
}
for _, field := range []struct {
name string
value int
}{
{"permID", order.PermID},
{"displaySize", order.DisplaySize},
{"shortSaleSlot", order.ShortSaleSlot},
{"minQty", order.MinQty},
{"auctionStrategy", order.AuctionStrategy},
{"volatilityType", order.VolatilityType},
{"deltaNeutralConID", order.DeltaNeutralConID},
{"deltaNeutralShortSaleSlot", order.DeltaNeutralShortSaleSlot},
{"continuousUpdate", order.ContinuousUpdate},
{"referencePriceType", order.ReferencePriceType},
{"basisPointsType", order.BasisPointsType},
{"scaleInitLevelSize", order.ScaleInitLevelSize},
{"scaleSubsLevelSize", order.ScaleSubsLevelSize},
{"scalePriceAdjustInterval", order.ScalePriceAdjustInterval},
{"scaleInitPosition", order.ScaleInitPosition},
{"scaleInitFillQty", order.ScaleInitFillQty},
} {
if field.value != 0 {
return unsupportedPlaceOrderProtoField(field.name)
}
}
if order.ExemptCode != 0 && order.ExemptCode != -1 {
return unsupportedPlaceOrderProtoField("exemptCode")
}
openClose := strings.ToUpper(strings.TrimSpace(order.OpenClose))
if openClose != "" && openClose != "O" && openClose != "C" {
return unsupportedPlaceOrderProtoValue("openClose", order.OpenClose, "O/C only")
}
for _, field := range []struct {
name string
value float64
}{
{"discretionaryAmt", order.DiscretionaryAmt},
{"percentOffset", order.PercentOffset},
{"nbboPriceCap", order.NbboPriceCap},
{"startingPrice", order.StartingPrice},
{"stockRefPrice", order.StockRefPrice},
{"delta", order.Delta},
{"stockRangeLower", order.StockRangeLower},
{"stockRangeUpper", order.StockRangeUpper},
{"volatility", order.Volatility},
{"deltaNeutralAuxPrice", order.DeltaNeutralAuxPrice},
{"basisPoints", order.BasisPoints},
{"scalePriceIncrement", order.ScalePriceIncrement},
{"scalePriceAdjustValue", order.ScalePriceAdjustValue},
{"scaleProfitOffset", order.ScaleProfitOffset},
} {
if field.value != 0 {
return unsupportedPlaceOrderProtoField(field.name)
}
}
for _, field := range []struct {
name string
value bool
}{
{"blockOrder", order.BlockOrder},
{"sweepToFill", order.SweepToFill},
{"hidden", order.Hidden},
{"allOrNone", order.AllOrNone},
{"eTradeOnly", order.ETradeOnly},
{"firmQuoteOnly", order.FirmQuoteOnly},
{"overridePercentageConstraints", order.OverridePercentageConstraints},
{"deltaNeutralShortSale", order.DeltaNeutralShortSale},
{"scaleAutoReset", order.ScaleAutoReset},
{"scaleRandomPercent", order.ScaleRandomPercent},
{"optOutSmartRouting", order.OptOutSmartRouting},
{"notHeld", order.NotHeld},
} {
if field.value {
return unsupportedPlaceOrderProtoField(field.name)
}
}
return nil
}
func validateComboLegs(legs []ComboLeg) error {
if len(legs) < 2 {
return fmt.Errorf("protobuf placeOrder BAG requires at least two combo legs")
}
seen := make(map[int]struct{}, len(legs))
for i, leg := range legs {
if err := validateProtoInt32(fmt.Sprintf("comboLegs[%d].conID", i), leg.ConID); err != nil {
return err
}
if err := validateProtoInt32(fmt.Sprintf("comboLegs[%d].ratio", i), leg.Ratio); err != nil {
return err
}
if leg.ConID <= 0 {
return fmt.Errorf("protobuf placeOrder BAG combo leg %d requires a positive ConID", i)
}
if leg.Ratio <= 0 {
return fmt.Errorf("protobuf placeOrder BAG combo leg %d requires a positive ratio", i)
}
action := strings.ToUpper(strings.TrimSpace(leg.Action))
if action != "BUY" && action != "SELL" {
return unsupportedPlaceOrderProtoValue(fmt.Sprintf("comboLegs[%d].action", i), leg.Action, "BUY/SELL only")
}
if strings.TrimSpace(leg.Exchange) == "" {
return fmt.Errorf("protobuf placeOrder BAG combo leg %d requires an exchange", i)
}
if leg.OpenClose < 0 || leg.OpenClose > 3 {
return unsupportedPlaceOrderProtoValue(fmt.Sprintf("comboLegs[%d].openClose", i), strconv.Itoa(leg.OpenClose), "IBKR values 0-3")
}
if _, exists := seen[leg.ConID]; exists {
return fmt.Errorf("protobuf placeOrder BAG repeats combo leg ConID %d", leg.ConID)
}
seen[leg.ConID] = struct{}{}
}
return nil
}
func validatePlaceOrderTriggerMethod(orderType string, method int) error {
if method == 0 {
return nil
}
if orderType != "TRAIL" && orderType != "TRAIL LIMIT" && orderType != "STP" {
return unsupportedPlaceOrderProtoValue("triggerMethod", strconv.Itoa(method), "only for TRAIL/TRAIL LIMIT")
}
switch method {
case 1, 2, 3, 4, 7, 8:
return nil
default:
return unsupportedPlaceOrderProtoValue("triggerMethod", strconv.Itoa(method), "IBKR values 1,2,3,4,7,8")
}
}
func validateProtoOrderTypePrices(orderType string, order *IBKROrder) error {
hasAmount := order.AuxPrice > 0
hasPercent := order.TrailingPercent > 0
switch orderType {
case "STP":
if !isPositiveFiniteStockPrice(order.AuxPrice) || order.LmtPrice != 0 || order.TrailStopPrice != 0 || order.TrailingPercent != 0 || order.LmtPriceOffset != 0 {
return fmt.Errorf("attached stock stop requires only a positive fixed auxPrice")
}
case "LMT":
if strings.EqualFold(order.SecType, "BAG") {
if !order.LmtPriceSet || math.IsNaN(order.LmtPrice) || math.IsInf(order.LmtPrice, 0) {
return fmt.Errorf("protobuf placeOrder BAG requires a finite explicit lmtPrice")
}
} else if order.LmtPrice <= 0 {
return fmt.Errorf("protobuf placeOrder requires positive lmtPrice")
}
if order.AuxPrice != 0 || order.TrailStopPrice != 0 || order.TrailingPercent != 0 || order.LmtPriceOffset != 0 {
return fmt.Errorf("protobuf placeOrder LMT does not support populated trail/auxiliary price fields")
}
case "TRAIL":
if order.LmtPrice != 0 {
return unsupportedPlaceOrderProtoField("lmtPrice")
}
if order.TrailStopPrice <= 0 {
return fmt.Errorf("protobuf placeOrder TRAIL requires positive trailStopPrice")
}
if hasAmount == hasPercent {
return fmt.Errorf("protobuf placeOrder TRAIL requires exactly one of auxPrice or trailingPercent")
}
if order.LmtPriceOffset != 0 {
return unsupportedPlaceOrderProtoField("lmtPriceOffset")
}
case "TRAIL LIMIT":
if order.LmtPrice != 0 {
return fmt.Errorf("protobuf placeOrder TRAIL LIMIT must use lmtPriceOffset, not lmtPrice")
}
if order.TrailStopPrice <= 0 {
return fmt.Errorf("protobuf placeOrder TRAIL LIMIT requires positive trailStopPrice")
}
if hasAmount == hasPercent {
return fmt.Errorf("protobuf placeOrder TRAIL LIMIT requires exactly one of auxPrice or trailingPercent")
}
if order.LmtPriceOffset <= 0 {
return fmt.Errorf("protobuf placeOrder TRAIL LIMIT requires positive lmtPriceOffset")
}
}
return nil
}
func validateProtoInt32(name string, value int) error {
if value < minProtoInt32 || value > maxProtoInt32 {
return fmt.Errorf("protobuf placeOrder %s=%d is outside int32 range", name, value)
}
return nil
}
func unsupportedPlaceOrderProtoField(name string) error {
return fmt.Errorf("protobuf placeOrder does not support populated field %s in this order slice", name)
}
func unsupportedPlaceOrderProtoValue(name, value, supported string) error {
if value == "" {
value = "<empty>"
}
return fmt.Errorf("protobuf placeOrder does not support %s=%q in this order slice (%s)", name, value, supported)
}
func protoAppendTag(buf []byte, fieldNumber, wireType int) []byte {
return binary.AppendUvarint(buf, uint64(fieldNumber<<3|wireType))
}
func protoAppendInt32(buf []byte, fieldNumber int, value int32) []byte {
buf = protoAppendTag(buf, fieldNumber, protoWireVarint)
return binary.AppendUvarint(buf, uint64(int64(value)))
}
func protoAppendInt64(buf []byte, fieldNumber int, value int64) []byte {
buf = protoAppendTag(buf, fieldNumber, protoWireVarint)
return binary.AppendUvarint(buf, uint64(value))
}
func protoAppendBool(buf []byte, fieldNumber int, value bool) []byte {
buf = protoAppendTag(buf, fieldNumber, protoWireVarint)
if value {
return append(buf, 1)
}
return append(buf, 0)
}
func protoAppendDouble(buf []byte, fieldNumber int, value float64) []byte {
buf = protoAppendTag(buf, fieldNumber, protoWireFixed64)
return binary.LittleEndian.AppendUint64(buf, math.Float64bits(value))
}
func protoAppendString(buf []byte, fieldNumber int, value string) []byte {
if value == "" {
return buf
}
buf = protoAppendTag(buf, fieldNumber, protoWireBytes)
buf = binary.AppendUvarint(buf, uint64(len(value)))
return append(buf, value...)
}
func protoAppendMessage(buf []byte, fieldNumber int, value []byte) []byte {
buf = protoAppendTag(buf, fieldNumber, protoWireBytes)
buf = binary.AppendUvarint(buf, uint64(len(value)))
return append(buf, value...)
}
func (c *Connection) decodeOutboundMessage(msgBytes []byte) []string {
if c.serverVersion >= minServerVerProtoBufPlaceOrder {
switch determineMessageID(c.serverVersion, msgBytes) {
case protoPlaceOrderMsgID:
return summarizePlaceOrderProtoFrame(msgBytes)
case protoCancelOrderMsgID:
return summarizeCancelOrderProtoFrame(msgBytes)
}
}
return c.decodeMessage(msgBytes)
}
func summarizePlaceOrderProtoFrame(msgBytes []byte) []string {
fields := []string{strconv.Itoa(protoPlaceOrderMsgID), "protobuf", "base_msg_id=" + strconv.Itoa(placeOrder)}
if len(msgBytes) < 4 {
return append(fields, "decode_error=truncated")
}
summary, err := parsePlaceOrderProtoSummary(msgBytes[4:])
if err != nil {
return append(fields, "decode_error="+err.Error())
}
fields = append(fields,
"orderId="+strconv.Itoa(summary.orderID),
"conId="+strconv.Itoa(summary.conID),
"symbol="+summary.symbol,
"secType="+summary.secType,
"expiry="+summary.expiry,
"strike="+formatProtoSummaryFloat(summary.strike),
"right="+summary.right,
"multiplier="+summary.multiplier,
"clientID="+strconv.Itoa(summary.clientID),
"action="+summary.action,
"qty="+summary.quantity,
"orderType="+summary.orderType,
"lmtPrice="+formatProtoSummaryFloat(summary.lmtPrice),
"auxPrice="+formatProtoSummaryFloat(summary.auxPrice),
"trailingPercent="+formatProtoSummaryFloat(summary.trailingPercent),
"trailStopPrice="+formatProtoSummaryFloat(summary.trailStopPrice),
"lmtPriceOffset="+formatProtoSummaryFloat(summary.lmtPriceOffset),
"tif="+summary.tif,
"triggerMethod="+strconv.Itoa(summary.triggerMethod),
"account="+summary.account,
"orderRef="+summary.orderRef,
"outsideRth="+strconv.FormatBool(summary.outsideRth),
"whatIf="+strconv.FormatBool(summary.whatIf),
"transmit="+strconv.FormatBool(summary.transmit),
"openClose="+summary.openClose,
)
return fields
}
type placeOrderProtoSummary struct {
parentID int
orderID int
conID int
symbol string
secType string
expiry string
strike float64
right string
multiplier string
clientID int
action string
quantity string
orderType string
lmtPrice float64
auxPrice float64
trailingPercent float64
trailStopPrice float64
lmtPriceOffset float64
tif string
triggerMethod int
account string
orderRef string
outsideRth bool
whatIf bool
transmit bool
openClose string
}
func parsePlaceOrderProtoSummary(body []byte) (placeOrderProtoSummary, error) {
var summary placeOrderProtoSummary
err := forEachProtoField(body, func(fieldNumber, wireType int, value []byte) error {
switch fieldNumber {
case 1:
v, err := protoVarintValue(fieldNumber, wireType, value)
if err != nil {
return err
}
summary.orderID = int(v)
case 2:
return parseContractProtoSummary(value, &summary)
case 3:
return parseOrderProtoSummary(value, &summary)
}
return nil
})
return summary, err
}
func parseContractProtoSummary(body []byte, summary *placeOrderProtoSummary) error {
return forEachProtoField(body, func(fieldNumber, wireType int, value []byte) error {
switch fieldNumber {
case 1:
v, err := protoVarintValue(fieldNumber, wireType, value)
if err != nil {
return err
}
summary.conID = int(v)
case 2:
summary.symbol = string(value)
case 3:
summary.secType = string(value)
case 4:
summary.expiry = string(value)
case 5:
v, err := protoFixed64Value(fieldNumber, wireType, value)
if err != nil {
return err
}
summary.strike = math.Float64frombits(v)
case 6:
summary.right = string(value)
case 7:
summary.multiplier = string(value)
}
return nil
})
}
func parseOrderProtoSummary(body []byte, summary *placeOrderProtoSummary) error {
return forEachProtoField(body, func(fieldNumber, wireType int, value []byte) error {
switch fieldNumber {
case 1:
v, err := protoVarintValue(fieldNumber, wireType, value)
if err != nil {
return err
}
summary.clientID = int(v)
case 4:
v, err := protoVarintValue(fieldNumber, wireType, value)
if err != nil {
return err
}
summary.parentID = int(v)
case 5:
summary.action = string(value)
case 6:
summary.quantity = string(value)
case 8:
summary.orderType = string(value)
case 9:
v, err := protoFixed64Value(fieldNumber, wireType, value)
if err != nil {
return err
}
summary.lmtPrice = math.Float64frombits(v)
case 10:
v, err := protoFixed64Value(fieldNumber, wireType, value)
if err != nil {
return err
}
summary.auxPrice = math.Float64frombits(v)
case 11:
summary.tif = string(value)
case 12:
summary.account = string(value)
case 19:
v, err := protoVarintValue(fieldNumber, wireType, value)
if err != nil {
return err
}
summary.outsideRth = v != 0
case 28:
summary.orderRef = string(value)
case 31:
v, err := protoVarintValue(fieldNumber, wireType, value)
if err != nil {
return err
}
summary.triggerMethod = int(v)
case 22:
v, err := protoFixed64Value(fieldNumber, wireType, value)
if err != nil {
return err
}
summary.trailingPercent = math.Float64frombits(v)
case 23:
v, err := protoFixed64Value(fieldNumber, wireType, value)
if err != nil {
return err
}
summary.trailStopPrice = math.Float64frombits(v)
case 65:
v, err := protoVarintValue(fieldNumber, wireType, value)
if err != nil {
return err
}
summary.whatIf = v != 0
case 66:
v, err := protoVarintValue(fieldNumber, wireType, value)
if err != nil {
return err
}
summary.transmit = v != 0
case 68:
summary.openClose = string(value)
case 99:
v, err := protoFixed64Value(fieldNumber, wireType, value)
if err != nil {
return err
}
summary.lmtPriceOffset = math.Float64frombits(v)
}
return nil
})
}
func forEachProtoField(buf []byte, fn func(fieldNumber, wireType int, value []byte) error) error {
for len(buf) > 0 {
tag, n := binary.Uvarint(buf)
if n <= 0 {
return fmt.Errorf("invalid_tag")
}
buf = buf[n:]
fieldNumber := int(tag >> 3)
wireType := int(tag & 0x7)
var value []byte
switch wireType {
case protoWireVarint:
_, n := binary.Uvarint(buf)
if n <= 0 {
return fmt.Errorf("invalid_varint_field_%d", fieldNumber)
}
value = buf[:n]
buf = buf[n:]
case protoWireFixed64:
if len(buf) < 8 {
return fmt.Errorf("truncated_fixed64_field_%d", fieldNumber)
}
value = buf[:8]
buf = buf[8:]
case protoWireBytes:
length, n := binary.Uvarint(buf)
if n <= 0 {
return fmt.Errorf("invalid_length_field_%d", fieldNumber)
}
buf = buf[n:]
if uint64(len(buf)) < length {
return fmt.Errorf("truncated_bytes_field_%d", fieldNumber)
}
value = buf[:length]
buf = buf[length:]
default:
return fmt.Errorf("unsupported_wire_%d_field_%d", wireType, fieldNumber)
}
if err := fn(fieldNumber, wireType, value); err != nil {
return err
}
}
return nil
}
func protoVarintValue(fieldNumber, wireType int, value []byte) (uint64, error) {
if wireType != protoWireVarint {
return 0, fmt.Errorf("field_%d_not_varint", fieldNumber)
}
v, n := binary.Uvarint(value)
if n <= 0 {
return 0, fmt.Errorf("field_%d_invalid_varint", fieldNumber)
}
return v, nil
}
func protoFixed64Value(fieldNumber, wireType int, value []byte) (uint64, error) {
if wireType != protoWireFixed64 {
return 0, fmt.Errorf("field_%d_not_fixed64", fieldNumber)
}
if len(value) != 8 {
return 0, fmt.Errorf("field_%d_invalid_fixed64", fieldNumber)
}
return binary.LittleEndian.Uint64(value), nil
}
func formatProtoSummaryFloat(value float64) string {
return strconv.FormatFloat(value, 'g', -1, 64)
}
package ibkr
import (
"context"
"fmt"
"math"
"strconv"
"sync"
"time"
)
// AccountDailyPnL is the most recent account-level frame from an IBKR reqPnL
// subscription. Monetary values are expressed in the account's base currency,
// distinguish an observed zero from a missing, unavailable, or IBKR sentinel
type AccountDailyPnL struct {
DailyPnL *float64
UnrealizedTotalPnL *float64
RealizedTotalPnL *float64
AsOf time.Time
DailyPnLStatus DailyPnLFrameStatus
}
// DailyPnLFrameStatus distinguishes a usable Daily P&L value from a gateway
// placeholder and from malformed wire data. Callers must not infer those
// states from a nil value alone.
type DailyPnLFrameStatus string
// Daily P&L frame statuses reported by the connector.
const (
DailyPnLFrameAvailable DailyPnLFrameStatus = "available"
DailyPnLFrameUnavailable DailyPnLFrameStatus = "unavailable"
DailyPnLFrameMalformed DailyPnLFrameStatus = "malformed"
)
// PositionDailyPnL is the most recent per-contract frame from an IBKR
// reqPnLSingle subscription. Monetary values are expressed in the account's
// base currency, and AsOf is the UTC receive time. Pointer fields use the same
// missing-versus-zero semantics as [AccountDailyPnL]. UnrealizedTotalPnL and
// RealizedTotalPnL are lifetime totals, not components of DailyPnL.
type PositionDailyPnL struct {
DailyPnL *float64
UnrealizedTotalPnL *float64
RealizedTotalPnL *float64
AsOf time.Time
}
// pnlCache holds account and per-position subscription identities and the
// immutable snapshots published by their handlers.
type pnlCache struct {
mu sync.RWMutex
session ConnectorSessionBinding
accountReqID int // 0 means no active subscription
accountAcct string
// accountStartedAt distinguishes a healthy subscription still awaiting
accountStartedAt time.Time
account AccountDailyPnL
// positionReqIDs maps conId -> reqID. positionByReqID is the
// reverse map so the inbound handler (which sees reqID, not
// conId) can find the cache entry to update.
positionReqIDs map[int]int
positionByReqID map[int]int
positionSnapshot map[int]PositionDailyPnL
}
func newPnLCache() *pnlCache {
return &pnlCache{
positionReqIDs: make(map[int]int),
positionByReqID: make(map[int]int),
positionSnapshot: make(map[int]PositionDailyPnL),
}
}
// dblMaxNotSent is IBKR's "not yet computed" sentinel for double
// fields. The wire emits the exact DBL_MAX value (1.7976931348623157e+308);
const dblMaxNotSent = 1e300
// parsePnLFloat converts a wire field to a pointer-or-nil. Empty
// We don't surface parse errors — IBKR's gateway is the source of truth;
// a malformed field is something to be silent about, not a hard fail.
func parsePnLFloat(s string) *float64 {
if s == "" {
return nil
}
v, err := strconv.ParseFloat(s, 64)
if err != nil {
return nil
}
if math.IsNaN(v) || math.Abs(v) >= dblMaxNotSent {
return nil
}
return &v
}
func parseDailyPnLFloat(s string) (*float64, DailyPnLFrameStatus) {
if s == "" {
return nil, DailyPnLFrameUnavailable
}
v, err := strconv.ParseFloat(s, 64)
if err != nil || math.IsNaN(v) || math.IsInf(v, 0) {
return nil, DailyPnLFrameMalformed
}
if math.Abs(v) >= dblMaxNotSent {
return nil, DailyPnLFrameUnavailable
}
return &v, DailyPnLFrameAvailable
}
// RequestPnL starts a reqPnL stream for account using reqID. modelCode is empty
// for accounts without a Financial Advisor model. The caller owns reqID,
// response handling, and cancellation; [Connector.SubscribeAccountPnL]
func (c *Connection) RequestPnL(reqID int, account, modelCode string) error {
if !c.IsConnected() {
return fmt.Errorf("not connected to IBKR")
}
if account == "" {
return fmt.Errorf("account is required for reqPnL")
}
if err := ensureASCII("account", account); err != nil {
return err
}
if err := ensureASCII("modelCode", modelCode); err != nil {
return err
}
if err := c.claimRequestID(reqID); err != nil {
return err
}
msg := c.encodeMsg(reqPnL, reqID, account, modelCode)
return c.sendMessage(msg)
}
// CancelPnL requests cancellation of the reqPnL stream identified by reqID.
func (c *Connection) CancelPnL(reqID int) error {
if reqID <= 0 || reqID > maxProtoInt32 {
return fmt.Errorf("reqPnL request ID must be a positive signed 32-bit integer")
}
if !c.IsConnected() {
return nil
}
msg := c.encodeMsg(cancelPnL, reqID)
return c.sendMessage(msg)
}
// RequestPnLSingle starts a reqPnLSingle stream for conID on account using
// reqID. modelCode is empty for accounts without a Financial Advisor model.
func (c *Connection) RequestPnLSingle(reqID int, account, modelCode string, conID int) error {
return c.requestPnLSingleContext(context.Background(), reqID, account, modelCode, conID)
}
func (c *Connection) requestPnLSingleContext(ctx context.Context, reqID int, account, modelCode string, conID int) error {
if !c.IsConnected() {
return fmt.Errorf("not connected to IBKR")
}
if account == "" {
return fmt.Errorf("account is required for reqPnLSingle")
}
if conID <= 0 {
return fmt.Errorf("conId is required for reqPnLSingle")
}
if err := ensureASCII("account", account); err != nil {
return err
}
if err := ensureASCII("modelCode", modelCode); err != nil {
return err
}
if err := c.claimRequestID(reqID); err != nil {
return err
}
msg := c.encodeMsg(reqPnLSingle, reqID, account, modelCode, conID)
return c.sendMessageWithTypeContext(ctx, msg, RequestTypeGeneral)
}
// CancelPnLSingle requests cancellation of the reqPnLSingle stream identified
func (c *Connection) CancelPnLSingle(reqID int) error {
if reqID <= 0 || reqID > maxProtoInt32 {
return fmt.Errorf("reqPnLSingle request ID must be a positive signed 32-bit integer")
}
if !c.IsConnected() {
return nil
}
msg := c.encodeMsg(cancelPnLSingle, reqID)
return c.sendMessage(msg)
}
// parseAccountPnLFields parses an inbound msgPnL frame. The wire layout
// after the leading msgID is:
//
// [reqId] [dailyPnL] [unrealizedPnL] [realizedPnL]
//
// unrealizedPnL / realizedPnL (fields 3 & 4) are the account's TOTAL
// unrealized / realized P&L (inception to now), not a decomposition of
// dailyPnL — see AccountDailyPnL. Older gateways (server < 150) emit only
// the first two fields after reqId; we accept the short form and leave the
// remaining pointers nil. Returns reqID and a populated snapshot; ok=false
// on a malformed frame.
func parseAccountPnLFields(fields []string) (reqID int, snap AccountDailyPnL, ok bool) {
// fields[0] = msgID; fields[1] = reqId; fields[2+] = payload.
if len(fields) < 3 {
return 0, AccountDailyPnL{}, false
}
rid, err := strconv.Atoi(fields[1])
if err != nil {
return 0, AccountDailyPnL{}, false
}
snap.DailyPnL, snap.DailyPnLStatus = parseDailyPnLFloat(fields[2])
if len(fields) > 3 {
snap.UnrealizedTotalPnL = parsePnLFloat(fields[3])
}
if len(fields) > 4 {
snap.RealizedTotalPnL = parsePnLFloat(fields[4])
}
snap.AsOf = time.Now().UTC()
return rid, snap, true
}
// parsePositionPnLFields parses an inbound msgPnLSingle frame. Wire
// position size (we already have it from reqAccountUpdates), and value
func parsePositionPnLFields(fields []string) (reqID int, snap PositionDailyPnL, ok bool) {
// fields[0] = msgID; fields[1] = reqId; fields[2] = pos;
if len(fields) < 4 {
return 0, PositionDailyPnL{}, false
}
rid, err := strconv.Atoi(fields[1])
if err != nil {
return 0, PositionDailyPnL{}, false
}
snap.DailyPnL = parsePnLFloat(fields[3])
if len(fields) > 4 {
snap.UnrealizedTotalPnL = parsePnLFloat(fields[4])
}
if len(fields) > 5 {
snap.RealizedTotalPnL = parsePnLFloat(fields[5])
}
snap.AsOf = time.Now().UTC()
return rid, snap, true
}
// SubscribeAccountPnL starts and caches a streaming reqPnL subscription for
// account. account must be non-empty. Repeated calls for the same account are
// idempotent; changing the account cancels the previous stream, clears its
// stream. Use [Connector.AccountDailyPnL] for non-blocking cache reads. Callers
// must serialize attempts to switch one connector between different accounts.
func (c *Connector) SubscribeAccountPnL(account string) error {
origin, ok := c.CaptureSession()
if !ok {
return ErrIBKRUnavailable
}
return c.subscribeAccountPnLForSession(origin, account)
}
func (c *Connector) subscribeAccountPnLForSession(origin ConnectorSessionBinding, account string) error {
if account == "" {
return fmt.Errorf("account is required")
}
if err := ensureASCII("account", account); err != nil {
return err
}
conn := origin.connection
if !c.SessionCurrent(origin) {
return ErrIBKRUnavailable
}
c.pnl.mu.Lock()
if c.pnl.session == origin && c.pnl.accountReqID != 0 && c.pnl.accountAcct == account {
c.pnl.mu.Unlock()
return nil
}
// Account changed (rare): tear down the old subscription before
// claiming the new reqID slot.
oldReqID := 0
if c.pnl.session == origin {
oldReqID = c.pnl.accountReqID
}
c.pnl.mu.Unlock()
if oldReqID != 0 {
if err := c.sendPnLForSession(context.Background(), origin, conn.encodeMsg(cancelPnL, oldReqID), nil); err != nil {
connectorLogger.Debugf("CancelPnL(reqID=%d) failed during account change: %v", oldReqID, err)
}
}
reqID, _, err := conn.reserveNextRequestIDForEpoch(origin.epoch)
if err != nil {
return err
}
defer discardPnLReservation(origin, reqID)
adopted := false
if !c.mutatePnLForSession(origin, func() {
c.resetPnLSessionLocked(origin)
if c.pnl.accountReqID != 0 && c.pnl.accountAcct == account {
return
}
c.pnl.accountReqID, c.pnl.accountAcct = reqID, account
c.pnl.accountStartedAt, c.pnl.account = c.pnlResubClock().UTC(), AccountDailyPnL{}
adopted = true
}) {
return ErrIBKRUnavailable
}
if !adopted {
return nil
}
if _, err = conn.claimRequestIDForEpoch(reqID, origin.epoch); err == nil {
err = c.sendPnLForSession(context.Background(), origin, conn.encodeMsg(reqPnL, reqID, account, ""), func() bool { return c.pnl.session == origin && c.pnl.accountReqID == reqID })
}
if err != nil {
c.pnl.mu.Lock()
if c.pnl.session == origin && c.pnl.accountReqID == reqID {
c.pnl.accountReqID = 0
c.pnl.accountAcct = ""
c.pnl.accountStartedAt = time.Time{}
c.pnl.account = AccountDailyPnL{}
}
c.pnl.mu.Unlock()
return fmt.Errorf("request PnL: %w", err)
}
return nil
}
// AccountDailyPnL returns the most recently received account Daily P&L
// snapshot. ok is false until [Connector.SubscribeAccountPnL] is active and a
// frame has been received. The method neither blocks nor issues wire traffic.
func (c *Connector) AccountDailyPnL() (AccountDailyPnL, bool) {
c.pnl.mu.RLock()
defer c.pnl.mu.RUnlock()
if c.pnl.accountReqID == 0 || c.pnl.account.AsOf.IsZero() || !c.SessionCurrent(c.pnl.session) {
return AccountDailyPnL{}, false
}
// Defensive copy; the snapshot holds pointers but they aren't
return c.pnl.account, true
}
// SubscribePositionDailyPnL starts and caches a reqPnLSingle stream for conID
// on account. account must be non-empty and conID must be positive. Streams are
// returns [ErrIBKRUnavailable] when the connector is disconnected. One
// connector must not reuse the same conID for different accounts.
func (c *Connector) SubscribePositionDailyPnL(account string, conID int) error {
return c.SubscribePositionDailyPnLContext(context.Background(), account, conID)
}
// SubscribePositionDailyPnLContext starts the shared position P&L subscription
// with caller-owned cancellation. The connector retains at most 50 contracts;
// an existing contract is idempotent and remains daemon-owned after this returns.
func (c *Connector) SubscribePositionDailyPnLContext(ctx context.Context, account string, conID int) error {
origin, ok := c.CaptureSession()
if !ok {
return ErrIBKRUnavailable
}
return c.subscribePositionPnLForSession(ctx, origin, account, conID)
}
func (c *Connector) subscribePositionPnLForSession(ctx context.Context, origin ConnectorSessionBinding, account string, conID int) error {
if account == "" || conID <= 0 {
return fmt.Errorf("account and positive conId are required")
}
if err := ensureASCII("account", account); err != nil {
return err
}
if !c.SessionCurrent(origin) {
return ErrIBKRUnavailable
}
conn := origin.connection
c.pnl.mu.Lock()
if _, ok := c.pnl.positionReqIDs[conID]; ok && c.pnl.session == origin {
c.pnl.mu.Unlock()
return nil
}
c.pnl.mu.Unlock()
reqID, _, err := conn.reserveNextRequestIDForEpoch(origin.epoch)
if err != nil {
return err
}
defer discardPnLReservation(origin, reqID)
adopted := false
if !c.mutatePnLForSession(origin, func() {
c.resetPnLSessionLocked(origin)
if _, ok := c.pnl.positionReqIDs[conID]; ok {
return
}
if len(c.pnl.positionReqIDs) >= 50 {
err = fmt.Errorf("position PnL subscription limit reached")
return
}
c.pnl.positionReqIDs[conID], c.pnl.positionByReqID[reqID] = reqID, conID
c.pnl.positionSnapshot[conID] = PositionDailyPnL{}
adopted = true
}) {
return ErrIBKRUnavailable
}
if err != nil || !adopted {
return err
}
if _, err = conn.claimRequestIDForEpoch(reqID, origin.epoch); err == nil {
err = c.sendPnLForSession(ctx, origin, conn.encodeMsg(reqPnLSingle, reqID, account, "", conID), func() bool { return c.pnl.session == origin && c.pnl.positionReqIDs[conID] == reqID })
}
if err != nil {
c.pnl.mu.Lock()
if current, ok := c.pnl.positionReqIDs[conID]; ok && c.pnl.session == origin && current == reqID {
delete(c.pnl.positionReqIDs, conID)
delete(c.pnl.positionByReqID, reqID)
delete(c.pnl.positionSnapshot, conID)
}
c.pnl.mu.Unlock()
return fmt.Errorf("request PnL single: %w", err)
}
return nil
}
// PositionDailyPnL returns the cached per-contract Daily P&L snapshot for
// usable values. The method neither blocks nor issues wire traffic. Handlers
func (c *Connector) PositionDailyPnL(conID int) (PositionDailyPnL, bool) {
c.pnl.mu.RLock()
defer c.pnl.mu.RUnlock()
snap, ok := c.pnl.positionSnapshot[conID]
return snap, ok && c.SessionCurrent(c.pnl.session)
}
// ActiveDailyPnLSubscriptions reports the number of tracked per-contract
// reqPnLSingle streams. It does not include the account-level reqPnL stream.
func (c *Connector) ActiveDailyPnLSubscriptions() int {
c.pnl.mu.RLock()
defer c.pnl.mu.RUnlock()
return len(c.pnl.positionReqIDs)
}
// AccountID returns the account code most recently received from IBKR's
// managedAccounts frame. It returns an empty string before that frame is
// observed or when the connector has no connection.
func (c *Connector) AccountID() string {
c.mu.RLock()
conn := c.conn
c.mu.RUnlock()
if conn == nil {
return ""
}
return conn.GetAccountCode()
}
// cancelAllPnL is called from Connector.Stop to tear down every
// outstanding PnL subscription. Best-effort: the gateway drops
// subscriptions on socket close anyway, so cancel-time errors are
// logged at debug and otherwise ignored.
func (c *Connector) cancelAllPnL() {
c.mu.RLock()
conn := c.conn
c.mu.RUnlock()
if conn == nil {
return
}
c.pnl.mu.Lock()
acctReq := c.pnl.accountReqID
posReqs := make([]int, 0, len(c.pnl.positionReqIDs))
for _, r := range c.pnl.positionReqIDs {
posReqs = append(posReqs, r)
}
c.pnl.accountReqID = 0
c.pnl.accountAcct = ""
c.pnl.accountStartedAt = time.Time{}
c.pnl.positionReqIDs = make(map[int]int)
c.pnl.positionByReqID = make(map[int]int)
c.pnl.positionSnapshot = make(map[int]PositionDailyPnL)
c.pnl.mu.Unlock()
if acctReq != 0 {
if err := conn.CancelPnL(acctReq); err != nil {
connectorLogger.Debugf("CancelPnL(reqID=%d) on shutdown: %v", acctReq, err)
}
}
for _, r := range posReqs {
if err := conn.CancelPnLSingle(r); err != nil {
connectorLogger.Debugf("CancelPnLSingle(reqID=%d) on shutdown: %v", r, err)
}
}
}
// dailyPnLStaleResubscribe bounds how long the account daily-P&L stream may go
const dailyPnLStaleResubscribe = 90 * time.Second
func (c *Connector) pnlResubClock() time.Time {
if c.pnlResubNow != nil {
return c.pnlResubNow()
}
return time.Now()
}
// MaybeResubscribeStaleDailyPnL rebuilds all Daily P&L streams when marketOpen
// is true and the account stream has not produced its first frame or its last
func (c *Connector) MaybeResubscribeStaleDailyPnL(marketOpen bool) bool {
if !marketOpen || !c.isConnected() || c.BackendLink().Down {
return false
}
c.pnl.mu.RLock()
origin := c.pnl.session
reqID := c.pnl.accountReqID
asOf := c.pnl.account.AsOf
startedAt := c.pnl.accountStartedAt
c.pnl.mu.RUnlock()
if reqID == 0 {
// The startup/lazy-kick path in SubscribeAccountPnL owns a stream that
// has never been requested.
return false
}
now := c.pnlResubClock()
referenceAt := asOf
if referenceAt.IsZero() {
referenceAt = startedAt
}
if referenceAt.IsZero() || now.Sub(referenceAt) < dailyPnLStaleResubscribe {
return false
}
c.pnlResubMu.Lock()
if !c.pnlResubLastAt.IsZero() && now.Sub(c.pnlResubLastAt) < dailyPnLStaleResubscribe {
c.pnlResubMu.Unlock()
return false
}
c.pnlResubLastAt = now
c.pnlResubMu.Unlock()
warn, attempts, age := c.pnlSilenceLog.Observe(now)
if warn {
connectorLogger.Warnf("account daily P&L stream silent during market hours; rebuilding reqPnL subscriptions (attempts=%d, incident_duration=%s, last_frame_age=%s)", attempts, age.Round(time.Second), now.Sub(referenceAt).Round(time.Second))
}
return c.rebuildPnLForSession(origin, reqID)
}
// forceResubscribeDailyPnL tears down and re-issues the account and per-position
// daily-P&L subscriptions, preserving the same account/conId set. Unlike the
// idempotent SubscribeAccountPnL / SubscribePositionDailyPnL, it revives a
// the idempotency guards re-arm, then the old wire subscriptions are cancelled
// by handlePnL (reqID mismatch), so no stale frame races the rebuild.
func (c *Connector) forceResubscribeDailyPnL() {
origin, ok := c.CaptureSession()
if ok {
c.forceResubscribeDailyPnLForSession(origin)
}
}
func (c *Connector) forceResubscribeDailyPnLForSession(origin ConnectorSessionBinding) {
c.rebuildPnLForSession(origin, 0)
}
func (c *Connector) rebuildPnLForSession(origin ConnectorSessionBinding, expectedAccountRequest int) bool {
c.pnlRepairMu.Lock()
defer c.pnlRepairMu.Unlock()
defer c.display.notify()
conn := origin.connection
var acct string
var acctReq int
var posConIDs, posReqs []int
if !c.mutatePnLForSession(origin, func() {
if c.pnl.session != origin || (expectedAccountRequest != 0 && c.pnl.accountReqID != expectedAccountRequest) {
return
}
acct = c.pnl.accountAcct
acctReq = c.pnl.accountReqID
posConIDs = make([]int, 0, len(c.pnl.positionReqIDs))
posReqs = make([]int, 0, len(c.pnl.positionReqIDs))
for conID, r := range c.pnl.positionReqIDs {
posConIDs = append(posConIDs, conID)
posReqs = append(posReqs, r)
}
c.pnl.accountReqID = 0
c.pnl.accountAcct = ""
c.pnl.accountStartedAt = time.Time{}
c.pnl.account = AccountDailyPnL{}
c.pnl.positionReqIDs = make(map[int]int)
c.pnl.positionByReqID = make(map[int]int)
c.pnl.positionSnapshot = make(map[int]PositionDailyPnL)
}) {
return false
}
if acctReq != 0 {
if err := c.sendPnLForSession(context.Background(), origin, conn.encodeMsg(cancelPnL, acctReq), nil); err != nil {
connectorLogger.Debugf("CancelPnL(reqID=%d) during resubscribe: %v", acctReq, err)
}
}
for _, r := range posReqs {
if err := c.sendPnLForSession(context.Background(), origin, conn.encodeMsg(cancelPnLSingle, r), nil); err != nil {
connectorLogger.Debugf("CancelPnLSingle(reqID=%d) during resubscribe: %v", r, err)
}
}
if acct == "" {
return false
}
accountSent := c.subscribeAccountPnLForSession(origin, acct) == nil
positionFailures := 0
for _, conID := range posConIDs {
if err := c.subscribePositionPnLForSession(context.Background(), origin, acct, conID); err != nil {
positionFailures++
}
}
if c.SessionCurrent(origin) && (!accountSent || positionFailures > 0) {
connectorLogger.Warnf("P&L rebuild requests incomplete: account_request_sent=%t position_request_failures=%d; account upkeep and position demand can retry", accountSent, positionFailures)
}
return true
}
// handlePnL is the connector-side msgPnL handler. Decodes the frame
// and updates the cache so AccountDailyPnL reads see fresh values.
func (c *Connector) handlePnL(fields []string) {
reqID, snap, ok := parseAccountPnLFields(fields)
if !ok {
return
}
c.pnl.mu.Lock()
if c.pnl.accountReqID == 0 || c.pnl.accountReqID != reqID {
// Stale frame from a previous subscription (or we never
// reconnect can race with the last few frames of the prior
c.pnl.mu.Unlock()
return
}
c.pnl.account = snap
c.pnl.mu.Unlock()
if attempts, age := c.pnlSilenceLog.Recover(c.pnlResubClock()); attempts > 0 {
connectorLogger.Warnf("account daily P&L stream resumed after %s (%d rebuild attempts); frame quality remains in account health", age.Round(time.Second), attempts)
}
}
// handlePnLSingle is the connector-side msgPnLSingle handler.
func (c *Connector) handlePnLSingle(fields []string) {
reqID, snap, ok := parsePositionPnLFields(fields)
if !ok {
return
}
c.pnl.mu.Lock()
conID, known := c.pnl.positionByReqID[reqID]
if !known {
c.pnl.mu.Unlock()
return
}
c.pnl.positionSnapshot[conID] = snap
c.pnl.mu.Unlock()
}
package ibkr
import (
"context"
"time"
)
// Cache publication holds the session barriers only for a small memory update,
// never while waiting for pacing or a socket write.
func (c *Connector) mutatePnLForSession(origin ConnectorSessionBinding, change func()) bool {
c.publicationBarrier.RLock()
defer c.publicationBarrier.RUnlock()
c.evidenceBarrier.Lock()
defer c.evidenceBarrier.Unlock()
if !c.SessionCurrent(origin) {
return false
}
c.pnl.mu.Lock()
defer c.pnl.mu.Unlock()
change()
return true
}
func (c *Connector) resetPnLSessionLocked(origin ConnectorSessionBinding) {
if c.pnl.session == origin {
return
}
c.pnl.session = origin
c.pnl.accountReqID, c.pnl.accountAcct = 0, ""
c.pnl.accountStartedAt, c.pnl.account = time.Time{}, AccountDailyPnL{}
clear(c.pnl.positionReqIDs)
clear(c.pnl.positionByReqID)
clear(c.pnl.positionSnapshot)
}
func (c *Connector) sendPnLForSession(ctx context.Context, origin ConnectorSessionBinding, msg []byte, owns func() bool) error {
return origin.connection.sendMessageWithTypeContextForEpochGuarded(ctx, msg, RequestTypeGeneral, origin.epoch, true, func() error {
if !c.SessionCurrent(origin) {
return ErrIBKRUnavailable
}
if owns != nil {
c.pnl.mu.RLock()
valid := owns()
c.pnl.mu.RUnlock()
if !valid {
return ErrIBKRUnavailable
}
}
return nil
})
}
// receivePnLForSession runs under dispatch's publication, inbound and evidence
// leases, in that order. Reacquiring publication here would invert that order.
func (c *Connector) receivePnLForSession(origin ConnectorSessionBinding, receive func()) {
if !c.SessionReceiptCurrent(origin) {
return
}
c.pnl.mu.RLock()
owns := c.pnl.session == origin
c.pnl.mu.RUnlock()
if owns {
receive()
}
}
// An old repair must not discard a reservation from a successor namespace that
// happens to reuse the same numeric request ID.
func discardPnLReservation(origin ConnectorSessionBinding, id int) {
conn := origin.connection
conn.reqIDMu.Lock()
defer conn.reqIDMu.Unlock()
if conn.brokerSessionEpoch.Load() == origin.epoch {
delete(conn.reservedRequestIDs, id)
}
}
func (c *Connector) resubscribeAccountUpdatesForSession(origin ConnectorSessionBinding) error {
if !c.SessionCurrent(origin) {
return ErrIBKRUnavailable
}
c.acctUpdatesMu.Lock()
account := c.acctUpdatesAccount
c.acctUpdatesMu.Unlock()
conn := origin.connection
msg := conn.encodeMsg(reqAcctData, "2", "1", account)
return conn.sendMessageWithTypeContextForEpochGuarded(context.Background(), msg, RequestTypeGeneral, origin.epoch, true, func() error {
if !c.SessionCurrent(origin) {
return ErrIBKRUnavailable
}
now := c.acctUpdatesClock()
c.acctUpdatesMu.Lock()
c.acctUpdatesLastAt = now
c.acctUpdatesMu.Unlock()
conn.resetPortfolioStreamHealthUnderEvidence(account, now.UTC())
return nil
})
}
package ibkr
import (
"context"
"fmt"
"strings"
"time"
)
// Delayed requests support only a subset of the normal generic tick list.
const delayedQuoteGenericTicks = "101,106,165,221,236"
func (c *Connector) sharedQuoteMode(key string) (int, error) {
absent := c.marketDataAbsenceFor(key)
if absent == nil {
return 0, nil
}
if absent.Code != 354 {
return 0, absent
}
c.absenceMu.Lock()
state := c.mktDataAbsent[key]
allowed := state.delayedAvailable || state.delayedAttemptAt.IsZero() || c.absenceClock().Sub(state.delayedAttemptAt) >= marketDataAbsenceRetry
c.absenceMu.Unlock()
if !allowed {
return 0, absent
}
return 4, nil
}
func (c *Connector) recordDelayedQuoteAttempt(key string) {
c.absenceMu.Lock()
defer c.absenceMu.Unlock()
state, ok := c.mktDataAbsent[key]
if ok {
state.delayedAttemptAt = c.absenceClock()
c.mktDataAbsent[key] = state
}
}
func (c *Connector) recordDelayedQuoteAvailable(key string) {
c.absenceMu.Lock()
defer c.absenceMu.Unlock()
state, ok := c.mktDataAbsent[key]
if ok {
state.delayedAvailable = true
c.mktDataAbsent[key] = state
}
}
func (c *Connector) subscribeSharedQuote(ctx context.Context, key string, fields []string, spec mdReplaySpec) error {
c.subMu.RLock()
existing := c.subscriptions[key] != nil
c.subMu.RUnlock()
if existing {
return nil
}
mode, err := c.sharedQuoteMode(key)
if err != nil {
return err
}
contract, ticks, err := marketDataReplayRequest(spec)
if err != nil {
return err
}
sub := &Subscription{Symbol: key, Fields: fields, LastTime: time.Now(), RejectCh: make(chan SubscriptionRejection, 1), replaySpec: &spec, delayedFallback: mode == 4}
c.subMu.Lock()
if c.subscriptions[key] != nil {
c.subMu.Unlock()
return nil
}
c.subscriptions[key] = sub
c.subMu.Unlock()
c.mu.RLock()
conn := c.conn
c.mu.RUnlock()
if conn == nil || !conn.IsConnected() {
return nil
}
origin := ConnectorSessionBinding{connector: c, connection: conn, epoch: conn.BrokerSessionEpoch()}
if mode == 4 {
if !isBondMarketDataContract(contract) {
ticks = delayedQuoteGenericTicks
}
c.recordDelayedQuoteAttempt(key)
}
adopted := false
id, err := conn.requestMarketDataWithContractForEpochMode(ctx, contract, ticks, false, false, origin.epoch, false, mode, func(id int) func() {
c.subMu.Lock()
if c.subscriptions[key] == sub {
sub.ReqID = id
c.reqIDMap[id] = key
adopted = true
}
c.subMu.Unlock()
return func() { c.removeSharedQuoteRequest(key, sub, id) }
})
if err != nil {
c.removeSharedQuoteRequest(key, sub, id)
return err
}
if !adopted {
_ = conn.cancelMarketDataForEpoch(ctx, id, origin.epoch)
return fmt.Errorf("quote subscription removed during request")
}
if mode == 4 {
// A line opened on the delayed feed because of a recorded refusal
// re-probes live when that refusal's window lifts. Status names the
// refusal for exactly that window, so a probe timed from the
// subscription instead would leave the line delayed and undisclosed
// for up to a whole window.
probeAt := time.Now().Add(marketDataAbsenceRetry)
if absent := c.marketDataAbsenceFor(key); absent != nil {
probeAt = absent.RetryAt
}
c.scheduleQuoteLiveRetryAt(origin, key, sub, probeAt)
}
return nil
}
func (c *Connector) removeSharedQuoteRequest(key string, sub *Subscription, id int) {
c.subMu.Lock()
defer c.subMu.Unlock()
if c.subscriptions[key] == sub && (id == 0 || sub.ReqID == id) {
delete(c.reqIDMap, sub.ReqID)
if sub.liveRetryTimer != nil {
sub.liveRetryTimer.Stop()
}
delete(c.subscriptions, key)
}
}
// prepareDelayedQuoteRecovery runs under the incoming notice's session lease.
// Claim the one attempt before returning outbound work to the post-barrier path.
// Exact-session and derivative subscriptions retain their existing owners.
// The line keeps the refusal message so the delayed service stays disclosed.
func (c *Connector) prepareDelayedQuoteRecovery(origin ConnectorSessionBinding, id int, message string) func() {
c.subMu.Lock()
key := c.reqIDMap[id]
sub := c.subscriptions[key]
if sub == nil || sub.ReqID != id || sub.SessionEpoch != 0 || sub.replaySpec == nil {
c.subMu.Unlock()
return nil
}
contract, _, err := marketDataReplayRequest(*sub.replaySpec)
switch strings.ToUpper(contract.SecType) {
case "OPT", "FOP", "WAR", "BAG":
c.subMu.Unlock()
return nil
}
if err != nil || sub.delayedFallback {
c.subMu.Unlock()
c.absenceMu.Lock()
state, ok := c.mktDataAbsent[key]
if ok {
state.delayedAvailable = false
c.mktDataAbsent[key] = state
}
c.absenceMu.Unlock()
return nil
}
sub.delayedFallback = true
sub.fallbackRefusal = &marketDataAbsence{code: 354, message: message, at: c.absenceClock()}
c.subMu.Unlock()
c.recordDelayedQuoteAttempt(key)
return func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := c.replaceSharedQuote(ctx, origin, key, sub, id, 4); err != nil {
c.pushSubscriptionRejection(id, 354, "delayed quote recovery unavailable")
c.logWarn("Delayed quote recovery failed for %s: %v", key, err)
}
c.scheduleQuoteLiveRetry(origin, key, sub)
}
}
// replaceSharedQuote keeps the prior observation visible, with its original
// clock and feed type, until the replacement supplies an actual positive price.
func (c *Connector) replaceSharedQuote(ctx context.Context, origin ConnectorSessionBinding, key string, sub *Subscription, oldID, mode int) error {
if !c.SessionCurrent(origin) {
return fmt.Errorf("quote recovery session changed")
}
c.subMu.Lock()
if c.subscriptions[key] != sub || sub.ReqID != oldID {
c.subMu.Unlock()
return fmt.Errorf("quote recovery subscription changed")
}
spec := *sub.replaySpec
cancelOld := wireCancelNeeded(sub)
c.subMu.Unlock()
contract, ticks, err := marketDataReplayRequest(spec)
if err != nil {
return err
}
if mode == 4 && !isBondMarketDataContract(contract) {
ticks = delayedQuoteGenericTicks
}
if cancelOld {
if err := origin.connection.cancelMarketDataForEpoch(ctx, oldID, origin.epoch); err != nil {
return err
}
} else {
origin.connection.releaseMarketDataSlotAtEpoch(oldID, origin.epoch)
}
adopted := false
id, err := origin.connection.requestMarketDataWithContractForEpochMode(ctx, contract, ticks, false, false, origin.epoch, false, mode, func(id int) func() {
c.subMu.Lock()
if c.subscriptions[key] == sub && sub.ReqID == oldID {
if !sub.LastPriceTickAt.IsZero() {
previous := *sub
previous.previousQuote, previous.liveRetryTimer = nil, nil
sub.previousQuote, sub.previousDataType = &previous, c.subscriptionDataType(sub)
}
resetSubscriptionObservations(sub)
sub.delayedFallback = mode == 4
sub.ReqID, sub.LastTime = id, time.Now()
delete(c.reqIDMap, oldID)
c.reqIDMap[id] = key
select {
case <-sub.RejectCh:
default:
}
adopted = true
}
c.subMu.Unlock()
return func() {
c.subMu.Lock()
defer c.subMu.Unlock()
if adopted && c.subscriptions[key] == sub && sub.ReqID == id {
delete(c.reqIDMap, id)
c.reqIDMap[oldID] = key
sub.ReqID, sub.rejectedReqID = oldID, oldID
}
}
})
if err != nil {
return err
}
if !adopted {
_ = origin.connection.cancelMarketDataForEpoch(ctx, id, origin.epoch)
return fmt.Errorf("quote recovery owner retired")
}
c.display.notify()
return nil
}
func (c *Connector) scheduleQuoteLiveRetry(origin ConnectorSessionBinding, key string, sub *Subscription) {
c.scheduleQuoteLiveRetryAt(origin, key, sub, time.Now().Add(marketDataAbsenceRetry))
}
// scheduleQuoteLiveRetryAt arms the line's next live probe for at.
func (c *Connector) scheduleQuoteLiveRetryAt(origin ConnectorSessionBinding, key string, sub *Subscription, at time.Time) {
c.subMu.Lock()
defer c.subMu.Unlock()
if c.subscriptions[key] != sub {
return
}
if !sub.delayedFallback && sub.previousQuote == nil && !sub.LastPriceTickAt.IsZero() {
return
}
if sub.liveRetryTimer != nil {
sub.liveRetryTimer.Stop()
}
sub.liveRetryAt = at
sub.liveRetryTimer = time.AfterFunc(max(0, time.Until(at)), func() { c.retrySharedQuoteLive(origin, key, sub) })
}
func (c *Connector) retrySharedQuoteLive(origin ConnectorSessionBinding, key string, sub *Subscription) {
if !c.SessionCurrent(origin) {
return
}
c.subMu.Lock()
if c.subscriptions[key] != sub {
c.subMu.Unlock()
return
}
id := sub.ReqID
c.subMu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Type 2 retains the ordinary live/frozen behavior. A new 354 rearms one
// fallback; a silent probe retains the cached delayed value and is bounded.
if err := c.replaceSharedQuote(ctx, origin, key, sub, id, 2); err != nil {
c.logWarn("Live quote retry failed for %s: %v", key, err)
} else {
// A silent live probe must not leave the delayed stream cancelled until
// the next half-hour retry. Restore best-available acquisition promptly.
for ctx.Err() == nil {
c.subMu.RLock()
resolved := c.subscriptions[key] != sub || sub.delayedFallback || !sub.LastPriceTickAt.IsZero()
c.subMu.RUnlock()
if resolved {
break
}
select {
case <-ctx.Done():
case <-time.After(25 * time.Millisecond):
}
}
c.subMu.RLock()
pending := c.subscriptions[key] == sub && !sub.delayedFallback && sub.LastPriceTickAt.IsZero()
probeID := sub.ReqID
c.subMu.RUnlock()
if pending {
fallbackCtx, stop := context.WithTimeout(context.Background(), 5*time.Second)
if err := c.replaceSharedQuote(fallbackCtx, origin, key, sub, probeID, 4); err != nil {
c.logWarn("Quote fallback after silent live probe failed for %s: %v", key, err)
}
stop()
}
}
c.scheduleQuoteLiveRetry(origin, key, sub)
}
package ibkr
import (
"context"
"errors"
"fmt"
"sync"
"time"
"github.com/osauer/canary/v2/pkg/ibkr/internal/logging"
)
var rateLimiterLogger = logging.Component("IBKR RateLimiter")
// RateLimiter manages rate limiting for IBKR API requests. The IBKR limits it
// enforces are recorded on the individual buckets and semaphores below.
type RateLimiter struct {
// Token buckets for different rate limits
messageRate *TokenBucket // General messages: 40/sec (safe from 50/sec max)
historicalRate *TokenBucket // Historical data: 60 requests per 10 minutes
// Semaphores for concurrent limits
historicalConcurrent *Semaphore // Max 50 concurrent historical requests
marketDataSubs *Semaphore // Max 100 concurrent market data subscriptions (retail)
// Background-lane admission pools (see backgroundMessageSlots). One per
// token bucket a background request can pre-book.
backgroundMessage *Semaphore
backgroundHistorical *Semaphore
// Queue for pacing requests
requestQueue chan *RateLimitedRequest
// Metrics
metrics *RateLimiterMetrics
metricsMu sync.RWMutex
// Circuit breaker for repeated rate limit violations
circuitMu sync.Mutex
circuitOpenUntil time.Time
circuitThreshold int
circuitCooldown time.Duration
// Control
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
// submitTimeoutFn is an internal test seam for proving that a request
submitTimeoutFn func(RequestType) time.Duration
}
// RateLimiterMetrics tracks rate limiting statistics
type RateLimiterMetrics struct {
TotalRequests uint64
ThrottledRequests uint64
RejectedRequests uint64
CurrentQueueDepth int
MessageRatePerSec float64
HistoricalRatePerMin float64
LastRateLimitError time.Time
ConsecutiveErrors int
}
// RateLimitedRequest wraps a scheduler-owned request with rate-limit metadata.
// It remains exported for v2 source compatibility.
type RateLimitedRequest struct {
Type RequestType
Priority RequestPriority
Context context.Context
SendFunc func(context.Context) error
ResultChan chan error
Timestamp time.Time
Retries int
MaxRetries int
}
// RequestType categorizes different IBKR request types for proper rate limiting
type RequestType int
// Request types select the limiter bucket and pacing policy used for a call.
const (
RequestTypeGeneral RequestType = iota
RequestTypeMarketData
RequestTypeHistorical
RequestTypeOrder
RequestTypeHeartbeat
// RequestTypeMarketDataCancel frees a read subscription even during a
// pacing cooldown. It still consumes the general message-rate token.
RequestTypeMarketDataCancel
)
// RequestPriority selects the pacing lane for a submitted request. The
type RequestPriority int
const (
// PriorityInteractive requests reserve pacing tokens immediately, in
PriorityInteractive RequestPriority = iota
// PriorityBackground requests must hold one of a small pool of
// in-flight slots before reserving pacing tokens. The pool bounds how
// many token reservations a fan-out can book ahead of an interactive
// arrival, so the interactive request waits behind at most the pool,
// not the whole fan-out. Token reservations stay FIFO across lanes and
// slots release as sends complete, so background work keeps the full
// bucket rate whenever no interactive request is competing — bounded
// interactive delay, no starvation in either direction.
PriorityBackground
)
// Background-lane pool sizes. A background request's token debt is capped
// background work alone (slots turn over as fast as tokens refill).
const (
backgroundMessageSlots = 8
backgroundHistoricalSlots = 2
)
type requestPriorityContextKey struct{}
// WithRequestPriority returns a context whose connector requests submit on
// the given pacing lane. The priority travels with ctx through the
// connector's send paths; contexts without it submit as PriorityInteractive.
func WithRequestPriority(ctx context.Context, p RequestPriority) context.Context {
return context.WithValue(ctx, requestPriorityContextKey{}, p)
}
// RequestPriorityFrom reports the pacing lane a context carries; contexts
// without one submit as PriorityInteractive.
func RequestPriorityFrom(ctx context.Context) RequestPriority { return requestPriorityFrom(ctx) }
func requestPriorityFrom(ctx context.Context) RequestPriority {
if ctx == nil {
return PriorityInteractive
}
if p, ok := ctx.Value(requestPriorityContextKey{}).(RequestPriority); ok {
return p
}
return PriorityInteractive
}
// effectivePriority reads the context's pacing lane. Broker writes and
// heartbeats never queue behind the background pool, whatever their
// context says: orders because interactive latency is part of the write
// contract, heartbeats because they keep the session alive.
func effectivePriority(ctx context.Context, reqType RequestType) RequestPriority {
switch reqType {
case RequestTypeOrder, RequestTypeHeartbeat:
return PriorityInteractive
}
return requestPriorityFrom(ctx)
}
// TokenBucket implements token bucket algorithm for rate limiting
type TokenBucket struct {
capacity int // Max tokens
tokens float64 // Current tokens (float for fractional refill)
refillRate float64 // Tokens per second
lastRefill time.Time
mu sync.Mutex
}
// NewTokenBucket creates a new token bucket
func NewTokenBucket(capacity int, refillRate float64) *TokenBucket {
return &TokenBucket{
capacity: capacity,
tokens: float64(capacity), // Start full
refillRate: refillRate,
lastRefill: time.Now(),
}
}
// TryAcquire attempts to acquire n tokens, returns true if successful
func (tb *TokenBucket) TryAcquire(n int) bool {
if n <= 0 {
return true
}
tb.mu.Lock()
defer tb.mu.Unlock()
tb.refillLocked(time.Now())
if tb.tokens < float64(n) {
return false
}
tb.tokens -= float64(n)
return true
}
// WaitForTokens blocks until n tokens are available
func (tb *TokenBucket) WaitForTokens(ctx context.Context, n int) error {
if n <= 0 {
return nil
}
if n > tb.capacity {
return fmt.Errorf("token request %d exceeds bucket capacity %d", n, tb.capacity)
}
for {
delay, reserved := tb.reserve(n, time.Now())
if delay == 0 {
return nil
}
timer := time.NewTimer(delay)
select {
case <-ctx.Done():
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
if reserved {
tb.cancelReservation(n, time.Now())
}
return ctx.Err()
case <-timer.C:
if reserved {
return nil
}
}
}
}
func (tb *TokenBucket) reserve(n int, now time.Time) (time.Duration, bool) {
tb.mu.Lock()
defer tb.mu.Unlock()
tb.refillLocked(now)
if tb.tokens >= float64(n) {
tb.tokens -= float64(n)
return 0, true
}
if tb.refillRate <= 0 {
return time.Second, false
}
needed := float64(n) - tb.tokens
delay := max(time.Duration(needed/tb.refillRate*float64(time.Second)), time.Millisecond)
tb.tokens -= float64(n)
return delay, true
}
func (tb *TokenBucket) refillLocked(now time.Time) {
elapsed := now.Sub(tb.lastRefill).Seconds()
if elapsed < 0 {
elapsed = 0
}
tb.tokens = min(float64(tb.capacity), tb.tokens+elapsed*tb.refillRate)
tb.lastRefill = now
}
func (tb *TokenBucket) cancelReservation(n int, now time.Time) {
tb.mu.Lock()
defer tb.mu.Unlock()
tb.refillLocked(now)
tb.tokens = min(float64(tb.capacity), tb.tokens+float64(n))
}
// Semaphore limits concurrent operations
type Semaphore struct {
ch chan struct{}
}
// NewSemaphore creates a semaphore with given capacity
func NewSemaphore(capacity int) *Semaphore {
return &Semaphore{
ch: make(chan struct{}, capacity),
}
}
// Acquire blocks until a slot is available
func (s *Semaphore) Acquire(ctx context.Context) error {
select {
case s.ch <- struct{}{}:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
// TryAcquire attempts to acquire without blocking
func (s *Semaphore) TryAcquire() bool {
select {
case s.ch <- struct{}{}:
return true
default:
return false
}
}
// Release frees a slot. Panics if the semaphore is empty — an over-release
func (s *Semaphore) Release() {
select {
case <-s.ch:
default:
panic("ibkr: Semaphore.Release called without matching Acquire")
}
}
// Count returns current number of acquired slots
func (s *Semaphore) Count() int {
return len(s.ch)
}
// NewRateLimiter creates a rate limiter with IBKR-compliant limits
func NewRateLimiter(ctx context.Context) *RateLimiter {
ctx, cancel := context.WithCancel(ctx)
rl := &RateLimiter{
// Message rate: 40/sec (safe from 50/sec max)
messageRate: NewTokenBucket(40, 40),
// Historical: 60 requests per 10 minutes = 0.1 requests/sec
historicalRate: NewTokenBucket(60, 0.1),
// Concurrent limits
historicalConcurrent: NewSemaphore(50), // Max 50 concurrent historical
marketDataSubs: NewSemaphore(100), // Max 100 market data subscriptions
// Background pacing lane
backgroundMessage: NewSemaphore(backgroundMessageSlots),
backgroundHistorical: NewSemaphore(backgroundHistoricalSlots),
// Request queue with buffer
requestQueue: make(chan *RateLimitedRequest, 1000),
metrics: &RateLimiterMetrics{},
ctx: ctx,
cancel: cancel,
circuitThreshold: 5,
circuitCooldown: 10 * time.Second,
}
// Start request processor
rl.wg.Add(1)
go rl.processRequests()
// Start metrics updater
rl.wg.Add(1)
go rl.updateMetrics()
return rl
}
// Stop gracefully shuts down the rate limiter. The request queue is not
// closed: producers (Submit and the retry goroutine) race with shutdown and
// would panic on send to a closed channel. Instead, ctx cancellation signals
// both producers and consumers to exit, and the queue is GC'd once unreferenced.
func (rl *RateLimiter) Stop() {
rl.cancel()
rl.wg.Wait()
}
// Submit submits a request for rate-limited execution with the default
// retry count (3). For one-shot requests where any failure should bubble
func (rl *RateLimiter) Submit(reqType RequestType, sendFunc func() error) error {
return rl.SubmitWithRetries(reqType, sendFunc, 3)
}
// SubmitContext submits a request for rate-limited execution and cancels the
// queue/token wait when ctx is done. The send function should also check ctx
func (rl *RateLimiter) SubmitContext(ctx context.Context, reqType RequestType, sendFunc func() error) error {
return rl.SubmitWithRetriesContext(ctx, reqType, sendFunc, 3)
}
// submitTimeout caps how long Submit waits for a queued request to
// must accommodate the slow historicalRate bucket (60 tokens, refills at
// 0.1/sec — IBKR's 60-per-10-min pacing window). When a fan-out empties
func submitTimeout(reqType RequestType) time.Duration {
switch reqType {
case RequestTypeHistorical:
return 12 * time.Minute
default:
return 30 * time.Second
}
}
// SubmitWithRetries submits a request with a custom retry count. Requests
// dispatch in arrival order; the only scheduling distinction is the
// context-carried pacing lane (see WithRequestPriority), which bounds how
// many token reservations PriorityBackground work may hold at once. A
// "queue jump" parameter existed before v0.16.0 but was never wired and
// was removed; the background lane is the deliberate replacement.
func (rl *RateLimiter) SubmitWithRetries(reqType RequestType, sendFunc func() error, maxRetries int) error {
return rl.SubmitWithRetriesContext(context.Background(), reqType, sendFunc, maxRetries)
}
// SubmitWithRetriesContext is SubmitWithRetries plus caller-owned
// cancellation. This matters for interactive historical reads: the historical
// bucket can legitimately wait minutes during breadth fan-out, but a CLI/RPC
// request with a 60 s budget must leave that queue promptly when its caller is
// gone.
func (rl *RateLimiter) SubmitWithRetriesContext(ctx context.Context, reqType RequestType, sendFunc func() error, maxRetries int) error {
return rl.SubmitWithRetriesContextFunc(ctx, reqType, func(context.Context) error {
return sendFunc()
}, maxRetries)
}
// SubmitWithRetriesContextFunc passes the limiter-owned request context to the
// admitted callback. In addition to caller cancellation, that context is
// canceled synchronously by the limiter's own completion timeout, so work
// already admitted but parked behind another transport cannot run late.
func (rl *RateLimiter) SubmitWithRetriesContextFunc(ctx context.Context, reqType RequestType, sendFunc func(context.Context) error, maxRetries int) error {
if err := ctx.Err(); err != nil {
return err
}
if err := rl.checkCircuit(reqType); err != nil {
return err
}
requestCtx, cancelRequest := context.WithCancel(ctx)
defer cancelRequest()
req := &RateLimitedRequest{
Type: reqType,
Priority: effectivePriority(ctx, reqType),
Context: requestCtx,
SendFunc: sendFunc,
ResultChan: make(chan error, 1),
Timestamp: time.Now(),
MaxRetries: maxRetries,
}
// Try to queue the request. Check ctx first so a shutdown in progress
select {
case <-rl.ctx.Done():
return fmt.Errorf("rate limiter stopped")
case <-ctx.Done():
return ctx.Err()
default:
}
timeout := submitTimeout(reqType)
if rl.submitTimeoutFn != nil {
timeout = rl.submitTimeoutFn(reqType)
}
select {
case rl.requestQueue <- req:
// Wait for result with timeout
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case err := <-req.ResultChan:
return err
case <-timer.C:
// Revoke the queued dispatch synchronously before returning. This
// and the request could still acquire a token and reach SendFunc.
cancelRequest()
rl.incrementRejected()
return fmt.Errorf("request timeout after %s", timeout)
case <-ctx.Done():
cancelRequest()
return ctx.Err()
case <-rl.ctx.Done():
cancelRequest()
return fmt.Errorf("rate limiter stopped")
}
case <-ctx.Done():
return ctx.Err()
case <-rl.ctx.Done():
return fmt.Errorf("rate limiter stopped")
default:
// Queue is full
rl.incrementRejected()
return fmt.Errorf("request queue full (1000 pending)")
}
}
// processRequests dispatches each queued request to its own goroutine.
// stuck in WaitForTokens (e.g. a historical-data request waiting on the
// behind historical waits and failed their 5 s caller timeouts —
// themselves: messageRate (40 tokens/sec) for every type, historicalRate
func (rl *RateLimiter) processRequests() {
defer rl.wg.Done()
for {
select {
case <-rl.ctx.Done():
return
case req := <-rl.requestQueue:
rl.wg.Add(1)
go rl.dispatch(req)
}
}
}
// dispatch runs one request's rate-limited execution. Extracted from the
// processRequests loop body so per-request goroutines have a clean
// completion-and-retry boundary. Always closes the request out via
// req.ResultChan unless the request is re-queued for retry.
func (rl *RateLimiter) dispatch(req *RateLimitedRequest) {
defer rl.wg.Done()
err := rl.executeRequest(req)
if err != nil && rl.shouldRetry(req, err) {
req.Retries++
// Re-queue with exponential backoff. Honor rl.ctx so a
// shutdown mid-backoff doesn't leak a goroutine sleeping
// out the remainder of the delay.
rl.wg.Add(1)
go func(backoff time.Duration) {
defer rl.wg.Done()
ctx := req.Context
if ctx == nil {
ctx = context.Background()
}
timer := time.NewTimer(backoff)
defer timer.Stop()
select {
case <-timer.C:
case <-ctx.Done():
req.ResultChan <- ctx.Err()
return
case <-rl.ctx.Done():
req.ResultChan <- fmt.Errorf("rate limiter stopped")
return
}
select {
case rl.requestQueue <- req:
// Re-queued successfully
case <-ctx.Done():
req.ResultChan <- ctx.Err()
case <-rl.ctx.Done():
req.ResultChan <- fmt.Errorf("rate limiter stopped")
default:
// Queue full, give up
req.ResultChan <- fmt.Errorf("retry failed: queue full")
}
}(time.Duration(req.Retries) * time.Second)
return
}
// Send result back
req.ResultChan <- err
}
// executeRequest executes a single request with appropriate rate limiting
func (rl *RateLimiter) executeRequest(req *RateLimitedRequest) error {
rl.incrementTotal()
ctx, cancel := rl.executionContext(req)
defer cancel()
// Background-lane admission: hold a pool slot before booking any
// tokens, so a fan-out's pending reservations can never queue more
if req.Priority == PriorityBackground {
pool := rl.backgroundMessage
if req.Type == RequestTypeHistorical {
pool = rl.backgroundHistorical
}
if err := pool.Acquire(ctx); err != nil {
rl.incrementThrottled()
return fmt.Errorf("background pacing lane: %w", err)
}
defer pool.Release()
}
// Wait for general message rate limit (all requests)
if err := rl.messageRate.WaitForTokens(ctx, 1); err != nil {
rl.incrementThrottled()
return fmt.Errorf("rate limit cancelled: %w", err)
}
// Apply type-specific limits
switch req.Type {
case RequestTypeHistorical:
// Wait for historical rate limit
if err := rl.historicalRate.WaitForTokens(ctx, 1); err != nil {
rl.incrementThrottled()
return fmt.Errorf("historical rate limit: %w", err)
}
// Acquire concurrent slot
if err := rl.historicalConcurrent.Acquire(ctx); err != nil {
rl.incrementThrottled()
return fmt.Errorf("historical concurrent limit: %w", err)
}
defer rl.historicalConcurrent.Release()
}
if err := ctx.Err(); err != nil {
return err
}
// Slot admission belongs to AcquireMarketDataSlot. A successful socket
// write is not a broker pacing acknowledgement; only inbound code 100
// updates the pacing incident counter.
return req.SendFunc(ctx)
}
func (rl *RateLimiter) executionContext(req *RateLimitedRequest) (context.Context, context.CancelFunc) {
base := req.Context
if base == nil {
base = context.Background()
}
ctx, cancel := context.WithCancel(base)
stop := context.AfterFunc(rl.ctx, cancel)
return ctx, func() {
stop()
cancel()
}
}
func (rl *RateLimiter) shouldRetry(req *RateLimitedRequest, err error) bool {
if err == nil || req.Retries >= req.MaxRetries {
return false
}
if isContextDone(err) || rl.ctx.Err() != nil {
return false
}
if errors.Is(err, errOutboundSessionEnded) {
return false // the session the send was admitted to does not come back
}
if req.Context != nil && req.Context.Err() != nil {
return false
}
return true
}
func isContextDone(err error) bool {
return errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded)
}
// AcquireMarketDataSlot acquires a market data subscription slot
func (rl *RateLimiter) AcquireMarketDataSlot(ctx context.Context) error {
return rl.marketDataSubs.Acquire(ctx)
}
// ReleaseMarketDataSlot releases a market data subscription slot
func (rl *RateLimiter) ReleaseMarketDataSlot() {
rl.marketDataSubs.Release()
}
// GetMetrics returns current rate limiter metrics
func (rl *RateLimiter) GetMetrics() RateLimiterMetrics {
rl.metricsMu.RLock()
defer rl.metricsMu.RUnlock()
metrics := *rl.metrics
metrics.CurrentQueueDepth = len(rl.requestQueue)
return metrics
}
// updateMetrics periodically updates rate metrics
func (rl *RateLimiter) updateMetrics() {
defer rl.wg.Done()
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
var lastTotal uint64
var lastHistorical uint64
for {
select {
case <-rl.ctx.Done():
return
case <-ticker.C:
rl.metricsMu.Lock()
// Calculate rates
currentTotal := rl.metrics.TotalRequests
rl.metrics.MessageRatePerSec = float64(currentTotal - lastTotal)
lastTotal = currentTotal
// Historical rate (per minute)
if time.Now().Unix()%60 == 0 {
rl.metrics.HistoricalRatePerMin = float64(rl.metrics.TotalRequests - lastHistorical)
lastHistorical = rl.metrics.TotalRequests
}
rl.metricsMu.Unlock()
}
}
}
// Helper methods for metrics
func (rl *RateLimiter) incrementTotal() {
rl.metricsMu.Lock()
rl.metrics.TotalRequests++
rl.metricsMu.Unlock()
}
func (rl *RateLimiter) incrementThrottled() {
rl.metricsMu.Lock()
rl.metrics.ThrottledRequests++
rl.metricsMu.Unlock()
}
func (rl *RateLimiter) incrementRejected() {
rl.metricsMu.Lock()
rl.metrics.RejectedRequests++
rl.metricsMu.Unlock()
}
func (rl *RateLimiter) recordRateLimitError() {
now := time.Now()
rl.metricsMu.Lock()
if now.Sub(rl.metrics.LastRateLimitError) >= rl.circuitCooldown {
rl.metrics.ConsecutiveErrors = 0
}
rl.metrics.LastRateLimitError = now
rl.metrics.ConsecutiveErrors++
count := rl.metrics.ConsecutiveErrors
rl.metricsMu.Unlock()
rateLimiterLogger.Warnf("IBKR rate limit error detected (consecutive: %d)", count)
if rl.circuitThreshold > 0 && count >= rl.circuitThreshold {
rl.openCircuit(now.Add(rl.circuitCooldown))
}
}
func (rl *RateLimiter) openCircuit(openUntil time.Time) {
rl.circuitMu.Lock()
if rl.circuitOpenUntil.Before(openUntil) {
rl.circuitOpenUntil = openUntil
rateLimiterLogger.Warnf("Circuit breaker open until %s", openUntil.Format(time.RFC3339))
}
rl.circuitMu.Unlock()
}
func (rl *RateLimiter) checkCircuit(reqType RequestType) error {
rl.circuitMu.Lock()
defer rl.circuitMu.Unlock()
if rl.circuitOpenUntil.IsZero() {
return nil
}
now := time.Now()
if now.After(rl.circuitOpenUntil) {
rl.circuitOpenUntil = time.Time{}
return nil
}
if reqType == RequestTypeHeartbeat || reqType == RequestTypeMarketDataCancel {
return nil
}
return fmt.Errorf("rate limiter circuit breaker open until %s", rl.circuitOpenUntil.Format(time.RFC3339))
}
package ibkr
import (
"maps"
"slices"
"strconv"
"strings"
"time"
)
// ReviewedTerminalStock is one stock the caller holds reviewed terminal
// evidence for (for example an equity the issuer cancelled). ConID, when
// positive, narrows routed keys that carry an explicit contract ID: a key
// naming a different ConID for the same ticker is not covered.
type ReviewedTerminalStock struct {
ConID int
Reason string
// ValidUntil bounds reviewed evidence; zero retains the caller-managed lifetime.
ValidUntil time.Time
// EvidenceFingerprint binds a carried entry to its reviewed source revision.
EvidenceFingerprint string
}
// defaultReviewedTerminalReason labels an entry supplied without a reason.
const defaultReviewedTerminalReason = "reviewed terminal evidence"
// SetReviewedTerminal atomically replaces the set of stocks, keyed by symbol
// (case-insensitive), that the Connector treats as inactive without asking
// the broker. Unlike the heuristic inactive mark, which needs repeated broker
// confirmations and expires, membership survives connection loss. An explicit
// ValidUntil expires it on lookup. A later call omitting the symbol also
// restores ordinary behaviour.
//
// Membership covers the bare symbol, its default route and any STK route key
// for it whose explicit ConID (if any) matches. Any market-data subscription
// a newly added stock still holds is detached: its retry timer is stopped and
// its broker request cancelled (or its slot released). The method sends no
// other broker request. A nil or empty map clears the set.
func (c *Connector) SetReviewedTerminal(stocks map[string]ReviewedTerminalStock) {
if c == nil {
return
}
next := make(map[string]ReviewedTerminalStock, len(stocks))
for symbol, entry := range stocks {
symbol = strings.ToUpper(strings.TrimSpace(symbol))
if symbol == "" || strings.Contains(symbol, "|") || (!entry.ValidUntil.IsZero() && !time.Now().Before(entry.ValidUntil)) {
continue
}
entry.Reason = strings.TrimSpace(entry.Reason)
if entry.Reason == "" {
entry.Reason = defaultReviewedTerminalReason
}
next[symbol] = entry
}
added := make(map[string]ReviewedTerminalStock)
var removed []string
c.inactiveMu.Lock()
previous := c.reviewedTerminal
for symbol, entry := range next {
if prior, ok := previous[symbol]; !ok || prior.ConID != entry.ConID || !prior.ValidUntil.Equal(entry.ValidUntil) || prior.EvidenceFingerprint != entry.EvidenceFingerprint {
added[symbol] = entry
delete(c.inactiveCandidates, symbol)
}
}
for symbol := range previous {
if _, kept := next[symbol]; !kept {
removed = append(removed, symbol)
}
}
c.reviewedTerminal = next
c.inactiveMu.Unlock()
if len(added) == 0 && len(removed) == 0 {
return
}
// The set is published before detaching, so a concurrent subscribe is
// refused by inactiveReason rather than re-creating what is torn down.
var keys []string
c.subMu.RLock()
for key := range c.subscriptions {
if _, covered := reviewedTerminalMatch(added, key); covered {
keys = append(keys, key)
}
}
c.subMu.RUnlock()
slices.Sort(keys)
var post func()
for _, key := range keys {
post = joinPostActions(post, c.detachSubscription(key))
}
for _, symbol := range slices.Sorted(maps.Keys(added)) {
c.logInfo("Suppressing broker requests for %s (%s)", symbol, added[symbol].Reason)
}
slices.Sort(removed)
for _, symbol := range removed {
c.logInfo("Lifting reviewed terminal suppression for %s", symbol)
}
// Broker-side cleanup runs outside every Connector lock.
if post != nil {
post()
}
}
// reviewedTerminalReason reports whether key is covered by the reviewed
// terminal set alone, ignoring heuristic inactive marks.
func (c *Connector) reviewedTerminalReason(key string) (string, bool) {
c.inactiveMu.RLock()
defer c.inactiveMu.RUnlock()
return reviewedTerminalMatch(c.reviewedTerminal, key)
}
// reviewedTerminalMatch reports whether a bare symbol or market-data route key
// is covered by set. Compound keys (see MarketDataKeyForContract) match only
// for STK, and only when any explicit ConID agrees with the entry's.
func reviewedTerminalMatch(set map[string]ReviewedTerminalStock, key string) (string, bool) {
if len(set) == 0 {
return "", false
}
key = strings.ToUpper(strings.TrimSpace(key))
symbol, rest, compound := strings.Cut(key, "|")
entry, ok := set[symbol]
if !ok || (!entry.ValidUntil.IsZero() && !time.Now().Before(entry.ValidUntil)) {
return "", false
}
if !compound {
return entry.Reason, true
}
secType, _, _ := strings.Cut(rest, "|")
if secType != "STK" {
return "", false
}
if entry.ConID > 0 {
if _, conID, found := strings.CutLast(rest, "|CONID:"); found && conID != strconv.Itoa(entry.ConID) {
return "", false
}
}
return entry.Reason, true
}
package ibkr
import (
"errors"
)
// SendDisposition classifies what an error proves about one broker
// instruction at the physical transport boundary. It says nothing about
// broker acceptance: even a nil local return still requires broker lifecycle
// evidence.
type SendDisposition string
const (
// SendDispositionDefinitelyUnsent proves that no byte from the instruction
// reached the socket writer. Callers may safely discard provisional local
// correlation created only for that attempt.
SendDispositionDefinitelyUnsent SendDisposition = "definitely_unsent"
// SendDispositionMayHaveWritten means at least one frame byte may have
// reached the socket writer. The instruction must not be replayed blindly.
SendDispositionMayHaveWritten SendDisposition = "may_have_written"
// SendDispositionUnknown is the conservative fallback when an error source
// cannot prove either of the stronger transport facts.
SendDispositionUnknown SendDisposition = "unknown"
)
// SendDispositionError preserves the original error while attaching a
// machine-readable broker-send disposition. Construct it with
// [WithSendDisposition].
type SendDispositionError struct {
err error
disposition SendDisposition
}
// Error returns the underlying broker-send error text.
func (e *SendDispositionError) Error() string { return e.err.Error() }
// Unwrap returns the underlying broker-send error.
func (e *SendDispositionError) Unwrap() error { return e.err }
// SendDisposition returns the attached transport fact.
func (e *SendDispositionError) SendDisposition() SendDisposition {
if e == nil || !validSendDisposition(e.disposition) {
return SendDispositionUnknown
}
return e.disposition
}
// WithSendDisposition attaches disposition to err without hiding its error
// chain. An existing typed disposition is retained because the innermost
// transport boundary has the most precise knowledge. Nil stays nil.
func WithSendDisposition(err error, disposition SendDisposition) error {
if err == nil {
return nil
}
if _, ok := errors.AsType[*SendDispositionError](err); ok {
return err
}
if !validSendDisposition(disposition) {
disposition = SendDispositionUnknown
}
return &SendDispositionError{err: err, disposition: disposition}
}
// SendDispositionOf extracts the strongest transport fact attached to err.
// Untyped errors, including errors returned by custom broker hooks, are
// conservatively unknown. A nil error also has no send disposition and returns
// unknown; callers should classify only non-nil results.
func SendDispositionOf(err error) SendDisposition {
disposition, ok := errors.AsType[*SendDispositionError](err)
if !ok {
return SendDispositionUnknown
}
return disposition.SendDisposition()
}
func validSendDisposition(disposition SendDisposition) bool {
switch disposition {
case SendDispositionDefinitelyUnsent, SendDispositionMayHaveWritten, SendDispositionUnknown:
return true
default:
return false
}
}
func definitelyUnsent(err error) error {
return WithSendDisposition(err, SendDispositionDefinitelyUnsent)
}
package ibkr
import (
"context"
"fmt"
"math"
"slices"
"strconv"
"sync"
"time"
)
const setupExpiryCallLimit = 2048
// FetchSetupExpiryCalls resolves one standard call expiry on the captured
// session. SecDefOptParams supplies separate expiry and strike sets, not valid
// pairs. This bounded partial-contract request supplies the actual pairs and
// their routing metadata; no partial response or shared cache is authoritative.
func (c *Connector) FetchSetupExpiryCalls(ctx context.Context, binding ConnectorSessionBinding, underlying Contract, expiry string, timeout time.Duration) ([]ResolvedOrderContract, error) {
return c.FetchSetupExpiryOptions(ctx, binding, underlying, expiry, "C", timeout)
}
// FetchSetupExpiryOptions is FetchSetupExpiryCalls for either right, C or P.
func (c *Connector) FetchSetupExpiryOptions(ctx context.Context, binding ConnectorSessionBinding, underlying Contract, expiry, right string, timeout time.Duration) ([]ResolvedOrderContract, error) {
date, dateErr := time.Parse("20060102", expiry)
if right != "C" && right != "P" || underlying.ConID <= 0 || underlying.Symbol == "" || underlying.SecType != "STK" || underlying.Currency != "USD" || underlying.Exchange != "SMART" || dateErr != nil || date.Format("20060102") != expiry || !c.SessionCurrent(binding) {
return nil, fmt.Errorf("exact US stock session and expiry required")
}
// Match the existing full-expiry prewarm budget. A short snapshot budget
// can discard a valid inventory before its required end receipt arrives.
// Caller cancellation, response count and exact-session fences still bound it.
if timeout <= 0 || timeout > 30*time.Second {
timeout = 30 * time.Second
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
// The stock's primary listing venue does not identify the option venue.
// Use the same SMART route as exact call resolution; validate the returned
// option's UnderConID rather than sending the stock's primary exchange.
request := Contract{Symbol: underlying.Symbol, SecType: "OPT", Exchange: "SMART", Currency: "USD", Expiry: expiry, Right: right, Multiplier: 100, TradingClass: underlying.Symbol}
conn := binding.connection
serverVersion := conn.serverVersion
id, err := conn.reserveRequestID(nil)
if err != nil {
return nil, err
}
defer conn.discardRequestIDReservation(id)
var mu sync.Mutex
var failure error
var frames int
var finished bool
rows := make(map[float64]ResolvedOrderContract)
details := make(map[float64]ContractDetailsLite)
ids := make(map[int]float64)
done := make(chan struct{}, 1)
signal := func() {
select {
case done <- struct{}{}:
default:
}
}
dataID := conn.RegisterHandlerAtEpoch(msgContractData, func(fields []string, epoch uint64) {
if epoch != binding.epoch || ctx.Err() != nil {
return
}
detail, ok := parseContractDetailsLite(fields, id, serverVersion)
if !ok {
return
}
mu.Lock()
defer mu.Unlock()
if failure != nil || finished {
return
}
frames++
if frames > setupExpiryCallLimit {
failure = fmt.Errorf("expiry call inventory exceeds %d responses", setupExpiryCallLimit)
signal()
return
}
one := request
one.Strike = detail.Strike
resolved, err := exactOrderContract(one, []ContractDetailsLite{*detail})
if err != nil || detail.Strike <= 0 || math.IsNaN(detail.Strike) || math.IsInf(detail.Strike, 0) || ValidateSetupOptionResolution(resolved, underlying, expiry, detail.Strike, right) != nil {
failure = fmt.Errorf("expiry call inventory contains a mismatched contract")
signal()
return
}
prior, duplicate := details[detail.Strike]
priorStrike, knownID := ids[detail.ConID]
if duplicate && prior != *detail || knownID && priorStrike != detail.Strike {
failure = fmt.Errorf("expiry call inventory contains conflicting identities")
signal()
return
}
details[detail.Strike], rows[detail.Strike], ids[detail.ConID] = *detail, resolved, detail.Strike
})
endID := conn.RegisterHandlerAtEpoch(msgContractDataEnd, func(fields []string, epoch uint64) {
if epoch != binding.epoch || len(fields) < 3 || fields[2] != strconv.Itoa(id) {
return
}
mu.Lock()
finished = true
mu.Unlock()
signal()
})
defer conn.UnregisterHandler(msgContractData, dataID)
defer conn.UnregisterHandler(msgContractDataEnd, endID)
req, release := c.registerContractDetailsRequest(id, "")
defer release()
if err := conn.sendContractDetailsRequestForEpoch(ctx, request, id, binding.epoch); err != nil {
return nil, err
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case err := <-req.fail:
return nil, err
case <-done:
}
mu.Lock()
defer mu.Unlock()
if failure != nil {
return nil, failure
}
if err := ctx.Err(); err != nil {
return nil, err
}
if !finished || !c.SessionCurrent(binding) {
return nil, fmt.Errorf("broker session changed during expiry call discovery")
}
out := make([]ResolvedOrderContract, 0, len(rows))
for _, row := range rows {
out = append(out, row)
}
slices.SortFunc(out, func(a, b ResolvedOrderContract) int {
if a.Contract.Strike < b.Contract.Strike {
return -1
}
if a.Contract.Strike > b.Contract.Strike {
return 1
}
return 0
})
return out, nil
}
package ibkr
import (
"context"
"fmt"
"math"
"time"
)
// FetchSetupBars reads at most seven days of exact-contract, five-minute RTH
// TRADES bars. End is explicit for bounded historical reconstruction; the
// shared historical client still owns pacing, registration and cancellation.
func (c *Connector) FetchSetupBars(ctx context.Context, contract Contract, start, end time.Time, timeout time.Duration) ([]HistoricalBar, error) {
if err := validateSetupHistoryWindow(contract, start, end); err != nil {
return nil, err
}
binding, ok := c.CaptureHistoricalSession()
if !ok {
return nil, fmt.Errorf("broker session unavailable")
}
if _, terminal := c.reviewedTerminalReason(MarketDataKeyForContract(normalizeMarketDataContract(contract))); terminal {
return nil, ErrSymbolInactive
}
days := int(math.Ceil(end.Sub(start).Hours() / 24))
bars, err := c.fetchHistoricalWithContractOptions(ctx, contract.Symbol, contract, days, timeout, "TRADES", historicalRequestOptions{endDateTime: end.UTC().Format("20060102-15:04:05"), formatDate: 2, chartBarSize: "5 mins", strictDaily: true, waitForEnd: true, maxBars: ChartMaxBars})
if !c.HistoricalSessionCurrent(binding) {
return nil, fmt.Errorf("broker session changed during setup history")
}
if err != nil {
return nil, err
}
out := make([]HistoricalBar, 0, len(bars))
for _, bar := range bars {
if !bar.Time.Before(start) && bar.Time.Before(end) {
out = append(out, bar)
}
}
return out, nil
}
func validateSetupHistoryWindow(contract Contract, start, end time.Time) error {
if contract.ConID <= 0 || contract.SecType != "STK" || contract.Currency != "USD" {
return fmt.Errorf("setup history requires an exact USD stock contract")
}
if start.IsZero() || end.IsZero() || !end.After(start) || end.Sub(start) > 7*24*time.Hour || end.After(time.Now()) {
return fmt.Errorf("setup history window must be past, positive and at most seven days")
}
return nil
}
package ibkr
import (
"context"
"fmt"
"math"
"strconv"
"strings"
"sync"
"time"
)
// FetchSetupOptionStrikes reads actual standard-class listings for one exact
// underlying session. Unlike exploratory chains it never adopts a symbol-cache
// identity, adjusted multiplier, partial timeout, or invented strike grid.
func (c *Connector) FetchSetupOptionStrikes(ctx context.Context, binding ConnectorSessionBinding, underlying Contract, timeout time.Duration) (map[string][]ExpiryClassedStrikes, error) {
if underlying.ConID <= 0 || underlying.SecType != "STK" || underlying.Currency != "USD" || underlying.Exchange != "SMART" || !c.SessionCurrent(binding) {
return nil, fmt.Errorf("exact US stock broker session required")
}
if timeout <= 0 || timeout > 12*time.Second {
timeout = 12 * time.Second
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
conn := binding.connection
fetch := newOptionExpiryFetch()
var stateMu sync.Mutex
var failure error
var frames, pairs int
failed := make(chan struct{}, 1)
var dataID, endID uint64
_, err := conn.requestSecDefOptParamsContext(ctx, underlying.Symbol, "", "STK", underlying.ConID, func(id int) {
dataID = conn.RegisterHandlerAtEpoch(msgSecurityDefinitionOptionalParameter, func(fields []string, epoch uint64) {
if epoch != binding.epoch || ctx.Err() != nil || !setupOptionParameterMatches(fields, id, underlying) {
return
}
stateMu.Lock()
defer stateMu.Unlock()
if failure != nil {
return
}
n, err := setupOptionParameterSize(fields)
if err != nil || frames >= 32 || pairs+n > 131072 {
failure = fmt.Errorf("option listing malformed or exceeds bounded inventory")
failed <- struct{}{}
return
}
frames++
pairs += n
c.handleSecDefOptParam(id, fetch, fields)
})
endID = conn.RegisterHandlerAtEpoch(msgSecurityDefinitionOptionalParameterEnd, func(fields []string, epoch uint64) {
if epoch == binding.epoch {
c.handleSecDefOptParamEnd(id, fetch, fields)
}
})
})
defer conn.UnregisterHandler(msgSecurityDefinitionOptionalParameter, dataID)
defer conn.UnregisterHandler(msgSecurityDefinitionOptionalParameterEnd, endID)
if err != nil {
return nil, err
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-failed:
case <-fetch.done:
}
stateMu.Lock()
listingErr := failure
stateMu.Unlock()
if listingErr != nil {
return nil, listingErr
}
if !c.SessionCurrent(binding) {
return nil, fmt.Errorf("broker session changed during option discovery")
}
_, _, classed := fetch.snapshot()
return classed, nil
}
func setupOptionParameterMatches(fields []string, id int, underlying Contract) bool {
if len(fields) < 6 || fields[1] != strconv.Itoa(id) || fields[2] != "SMART" || fields[3] != strconv.Itoa(underlying.ConID) || strings.TrimSpace(fields[4]) != underlying.Symbol || fields[5] != "100" {
return false
}
return true
}
// Bound the collector's expiry×strike work before it allocates any map rows.
func setupOptionParameterSize(fields []string) (int, error) {
invalid := fmt.Errorf("option parameter counts or values invalid")
if len(fields) < 8 || len(fields) > 4096 {
return 0, invalid
}
expiries, err := strconv.Atoi(fields[6])
if err != nil || expiries < 0 || expiries > 128 || 7+expiries >= len(fields) {
return 0, invalid
}
for _, date := range fields[7 : 7+expiries] {
if d, err := time.Parse("20060102", date); err != nil || d.Format("20060102") != date {
return 0, invalid
}
}
strikes, err := strconv.Atoi(fields[7+expiries])
end := 8 + expiries + strikes
if err != nil || strikes < 0 || strikes > 2048 || expiries*strikes > 65536 || end > len(fields) || !(end == len(fields) || end+1 == len(fields) && fields[end] == "") {
return 0, invalid
}
for _, value := range fields[8+expiries : end] {
if n, err := strconv.ParseFloat(value, 64); err != nil || n <= 0 || math.IsNaN(n) || math.IsInf(n, 0) {
return 0, invalid
}
}
return expiries * strikes, nil
}
// ValidateSetupCallResolution checks the broker's exact underlying linkage and
// standard call tuple after ordinary exact-contract resolution has succeeded.
func ValidateSetupCallResolution(r ResolvedOrderContract, underlying Contract, expiry string, strike float64) error {
return ValidateSetupOptionResolution(r, underlying, expiry, strike, "C")
}
// ValidateSetupOptionResolution is ValidateSetupCallResolution for either
// right, C or P.
func ValidateSetupOptionResolution(r ResolvedOrderContract, underlying Contract, expiry string, strike float64, right string) error {
c := r.Contract
if (right != "C" && right != "P") || r.UnderConID != underlying.ConID || r.UnderConID <= 0 || c.ConID <= 0 || c.Symbol != underlying.Symbol || c.SecType != "OPT" || c.Currency != "USD" || c.Exchange != "SMART" || c.Expiry != expiry || c.Right != right || c.Strike != strike || c.Multiplier != 100 || c.TradingClass != underlying.Symbol || strings.TrimSpace(c.LocalSymbol) == "" {
return fmt.Errorf("resolved option is not the exact standard option for this underlying")
}
return nil
}
package ibkr
import (
"context"
"fmt"
"math"
"strconv"
"strings"
"time"
)
// SubmitStockEntryStopForSessionGuarded sends exactly one held stock entry and
// its releasing fixed stop under one first-byte guard. Success means written,
// never broker acceptance or protection. There is no retry after uncertainty.
func (c *Connector) SubmitStockEntryStopForSessionGuarded(ctx context.Context, binding ConnectorSessionBinding, contract *Contract, entry *RawOrder, childID int, childRef string, stop float64, guard func() error) error {
if !tradingEnabled {
return definitelyUnsent(ErrTradingDisabled)
}
return c.submitStockEntryStop(ctx, binding, nil, contract, entry, childID, childRef, stop, guard)
}
// SubmitPaperStockEntryStopForSessionGuarded applies the ordinary paper gate
// to the same indivisible transport admission.
func (c *Connector) SubmitPaperStockEntryStopForSessionGuarded(ctx context.Context, binding ConnectorSessionBinding, gate PaperOrderGate, contract *Contract, entry *RawOrder, childID int, childRef string, stop float64, guard func() error) error {
if err := gate.validate(); err != nil {
return definitelyUnsent(err)
}
return c.submitStockEntryStop(ctx, binding, &gate, contract, entry, childID, childRef, stop, guard)
}
func (c *Connector) submitStockEntryStop(ctx context.Context, binding ConnectorSessionBinding, gate *PaperOrderGate, contract *Contract, entry *RawOrder, childID int, childRef string, stop float64, guard func() error) error {
if c == nil || ctx == nil || contract == nil || entry == nil {
return definitelyUnsent(fmt.Errorf("stock entry and stop require an exact session, contract and entry"))
}
if err := ctx.Err(); err != nil {
return definitelyUnsent(err)
}
if !c.SessionCurrent(binding) {
return definitelyUnsent(fmt.Errorf("stock entry session changed"))
}
if down, _ := c.backendConnectivityDown(); down {
return definitelyUnsent(fmt.Errorf("broker backend connectivity unavailable"))
}
if contract.ConID <= 0 || contract.SecType != "STK" || len(contract.ComboLegs) > 0 || entry.Action != "BUY" || entry.OrderType != "LMT" || entry.TIF != "DAY" || entry.TotalQty < 1 || entry.TotalQty > 1000000 || entry.OrderID <= 0 || childID <= entry.OrderID || entry.Changes != nil || entry.OcaGroup != "" || entry.OcaType != 0 || entry.OutsideRth || entry.Account == "" || strings.TrimSpace(entry.OrderRef) == "" || strings.TrimSpace(childRef) == "" || childRef == entry.OrderRef || !isPositiveFiniteStockPrice(entry.LmtPrice) || !isPositiveFiniteStockPrice(stop) || stop >= entry.LmtPrice {
return definitelyUnsent(fmt.Errorf("invalid stock entry and attached stop terms"))
}
conn := binding.connection
if gate != nil && (entry.Account != gate.Account || entry.ClientID != gate.ClientID) {
return definitelyUnsent(fmt.Errorf("stock entry does not match paper gate"))
}
if conn.serverVersion < minServerVerProtoBufPlaceOrder {
return definitelyUnsent(fmt.Errorf("attached stock stop requires current broker protocol"))
}
parent := rawIBKROrder(contract, entry)
parent.Transmit = false
parent.WhatIf = false
child := rawIBKROrder(contract, &RawOrder{OrderID: childID, ClientID: entry.ClientID, Account: entry.Account, OrderRef: childRef, Action: "SELL", TotalQty: entry.TotalQty, OrderType: "STP", AuxPrice: stop, TIF: "GTC", TriggerMethod: 2, OpenClose: "C"})
child.ParentID = entry.OrderID
child.Transmit = true
orders := []*IBKROrder{parent, child}
// Fully validate and encode both legs before claiming either for dispatch.
frames := make([][]byte, 2)
for i, o := range orders {
if err := ValidateOrder(o); err != nil {
return definitelyUnsent(err)
}
for _, v := range []string{o.Account, o.OrderRef, o.Symbol, o.Exchange, o.Currency, o.LocalSymbol, o.TradingClass, o.PrimaryExch} {
if err := ensureASCII("stock entry/stop identity", v); err != nil {
return definitelyUnsent(err)
}
}
if gate != nil {
if err := gate.validateConnection(conn); err != nil {
return definitelyUnsent(err)
}
}
var err error
frames[i], err = encodePlaceOrderProtoFrame(o)
if err != nil {
return definitelyUnsent(err)
}
}
c.brokerIDNamespaceMu.Lock()
for _, o := range orders {
if c.feeRequestOwnsID(o.OrderID) || c.PlacedOrderID(o.OrderID) {
c.brokerIDNamespaceMu.Unlock()
return definitelyUnsent(fmt.Errorf("attached order ID already in use"))
}
}
for _, o := range orders {
_, fresh, err := conn.claimOrderIDForForwardingAtEpochFresh(o.OrderID, false, &binding.epoch, nil)
if err != nil || !fresh {
for _, p := range orders {
conn.discardOrderIDReservation(p.OrderID)
}
c.brokerIDNamespaceMu.Unlock()
if err == nil {
err = fmt.Errorf("attached order ID must be newly reserved")
}
return definitelyUnsent(err)
}
c.orderIDHighWater = max(c.orderIDHighWater, o.OrderID)
}
defer func() {
for _, o := range orders {
conn.discardOrderIDReservation(o.OrderID)
}
}()
c.orderMu.Lock()
// References are reserved by the durable pair journal. Refuse aliasing rather
// than overwriting a pending entry belonging to another operation.
for _, o := range orders {
_, refExists := c.openOrders[o.OrderRef]
_, idExists := c.brokerOrderIndex[strconv.Itoa(o.OrderID)]
if refExists || idExists {
c.orderMu.Unlock()
c.brokerIDNamespaceMu.Unlock()
return definitelyUnsent(fmt.Errorf("attached order reference already in use"))
}
}
for _, o := range orders {
id := strconv.Itoa(o.OrderID)
now := time.Now()
c.openOrders[o.OrderRef] = &trackedOrder{ID: o.OrderRef, BrokerID: id, Symbol: o.Symbol, Side: OrderSide(o.Action), Quantity: float64(o.TotalQty), OrderType: mapIBOrderType(o.OrderType), LimitPrice: o.LmtPrice, StopPrice: o.AuxPrice, TimeInForce: mapIBTimeInForce(o.TIF), Status: OrderStatusPending, CreatedAt: now, UpdatedAt: now}
c.brokerOrderIndex[id] = o.OrderRef
}
c.orderMu.Unlock()
c.brokerIDNamespaceMu.Unlock()
err := conn.sendBoundFrames(ctx, frames[0], RequestTypeOrder, binding.epoch, true, guard, 0, frames[1:])
c.orderMu.Lock()
if err != nil && SendDispositionOf(err) == SendDispositionDefinitelyUnsent {
for _, o := range orders {
delete(c.openOrders, o.OrderRef)
delete(c.brokerOrderIndex, strconv.Itoa(o.OrderID))
}
} else {
if c.placedOrderIDs == nil {
c.placedOrderIDs = make(map[int]struct{})
}
for _, o := range orders {
c.placedOrderIDs[o.OrderID] = struct{}{}
}
}
c.orderMu.Unlock()
// Never claim Submitted from a socket write; exact callbacks own lifecycle.
return err
}
func isPositiveFiniteStockPrice(p float64) bool { return p > 0 && !math.IsNaN(p) && !math.IsInf(p, 0) }
package ibkr
import (
"regexp"
"strings"
)
// Symbol classification helpers.
// IBKR requires correct contract hints (secType, exchange, primary) for
// canonical wire identifier, matching IBKR's own LocalSymbol convention)
// constructing a wire-side Contract must additionally call FxPair to lift
// a valid IBKR symbol field on its own.
// fxMajors is the G10 set we recognise as FX pairs. Keeping the list
var fxMajors = map[string]struct{}{
"USD": {}, "EUR": {}, "JPY": {}, "GBP": {}, "CHF": {},
"AUD": {}, "NZD": {}, "CAD": {},
}
// FxPair parses an FX-pair symbol in either dotted (USD.JPY) or slash
// only when both legs are in fxMajors. Case-insensitive; trims whitespace.
func FxPair(symbol string) (base, quote string, ok bool) {
s := strings.ToUpper(strings.TrimSpace(symbol))
var sep string
switch {
case strings.Count(s, ".") == 1:
sep = "."
case strings.Count(s, "/") == 1:
sep = "/"
default:
return "", "", false
}
left, right, _ := strings.Cut(s, sep)
if len(left) != 3 || len(right) != 3 {
return "", "", false
}
if _, ok := fxMajors[left]; !ok {
return "", "", false
}
if _, ok := fxMajors[right]; !ok {
return "", "", false
}
return left, right, true
}
// dualClassWireSymbol translates an S&P ticker for a dual-class share
// into the wire-format symbol IBKR's TWS API expects. The S&P / index
// convention uses a dot ("BRK.B", "BF.B") but IBKR rejects that form
// with code 200 "No security definition has been found for the request"
func dualClassWireSymbol(input string) string {
upper := strings.ToUpper(strings.TrimSpace(input))
if dualClassPattern.MatchString(upper) {
return strings.Replace(upper, ".", " ", 1)
}
return input
}
// dualClassPattern matches the US dual-class ticker convention: a 1–4
var dualClassPattern = regexp.MustCompile(`^[A-Z]{1,4}\.[A-Z]$`)
// classifySymbol returns (secType, exchange, currency, primaryExchangeHint)
func classifySymbol(symbol string) (string, string, string, string) {
// FX pairs route through IDEALPRO with the quote currency on the
// must apply FxPair when building the wire contract).
if _, quote, ok := FxPair(symbol); ok {
return "CASH", "IDEALPRO", quote, "IDEALPRO"
}
// Defaults
secType := "STK"
exchange := "SMART"
currency := "USD"
primary := ""
switch symbol {
// Broad indices
case "VIX":
secType = "IND"
exchange = "CBOE"
primary = "CBOE"
case "VVIX":
secType = "IND"
exchange = "CBOE"
primary = "CBOE"
// VIX3M is the CBOE 3-month implied volatility index, the
case "VIX3M":
secType = "IND"
exchange = "CBOE"
primary = "CBOE"
case "SPX":
secType = "IND"
exchange = "CBOE"
primary = "CBOE"
case "NDX":
secType = "IND"
exchange = "NASDAQ"
primary = "NASDAQ"
// IBKR lists the Russell 2000 index on its provider's venue, as it does
// NDX; CBOE carries only the options on it and answers a RUT index
// described there with "no security definition".
case "RUT":
secType = "IND"
exchange = "RUSSELL"
primary = "RUSSELL"
case "DJI", "DJX":
secType = "IND"
exchange = "CBOE"
primary = "CBOE"
// The S&P 500 stocks-above-50DMA breadth index (S5FI family on
// catalogued in IBKR's contract database under any of the standard
// US Indexes subscription. IBKR's "CBOE US Indexes" feed covers
// they're a different data product that IBKR doesn't appear to
// Common ETFs
case "SPY", "QQQ", "IWM", "DIA", "GLD", "TLT", "HYG",
"SMH", "XLK", "XLF", "XLI", "XLE", "XLV", "XLY", "XLP",
"XLU", "XLB", "XLRE", "IBIT":
secType = "STK"
exchange = "SMART"
primary = "ARCA" // Often better coverage OOH
// Dollar index (ICE US)
case "DXY":
secType = "IND"
exchange = "ICEUS"
primary = "ICEUS"
// "ES" historically mapped here to FUT/GLOBEX for the E-mini S&P
// must build a Contract with SecType=FUT explicitly rather than
default:
// leave defaults
}
return secType, exchange, currency, primary
}
// DefaultHistoricalCurrency reports the currency used by the symbol-only daily
// history route. It describes the request, not an independently observed quote.
func DefaultHistoricalCurrency(symbol string) string {
_, _, currency, _ := classifySymbol(strings.ToUpper(strings.TrimSpace(symbol)))
return currency
}
func optionUnderlyingPrimaryExchangeHint(symbol string) string {
secType, _, _, primary := classifySymbol(strings.ToUpper(strings.TrimSpace(symbol)))
if secType != "STK" {
return ""
}
return primary
}
func contractDisplayHints(symbol, secType string) (string, string) {
symbol = strings.ToUpper(strings.TrimSpace(symbol))
switch secType {
case "OPT":
// Equity/ETF options usually leave LocalSymbol empty during contract
return "", symbol
case "IND":
switch symbol {
case "VIX", "VVIX", "VIX3M", "SPX", "NDX", "DJI", "DJX", "DXY":
return symbol, symbol
}
case "CMDTY":
return symbol, symbol
}
return "", ""
}
package ibkr
import (
"context"
"errors"
"fmt"
"math"
"strconv"
"strings"
"sync"
"time"
)
// TWS order control is the one path on which Canary touches an order placed
// by hand in TWS (owner decision "TWS B", 2026-10-11 19:36 CEST). It opens a
// separate, short-lived connection as client id 0, the only client TWS lets
// bind its manual orders, binds them with reqOpenOrders, finds the exact
// order by its permanent id and the terms the owner reviewed, cancels or
// modifies it, waits a bounded time for the broker's answer, and
// disconnects. The daemon's own connection, its client id and its order
// namespace are not involved. Side effect, by TWS design: once bound, the
// order is an API order for the rest of the TWS session.
//
// Every failure leaves the order untouched or says exactly what happened:
// a busy client-0 slot, a connect failure, an incomplete bind, an absent or
// changed order and a broker refusal are all refusals with the order
// untouched; a send that may have reached TWS without a confirming callback
// is reported uncertain and is never retried here.
// TWSOrderControlAction names what the owner confirmed.
const (
TWSOrderControlCancel = "cancel"
TWSOrderControlModify = "modify"
)
// TWS order control outcomes.
const (
// TWSOrderControlConfirmed means the broker confirmed the cancel or the
// replacement before the connection closed.
TWSOrderControlConfirmed = "confirmed"
// TWSOrderControlPending means the broker acknowledged the request
// (PendingCancel, or a PreSubmitted echo) without a final status before
// the connection closed; a later open-order read settles it.
TWSOrderControlPending = "pending"
// TWSOrderControlRefused means nothing changed: a gate, the bind, the
// identity check or the broker refused before or instead of acting.
TWSOrderControlRefused = "refused"
// TWSOrderControlUncertain means the request may have reached TWS and
// nothing proves either way. It is settled by reading the broker's open
// orders again, never by a blind retry.
TWSOrderControlUncertain = "uncertain"
)
// TWS order control refusals. Each wraps a reason; none means the order
// changed.
var (
ErrTWSOrderControlGate = errors.New("tws order control gate")
ErrTWSOrderControlSlotBusy = errors.New("client id 0 is already connected to TWS")
ErrTWSOrderControlConnect = errors.New("could not open the client-0 control connection")
ErrTWSOrderControlBind = errors.New("TWS did not finish listing its manual orders")
ErrTWSOrderControlNotFound = errors.New("the order is not among the manual TWS orders")
ErrTWSOrderControlNotWorking = errors.New("the order is no longer working")
ErrTWSOrderControlChanged = errors.New("the order differs from the reviewed terms")
ErrTWSOrderControlRefused = errors.New("TWS refused the request")
// ErrTWSOrderControlUncertain is returned with a receipt whose Outcome
// is TWSOrderControlUncertain.
ErrTWSOrderControlUncertain = errors.New("the request may have reached TWS; no confirmation arrived")
)
// TWSOrderControlConfig is the control connection: the same TWS endpoint
// and account the daemon is connected to, in the mode it trades in.
type TWSOrderControlConfig struct {
// Authority fences client-0 writes against the primary connector's
// unpublication. It never supplies the client-0 connection or order id.
Authority *Connector
// AuthoritySession is the primary session captured by daemon admission.
AuthoritySession ConnectorSessionBinding
authorityOutboundState uint64
// NewWireGuard creates one final-send guard and its release. The release
// runs after the frame write, before waiting for any broker callback.
NewWireGuard func() (func() error, func())
Host string
Port int
// Mode is "paper" or "live". Paper requires a DU account and is
// available in every build; live requires the trading build.
Mode string
Account string
// UseTLS and EnableTLSFallback mirror the daemon's gateway settings.
UseTLS bool
EnableTLSFallback bool
// ConnectTimeout bounds dial, handshake and startAPI (default 10s).
// BindTimeout bounds reqOpenOrders to openOrderEnd (default 10s).
// AckTimeout bounds the wait for the broker's answer to the cancel or
// the replacement (default 5s).
ConnectTimeout time.Duration
BindTimeout time.Duration
AckTimeout time.Duration
}
// TWSOrderControlTarget is the order as the owner reviewed it. The bound
// order must match every field, so a change TWS made meanwhile (a fill, a
// re-price by hand) refuses the request instead of acting on terms the owner
// did not see.
type TWSOrderControlTarget struct {
PermID int
ConID int // checked when both sides carry one
Symbol string
SecType string
Action string
Quantity float64
Filled float64
OrderType string
LimitPrice float64
AuxPrice float64
TIF string
}
// TWSOrderControlReplacement is the new quantity and limit price of a
// modify. Only LMT orders are modified; the contract, side, type and
// time-in-force stay as bound.
type TWSOrderControlReplacement struct {
Quantity float64
LimitPrice float64
}
// TWSOrderControlReceipt is what happened, for the journal and the caller.
type TWSOrderControlReceipt struct {
Action string
PermID int
BoundOrderID int
Outcome string
BrokerStatus string
BrokerCode int
BrokerMessage string
// Bound is the order as TWS reported it on binding; zero when the bind
// did not reach it.
Bound OrderLifecycleEvent
ServerVersion int
ConnectedAt time.Time
DisconnectedAt time.Time
}
const (
defaultTWSOrderControlConnectTimeout = 10 * time.Second
defaultTWSOrderControlBindTimeout = 10 * time.Second
defaultTWSOrderControlAckTimeout = 5 * time.Second
)
func (cfg TWSOrderControlConfig) withDefaults() TWSOrderControlConfig {
if cfg.ConnectTimeout <= 0 {
cfg.ConnectTimeout = defaultTWSOrderControlConnectTimeout
}
if cfg.BindTimeout <= 0 {
cfg.BindTimeout = defaultTWSOrderControlBindTimeout
}
if cfg.AckTimeout <= 0 {
cfg.AckTimeout = defaultTWSOrderControlAckTimeout
}
return cfg
}
// gate refuses a configuration the path must not act on: no endpoint, no
// concrete account, a mode other than paper or live, a paper mode without
// a DU account, or a live mode in the read-only build.
func (cfg TWSOrderControlConfig) gate() error {
if strings.TrimSpace(cfg.Host) == "" || cfg.Port <= 0 {
return fmt.Errorf("%w: endpoint is required", ErrTWSOrderControlGate)
}
account := strings.TrimSpace(cfg.Account)
if account == "" || strings.EqualFold(account, "All") {
return fmt.Errorf("%w: a concrete account is required", ErrTWSOrderControlGate)
}
switch strings.ToLower(strings.TrimSpace(cfg.Mode)) {
case paperOrderMode:
if !strings.HasPrefix(strings.ToUpper(account), "DU") {
return fmt.Errorf("%w: paper mode requires a DU account", ErrTWSOrderControlGate)
}
case "live":
if !tradingEnabled {
return fmt.Errorf("%w: %w", ErrTWSOrderControlGate, ErrTradingDisabled)
}
default:
return fmt.Errorf("%w: mode must be paper or live", ErrTWSOrderControlGate)
}
return nil
}
func (t TWSOrderControlTarget) validate() error {
if t.PermID <= 0 {
return fmt.Errorf("%w: a permanent order id is required", ErrTWSOrderControlGate)
}
if strings.TrimSpace(t.Symbol) == "" || strings.TrimSpace(t.Action) == "" || strings.TrimSpace(t.OrderType) == "" {
return fmt.Errorf("%w: the reviewed symbol, side and order type are required", ErrTWSOrderControlGate)
}
if t.Quantity <= 0 || math.IsNaN(t.Quantity) || math.IsInf(t.Quantity, 0) {
return fmt.Errorf("%w: the reviewed quantity must be positive", ErrTWSOrderControlGate)
}
return nil
}
func (r TWSOrderControlReplacement) validate(target TWSOrderControlTarget) error {
if !strings.EqualFold(strings.TrimSpace(target.OrderType), "LMT") {
return fmt.Errorf("%w: only LMT orders are modified; cancel and re-enter other types in TWS", ErrTWSOrderControlGate)
}
if tif := strings.ToUpper(strings.TrimSpace(target.TIF)); tif != "" && tif != "DAY" {
return fmt.Errorf("%w: only DAY limit orders are modified; cancel and re-enter a %s order in TWS", ErrTWSOrderControlGate, tif)
}
if secType := strings.ToUpper(strings.TrimSpace(target.SecType)); secType != "" && secType != "STK" && secType != "ETF" {
return fmt.Errorf("%w: only stock and ETF orders are modified; cancel and re-enter a %s order in TWS", ErrTWSOrderControlGate, secType)
}
if r.Quantity <= 0 || math.IsNaN(r.Quantity) || math.IsInf(r.Quantity, 0) || r.Quantity != math.Trunc(r.Quantity) {
return fmt.Errorf("%w: the replacement quantity must be a positive whole number", ErrTWSOrderControlGate)
}
if r.Quantity > target.Quantity {
return fmt.Errorf("%w: the replacement quantity cannot exceed the reviewed quantity", ErrTWSOrderControlGate)
}
if r.Quantity <= target.Filled {
return fmt.Errorf("%w: the replacement quantity must exceed the filled quantity", ErrTWSOrderControlGate)
}
if r.LimitPrice <= 0 || math.IsNaN(r.LimitPrice) || math.IsInf(r.LimitPrice, 0) {
return fmt.Errorf("%w: the replacement limit price must be positive", ErrTWSOrderControlGate)
}
return nil
}
// twsOrderControlSession collects the broker's answers on the control
// connection: the bound orders, the status callbacks and the error notices
// for one order id.
type twsOrderControlSession struct {
mu sync.Mutex
bound []OrderLifecycleEvent
bindEnd chan struct{}
bindOnce sync.Once
answers chan twsOrderControlAnswer
}
type twsOrderControlAnswer struct {
event OrderLifecycleEvent // openOrder or orderStatus echo
code int // broker error code; zero for an echo
msg string
}
func newTWSOrderControlSession() *twsOrderControlSession {
return &twsOrderControlSession{bindEnd: make(chan struct{}), answers: make(chan twsOrderControlAnswer, 64)}
}
func (s *twsOrderControlSession) attach(conn *Connection) {
conn.RegisterHandler(msgOpenOrder, func(fields []string) {
ev, ok := ParseOrderLifecycleEvent(fields)
if !ok || ev.Type != OrderLifecycleEventOpenOrder || ev.WhatIf {
return
}
s.mu.Lock()
s.bound = append(s.bound, ev)
s.mu.Unlock()
s.offer(twsOrderControlAnswer{event: ev})
})
conn.RegisterHandler(msgOpenOrderEnd, func([]string) {
s.mu.Lock()
defer s.mu.Unlock()
s.bindOnce.Do(func() { close(s.bindEnd) })
})
conn.RegisterHandler(msgOrderStatus, func(fields []string) {
ev, ok := ParseOrderLifecycleEvent(fields)
if !ok || ev.Type != OrderLifecycleEventStatus {
return
}
// During the bind, TWS follows each openOrder with its orderStatus;
// that is where the filled quantity and the working status live.
s.mu.Lock()
for i := range s.bound {
if s.bound[i].OrderID == ev.OrderID {
s.bound[i].Status, s.bound[i].Filled, s.bound[i].Remaining = ev.Status, ev.Filled, ev.Remaining
}
}
s.mu.Unlock()
s.offer(twsOrderControlAnswer{event: ev})
})
// Legacy error frames: [4, version, id, code, message].
conn.RegisterHandler(msgErrMsg, func(fields []string) {
if len(fields) < 5 {
return
}
id, _ := strconv.Atoi(strings.TrimSpace(fields[2]))
code, _ := strconv.Atoi(strings.TrimSpace(fields[3]))
s.offer(twsOrderControlAnswer{event: OrderLifecycleEvent{Type: OrderLifecycleEventError, OrderID: id}, code: code, msg: strings.TrimSpace(fields[4])})
})
// Protocol-buffer notices carry the same id, code and message.
conn.SetSystemNoticeHandler(func(note *systemNotification, _ reqAliasEntry) {
if note == nil {
return
}
s.offer(twsOrderControlAnswer{event: OrderLifecycleEvent{Type: OrderLifecycleEventError, OrderID: int(note.tickerID)}, code: note.code, msg: note.message})
})
}
func (s *twsOrderControlSession) offer(a twsOrderControlAnswer) {
select {
case s.answers <- a:
default:
}
}
// beginBind forgets what the connect's startup burst delivered: with open
// orders downloaded on connection, TWS sends client 0's orders and an
// openOrderEnd before nextValidId, inside Connect. Without it that
// openOrderEnd ended the bind before the answer to its own reqOpenOrders,
// whose late statuses then read as the action's acknowledgement. It returns
// the channel the bind's own openOrderEnd closes.
func (s *twsOrderControlSession) beginBind() <-chan struct{} {
s.mu.Lock()
s.bound = nil
s.bindEnd = make(chan struct{})
s.bindOnce = sync.Once{}
end := s.bindEnd
s.mu.Unlock()
s.drain()
return end
}
func (s *twsOrderControlSession) boundOrders() []OrderLifecycleEvent {
s.mu.Lock()
defer s.mu.Unlock()
return append([]OrderLifecycleEvent(nil), s.bound...)
}
// drain discards the answers the bind produced, so only callbacks that
// follow the sent request can settle it.
func (s *twsOrderControlSession) drain() {
for {
select {
case <-s.answers:
default:
return
}
}
}
func newTWSOrderControlConnection(cfg TWSOrderControlConfig) *Connection {
return NewConnection(&ConnectionConfig{
Host: cfg.Host, Port: cfg.Port, ClientID: 0, Account: cfg.Account,
ConnectTimeout: cfg.ConnectTimeout, MaxClientIDRetries: 1, MaxRetries: 1,
AutoReconnect: false, ManagedConnectionLogging: true,
UseTLS: cfg.UseTLS, EnableTLSFallback: cfg.EnableTLSFallback,
})
}
// RunTWSOrderControl cancels or modifies one manual TWS order as described
// above. The receipt is always returned, with the error that names the
// refusal or the uncertainty; a nil error means the outcome is confirmed or
// pending.
func RunTWSOrderControl(ctx context.Context, cfg TWSOrderControlConfig, action string, target TWSOrderControlTarget, replacement TWSOrderControlReplacement) (receipt TWSOrderControlReceipt, err error) {
receipt = TWSOrderControlReceipt{Action: action, PermID: target.PermID, Outcome: TWSOrderControlRefused}
if ctx == nil {
return receipt, fmt.Errorf("%w: context is nil", ErrTWSOrderControlGate)
}
cfg = cfg.withDefaults()
if err := cfg.gate(); err != nil {
return receipt, err
}
if err := target.validate(); err != nil {
return receipt, err
}
switch action {
case TWSOrderControlCancel:
case TWSOrderControlModify:
if err := replacement.validate(target); err != nil {
return receipt, err
}
default:
return receipt, fmt.Errorf("%w: action must be cancel or modify", ErrTWSOrderControlGate)
}
if err := ctx.Err(); err != nil {
return receipt, fmt.Errorf("%w: %w", ErrTWSOrderControlGate, err)
}
if cfg.AuthoritySession.connection != nil {
cfg.authorityOutboundState = cfg.AuthoritySession.connection.outboundSessionState.Load()
}
conn := newTWSOrderControlConnection(cfg)
if cfg.Authority != nil {
conn.publicationBarrier = &cfg.Authority.publicationBarrier
conn.evidenceBarrier = &cfg.Authority.evidenceBarrier
}
session := newTWSOrderControlSession()
session.attach(conn)
defer func() {
_ = conn.Disconnect()
receipt.DisconnectedAt = time.Now().UTC()
}()
connectCtx, cancelConnect := context.WithTimeout(ctx, cfg.ConnectTimeout)
err = conn.Connect(connectCtx)
cancelConnect()
if err != nil {
if isClientIDInUseError(err) {
return receipt, fmt.Errorf("%w: close the other API client on id 0 and try again", ErrTWSOrderControlSlotBusy)
}
return receipt, fmt.Errorf("%w: %w", ErrTWSOrderControlConnect, err)
}
receipt.ConnectedAt = time.Now().UTC()
receipt.ServerVersion = conn.ServerVersion()
bound, err := bindTWSOrders(ctx, conn, session, cfg)
if err != nil {
return receipt, err
}
order, err := findTWSOrder(bound, target)
if err != nil {
return receipt, err
}
receipt.Bound = order
receipt.BoundOrderID = order.OrderID
epoch, err := conn.captureBrokerInstructionEpoch()
if err != nil {
return receipt, fmt.Errorf("%w: %w", ErrTWSOrderControlConnect, err)
}
session.drain()
guard, release := cfg.newWireGuard()
defer release()
switch action {
case TWSOrderControlCancel:
err = conn.cancelOrderForEpochGuarded(ctx, order.OrderID, epoch, guard)
default:
var modified *IBKROrder
modified, err = twsReplacementOrder(order, replacement, cfg.Account)
if err != nil {
return receipt, err
}
// The bound id is this connection's to modify: the bind proved it.
conn.ordersMu.Lock()
conn.openOrders[order.OrderID] = modified
conn.ordersMu.Unlock()
err = conn.placeOrder(ctx, modified, &epoch, guard)
}
release()
if err != nil {
if brokerSendMayHaveBeenWritten(err) {
receipt.Outcome = TWSOrderControlUncertain
return receipt, fmt.Errorf("%w: %w", ErrTWSOrderControlUncertain, err)
}
return receipt, fmt.Errorf("%w: %w", ErrTWSOrderControlRefused, err)
}
return awaitTWSOrderControlAnswer(ctx, session, receipt, action, replacement, cfg.AckTimeout)
}
func (cfg TWSOrderControlConfig) newWireGuard() (func() error, func()) {
var guard func() error
release := func() {}
if cfg.NewWireGuard != nil {
guard, release = cfg.NewWireGuard()
}
return func() error {
if guard != nil {
if err := guard(); err != nil {
return err
}
}
// Primary Disconnect revokes outbound authority before updating its
// connected status. Fence that interval at the same final-write
// boundary as the client-0 connection's own outbound generation.
if cfg.Authority != nil {
primary := cfg.AuthoritySession.connection
if primary == nil || !cfg.Authority.SessionCurrent(cfg.AuthoritySession) || cfg.authorityOutboundState&1 != 0 || primary.outboundSessionState.Load() != cfg.authorityOutboundState {
return fmt.Errorf("%w: primary broker session revoked before transmit", ErrTWSOrderControlGate)
}
}
return nil
}, sync.OnceFunc(release)
}
// bindTWSOrders asks TWS for client 0's orders, which binds its manual
// orders, and waits for the openOrderEnd terminator.
func bindTWSOrders(ctx context.Context, conn *Connection, session *twsOrderControlSession, cfg TWSOrderControlConfig) ([]OrderLifecycleEvent, error) {
epoch, err := conn.captureBrokerInstructionEpoch()
if err != nil {
return nil, fmt.Errorf("%w: %w", ErrTWSOrderControlBind, err)
}
bindEnd := session.beginBind()
msg := conn.encodeMsg(reqOpenOrders, 1)
guard, release := cfg.newWireGuard()
err = conn.sendMessageWithTypeContextForEpochGuarded(ctx, msg, RequestTypeOrder, epoch, true, guard)
release()
if err != nil {
return nil, fmt.Errorf("%w: %w", ErrTWSOrderControlBind, err)
}
timeout := cfg.BindTimeout
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case <-bindEnd:
return session.boundOrders(), nil
case <-timer.C:
return nil, fmt.Errorf("%w: no openOrderEnd within %s", ErrTWSOrderControlBind, timeout)
case <-ctx.Done():
return nil, fmt.Errorf("%w: %w", ErrTWSOrderControlBind, ctx.Err())
}
}
// findTWSOrder picks the bound order with the reviewed permanent id and
// refuses when it is absent, owned by another API client, no longer
// working, or different from the reviewed terms.
func findTWSOrder(bound []OrderLifecycleEvent, target TWSOrderControlTarget) (OrderLifecycleEvent, error) {
for _, ev := range bound {
if ev.PermID != target.PermID {
continue
}
if ev.ClientIDPresent && ev.ClientID != 0 {
return OrderLifecycleEvent{}, fmt.Errorf("%w: API client %d owns it", ErrTWSOrderControlNotFound, ev.ClientID)
}
if ev.OrderID <= 0 {
return OrderLifecycleEvent{}, fmt.Errorf("%w: TWS reported it without an order id", ErrTWSOrderControlBind)
}
if !twsOrderWorking(ev) {
return OrderLifecycleEvent{}, fmt.Errorf("%w: status %s, filled %s of %s", ErrTWSOrderControlNotWorking, nonEmptyTWS(ev.Status, "unknown"), formatTWSQuantity(ev.Filled), formatTWSQuantity(ev.TotalQuantity))
}
if reason := twsOrderTermsDiffer(ev, target); reason != "" {
return OrderLifecycleEvent{}, fmt.Errorf("%w: %s", ErrTWSOrderControlChanged, reason)
}
return ev, nil
}
return OrderLifecycleEvent{}, fmt.Errorf("%w: it was filled, cancelled or placed by another API client", ErrTWSOrderControlNotFound)
}
func twsOrderWorking(ev OrderLifecycleEvent) bool {
switch strings.ToLower(strings.TrimSpace(ev.Status)) {
case "filled", "cancelled", "canceled", "apicancelled", "apicanceled", "inactive", "pendingcancel":
return false
}
if ev.TotalQuantity > 0 && ev.TotalQuantity-ev.Filled <= 1e-9 {
return false
}
if ev.Deactivated != nil && *ev.Deactivated {
return false
}
return true
}
func twsOrderTermsDiffer(ev OrderLifecycleEvent, target TWSOrderControlTarget) string {
text := func(name, got, want string) string {
if strings.EqualFold(strings.TrimSpace(got), strings.TrimSpace(want)) {
return ""
}
return fmt.Sprintf("%s is %s, reviewed %s", name, nonEmptyTWS(got, "empty"), nonEmptyTWS(want, "empty"))
}
number := func(name string, got, want float64) string {
if math.Abs(got-want) <= 1e-9 {
return ""
}
return fmt.Sprintf("%s is %s, reviewed %s", name, formatTWSQuantity(got), formatTWSQuantity(want))
}
if target.ConID != 0 && ev.ConID != 0 && target.ConID != ev.ConID {
return fmt.Sprintf("contract id is %d, reviewed %d", ev.ConID, target.ConID)
}
checks := []string{
text("symbol", ev.Symbol, target.Symbol),
text("security type", ev.SecType, nonEmptyTWS(target.SecType, ev.SecType)),
text("side", ev.Action, target.Action),
text("order type", ev.OrderType, target.OrderType),
text("time in force", ev.TIF, nonEmptyTWS(target.TIF, ev.TIF)),
number("quantity", ev.TotalQuantity, target.Quantity),
number("filled", ev.Filled, target.Filled),
number("limit price", ev.LimitPrice, target.LimitPrice),
number("stop or trail price", ev.AuxPrice, target.AuxPrice),
}
for _, reason := range checks {
if reason != "" {
return reason
}
}
return ""
}
// twsReplacementOrder is the bound order with the new quantity and limit
// price, sent under the bound order id so TWS treats it as a modification.
func twsReplacementOrder(bound OrderLifecycleEvent, replacement TWSOrderControlReplacement, account string) (*IBKROrder, error) {
if !strings.EqualFold(bound.OrderType, "LMT") || (bound.TIF != "" && !strings.EqualFold(bound.TIF, "DAY")) {
return nil, fmt.Errorf("%w: only DAY limit orders are modified", ErrTWSOrderControlGate)
}
if !bound.ModifyTermsKnown {
return nil, fmt.Errorf("%w: broker order terms are incomplete; modify in TWS", ErrTWSOrderControlGate)
}
if bound.ModifyUnsupported != "" {
return nil, fmt.Errorf("%w: %s; modify in TWS", ErrTWSOrderControlGate, bound.ModifyUnsupported)
}
if !bound.AlgoKnown || (bound.AlgoStrategy != "" && bound.AlgoStrategy != "Adaptive") ||
(bound.AlgoStrategy == "" && bound.AdaptivePriority != "") ||
(bound.AlgoStrategy == "Adaptive" && bound.AdaptivePriority != "Urgent" && bound.AdaptivePriority != "Normal" && bound.AdaptivePriority != "Patient") {
return nil, fmt.Errorf("%w: unsupported or unknown algorithm terms; modify in TWS", ErrTWSOrderControlGate)
}
if replacement.Quantity <= bound.Filled {
return nil, fmt.Errorf("%w: filled %s since review", ErrTWSOrderControlChanged, formatTWSQuantity(bound.Filled))
}
// The permanent id identifies the order for the journal, not the wire:
// a modify is the bound order id with the new terms.
order := &IBKROrder{
OrderID: bound.OrderID, ClientID: 0, Account: nonEmptyTWS(bound.Account, account),
Symbol: bound.Symbol, SecType: bound.SecType, ConID: bound.ConID, Exchange: bound.Exchange, Currency: bound.Currency,
Expiry: bound.Expiry, Strike: bound.Strike, Right: bound.Right, LocalSymbol: bound.LocalSymbol, TradingClass: bound.TradingClass,
Action: bound.Action, TotalQty: int(replacement.Quantity), OrderType: "LMT", LmtPrice: replacement.LimitPrice,
TIF: nonEmptyTWS(bound.TIF, "DAY"), OutsideRth: bound.OutsideRth, TriggerMethod: bound.TriggerMethod, OrderRef: bound.OrderRef,
Transmit: true, OcaGroup: bound.OcaGroup, OcaType: bound.OcaType,
AdaptivePriority: bound.AdaptivePriority, PrimaryExch: bound.PrimaryExch, OpenClose: bound.OpenClose,
}
if bound.Multiplier != 0 {
order.Multiplier = strconv.Itoa(bound.Multiplier)
}
if order.Exchange == "" {
order.Exchange = "SMART"
}
return order, nil
}
// awaitTWSOrderControlAnswer waits a bounded time for the broker's answer
// to the sent request. A confirming status or echo is confirmed, an
// acknowledgement without finality is pending, a broker error for the bound
// id is a refusal, and silence is uncertain.
func awaitTWSOrderControlAnswer(ctx context.Context, session *twsOrderControlSession, receipt TWSOrderControlReceipt, action string, replacement TWSOrderControlReplacement, timeout time.Duration) (TWSOrderControlReceipt, error) {
timer := time.NewTimer(timeout)
defer timer.Stop()
pending := false
for {
select {
case a := <-session.answers:
if a.event.OrderID != receipt.BoundOrderID {
continue
}
if a.code != 0 {
if a.code >= 2100 && a.code < 2200 { // warnings travel with acceptance
continue
}
receipt.Outcome = TWSOrderControlRefused
receipt.BrokerCode, receipt.BrokerMessage = a.code, a.msg
return receipt, fmt.Errorf("%w: %d %s", ErrTWSOrderControlRefused, a.code, a.msg)
}
status := strings.ToLower(strings.TrimSpace(a.event.Status))
if a.event.Status != "" {
receipt.BrokerStatus = a.event.Status
}
switch action {
case TWSOrderControlCancel:
switch status {
case "cancelled", "canceled", "apicancelled", "apicanceled":
receipt.Outcome = TWSOrderControlConfirmed
return receipt, nil
case "pendingcancel":
pending = true
}
default:
// Only the openOrder echo carrying the new terms proves the
// replacement; a status alone could describe the old order.
if a.event.Type == OrderLifecycleEventOpenOrder {
if math.Abs(a.event.TotalQuantity-replacement.Quantity) <= 1e-9 && math.Abs(a.event.LimitPrice-replacement.LimitPrice) <= 1e-9 {
receipt.Outcome = TWSOrderControlConfirmed
return receipt, nil
}
continue
}
switch status {
case "submitted", "presubmitted", "pendingsubmit":
pending = true
case "filled", "cancelled", "canceled", "apicancelled", "apicanceled", "inactive":
receipt.Outcome = TWSOrderControlRefused
return receipt, fmt.Errorf("%w: the order is %s", ErrTWSOrderControlNotWorking, a.event.Status)
}
}
case <-timer.C:
if pending {
receipt.Outcome = TWSOrderControlPending
return receipt, nil
}
receipt.Outcome = TWSOrderControlUncertain
return receipt, fmt.Errorf("%w: no answer within %s", ErrTWSOrderControlUncertain, timeout)
case <-ctx.Done():
if pending {
receipt.Outcome = TWSOrderControlPending
return receipt, nil
}
receipt.Outcome = TWSOrderControlUncertain
return receipt, fmt.Errorf("%w: %w", ErrTWSOrderControlUncertain, ctx.Err())
}
}
}
func nonEmptyTWS(value, fallback string) string {
if strings.TrimSpace(value) == "" {
return fallback
}
return value
}
func formatTWSQuantity(v float64) string {
return strconv.FormatFloat(v, 'f', -1, 64)
}
package ibkr
import (
"encoding/binary"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
)
// WireDirection indicates message flow relative to the IBKR gateway.
type WireDirection string
// Wire directions are relative to the client connection.
const (
WireOutbound WireDirection = "OUT"
WireInbound WireDirection = "IN"
)
// wireEnv keys
const (
// docgen:env IBKR_WIRE_INTERCEPTOR | Enable the account-sensitive decoded wire-frame recorder. Unset or false disables it.
envWireEnable = "IBKR_WIRE_INTERCEPTOR"
// docgen:env IBKR_WIRE_LOG_PATH | Append decoded account-sensitive wire frames as JSONL at this path. Unset keeps frames in memory only.
envWireLogPath = "IBKR_WIRE_LOG_PATH"
// docgen:env IBKR_WIRE_RING_SIZE | Maximum decoded wire frames retained in memory when the interceptor is enabled; default 256.
envWireRingSize = "IBKR_WIRE_RING_SIZE"
)
// WireFrame captures a single encoded message with decoded fields.
type WireFrame struct {
Seq uint64 `json:"seq"`
When time.Time `json:"ts"`
Direction WireDirection `json:"direction"`
MsgID int `json:"msg_id"`
MsgName string `json:"msg_name"`
ReqID string `json:"req_id,omitempty"`
Symbol string `json:"symbol,omitempty"`
LengthBytes string `json:"len_hex,omitempty"`
Fields []string `json:"fields"`
RawHex string `json:"hex"`
Notes string `json:"notes,omitempty"`
}
// WireInterceptor passively records the IBKR wire protocol for diagnostics.
// Off by default; enable with IBKR_WIRE_INTERCEPTOR=1. The recorder mirrors
// every frame into a per-process ring buffer (size IBKR_WIRE_RING_SIZE,
// default 256) and, when IBKR_WIRE_LOG_PATH is set, also appends one JSON
// object per line to the named file. Captured frames are account-sensitive
// — see SECURITY.md.
type WireInterceptor struct {
clientID int
ringMu sync.Mutex
ring []WireFrame
ringCap int
seq uint64
jsonMu sync.Mutex
jsonFile *os.File
jsonEnc *json.Encoder
enabled bool
}
// Close releases any resources associated with the interceptor.
func (w *WireInterceptor) Close() error {
if w == nil {
return nil
}
w.jsonMu.Lock()
defer w.jsonMu.Unlock()
if w.jsonFile != nil {
err := w.jsonFile.Close()
w.jsonFile = nil
return err
}
return nil
}
// NewWireInterceptorFromEnv instantiates a wire interceptor using environment flags.
func NewWireInterceptorFromEnv(clientID int) (*WireInterceptor, error) {
enable, ok := lookupEnvBool(envWireEnable)
if ok && !enable {
return nil, nil
}
if !ok && os.Getenv(envWireEnable) == "" {
// Default disabled unless explicitly requested.
return nil, nil
}
ringSize := 256
if value := strings.TrimSpace(os.Getenv(envWireRingSize)); value != "" {
if v, err := strconv.Atoi(value); err == nil && v > 0 {
ringSize = v
}
}
var jsonFile *os.File
if path := strings.TrimSpace(os.Getenv(envWireLogPath)); path != "" {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, fmt.Errorf("wire interceptor: mkdir %s: %w", dir, err)
}
if err := os.Chmod(dir, 0o700); err != nil {
return nil, fmt.Errorf("wire interceptor: chmod %s: %w", dir, err)
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
if err != nil {
return nil, fmt.Errorf("wire interceptor: open log %s: %w", path, err)
}
if err := f.Chmod(0o600); err != nil {
_ = f.Close()
return nil, fmt.Errorf("wire interceptor: chmod log %s: %w", path, err)
}
jsonFile = f
wireLogger.Infof("Wire interceptor logging to %s", path)
}
var enc *json.Encoder
if jsonFile != nil {
enc = json.NewEncoder(jsonFile)
}
return &WireInterceptor{
clientID: clientID,
ringCap: ringSize,
ring: make([]WireFrame, 0, ringSize),
jsonFile: jsonFile,
jsonEnc: enc,
enabled: true,
}, nil
}
// Enabled returns true if the interceptor is active.
func (w *WireInterceptor) Enabled() bool {
return w != nil && w.enabled
}
// RecordOutbound processes an outgoing frame. fields may be nil; in that case decode will be skipped.
func (w *WireInterceptor) RecordOutbound(msgID int, raw []byte, fields []string) {
if !w.Enabled() {
return
}
frame := w.newFrame(WireOutbound, msgID, raw, fields)
w.appendFrame(frame)
w.persistFrame(frame)
w.logFrame(frame)
}
// RecordInbound records an incoming frame from IBKR.
func (w *WireInterceptor) RecordInbound(msgID int, raw []byte, fields []string) {
if !w.Enabled() {
return
}
frame := w.newFrame(WireInbound, msgID, raw, fields)
w.appendFrame(frame)
w.persistFrame(frame)
w.logFrame(frame)
}
func (w *WireInterceptor) newFrame(dir WireDirection, msgID int, raw []byte, fields []string) WireFrame {
w.ringMu.Lock()
w.seq++
seq := w.seq
w.ringMu.Unlock()
length := make([]byte, 4)
binary.BigEndian.PutUint32(length, uint32(len(raw)))
frame := WireFrame{
Seq: seq,
When: time.Now(),
Direction: dir,
MsgID: msgID,
MsgName: resolveMessageNameForDirection(dir, msgID),
LengthBytes: hex.EncodeToString(length),
Fields: cloneStringSlice(fields),
RawHex: hex.EncodeToString(raw),
}
if len(frame.Fields) > 0 {
if req, sym := extractReqInfo(msgID, frame.Fields); req != "" {
frame.ReqID = req
frame.Symbol = sym
}
}
return frame
}
func (w *WireInterceptor) appendFrame(frame WireFrame) {
w.ringMu.Lock()
defer w.ringMu.Unlock()
if len(w.ring) == w.ringCap {
copy(w.ring, w.ring[1:])
w.ring[len(w.ring)-1] = frame
} else {
w.ring = append(w.ring, frame)
}
}
func (w *WireInterceptor) persistFrame(frame WireFrame) {
if w.jsonEnc == nil {
return
}
w.jsonMu.Lock()
defer w.jsonMu.Unlock()
if err := w.jsonEnc.Encode(frame); err != nil {
wireLogger.Warnf("Wire interceptor failed to persist frame: %v", err)
}
}
func (w *WireInterceptor) logFrame(frame WireFrame) {
wireLogger.Infof("[%s] #%d %s len=%s reqID=%s symbol=%s fields=%v",
frame.Direction, frame.Seq, frame.MsgName, frame.LengthBytes, frame.ReqID, frame.Symbol, frame.Fields)
}
func cloneStringSlice(src []string) []string {
if len(src) == 0 {
return nil
}
dst := make([]string, len(src))
copy(dst, src)
return dst
}
func extractReqInfo(msgID int, fields []string) (string, string) {
if len(fields) == 0 {
return "", ""
}
if msgID == protoPlaceOrderMsgID {
return summaryFieldValue(fields, "orderId="), summaryFieldValue(fields, "symbol=")
}
if len(fields) > 1 && fields[1] == "protobuf" {
return summaryFieldValue(fields, "orderId="), summaryFieldValue(fields, "symbol=")
}
switch msgID {
case placeOrder:
return fieldValue(fields, 1), fieldValue(fields, 3)
case reqMktData:
return fieldValue(fields, 2), fieldValue(fields, 4)
case reqContractData:
return fieldValue(fields, 2), fieldValue(fields, 4)
case reqHistoricalData:
return fieldValue(fields, 1), fieldValue(fields, 3)
case msgErrMsg:
return fieldValue(fields, 2), ""
default:
return "", ""
}
}
func resolveMessageName(msgID int) string {
if msgID == protoPlaceOrderMsgID {
return "placeOrderProtoBuf"
}
switch msgID {
case placeOrder:
return "placeOrder"
case reqMktData:
return "reqMktData"
case reqContractData:
return "reqContractData"
case reqHistoricalData:
return "reqHistoricalData"
case reqCalcImpliedVolatility:
return "reqCalcImpliedVol"
case cancelMktData:
return "cancelMktData"
case msgOpenOrder:
return "openOrder"
case msgExecDetails:
return "execDetails"
case msgErrMsg:
return "error"
case msgSystemNotification:
return "systemNotification"
default:
return fmt.Sprintf("msg(%d)", msgID)
}
}
func resolveMessageNameForDirection(dir WireDirection, msgID int) string {
if dir == WireOutbound && msgID == protoCancelOrderMsgID {
return "cancelOrderProtoBuf"
}
return resolveMessageName(msgID)
}
func summaryFieldValue(fields []string, prefix string) string {
for _, field := range fields {
if after, ok := strings.CutPrefix(field, prefix); ok {
return after
}
}
return ""
}
package ibkr
import (
"context"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
)
const (
minServerVerWSHCalendar = 161
minServerVerWSHEventFilters = 171
minServerVerWSHEventFilterDates = 173
wshRequestLimit = 10
wshMetadataReadyTTL = 24 * time.Hour
)
// WSHErrorKind is a stable, sanitized classification for a failed Wall Street
// branch on Kind and Code without persisting an untrusted broker message.
type WSHErrorKind string
const (
// WSHErrorCanceled means the caller canceled the queued or active request.
WSHErrorCanceled WSHErrorKind = "canceled"
// WSHErrorTimeout means the caller's deadline expired.
WSHErrorTimeout WSHErrorKind = "timeout"
// WSHErrorTransport means no usable broker transport was available.
WSHErrorTransport WSHErrorKind = "transport_failure"
// WSHErrorUnsupportedProtocol means TWS or Gateway is too old for the request.
WSHErrorUnsupportedProtocol WSHErrorKind = "unsupported_protocol"
// WSHErrorConnectorInactive means the current connector temporarily marked
// the symbol inactive after repeated definition failures. The mark expires
// after a bounded interval and is cleared on reconnect, so callers must not
// persist it as a provider verdict about the security.
WSHErrorConnectorInactive WSHErrorKind = "connector_inactive"
// WSHErrorUnsupportedSecurity means WSH cannot be queried for the instrument.
WSHErrorUnsupportedSecurity WSHErrorKind = "unsupported_security"
// WSHErrorContractResolution means the stock conId could not be resolved.
WSHErrorContractResolution WSHErrorKind = "contract_resolution_failure"
// WSHErrorEntitlementRequired means the account lacks the WSH subscription.
WSHErrorEntitlementRequired WSHErrorKind = "entitlement_required"
// WSHErrorDuplicateRequest means TWS rejected a concurrent WSH request.
WSHErrorDuplicateRequest WSHErrorKind = "duplicate_request"
// WSHErrorMetadataRequired means event data was requested without current metadata.
WSHErrorMetadataRequired WSHErrorKind = "metadata_required"
// WSHErrorProviderFailure means WSH rejected or failed the request.
WSHErrorProviderFailure WSHErrorKind = "provider_failure"
// WSHErrorMalformedResponse means WSH returned empty or invalid JSON.
WSHErrorMalformedResponse WSHErrorKind = "malformed_response"
// WSHErrorEventTypeUnavailable means metadata did not advertise earnings dates.
WSHErrorEventTypeUnavailable WSHErrorKind = "event_type_unavailable"
)
// WSHError describes a failed read-only Wall Street Horizon request. Operation
// and Code are allowlisted protocol facts; broker and transport prose is not
// retained. Context cancellation is the only wrapped cause.
type WSHError struct {
Kind WSHErrorKind
Operation string
Code int
cause error
}
// Error returns a sanitized classification without broker response prose.
func (e *WSHError) Error() string {
if e == nil {
return "ibkr WSH request failed"
}
if e.Code != 0 {
return fmt.Sprintf("ibkr WSH %s: %s (code %d)", e.Operation, e.Kind, e.Code)
}
return fmt.Sprintf("ibkr WSH %s: %s", e.Operation, e.Kind)
}
// Unwrap exposes only a caller context cancellation or deadline cause.
func (e *WSHError) Unwrap() error {
if e == nil {
return nil
}
return e.cause
}
// WSHEarningsResult pairs WSH event JSON with an optional exact broker stock
// row matched the requested ConID, stock security type, and symbol.
type WSHEarningsResult struct {
EventJSON string
StockIdentity *ContractDetailsLite
}
// FetchWSHEarnings returns the raw WSH earnings-event JSON for a stock symbol.
// It preserves the legacy symbol-resolution and temporary-inactive-cache
// positive held contract ID and needs fresh broker identity evidence.
func (c *Connector) FetchWSHEarnings(ctx context.Context, symbol string) (string, error) {
result, err := c.fetchWSHEarnings(ctx, symbol, 0, false)
return result.EventJSON, err
}
// FetchWSHEarningsWithIdentity reads WSH earnings events using a caller-proven
// positive held contract ID and independently attempts an exact contract-
// suppress this exact lookup. If exact details are unavailable or contradict
// callers must not infer issuer classification from the supplied ID or cache.
func (c *Connector) FetchWSHEarningsWithIdentity(ctx context.Context, symbol string, conID int) (WSHEarningsResult, error) {
if conID <= 0 {
return WSHEarningsResult{}, &WSHError{Kind: WSHErrorUnsupportedSecurity, Operation: "resolve_contract"}
}
return c.fetchWSHEarnings(ctx, symbol, conID, true)
}
// ResolveWSHStockIdentity independently reads exact broker contract details
// event request. All failures are returned as sanitized WSH errors.
func (c *Connector) ResolveWSHStockIdentity(ctx context.Context, symbol string, conID int) (*ContractDetailsLite, error) {
if ctx == nil {
ctx = context.Background()
}
return c.resolveWSHStockIdentity(ctx, symbol, conID, boundedWSHResolveTimeout(ctx, 5*time.Second))
}
// ProbeWSHEntitlement reads WSH metadata once: the account-level proof that
// this account holds the Wall Street Horizon subscription. It performs no
// broker write and resolves no contract, so an account without the
// subscription learns that from one request instead of one per symbol. A
// metadata answer already held for the current broker session counts as
// entitled. A missing subscription returns a WSHError of kind
// WSHErrorEntitlementRequired.
func (c *Connector) ProbeWSHEntitlement(ctx context.Context) error {
if ctx == nil {
ctx = context.Background()
}
if c == nil {
return &WSHError{Kind: WSHErrorTransport, Operation: "acquire"}
}
if err := ctx.Err(); err != nil {
return newWSHContextError("acquire", err)
}
release, err := c.acquireWSH(ctx)
if err != nil {
return err
}
defer release()
c.mu.RLock()
conn := c.conn
c.mu.RUnlock()
if conn == nil || !conn.IsConnected() {
return &WSHError{Kind: WSHErrorTransport, Operation: "metadata"}
}
if v := conn.ServerVersion(); v < minServerVerWSHEventFilters || v > maxClientVersion {
return &WSHError{Kind: WSHErrorUnsupportedProtocol, Operation: "metadata"}
}
now := time.Now().UTC()
if c.wshNow != nil {
now = c.wshNow().UTC()
}
if _, ready := c.wshMetadataEarningsTag(conn, now); ready {
return nil
}
_, err = c.refreshWSHMetadata(ctx, conn, now)
return err
}
// fetchWSHEarnings performs no broker write: it establishes daily WSH metadata
// readiness for the current broker session, then issues a serialized event-
// calendar read filtered to the broker-resolved event tag. IBKR permits only
// one WSH request of each kind for a client; the gate covers the complete
// contract-details -> metadata -> event sequence.
func (c *Connector) fetchWSHEarnings(ctx context.Context, symbol string, exactConID int, exactIdentity bool) (WSHEarningsResult, error) {
if ctx == nil {
ctx = context.Background()
}
if c == nil {
return WSHEarningsResult{}, &WSHError{Kind: WSHErrorTransport, Operation: "acquire"}
}
if err := ctx.Err(); err != nil {
return WSHEarningsResult{}, newWSHContextError("acquire", err)
}
symbol = strings.ToUpper(strings.TrimSpace(symbol))
if symbol == "" {
return WSHEarningsResult{}, &WSHError{Kind: WSHErrorUnsupportedSecurity, Operation: "resolve_contract"}
}
secType, _, _, _ := classifySymbol(symbol)
if !strings.EqualFold(secType, "STK") {
return WSHEarningsResult{}, &WSHError{Kind: WSHErrorUnsupportedSecurity, Operation: "resolve_contract"}
}
// A positive contract cache entry can outlive a later broker-confirmed
// contract resolution or the event-calendar wire path.
if !exactIdentity && c.IsSymbolInactive(symbol) {
return WSHEarningsResult{}, &WSHError{Kind: WSHErrorConnectorInactive, Operation: "resolve_contract"}
}
release, err := c.acquireWSH(ctx)
if err != nil {
return WSHEarningsResult{}, err
}
defer release()
c.mu.RLock()
conn := c.conn
c.mu.RUnlock()
if conn == nil || !conn.IsConnected() {
return WSHEarningsResult{}, &WSHError{Kind: WSHErrorTransport, Operation: "metadata"}
}
serverVersion := conn.ServerVersion()
if serverVersion < minServerVerWSHEventFilters || serverVersion > maxClientVersion {
return WSHEarningsResult{}, &WSHError{Kind: WSHErrorUnsupportedProtocol, Operation: "event_data"}
}
resolveTimeout := boundedWSHResolveTimeout(ctx, 5*time.Second)
result := WSHEarningsResult{}
eventConID := exactConID
if exactIdentity {
detail, resolveErr := c.resolveWSHStockIdentity(ctx, symbol, exactConID, resolveTimeout)
if errors.Is(resolveErr, context.Canceled) || errors.Is(resolveErr, context.DeadlineExceeded) {
return WSHEarningsResult{}, resolveErr
}
if resolveErr == nil {
copy := *detail
result.StockIdentity = ©
}
} else {
resolver := c.resolveWSHContract
if resolver == nil {
resolver = c.resolveWSHStockContract
}
detail, resolveErr := resolver(ctx, symbol, resolveTimeout)
if resolveErr != nil {
if errors.Is(resolveErr, context.Canceled) || errors.Is(resolveErr, context.DeadlineExceeded) {
return WSHEarningsResult{}, newWSHContextError("resolve_contract", resolveErr)
}
if errors.Is(resolveErr, ErrSymbolInactive) || c.IsSymbolInactive(symbol) {
return WSHEarningsResult{}, &WSHError{Kind: WSHErrorConnectorInactive, Operation: "resolve_contract"}
}
return WSHEarningsResult{}, &WSHError{Kind: WSHErrorContractResolution, Operation: "resolve_contract"}
}
if detail == nil || detail.ConID <= 0 || (detail.SecType != "" && !strings.EqualFold(detail.SecType, "STK")) {
return WSHEarningsResult{}, &WSHError{Kind: WSHErrorUnsupportedSecurity, Operation: "resolve_contract"}
}
if c.IsSymbolInactive(symbol) {
return WSHEarningsResult{}, &WSHError{Kind: WSHErrorConnectorInactive, Operation: "resolve_contract"}
}
eventConID = detail.ConID
}
now := time.Now().UTC()
if c.wshNow != nil {
now = c.wshNow().UTC()
}
eventTag, metadataReady := c.wshMetadataEarningsTag(conn, now)
if !metadataReady {
var err error
eventTag, err = c.refreshWSHMetadata(ctx, conn, now)
if err != nil {
return result, err
}
}
for attempt := range 2 {
request := wshEventRequest{conID: eventConID, limit: wshRequestLimit, eventTag: eventTag}
events, err := fetchWSHJSON(ctx, conn, wshPhase{
operation: "event_data",
responseID: msgWSHEventData,
send: func(ctx context.Context, reqID int) error {
return conn.sendWSHEventDataRequest(ctx, reqID, request)
},
cancel: conn.cancelWSHEventDataRequest,
})
if err == nil {
if !json.Valid([]byte(events)) {
return result, &WSHError{Kind: WSHErrorMalformedResponse, Operation: "event_data"}
}
result.EventJSON = events
return result, nil
}
var typed *WSHError
if !errors.As(err, &typed) || typed.Kind != WSHErrorMetadataRequired {
return result, err
}
// TWS can invalidate its metadata cache without dropping the socket.
c.resetWSHMetadataReadiness()
if attempt == 1 {
return result, err
}
eventTag, err = c.refreshWSHMetadata(ctx, conn, now)
if err != nil {
return result, err
}
}
return result, &WSHError{Kind: WSHErrorProviderFailure, Operation: "event_data"}
}
func (c *Connector) refreshWSHMetadata(ctx context.Context, conn *Connection, now time.Time) (string, error) {
metadata, err := fetchWSHJSON(ctx, conn, wshPhase{
operation: "metadata",
responseID: msgWSHMetaData,
send: conn.sendWSHMetaDataRequest,
cancel: conn.cancelWSHMetaDataRequest,
})
if err != nil {
return "", err
}
if !json.Valid([]byte(metadata)) {
return "", &WSHError{Kind: WSHErrorMalformedResponse, Operation: "metadata"}
}
eventTag, ok := wshMetadataEarningsEventTag(metadata)
if !ok {
return "", &WSHError{Kind: WSHErrorEventTypeUnavailable, Operation: "metadata"}
}
c.markWSHMetadataReady(conn, now, eventTag)
return eventTag, nil
}
func (c *Connector) acquireWSH(ctx context.Context) (func(), error) {
c.wshGateMu.Lock()
if c.wshGate == nil {
c.wshGate = make(chan struct{}, 1)
c.wshGate <- struct{}{}
}
gate := c.wshGate
c.wshGateMu.Unlock()
select {
case <-ctx.Done():
return nil, newWSHContextError("acquire", ctx.Err())
case <-gate:
return func() { gate <- struct{}{} }, nil
}
}
func (c *Connector) resetWSHMetadataReadiness() {
if c == nil {
return
}
c.wshGateMu.Lock()
c.wshMetadataConn = nil
c.wshMetadataReadyAt = time.Time{}
c.wshEarningsEventTag = ""
c.wshGateMu.Unlock()
}
func (c *Connector) wshMetadataEarningsTag(conn *Connection, now time.Time) (string, bool) {
c.wshGateMu.Lock()
defer c.wshGateMu.Unlock()
if conn == nil || c.wshMetadataConn != conn || c.wshMetadataReadyAt.IsZero() || !validWSHEarningsEventTag(c.wshEarningsEventTag) {
return "", false
}
age := now.Sub(c.wshMetadataReadyAt)
return c.wshEarningsEventTag, age >= 0 && age < wshMetadataReadyTTL
}
func (c *Connector) markWSHMetadataReady(conn *Connection, now time.Time, eventTags ...string) {
eventTag := "wsh_ed"
if len(eventTags) > 0 {
eventTag = eventTags[0]
}
if !validWSHEarningsEventTag(eventTag) {
return
}
c.wshGateMu.Lock()
c.wshMetadataConn = conn
c.wshMetadataReadyAt = now
c.wshEarningsEventTag = eventTag
c.wshGateMu.Unlock()
}
func (c *Connector) resolveWSHStockContract(ctx context.Context, symbol string, timeout time.Duration) (*ContractDetailsLite, error) {
if c.IsSymbolInactive(symbol) {
return nil, ErrSymbolInactive
}
if detail := c.cachedContractDetail(symbol); detail != nil && detail.ConID > 0 {
copy := *detail
return ©, nil
}
_, exchange, currency, primary := classifySymbol(symbol)
contract := Contract{
Symbol: dualClassWireSymbol(symbol),
SecType: "STK",
Exchange: exchange,
PrimaryExch: primary,
Currency: currency,
}
detail, err := c.ContractDetailsFirst(ctx, contract, timeout)
if err != nil {
return nil, err
}
if detail != nil && detail.ConID > 0 {
c.contractMu.Lock()
if existing, ok := c.contractCache[symbol]; !ok || existing.ConID == 0 {
c.contractCache[symbol] = *detail
}
c.contractMu.Unlock()
}
return detail, nil
}
func (c *Connector) resolveWSHExactStockContract(ctx context.Context, symbol string, conID int, timeout time.Duration) (*ContractDetailsLite, error) {
if conID <= 0 {
return nil, &WSHError{Kind: WSHErrorUnsupportedSecurity, Operation: "resolve_contract"}
}
lookup := Contract{ConID: conID, SecType: "STK"}
// A position-seeded cache row may supply a currency constraint, but it is
// never returned as identity evidence. The exact response must independently
if cached := c.cachedContractDetail(symbol); cached != nil && cached.ConID == conID &&
(cached.SecType == "" || strings.EqualFold(cached.SecType, "STK")) {
lookup.Currency = strings.TrimSpace(cached.Currency)
}
return c.ContractDetailsFirst(ctx, lookup, timeout)
}
func (c *Connector) resolveWSHStockIdentity(ctx context.Context, symbol string, conID int, timeout time.Duration) (*ContractDetailsLite, error) {
if c == nil {
return nil, &WSHError{Kind: WSHErrorTransport, Operation: "resolve_contract"}
}
if err := ctx.Err(); err != nil {
return nil, newWSHContextError("resolve_contract", err)
}
symbol = strings.ToUpper(strings.TrimSpace(symbol))
secType, _, _, _ := classifySymbol(symbol)
if symbol == "" || conID <= 0 || !strings.EqualFold(secType, "STK") {
return nil, &WSHError{Kind: WSHErrorUnsupportedSecurity, Operation: "resolve_contract"}
}
resolver := c.resolveWSHExactContract
if resolver == nil {
resolver = c.resolveWSHExactStockContract
}
detail, err := resolver(ctx, symbol, conID, timeout)
if err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return nil, newWSHContextError("resolve_contract", err)
}
return nil, &WSHError{Kind: WSHErrorContractResolution, Operation: "resolve_contract"}
}
if !validWSHExactStockIdentity(symbol, conID, detail) {
return nil, &WSHError{Kind: WSHErrorContractResolution, Operation: "resolve_contract"}
}
copy := *detail
return ©, nil
}
func validWSHExactStockIdentity(symbol string, conID int, detail *ContractDetailsLite) bool {
if conID <= 0 || detail == nil || detail.ConID != conID || !strings.EqualFold(strings.TrimSpace(detail.SecType), "STK") {
return false
}
wantSymbol := strings.ToUpper(strings.TrimSpace(dualClassWireSymbol(symbol)))
gotSymbol := strings.ToUpper(strings.TrimSpace(detail.Symbol))
return wantSymbol != "" && gotSymbol == wantSymbol
}
func boundedWSHResolveTimeout(ctx context.Context, fallback time.Duration) time.Duration {
if deadline, ok := ctx.Deadline(); ok {
remaining := time.Until(deadline)
if remaining > 0 && remaining < fallback {
return remaining
}
}
return fallback
}
type wshEventRequest struct {
conID int
limit int
eventTag string
}
type wshPhase struct {
operation string
responseID int
send func(context.Context, int) error
cancel func(context.Context, int) error
}
type wshPhaseResult struct {
data string
err error
}
func fetchWSHJSON(ctx context.Context, conn *Connection, phase wshPhase) (string, error) {
if conn == nil {
return "", &WSHError{Kind: WSHErrorTransport, Operation: phase.operation}
}
reqID, err := conn.nextRequestID()
if err != nil {
return "", &WSHError{Kind: WSHErrorTransport, Operation: phase.operation}
}
resultCh := make(chan wshPhaseResult, 1)
deliver := func(result wshPhaseResult) {
select {
case resultCh <- result:
default:
}
}
responseHandler := conn.RegisterHandler(phase.responseID, func(fields []string) {
data, ok := parseWSHDataFields(fields, reqID)
if ok {
deliver(wshPhaseResult{data: data})
}
})
errorHandler := conn.RegisterHandler(msgErrMsg, func(fields []string) {
rid, code, message, ok := parseWSHErrorFields(fields)
if ok && rid == reqID {
deliver(wshPhaseResult{err: classifyWSHBrokerError(phase.operation, code, message)})
}
})
systemHandler := conn.RegisterHandler(msgSystemNotification, func(fields []string) {
if len(fields) < 2 {
return
}
note, parseErr := parseSystemNotificationPayload([]byte(fields[1]))
if parseErr == nil && note.tickerID == int64(reqID) {
deliver(wshPhaseResult{err: classifyWSHBrokerError(phase.operation, note.code, note.message)})
}
})
defer conn.UnregisterHandler(phase.responseID, responseHandler)
defer conn.UnregisterHandler(msgErrMsg, errorHandler)
defer conn.UnregisterHandler(msgSystemNotification, systemHandler)
if err := phase.send(ctx, reqID); err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return "", newWSHContextError(phase.operation, err)
}
if typed, ok := errors.AsType[*WSHError](err); ok {
return "", typed
}
return "", &WSHError{Kind: WSHErrorTransport, Operation: phase.operation}
}
select {
case result := <-resultCh:
if result.err != nil {
return "", result.err
}
if strings.TrimSpace(result.data) == "" {
return "", &WSHError{Kind: WSHErrorMalformedResponse, Operation: phase.operation}
}
return strings.TrimSpace(result.data), nil
case <-ctx.Done():
cancelCtx, cancel := context.WithTimeout(context.Background(), time.Second)
_ = phase.cancel(cancelCtx, reqID)
cancel()
return "", newWSHContextError(phase.operation, ctx.Err())
}
}
func (c *Connection) sendWSHMetaDataRequest(ctx context.Context, reqID int) error {
serverVersion := c.ServerVersion()
if serverVersion < minServerVerWSHCalendar || serverVersion > maxClientVersion {
return &WSHError{Kind: WSHErrorUnsupportedProtocol, Operation: "metadata"}
}
return c.sendMessageWithTypeContext(ctx, c.encodeMsg(reqWSHMetaData, reqID), RequestTypeGeneral)
}
func (c *Connection) cancelWSHMetaDataRequest(ctx context.Context, reqID int) error {
return c.sendMessageWithTypeContext(ctx, c.encodeMsg(cancelWSHMetaData, reqID), RequestTypeGeneral)
}
func (c *Connection) sendWSHEventDataRequest(ctx context.Context, reqID int, request wshEventRequest) error {
serverVersion := c.ServerVersion()
if serverVersion < minServerVerWSHEventFilters || serverVersion > maxClientVersion {
return &WSHError{Kind: WSHErrorUnsupportedProtocol, Operation: "event_data"}
}
if request.conID <= 0 {
return &WSHError{Kind: WSHErrorUnsupportedSecurity, Operation: "event_data"}
}
eventTag := request.eventTag
if eventTag == "" {
eventTag = "wsh_ed"
}
if !validWSHEarningsEventTag(eventTag) {
return &WSHError{Kind: WSHErrorEventTypeUnavailable, Operation: "event_data"}
}
filterJSON, err := json.Marshal(map[string]any{
"country": "All",
"watchlist": []string{strconv.Itoa(request.conID)},
"limit_region": request.limit,
"limit": request.limit,
eventTag: "true",
})
if err != nil {
return &WSHError{Kind: WSHErrorMalformedResponse, Operation: "event_data"}
}
fields := []any{
reqWSHEventData,
reqID,
0, // conId and filter are mutually exclusive; watchlist carries it.
string(filterJSON),
false,
false,
false,
}
if serverVersion >= minServerVerWSHEventFilterDates {
// Filter mode and conId/date mode are mutually exclusive. The conId is
// inside the allowlisted watchlist filter, so date bounds stay empty.
fields = append(fields, "", "", request.limit)
}
return c.sendMessageWithTypeContext(ctx, c.encodeMsg(fields...), RequestTypeGeneral)
}
func (c *Connection) cancelWSHEventDataRequest(ctx context.Context, reqID int) error {
return c.sendMessageWithTypeContext(ctx, c.encodeMsg(cancelWSHEventData, reqID), RequestTypeGeneral)
}
func parseWSHDataFields(fields []string, expectedReqID int) (string, bool) {
if len(fields) < 3 {
return "", false
}
reqID, err := strconv.Atoi(strings.TrimSpace(fields[1]))
if err != nil || reqID != expectedReqID {
return "", false
}
return fields[2], true
}
func parseWSHErrorFields(fields []string) (reqID, code int, message string, ok bool) {
if len(fields) >= 5 {
reqID, reqErr := strconv.Atoi(strings.TrimSpace(fields[2]))
code, codeErr := strconv.Atoi(strings.TrimSpace(fields[3]))
if reqErr == nil && codeErr == nil {
return reqID, code, fields[4], true
}
}
if len(fields) >= 4 {
reqID, reqErr := strconv.Atoi(strings.TrimSpace(fields[1]))
code, codeErr := strconv.Atoi(strings.TrimSpace(fields[2]))
if reqErr == nil && codeErr == nil {
return reqID, code, fields[3], true
}
}
return 0, 0, "", false
}
func classifyWSHBrokerError(operation string, code int, _ string) error {
kind := WSHErrorProviderFailure
switch code {
case 10278, 10281:
kind = WSHErrorDuplicateRequest
case 10282:
kind = WSHErrorMetadataRequired
case 354, 10089, 10090, 10186, 10276, 10277:
kind = WSHErrorEntitlementRequired
case 200, 203:
kind = WSHErrorUnsupportedSecurity
case 502, 504:
kind = WSHErrorTransport
case 503:
kind = WSHErrorUnsupportedProtocol
case 10279, 10280, 10283, 10284:
kind = WSHErrorProviderFailure
}
return &WSHError{Kind: kind, Operation: operation, Code: code}
}
func newWSHContextError(operation string, err error) error {
kind := WSHErrorCanceled
if errors.Is(err, context.DeadlineExceeded) {
kind = WSHErrorTimeout
}
return &WSHError{Kind: kind, Operation: operation, cause: err}
}
func wshMetadataEarningsEventTag(raw string) (string, bool) {
var envelope map[string]json.RawMessage
if json.Unmarshal([]byte(raw), &envelope) != nil {
return "", false
}
if nestedRaw, ok := envelope["meta_data"]; ok {
var nested map[string]json.RawMessage
if json.Unmarshal(nestedRaw, &nested) != nil {
return "", false
}
envelope = nested
}
eventTypes, ok := envelope["event_types"]
if !ok || len(eventTypes) == 0 {
return "", false
}
var entries []json.RawMessage
if json.Unmarshal(eventTypes, &entries) != nil {
return "", false
}
hasCurrent, hasLegacy := false, false
for _, entry := range entries {
switch typedWSHMetadataEventTag(entry) {
case "wshe_ed":
hasCurrent = true
case "wsh_ed":
hasLegacy = true
}
}
if hasCurrent {
return "wshe_ed", true
}
if hasLegacy {
return "wsh_ed", true
}
return "", false
}
func typedWSHMetadataEventTag(raw json.RawMessage) string {
var direct string
if json.Unmarshal(raw, &direct) == nil {
direct = strings.TrimSpace(direct)
if validWSHEarningsEventTag(direct) {
return direct
}
return ""
}
var object map[string]json.RawMessage
if json.Unmarshal(raw, &object) != nil {
return ""
}
values := make([]string, 0, 2)
for _, key := range []string{"tag", "code"} {
valueRaw, ok := object[key]
if !ok {
continue
}
var value string
if json.Unmarshal(valueRaw, &value) != nil {
return ""
}
value = strings.TrimSpace(value)
if value != "" {
values = append(values, value)
}
}
if len(values) == 0 || len(values) == 2 && values[0] != values[1] {
return ""
}
if validWSHEarningsEventTag(values[0]) {
return values[0]
}
return ""
}
func validWSHEarningsEventTag(tag string) bool {
return tag == "wsh_ed" || tag == "wshe_ed"
}