domain

package module
v0.1.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: May 16, 2026 License: MIT Imports: 11 Imported by: 0

README

kite-mcp-domain

Go Reference

Domain-Driven Design (DDD) value objects + entities for the algo2go ecosystem. Defines the canonical shape of trading-domain types (Money, Quantity, Order, Position, Holding, Profile, Session, Alert, Family, Glossary) plus domain events (TierChangedEvent, OrderPlacedEvent, etc.) and an EventDispatcher.

Used by Sundeepg98/kite-mcp-server across 165+ files for trading domain modeling — riskguard checks, billing tier changes, audit projections, paper-trading engine, order workflows, alert evaluations, etc.

Why a separate module?

Domain types are the canonical interop layer between trading-domain consumers (broker dashboards, monitoring, future broker adapters, tier billing). Hosting as a module:

  • Centralizes the trading-domain vocabulary across consumers
  • Lets DDD entities + value objects version independently of application logic
  • Pairs cleanly with algo2go/kite-mcp-broker (DTO interop) and algo2go/kite-mcp-money (Money type) for a coherent trading-types stack

Stability promise

v0.x — unstable. Type signatures may evolve as DDD review surfaces issues. Pin v0.1.0 deliberately. v1.0 ships only after the public type surface (entities + value objects + events) is reviewed for stability and at least one external consumer ships against it.

Install

go get github.com/algo2go/kite-mcp-domain@v0.1.0

Public API (selected highlights)

Value objects
  • Money — wraps algo2go/kite-mcp-money.Money with domain semantics
  • Quantity — order size with sign + precision
  • Specs — instrument lookup keys
Entities
  • Order — order aggregate with invariants (CanPlace, IsTerminal, ...)
  • Position — open/closed positions with PnL projection
  • Holding — long-term holdings with FIFO cost basis
  • Profile — user profile with tier + region
  • Session — auth session with IST-timezone expiry
  • Alert / CompositeAlert — threshold + multi-condition alerts
  • Family — family-mode subscription ledger
Domain events
  • TierChangedEvent, OrderPlacedEvent, OrderCancelledEvent, OrderFilledEvent, AlertTriggeredEvent, ...
  • EventDispatcher for in-process pub/sub

Dependencies

  • github.com/algo2go/kite-mcp-broker — DTO interop (Order/Position/Holding/Profile)
  • github.com/algo2go/kite-mcp-isttz — IST timezone helper for Session
  • github.com/algo2go/kite-mcp-money — Money value object
  • github.com/stretchr/testify — assertions

All deps are algo2go-published modules; no upstream replace directives needed.

Reference consumer

Sundeepg98/kite-mcp-server — consumed across:

  • kc/usecases/*.go — every use case threads domain types
  • kc/eventsourcing/*.go — aggregates project domain events
  • kc/riskguard/*.go — checks operate on Order/Position
  • kc/billing/*.go — TierChangedEvent + Money for tier accounting
  • kc/alerts/*.go — Alert entity + AlertTriggeredEvent
  • kc/papertrading/*.go — Order lifecycle in virtual portfolio
  • kc/audit/*.go — domain-event projection for audit trail
  • mcp/trade/*.go, mcp/analytics/*.go — domain types in tool args/returns

License

MIT — see LICENSE.

Authors

Original DDD design + entity implementations: Sundeepg98 (Zerodha Tech). Multi-module promotion (2026-05-10): algo2go contributors.

Documentation

Overview

Ubiquitous language glossary for the Kite MCP trading platform.

This file disambiguates domain terms that appear throughout the codebase under multiple names or with overlapping meanings. Each term is defined as a documented type alias or constant so that code reads as domain prose and the compiler enforces consistent usage.

Reading this file should give any new contributor a mental model of the bounded contexts and their key concepts.

Package domain contains DDD value objects and domain events for the trading platform. These are pure domain types with zero external dependencies — they model the ubiquitous language of Indian equity trading (Money in INR, Quantities, InstrumentKeys like "NSE:RELIANCE") and domain events that capture what happened in the system.

Index

Constants

View Source
const (
	// TransactionBuy represents a buy order.
	TransactionBuy = "BUY"
	// TransactionSell represents a sell order.
	TransactionSell = "SELL"
)
View Source
const (
	// OrderTypeMarket is a market order — executed at best available price.
	OrderTypeMarket = "MARKET"
	// OrderTypeLimit is a limit order — executed at specified price or better.
	OrderTypeLimit = "LIMIT"
	// OrderTypeSL is a stop-loss order — becomes a limit order when trigger price is hit.
	OrderTypeSL = "SL"
	// OrderTypeSLM is a stop-loss market order — becomes a market order when trigger price is hit.
	OrderTypeSLM = "SL-M"
)
View Source
const (
	// ProductCNC is Cash & Carry — delivery-based equity holding.
	ProductCNC = "CNC"
	// ProductMIS is Margin Intraday Settlement — intraday leveraged trading.
	ProductMIS = "MIS"
	// ProductNRML is Normal — used for F&O positions carried overnight.
	ProductNRML = "NRML"
)
View Source
const (
	// ExchangeNSE is the National Stock Exchange.
	ExchangeNSE = "NSE"
	// ExchangeBSE is the Bombay Stock Exchange.
	ExchangeBSE = "BSE"
	// ExchangeNFO is the NSE Futures & Options segment.
	ExchangeNFO = "NFO"
	// ExchangeBFO is the BSE Futures & Options segment.
	ExchangeBFO = "BFO"
	// ExchangeMCX is the Multi Commodity Exchange.
	ExchangeMCX = "MCX"
	// ExchangeCDS is the Currency Derivatives Segment.
	ExchangeCDS = "CDS"
)
View Source
const (
	// OrderStatusOpen means the order is accepted by the exchange and sitting
	// in the book awaiting fill. Cancellable.
	OrderStatusOpen = "OPEN"
	// OrderStatusTriggerPending means a stop-loss / SL-M order's trigger
	// price has not yet been crossed. Cancellable.
	OrderStatusTriggerPending = "TRIGGER PENDING"
	// OrderStatusValidationPending is a transient state while Kite validates
	// the order. Not cancellable (will transition to OPEN or REJECTED).
	OrderStatusValidationPending = "VALIDATION PENDING"
	// OrderStatusAMOReqReceived is the overnight-order accepted state.
	// Pending but not yet open in the book.
	OrderStatusAMOReqReceived = "AMO REQ RECEIVED"
	// OrderStatusComplete is the terminal filled state.
	OrderStatusComplete = "COMPLETE"
	// OrderStatusCancelled is the terminal user/system-cancelled state.
	OrderStatusCancelled = "CANCELLED"
	// OrderStatusRejected is the terminal risk-rejected state.
	OrderStatusRejected = "REJECTED"
)
View Source
const (
	// PositionLong means the position quantity is > 0 — the user owns the
	// instrument / is net-long.
	PositionLong = "LONG"
	// PositionShort means the position quantity is < 0 — the user has sold
	// more than bought / is net-short.
	PositionShort = "SHORT"
	// PositionFlat means the position quantity is exactly 0 — the position
	// is closed out but may still show residual P&L from the day's activity.
	PositionFlat = "FLAT"
)

--- Position direction constants ---

Direction is exposed as a stringly-typed value (not a domain.Direction, which is already taken by alerts). The values are display-friendly identifiers used in dashboards and debug logs.

Variables

View Source
var NewINR = money.NewINR

NewINR re-exports money.NewINR so existing call sites `domain.NewINR(N)` continue to work unchanged. Package-level function value (`var`, not `func`) is the cheapest re-export — Go resolves the call exactly like the original.

View Source
var NewMoney = money.NewMoney

NewMoney re-exports money.NewMoney for the validated-positive constructor. Same identity guarantee as NewINR.

ValidCompositeLogics is the set of accepted CompositeLogic values — mirrors ValidDirections and simplifies caller validation.

ValidDirections is the set of all supported alert directions.

Functions

func AnomalyCacheAggregateID

func AnomalyCacheAggregateID(email string) string

AnomalyCacheAggregateID returns the natural aggregate key for anomaly-cache events. Format: "anomaly:<email>". The "anomaly:" prefix keeps these streams disjoint from per-email user-aggregate streams (UserFrozenEvent, etc.) and the "riskguard:<email>" stream, so a future projector can replay the anomaly cache aggregate cleanly without filtering by event type.

func GTTAggregateID

func GTTAggregateID(triggerID int) string

GTTAggregateID returns the natural aggregate key for GTT success events. Format: fmt.Sprintf("%d", triggerID) — matches the existing appendAuxEvent aggregate IDs and the GTTRejectedAggregateID format so all GTT lifecycle events sort under one stream.

func GTTRejectedAggregateID

func GTTRejectedAggregateID(triggerID int, email string, occurredAt time.Time) string

GTTRejectedAggregateID returns the natural aggregate key for GTTRejectedEvent. TriggerID is fmt.Sprintf'd with "%d" to match the existing success-path aggregate ID format in kc/usecases/gtt_usecases.go (which emits gtt.placed/modified/deleted keyed by fmt.Sprintf("%d", triggerID)). Empty TriggerID + email falls back to "gtt-rejected:<email>:<rfc3339-nanos>"; both empty lands in "gtt-rejected:unknown".

func IsPercentageDirection

func IsPercentageDirection(d Direction) bool

IsPercentageDirection returns true if the direction is a percentage-change type.

func MFAggregateID

func MFAggregateID(id string) string

MFAggregateID returns the natural aggregate key for MF success events. The id parameter is OrderID (for MFOrder*) or SIPID (for MFSIP*). Both ID namespaces are Kite-assigned strings, distinct across the surface, so a single helper handles both. Empty falls back to "mf:unknown" so malformed dispatches don't collide with real rows.

func MFOrderRejectedAggregateID

func MFOrderRejectedAggregateID(orderID, email string, occurredAt time.Time) string

MFOrderRejectedAggregateID returns the natural aggregate key for MFOrderRejectedEvent. Format mirrors OrderRejectedAggregateID: non-empty OrderID joins the aggregate stream; empty OrderID + non-empty email falls back to "mf-rejected:<email>:<rfc3339-nanos>"; pathological "neither" lands in "mf-rejected:unknown".

func NativeAlertAggregateID

func NativeAlertAggregateID(uuid, email string) string

NativeAlertAggregateID returns the natural aggregate key for native alert events. Non-empty UUID joins the alert aggregate stream; empty UUID falls back to the email (matching the prior PlaceNativeAlertUseCase aggregate-id choice for placement events where the broker hadn't yet assigned a UUID). Both empty falls back to "native-alert:unknown" so malformed dispatches don't collide with real rows.

func OrderRejectedAggregateID

func OrderRejectedAggregateID(orderID, email string, occurredAt time.Time) string

OrderRejectedAggregateID returns the natural aggregate key for an OrderRejectedEvent. When OrderID is non-empty (modify/cancel paths, where the caller supplied the broker-assigned ID), the rejection joins the existing order aggregate stream — a downstream projector walking aggregate_id="ORD-123" sees place→reject→modify→reject→cancel transitions in one chronological view. When OrderID is empty (place_order failure, no broker ID was assigned), the event keys by "rejected:<email>:<unix-nanos>" so each rejection lands in its own aggregate slot without colliding with other users' rejections or future rejections from the same user. The "rejected:" prefix keeps these stand-alone streams disjoint from real order streams so a projector that filters by aggregate_type="Order" doesn't conflate "no broker ID issued" rejections with placed orders.

func PaperOrderAggregateID

func PaperOrderAggregateID(orderID string) string

PaperOrderAggregateID returns the natural aggregate key for paper- trading order events. Currently used by PaperOrderRejectedEvent only; future paper.* events should reuse this helper so the per-paper- order aggregate stream stays coherent. Empty OrderID falls back to "paper:unknown" so malformed dispatches don't collide with real rows.

func PaperTradingAggregateID

func PaperTradingAggregateID(email string) string

PaperTradingAggregateID returns the natural aggregate key for paper-trading lifecycle events. Keyed by email — the user's paper account is per-user so the full enable / reset / disable lifecycle replays under one stream. Empty falls back to "paper-trading:unknown" so malformed dispatches don't collide with real rows.

Note the disjoint key prefix from PaperOrderAggregateID ("PAPER_<n>" for individual paper orders) — these two paper aggregate streams (per-user lifecycle vs per-order rejection) live under different ID shapes intentionally so projector consumers can query them separately.

func PluginWatcherAggregateID

func PluginWatcherAggregateID(path string) string

PluginWatcherAggregateID returns the natural aggregate key for plugin- watcher events. Per-path mutations (registered, unregistered, reload_triggered) key by the absolute Path so a single plugin's lifecycle replays as a coherent stream. Watcher-lifecycle events (started, stopped) have no path and key by the singleton string "plugin-watcher:global" so they form their own aggregate stream disjoint from per-plugin streams.

func PositionAggregateID

func PositionAggregateID(email string, instrument InstrumentKey, product string) string

PositionAggregateID returns the natural aggregate key for position events. Format: "email:exchange:tradingsymbol:product". Both PositionOpenedEvent and PositionClosedEvent for the same (user, instrument, product) triple land under the same aggregate ID, allowing event-store replay to reconstruct the full position history.

func PositionConvertedAggregateID

func PositionConvertedAggregateID(email, exchange, tradingsymbol, oldProduct string) string

PositionConvertedAggregateID returns the natural aggregate key for position-conversion events. Format: "<email>|<exchange>|<tradingsymbol>|<oldProduct>". The pipe separator (rather than colon) avoids ambiguity with the time.RFC3339 colons projector consumers parse out of OrderRejectedEvent's synthetic keys. Empty email falls back to "position-converted:unknown" so a malformed dispatch lands in its own quarantine slot rather than colliding with real rows.

func RiskguardCountersAggregateID

func RiskguardCountersAggregateID(email string) string

RiskguardCountersAggregateID returns the natural aggregate key for per-user riskguard counter events. Format: "riskguard:<email>" or "riskguard:global" for the kill-switch (global-scope) events. The "riskguard:" prefix keeps these aggregate streams disjoint from per-email user-aggregate streams (UserFrozenEvent, etc.) so a future projector can replay the counters aggregate cleanly.

func TelegramSubscriptionAggregateID

func TelegramSubscriptionAggregateID(email string) string

TelegramSubscriptionAggregateID returns the natural aggregate key for per-user Telegram subscription events. Format: "telegram:<email>". The "telegram:" prefix keeps these aggregate streams disjoint from per-email user-aggregate streams (UserFrozenEvent, etc.) and the "riskguard:<email>" / "anomaly:<email>" streams, so a future projector can replay the Telegram subscription aggregate cleanly without filtering by event type.

func TrailingStopAggregateID

func TrailingStopAggregateID(trailingStopID string) string

TrailingStopAggregateID returns the natural aggregate key for trailing-stop trigger events. Keyed by TrailingStopID (uuid-derived 8-char prefix from kc/alerts/trailing.go) — globally unique across users, so no email prefix is needed. Empty falls back to "trailing-stop:unknown" so malformed dispatches don't collide with real trailing-stop rows.

func ValidateAlertSpec

func ValidateAlertSpec(direction Direction, targetPrice, referencePrice float64) error

ValidateAlertSpec enforces construction invariants for a single-leg price alert. Rules:

  • Direction must be in ValidDirections.
  • TargetPrice must be strictly positive (a zero / negative threshold is never a valid trigger).
  • For percentage directions (drop_pct / rise_pct): ReferencePrice must be positive AND TargetPrice must be ≤ 100 (percent).

Called from CreateAlertUseCase.Execute and the telegram /setalert handler so the rule lives in one place. Previously duplicated at kc/usecases/create_alert.go:75, kc/eventsourcing/alert_aggregate.go:76, and kc/telegram/trading_commands.go:349.

func ValidateIceberg

func ValidateIceberg(total, disclosed int) error

ValidateIceberg enforces the "disclosed ≤ total" invariant for iceberg orders on Kite. Iceberg variety splits a large order into visible chunks of `disclosed` quantity at a time; disclosed > total is logically impossible, and zero/negative values are nonsensical for a chunked order.

This is a free function rather than a value-object constructor because iceberg is a property of the order placement request, not of the order itself after acceptance — the broker stores the expanded legs, not the configuration.

func ValidateLotSize

func ValidateLotSize(qty, lotSize int) error

ValidateLotSize enforces that an order's total quantity is a positive integer multiple of the instrument's lot size. Equity typically has lotSize = 1 so any positive qty passes; derivatives (F&O) enforce the contract size (NIFTY futures lotSize = 50 at time of writing).

lotSize <= 0 is treated as bad configuration — legitimate equity rows report lotSize = 1, never 0.

func ValidateTickSize

func ValidateTickSize(price, tickSize float64) error

ValidateTickSize enforces that an order's price is aligned to the instrument's tick size. NSE equity ticks are typically 0.05; some indices / MF entries have tickSize = 0, which we treat as "no tick rule" (the broker accepts arbitrary precision).

Uses a 1e-9 epsilon because float remainders on decimal-rational tick sizes drift (2500.45 mod 0.05 is not exactly zero in IEEE-754).

func ValidateTradingsymbolFormat

func ValidateTradingsymbolFormat(symbol string) error

ValidateTradingsymbolFormat enforces that a tradingsymbol is non-empty and contains only printable, non-whitespace ASCII-ish characters. Kite's tradingsymbol convention is uppercase alphanumerics plus a few separators like "-" and "&" (e.g. "M&M"); we don't enforce the exact charset — just reject clearly bad inputs (empty, whitespace, control chars) so invalid rows fail fast at the domain boundary instead of surfacing as opaque Kite API errors later.

Types

type APIKey

type APIKey struct {
	// contains filtered or unexported fields
}

APIKey is a value object for a Kite Connect developer-app API key. Constructor rejects empty / whitespace-only values so downstream persistence layers can treat "has APIKey" as a non-null invariant.

func NewAPIKey

func NewAPIKey(s string) (APIKey, error)

NewAPIKey constructs a validated APIKey. Leading/trailing whitespace is stripped; the resulting value must be non-empty. The Kite developer console emits fixed-length alphanumeric keys — we deliberately do not enforce length or character class here because the API evolves and per-app key formats differ across regions (paper-trading keys carry dash prefixes, for example). Non-empty is the load-bearing invariant.

func (APIKey) IsValid

func (k APIKey) IsValid() bool

IsValid reports whether the APIKey carries a non-empty value.

func (APIKey) String

func (k APIKey) String() string

String returns the underlying key value.

type APISecret

type APISecret struct {
	// contains filtered or unexported fields
}

APISecret is a value object for the paired Kite Connect API secret. Secrets are sensitive credentials — this type mirrors APIKey's invariants and carries a masking helper so log-sites do not accidentally leak the full value.

func NewAPISecret

func NewAPISecret(s string) (APISecret, error)

NewAPISecret constructs a validated APISecret. Non-empty (after trimming) is the only construction invariant — length / char-class rules vary per app.

func (APISecret) IsValid

func (s APISecret) IsValid() bool

IsValid reports whether the APISecret carries a non-empty value.

func (APISecret) Masked

func (s APISecret) Masked() string

Masked returns a log-safe hint: first 4 + "****" + last 3 for long secrets, or "****" for anything <= 7 chars. Mirrors the legacy maskSecret helper in kc/credential_store.go so callers have one source of truth.

func (APISecret) String

func (s APISecret) String() string

String returns the raw secret value. Call sites that might log or serialise should prefer Masked().

type AdminActor

type AdminActor = string

AdminActor identifies the email of a user performing an admin action. Used in audit events (GlobalFreezeEvent.By, UserSuspendedEvent.By) and family management (FamilyInvitedEvent.AdminEmail). This is a runtime identity — who did it — not a permission flag.

type AdminRole

type AdminRole = bool

AdminRole is the authorization concept: whether a user email is in the ADMIN_EMAILS allow-list. Checked by middleware before admin endpoints. This is a compile-time hint only — actual enforcement is in the auth layer.

type Alert

type Alert struct {
	ID                 string    `json:"id"`
	Email              string    `json:"email"`
	Tradingsymbol      string    `json:"tradingsymbol"`
	Exchange           string    `json:"exchange"`
	InstrumentToken    uint32    `json:"instrument_token"`
	TargetPrice        float64   `json:"target_price"`
	Direction          Direction `json:"direction"`
	ReferencePrice     float64   `json:"reference_price,omitempty"`
	Triggered          bool      `json:"triggered"`
	CreatedAt          time.Time `json:"created_at"`
	TriggeredAt        time.Time `json:"triggered_at,omitempty"`
	TriggeredPrice     float64   `json:"triggered_price,omitempty"`
	NotificationSentAt time.Time `json:"notification_sent_at,omitempty"`

	// Composite alert fields (populated only when AlertType == AlertTypeComposite).
	// These live alongside the single-leg fields in the same row — Option B
	// from the session handoff. A NULL alert_type in the DB is normalized
	// to AlertTypeSingle on load.
	AlertType      AlertType            `json:"alert_type,omitempty"`
	CompositeName  string               `json:"composite_name,omitempty"`
	CompositeLogic CompositeLogic       `json:"composite_logic,omitempty"`
	Conditions     []CompositeCondition `json:"conditions,omitempty"`
}

Alert represents a price alert for a specific instrument. Rich domain entity with lifecycle behavior — the 8 methods below are the canonical definition of "what it means for an alert to trigger / fire / need notification". Persistence is handled by kc/alerts.Store which wraps this entity.

Composite alerts reuse the same table (Option B per session handoff): AlertType = AlertTypeComposite, CompositeLogic + Conditions populated, and the top-level Direction/TargetPrice ignored by the evaluator. Single-leg alerts leave the composite fields zero-valued.

func (*Alert) InstrumentKey

func (a *Alert) InstrumentKey() string

InstrumentKey returns the "exchange:tradingsymbol" identifier for the alerted instrument.

func (*Alert) IsActive

func (a *Alert) IsActive() bool

IsActive returns true if the alert has not yet fired.

func (*Alert) IsComposite

func (a *Alert) IsComposite() bool

IsComposite returns true if this alert aggregates 2+ conditions.

func (*Alert) IsPercentageAlert

func (a *Alert) IsPercentageAlert() bool

IsPercentageAlert returns true if this alert uses a percentage-change direction.

func (*Alert) MarkTriggered

func (a *Alert) MarkTriggered(currentPrice float64) bool

MarkTriggered transitions the alert to triggered state with the given price. Returns true if newly triggered, false if already triggered.

func (*Alert) MatchesInstrument

func (a *Alert) MatchesInstrument(instrumentToken uint32) bool

MatchesInstrument returns true if the alert is for the given instrument token.

func (*Alert) NeedsNotification

func (a *Alert) NeedsNotification() bool

NeedsNotification returns true if the alert has fired but no notification has been sent.

func (*Alert) PercentageChange

func (a *Alert) PercentageChange(currentPrice float64) float64

PercentageChange returns the signed percentage change of currentPrice from ReferencePrice. Returns 0 if ReferencePrice is not set (<= 0).

func (*Alert) ShouldTrigger

func (a *Alert) ShouldTrigger(currentPrice float64) bool

ShouldTrigger checks if the current price meets this alert's trigger condition.

type AlertCreatedEvent

type AlertCreatedEvent struct {
	Email       string
	AlertID     string
	Instrument  InstrumentKey
	TargetPrice Money
	Direction   string // "above", "below", "drop_pct", "rise_pct"
	Timestamp   time.Time
}

AlertCreatedEvent is emitted when a new price alert is created.

func (AlertCreatedEvent) EventType

func (e AlertCreatedEvent) EventType() string

func (AlertCreatedEvent) OccurredAt

func (e AlertCreatedEvent) OccurredAt() time.Time

type AlertDeletedEvent

type AlertDeletedEvent struct {
	Email     string
	AlertID   string
	Timestamp time.Time
}

AlertDeletedEvent is emitted when a price alert is deleted.

func (AlertDeletedEvent) EventType

func (e AlertDeletedEvent) EventType() string

func (AlertDeletedEvent) OccurredAt

func (e AlertDeletedEvent) OccurredAt() time.Time

type AlertTriggeredEvent

type AlertTriggeredEvent struct {
	Email        string
	AlertID      string
	Instrument   InstrumentKey
	TargetPrice  Money
	CurrentPrice Money
	Direction    string // "above", "below", "drop_pct", "rise_pct"
	Timestamp    time.Time
}

AlertTriggeredEvent is emitted when a price alert fires.

func (AlertTriggeredEvent) EventType

func (e AlertTriggeredEvent) EventType() string

func (AlertTriggeredEvent) OccurredAt

func (e AlertTriggeredEvent) OccurredAt() time.Time

type AlertType

type AlertType string

AlertType distinguishes a single-leg alert from a composite (multi-leg) alert. Stored in the `alert_type` column of the alerts table.

const (
	// AlertTypeSingle is the default — a plain single-instrument, single-
	// condition alert. Existing pre-composite rows are normalized to this
	// value on load.
	AlertTypeSingle AlertType = "single"
	// AlertTypeComposite is a multi-leg alert that combines 2+ conditions
	// across instruments via CompositeLogic (AND/ANY). Composite rows carry
	// a non-empty Conditions slice; the top-level Direction/TargetPrice
	// fields are ignored by the evaluator.
	AlertTypeComposite AlertType = "composite"
)

type AndSpec

type AndSpec[T any] struct {
	Left  Spec[T]
	Right Spec[T]
	// contains filtered or unexported fields
}

AndSpec is satisfied only when both Left and Right are satisfied.

func And

func And[T any](left, right Spec[T]) *AndSpec[T]

And returns a new specification that is the logical conjunction of two specs.

func (*AndSpec[T]) IsSatisfiedBy

func (s *AndSpec[T]) IsSatisfiedBy(candidate T) bool

func (*AndSpec[T]) Reason

func (s *AndSpec[T]) Reason() string

type AnomalyBaselineSnapshottedEvent

type AnomalyBaselineSnapshottedEvent struct {
	UserEmail  string
	Days       int
	Mean       float64
	Stdev      float64
	Count      float64
	BelowFloor bool // true when count<minBaselineOrders and mean/stdev are zeroed sentinels
	Timestamp  time.Time
}

AnomalyBaselineSnapshottedEvent is emitted when the audit Store's in-memory UserOrderStats baseline cache writes a fresh (mean, stdev, count) tuple for a (user, days) pair. Carries the user email plus the snapshotted statistical fields so a downstream consumer can reconstruct the anomaly baseline projection without re-querying the 30-day order history.

Aggregate-ID rule: keyed by AnomalyCacheAggregateID(UserEmail) (one logical baseline per user across all days windows; Days is a payload field, not part of the aggregate key). Below-threshold snapshots (count < minBaselineOrders) write the floor sentinel (mean=0, stdev=0) — BelowFloor=true so projector consumers can distinguish "user is new and we suppressed stats" from "real distribution centred at zero" (the latter is impossible at the SQL layer but BelowFloor makes the intent unambiguous).

func (AnomalyBaselineSnapshottedEvent) EventType

func (AnomalyBaselineSnapshottedEvent) OccurredAt

func (e AnomalyBaselineSnapshottedEvent) OccurredAt() time.Time

type AnomalyCacheEvictedEvent

type AnomalyCacheEvictedEvent struct {
	UserEmail string
	Days      int
	Reason    string // "ttl_expired" / "size_overflow"
	Timestamp time.Time
}

AnomalyCacheEvictedEvent is emitted when the cache drops a single entry for a reason other than user-scoped invalidation. Reason distinguishes the two eviction paths:

  • "ttl_expired" — lazy eviction on Get() when storedAt+ttl < now
  • "size_overflow" — random single-entry eviction on Set() when len(entries) >= maxEntries and the incoming key is net-new

Aggregate-ID rule: keyed by AnomalyCacheAggregateID(UserEmail). UserEmail may be empty for size_overflow when the evicted entry's key was not parseable — defence in depth, since cacheKey() is the only writer, but the event still carries enough to forensically trace the dropped slot.

func (AnomalyCacheEvictedEvent) EventType

func (e AnomalyCacheEvictedEvent) EventType() string

func (AnomalyCacheEvictedEvent) OccurredAt

func (e AnomalyCacheEvictedEvent) OccurredAt() time.Time

type AnomalyCacheInvalidatedEvent

type AnomalyCacheInvalidatedEvent struct {
	UserEmail string
	Reason    string
	Timestamp time.Time
}

AnomalyCacheInvalidatedEvent is emitted when every cached UserOrderStats entry for a given user is purged — typically when a new place_order / modify_order row lands in the audit log so the next anomaly check sees the fresh data. Reason tags the trigger ("order_recorded" / "manual" / "admin_clear").

Aggregate-ID rule: keyed by AnomalyCacheAggregateID(UserEmail) — invalidation is per user, all days windows at once.

func (AnomalyCacheInvalidatedEvent) EventType

func (e AnomalyCacheInvalidatedEvent) EventType() string

func (AnomalyCacheInvalidatedEvent) OccurredAt

func (e AnomalyCacheInvalidatedEvent) OccurredAt() time.Time

type CompositeCondition

type CompositeCondition struct {
	Exchange        string    `json:"exchange"`
	Tradingsymbol   string    `json:"tradingsymbol"`
	InstrumentToken uint32    `json:"instrument_token"`
	Operator        Direction `json:"operator"`
	Value           float64   `json:"value"`
	ReferencePrice  float64   `json:"reference_price,omitempty"`
}

CompositeCondition is one leg of a composite alert. Every leg targets a distinct instrument with its own operator + threshold. `ReferencePrice` is required for percentage-change operators (drop_pct/rise_pct).

This type is the on-disk wire format for the `conditions_json` column. Tag renames are schema breaks — add a migration if you rename.

func NewCompositeConditionStrict

func NewCompositeConditionStrict(
	exchange, tradingsymbol string,
	operator Direction,
	value, referencePrice float64,
) (CompositeCondition, error)

NewCompositeConditionStrict constructs a CompositeCondition with its invariants enforced: non-empty exchange + symbol, valid direction, and the same threshold / reference-price rules as ValidateAlertSpec. Returns a zero-value CompositeCondition on error so callers can't accidentally persist a half-valid leg.

instrumentToken is optional — it may be 0 when the caller hasn't resolved the token yet (composite-creation flow looks up tokens per leg after validation).

type CompositeLogic

type CompositeLogic string

CompositeLogic describes how the per-leg Conditions are combined. AND = every leg must satisfy its condition simultaneously. ANY = any single leg firing triggers the composite.

const (
	// CompositeLogicAnd requires every leg to satisfy its condition.
	CompositeLogicAnd CompositeLogic = "AND"
	// CompositeLogicAny fires as soon as any single leg's condition is met.
	CompositeLogicAny CompositeLogic = "ANY"
)

type ConsentWithdrawnEvent

type ConsentWithdrawnEvent struct {
	Email     string
	EmailHash string
	Reason    string
	Timestamp time.Time
}

ConsentWithdrawnEvent is emitted when a user invokes their DPDP §6(4) right to rescind previously-granted consent. The withdrawal does not erase the original grant from consent_log (the log is append-only and auditors need to see the full history); instead, the prior grant row gets stamped with withdrawn_at and a new "withdraw" action row is appended.

EmailHash is the SHA-256 hex digest of the lowercased email — same canonical form audit.HashEmail produces — so the event correlates to the audit log without leaking the plaintext email through the event dispatcher / persister chain. Plaintext Email is retained alongside for in-process consumers (Telegram notifier, riskguard) that need it for operational outreach. PR-D Item 2 will migrate other domain events to this dual-field shape.

func (ConsentWithdrawnEvent) EventType

func (e ConsentWithdrawnEvent) EventType() string

func (ConsentWithdrawnEvent) OccurredAt

func (e ConsentWithdrawnEvent) OccurredAt() time.Time

type Credential

type Credential struct {
	// contains filtered or unexported fields
}

Credential is the rich domain aggregate for a single user's Kite developer app credentials. It binds an email identity to an (APIKey, APISecret) pair and enforces construction invariants via value-object types and an email-presence check. Rotation-detection lives here so the infrastructure store stays a thin persistence gate.

func NewCredential

func NewCredential(email string, apiKey APIKey, apiSecret APISecret) (Credential, error)

NewCredential constructs a Credential after validating all three fields. Email must be non-empty; apiKey and apiSecret must each be valid value objects. Email is normalised to lower-case so rotation detection matches the case-insensitive semantics of the persistence store.

func (Credential) APIKey

func (c Credential) APIKey() APIKey

APIKey returns the credential's API key value object.

func (Credential) APISecret

func (c Credential) APISecret() APISecret

APISecret returns the credential's API secret value object.

func (Credential) AppID

func (c Credential) AppID() string

AppID codifies the Kite convention that "AppID = API key" for developer apps. Centralising it on the aggregate means downstream callers (store, registry backfill, admin UI) query one source of truth rather than hard-coding the equivalence.

func (Credential) Email

func (c Credential) Email() string

Email returns the lower-cased email identity of this credential.

func (Credential) IsRotationOf

func (c Credential) IsRotationOf(prior Credential) bool

IsRotationOf reports whether this credential represents a key rotation relative to prior. Rotation = same user (case-insensitive email) with a different APIKey. Used by the persistence store to trigger cached-token invalidation when a user replaces their developer-app credentials.

Same APIKey or different user returns false — only a deliberate key swap for an existing identity qualifies, mirroring the legacy credential_store.go:95 condition `existing.APIKey != stored.APIKey`.

type CredentialRegisteredEvent

type CredentialRegisteredEvent struct {
	Email     string
	Timestamp time.Time
}

CredentialRegisteredEvent is emitted the first time a user registers Kite API credentials (no prior CredentialStore entry for this email). Distinct from CredentialRotatedEvent so auditors can tell onboarding from key rotation without walking the full credential history.

func (CredentialRegisteredEvent) EventType

func (e CredentialRegisteredEvent) EventType() string

func (CredentialRegisteredEvent) OccurredAt

func (e CredentialRegisteredEvent) OccurredAt() time.Time

type CredentialResolution

type CredentialResolution struct {
	// contains filtered or unexported fields
}

CredentialResolution carries the outcome of resolving credentials for a user — either a per-user pair, a global fallback, or nothing usable. The rules deciding which one applies live on this type's constructors so CredentialService can be a thin pass-through.

func ResolveCredentials

func ResolveCredentials(perUser Credential, globalKey, globalSecret string) (CredentialResolution, bool)

ResolveCredentials applies the per-user-then-global fallback rule. The rule lives on the domain (not on a Service) so any caller — REST adapter, CLI, future SDK — gets identical behaviour by construction.

Return value: a CredentialResolution + a boolean. The boolean is `true` iff at least one of the two sources yielded a non-empty pair; `false` means the user can't authenticate at all and the caller should fail loudly.

Inputs:

  • perUser: the per-user credential as resolved from the credential store. Pass the zero value when the user has nothing on record.
  • globalKey/globalSecret: the server-wide creds (env-var sourced). Either may be empty; both empty + perUser empty → none.

func (CredentialResolution) APIKey

func (r CredentialResolution) APIKey() string

APIKey returns the resolved key. Empty when Source == None.

func (CredentialResolution) APISecret

func (r CredentialResolution) APISecret() string

APISecret returns the resolved secret. Empty when Source == None.

func (CredentialResolution) IsResolved

func (r CredentialResolution) IsResolved() bool

IsResolved is the canonical "do we have anything to talk to Kite with" rule — replaces a sprinkle of "apiKey != \"\" && apiSecret != \"\"" boolean hand-rolling at call sites.

func (CredentialResolution) QualifiesForTrading

func (r CredentialResolution) QualifiesForTrading(s Session) bool

QualifiesForTrading reports whether the resolved credentials, paired with a specific Kite session, are good enough to place an order. Three rules:

  1. The credential resolution itself must be Source != None.
  2. The associated Session must IsAuthenticated() (has a non-expired token cached for THIS user).
  3. The session's email matches the user we're resolving for — guards against cross-account reuse if a caller mixes up the inputs.

This is the single rule the trading layer needs to consult before place_order / modify_order / etc. Putting it on the domain object (not on CredentialService) means any layer with a Credential + a Session in hand can answer the question identically — no service indirection.

func (CredentialResolution) Source

Source returns where the resolution came from.

type CredentialRevokedEvent

type CredentialRevokedEvent struct {
	Email     string
	Reason    string
	Timestamp time.Time
}

CredentialRevokedEvent is emitted when credentials are removed — via the dashboard DELETE endpoint, admin force-revoke, or the credentials half of DeleteMyAccount. Reason tags the lifecycle narrative ("user_self", "admin_revoke", "credential_rotation", "account_deleted").

func (CredentialRevokedEvent) EventType

func (e CredentialRevokedEvent) EventType() string

func (CredentialRevokedEvent) OccurredAt

func (e CredentialRevokedEvent) OccurredAt() time.Time

type CredentialRotatedEvent

type CredentialRotatedEvent struct {
	Email     string
	Timestamp time.Time
}

CredentialRotatedEvent is emitted when a user replaces existing Kite API credentials with a new key/secret pair. Emitted by UpdateMyCredentials when a prior credential entry exists for the email.

func (CredentialRotatedEvent) EventType

func (e CredentialRotatedEvent) EventType() string

func (CredentialRotatedEvent) OccurredAt

func (e CredentialRotatedEvent) OccurredAt() time.Time

type CredentialSource

type CredentialSource int

CredentialSource enumerates where a resolved credential came from. Carrying the source on the resolution makes downstream telemetry, logging, and registry-sync decisions explicit instead of inferred.

const (
	// CredentialSourceNone means no credentials are available for the
	// user; the caller must reject the operation or trigger onboarding.
	CredentialSourceNone CredentialSource = iota
	// CredentialSourcePerUser means the user brought their own Kite
	// developer-app credentials and we use those.
	CredentialSourcePerUser
	// CredentialSourceGlobal means the user is using the server's global
	// API key/secret (single-user / dev-mode deployments).
	CredentialSourceGlobal
)

func (CredentialSource) String

func (s CredentialSource) String() string

String returns a stable label suitable for logging.

type Direction

type Direction string

Direction specifies the alert trigger direction.

const (
	DirectionAbove   Direction = "above"
	DirectionBelow   Direction = "below"
	DirectionDropPct Direction = "drop_pct"
	DirectionRisePct Direction = "rise_pct"
)

Alert direction constants. Percentage directions use ReferencePrice as the baseline and TargetPrice as the percentage threshold.

type Event

type Event interface {
	// EventType returns a unique string identifier for the event kind.
	EventType() string
	// OccurredAt returns the timestamp when the event was created.
	OccurredAt() time.Time
}

Event is the interface all domain events must satisfy.

type EventDispatcher

type EventDispatcher struct {
	// contains filtered or unexported fields
}

EventDispatcher is a simple in-process pub/sub for domain events. Handlers are called synchronously in the order they were registered. Use goroutines inside handlers if async processing is needed.

func NewEventDispatcher

func NewEventDispatcher() *EventDispatcher

NewEventDispatcher creates a ready-to-use dispatcher.

func (*EventDispatcher) Dispatch

func (d *EventDispatcher) Dispatch(event Event)

Dispatch sends an event to all registered handlers for its type. Handlers are called synchronously under a read lock, so Subscribe calls from within a handler will deadlock — use a goroutine if needed.

func (*EventDispatcher) Subscribe

func (d *EventDispatcher) Subscribe(eventType string, handler func(Event))

Subscribe registers a handler for the given event type. The handler will be called every time an event of that type is dispatched.

type Family

type Family struct {
	// contains filtered or unexported fields
}

Family represents an admin's family-billing membership state at a specific moment. Construct via NewFamily; the value type is immutable after construction so it's safe to pass through layers without defensive copying.

AdminEmail is the lower-cased identity of the plan owner. CurrentSize is how many family members are currently linked. MaxSize is the cap from the admin's billing plan (1 for free / dev / no-plan).

func NewFamily

func NewFamily(adminEmail string, currentSize, maxSize int) (Family, error)

NewFamily constructs a validated Family value. adminEmail must be non-empty (after trim+lower); currentSize and maxSize must be non-negative; maxSize must be >= 1 (every plan supports at least the admin themselves). Returns an error rather than panicking on bad input so callers can fail loudly.

func (Family) AdminEmail

func (f Family) AdminEmail() string

AdminEmail returns the lower-cased plan-owner email.

func (Family) AvailableSeats

func (f Family) AvailableSeats() int

AvailableSeats returns how many more family members can be added without breaching the plan cap. Always >= 0.

func (Family) CanInvite

func (f Family) CanInvite() bool

CanInvite reports whether the admin has room for one more family member. The canonical rule for invite eligibility — call sites should not re-derive this from MemberCount() < MaxUsers().

func (Family) CurrentSize

func (f Family) CurrentSize() int

CurrentSize returns the number of currently-linked family members.

func (Family) IsAtCapacity

func (f Family) IsAtCapacity() bool

IsAtCapacity reports whether the family has reached the plan limit. Equivalent to !CanInvite(); both names exist because call sites read more naturally one way or the other ("can we invite?" vs "are we full?").

func (Family) IsMemberOf

func (f Family) IsMemberOf(memberEmail, candidateAdminEmail string) bool

IsMemberOf reports whether memberEmail belongs to this family. Email matching is case-insensitive.

func (Family) MaxSize

func (f Family) MaxSize() int

MaxSize returns the plan's family-member cap.

type FamilyInvitedEvent

type FamilyInvitedEvent struct {
	AdminEmail   string
	InvitedEmail string
	Timestamp    time.Time
}

FamilyInvitedEvent is emitted when an admin invites a family member.

func (FamilyInvitedEvent) EventType

func (e FamilyInvitedEvent) EventType() string

func (FamilyInvitedEvent) OccurredAt

func (e FamilyInvitedEvent) OccurredAt() time.Time

type FamilyMemberRemovedEvent

type FamilyMemberRemovedEvent struct {
	AdminEmail   string
	RemovedEmail string
	Timestamp    time.Time
}

FamilyMemberRemovedEvent is emitted when an admin unlinks a family member from their billing plan.

func (FamilyMemberRemovedEvent) EventType

func (e FamilyMemberRemovedEvent) EventType() string

func (FamilyMemberRemovedEvent) OccurredAt

func (e FamilyMemberRemovedEvent) OccurredAt() time.Time

type GTTDeletedEvent

type GTTDeletedEvent struct {
	Email     string
	TriggerID int
	Timestamp time.Time
}

GTTDeletedEvent is emitted on successful broker deletion of a GTT. Pairs with GTTPlacedEvent under the same TriggerID — the place / modify / delete lifecycle replays from the aggregate stream.

func (GTTDeletedEvent) EventType

func (e GTTDeletedEvent) EventType() string

func (GTTDeletedEvent) OccurredAt

func (e GTTDeletedEvent) OccurredAt() time.Time

type GTTModifiedEvent

type GTTModifiedEvent struct {
	Email             string
	TriggerID         int
	Instrument        InstrumentKey
	TransactionType   string
	Product           string
	Type              string
	TriggerValue      float64
	Quantity          float64 // see GTTPlacedEvent.Quantity comment
	LimitPrice        float64
	UpperTriggerValue float64
	UpperQuantity     float64
	UpperLimitPrice   float64
	LowerTriggerValue float64
	LowerQuantity     float64
	LowerLimitPrice   float64
	Timestamp         time.Time
}

GTTModifiedEvent is emitted on successful broker modification of a GTT. Carries the post-modify params; pre-modify state can be reconstructed by walking the GTT aggregate stream backward to the most recent GTTPlacedEvent / GTTModifiedEvent.

func (GTTModifiedEvent) EventType

func (e GTTModifiedEvent) EventType() string

func (GTTModifiedEvent) OccurredAt

func (e GTTModifiedEvent) OccurredAt() time.Time

type GTTPlacedEvent

type GTTPlacedEvent struct {
	Email           string
	TriggerID       int
	Instrument      InstrumentKey
	TransactionType string  // "BUY" / "SELL"
	Product         string  // CNC / MIS / NRML
	Type            string  // "single" or "two-leg"
	TriggerValue    float64 // single-leg trigger
	// Quantity is float64 to match the GTT cqrs commands (Kite GTT API
	// allows fractional quantities for some derivative products).
	Quantity          float64 // single-leg quantity
	LimitPrice        float64 // single-leg limit price
	UpperTriggerValue float64 // two-leg upper trigger
	UpperQuantity     float64 // two-leg upper quantity
	UpperLimitPrice   float64 // two-leg upper limit
	LowerTriggerValue float64 // two-leg lower trigger
	LowerQuantity     float64 // two-leg lower quantity
	LowerLimitPrice   float64 // two-leg lower limit
	Timestamp         time.Time
}

GTTPlacedEvent is emitted on successful broker placement of a GTT (Good-Till-Triggered) order. Captures the trigger params verbatim from the Kite GTTParams so a forensic walk can reconstruct the trigger window without re-querying the broker.

For "single" type, only TriggerValue / Quantity / LimitPrice are meaningful. For "two-leg" type, Upper* and Lower* fields carry the upper / lower trigger pairs and the single-leg fields are unused.

Aggregate-ID rule: keyed by TriggerID via GTTAggregateID. Pairs with GTTRejectedEvent / GTTModifiedEvent / GTTDeletedEvent under the same TriggerID so the full GTT lifecycle replays as a coherent stream.

func (GTTPlacedEvent) EventType

func (e GTTPlacedEvent) EventType() string

func (GTTPlacedEvent) OccurredAt

func (e GTTPlacedEvent) OccurredAt() time.Time

type GTTRejectedEvent

type GTTRejectedEvent struct {
	Email     string
	TriggerID int    // Kite-assigned GTT trigger ID; 0 for place failures
	Source    string // "place" / "modify" / "delete"
	Reason    string // broker error message, best-effort
	Timestamp time.Time
}

GTTRejectedEvent is emitted when a GTT mutation fails at the broker round-trip. Source distinguishes the three GTT mutation paths:

  • "place" — PlaceGTT broker call failed.
  • "modify" — ModifyGTT broker call failed (e.g. trigger inactive).
  • "delete" — DeleteGTT broker call failed (e.g. already triggered).

Aggregate-ID rule: when TriggerID is non-zero (modify/delete paths, where Kite assigned an int64 ID at GTT placement), the rejection joins the existing GTT aggregate stream stringified to "<id>" — same shape the success-path appendAuxEvent uses (kc/usecases/gtt_usecases.go). When TriggerID is 0 (place rejection — broker never assigned one), falls back to the synthetic "gtt-rejected:<email>:<rfc3339-nanos>" key.

func (GTTRejectedEvent) EventType

func (e GTTRejectedEvent) EventType() string

func (GTTRejectedEvent) OccurredAt

func (e GTTRejectedEvent) OccurredAt() time.Time

type GlobalFreezeEvent

type GlobalFreezeEvent struct {
	By        string // admin email
	Reason    string
	Timestamp time.Time
}

GlobalFreezeEvent is emitted when an admin activates the server-wide trading freeze.

func (GlobalFreezeEvent) EventType

func (e GlobalFreezeEvent) EventType() string

func (GlobalFreezeEvent) OccurredAt

func (e GlobalFreezeEvent) OccurredAt() time.Time

type GlobalFreezeReason

type GlobalFreezeReason = string

GlobalFreezeReason documents why a server-wide freeze was activated.

type Holding

type Holding struct {
	// contains filtered or unexported fields
}

Holding is the rich domain entity for a broker portfolio holding. It wraps a broker.Holding DTO and exposes the same Money-aware accessor pattern Position uses (Slice 6) — PnL() returns INR-tagged Money, plus convenience computations (InvestedValue, CurrentValue) that previously lived as inline float multiplication at consumer sites.

The wrapped DTO is the source of truth; existing broker / persistence code continues to use broker.Holding directly. Use ToDomainHolding or NewHoldingFromBroker at the adapter boundary.

Slice 6b of the Money VO sweep: this wrapper is the keystone for migrating Holding.PnL consumers (currently bare-float reads in kc/usecases/widget_usecases.go and mcp/plugin_widget_returns_matrix.go) to type-tagged Money at the JSON-emit boundary. Mirrors the Position wrapper added in commits a926f8a / 5ce3eb0.

func NewHoldingFromBroker

func NewHoldingFromBroker(b broker.Holding) Holding

NewHoldingFromBroker lifts a broker.Holding DTO into the rich domain entity. Identity-preserving — DTO() returns the original DTO unchanged.

func ToDomainHolding

func ToDomainHolding(b broker.Holding) Holding

ToDomainHolding is a converter alias — identical to NewHoldingFromBroker, named for ergonomic use at adapter boundaries (matches the ToDomainPosition naming convention).

func (Holding) CurrentValue

func (h Holding) CurrentValue() Money

CurrentValue returns the mark-to-market value as Money — lastPrice times quantity, INR-tagged. The Slice 1 boundary pattern: aggregations of CurrentValue across many holdings can now flow through Money.Add (currency-aware) rather than the bare-float `total += h.LastPrice * float64(h.Quantity)` idiom.

func (Holding) DTO

func (h Holding) DTO() broker.Holding

DTO returns the underlying broker DTO for passthrough to code that still consumes broker.Holding directly. Used by adapters and JSON-emit boundaries that need the bare-float wire shape.

func (Holding) InstrumentKey

func (h Holding) InstrumentKey() InstrumentKey

InstrumentKey returns the canonical (EXCHANGE:SYMBOL) identifier for this holding's instrument. Useful when joining holdings with LTP maps keyed by instrument key. Same shape Position uses so a unified holdings+positions LTP fetch can key consistently.

func (Holding) InvestedValue

func (h Holding) InvestedValue() Money

InvestedValue returns the cost basis as Money — averagePrice times quantity, INR-tagged. Useful for portfolio summary computations that need the cost basis as a typed value (and for downstream Money.Add aggregation across multiple holdings when the consumer wants typed totals).

func (Holding) IsHeld

func (h Holding) IsHeld() bool

IsHeld reports whether the holding has non-zero quantity. A holding row with Quantity == 0 may still appear (T+1 settlement quirks, recently-sold residuals) but is not "held" in the active-portfolio sense. Mirrors Position.IsOpen.

func (Holding) PnL

func (h Holding) PnL() Money

PnL returns the broker-reported holding P&L as a Money value. As of Slice 6e c2, broker.Holding.PnL is already Money — the adapter (broker/zerodha/convert.go) wraps gokiteconnect's INR float at the boundary. This wrapper accessor passes through.

Sign is preserved: a winning position returns positive Money, a losing position returns negative Money. The zero Money is the "no movement / freshly bought" sentinel — callers that branch on win-vs-loss should use IsPositive / IsNegative / IsZero rather than comparing the raw Float64() against zero.

type InstrumentKey

type InstrumentKey struct {
	Exchange      string
	Tradingsymbol string
}

InstrumentKey is a value object identifying a tradable instrument by exchange and trading symbol. This is the canonical format used across the Kite Connect API ("NSE:RELIANCE", "BSE:INFY", "NFO:NIFTY25APRFUT").

func NewInstrumentKey

func NewInstrumentKey(exchange, symbol string) InstrumentKey

NewInstrumentKey creates an InstrumentKey from an exchange and symbol without strict validation. Retained for existing callers (tests, legacy adapters) that feed in already-trusted broker data. New call sites that sit on the order-placement boundary should prefer NewInstrumentKeyStrict.

func NewInstrumentKeyStrict

func NewInstrumentKeyStrict(exchange, symbol string) (InstrumentKey, error)

NewInstrumentKeyStrict constructs an InstrumentKey with construction invariants enforced: non-empty exchange, non-empty trading symbol, and exchange against the whitelist. Whitespace is trimmed; result is uppercase. Use at system boundaries (MCP tool handlers, order commands) to reject bad input at domain entry rather than surfacing ambiguous broker errors later.

func ParseInstrumentKey

func ParseInstrumentKey(s string) (InstrumentKey, error)

ParseInstrumentKey parses a string like "NSE:RELIANCE" into an InstrumentKey. Returns an error if the format is invalid.

func (InstrumentKey) IsZero

func (k InstrumentKey) IsZero() bool

IsZero returns true if the key is unset.

func (InstrumentKey) String

func (k InstrumentKey) String() string

String returns the canonical "EXCHANGE:SYMBOL" representation.

type InstrumentRules

type InstrumentRules struct {
	// contains filtered or unexported fields
}

InstrumentRules bundles the lot-size and tick-size metadata for one instrument so a caller can run the two related checks without threading the primitives through every call site.

Construct with NewInstrumentRules; the Exchange + Tradingsymbol fields are informational (used in error messages) — actual whitelist validation lives on InstrumentKey.NewInstrumentKeyStrict.

func NewInstrumentRules

func NewInstrumentRules(exchange, tradingsymbol string, lotSize int, tickSize float64) InstrumentRules

NewInstrumentRules constructs an InstrumentRules bundle. Called from the order-placement pipeline once the instrument metadata has been resolved via instruments.Manager.Find.

func (InstrumentRules) CheckPrice

func (r InstrumentRules) CheckPrice(price float64) error

CheckPrice reports whether price is aligned to the instrument's tick size. Returns a descriptive error otherwise.

func (InstrumentRules) CheckQuantity

func (r InstrumentRules) CheckQuantity(qty int) error

CheckQuantity reports whether qty satisfies the instrument's lot-size rule. Returns a descriptive error otherwise.

func (InstrumentRules) Exchange

func (r InstrumentRules) Exchange() string

Exchange returns the exchange code (for error messages / diagnostics).

func (InstrumentRules) Tradingsymbol

func (r InstrumentRules) Tradingsymbol() string

Tradingsymbol returns the symbol (for error messages / diagnostics).

type KiteSessionData

type KiteSessionData struct {
	Kite   zerodha.KiteSDK // authenticated Kite SDK handle (interface — nil-checkable)
	Broker broker.Client   // broker-agnostic interface (wraps Kite via zerodha adapter)
	Email  string          // Google-authenticated email (empty for local dev)
}

KiteSessionData is the transient runtime state of an authenticated MCP session: the broker SDK handle, the broker-port adapter, and the OAuth-resolved email. It lives in kc/domain (not kc parent) so kc/ports/session.go can reference it without importing the kc parent package — the Anchor 5 PR 5.6 / Wave B-1 inversion goal.

Anchor 5 PR 5.6 (per .research/anchor-5-prs-design.md): the original declaration in kc/manager.go:239 used `Kite *KiteConnect` where kc.KiteConnect was a thin one-field wrapper around zerodha.KiteSDK (see prior kc/manager.go:205-208). The wrapper carried no auth/ retry/caching/telemetry logic of its own, so this PR collapsed the indirection: callsites that previously did `kiteSession.Kite.Client. METHOD(...)` now do `kiteSession.Kite.METHOD(...)` — saving one dereference and severing the kc-parent dependency. kc/manager.go keeps `type KiteSessionData = domain.KiteSessionData` as a backward- compatibility alias so the 56-file reverse-dep set continues to compile via the legacy kc.KiteSessionData reference path.

The field types reach only already-extracted modules:

  • zerodha.KiteSDK lives in github.com/zerodha/kite-mcp-server/ broker/zerodha (broker module — extracted at commit 5d74acf)
  • broker.Client lives in github.com/zerodha/kite-mcp-server/ broker (same module)

Both are reached from kc/domain via the existing broker require/ replace in kc/domain/go.mod (no new replaces needed).

type KiteToken

type KiteToken = string

KiteToken is the Kite Connect access token for broker API calls.

type MCPSessionID

type MCPSessionID = string

MCPSessionID uniquely identifies an MCP protocol session on this server.

type MFOrderCancelledEvent

type MFOrderCancelledEvent struct {
	Email     string
	OrderID   string
	Timestamp time.Time
}

MFOrderCancelledEvent is emitted on successful broker cancellation of an MF order. Aggregate-ID is OrderID — pairs with MFOrderPlacedEvent under the same key for full lifecycle replay.

func (MFOrderCancelledEvent) EventType

func (e MFOrderCancelledEvent) EventType() string

func (MFOrderCancelledEvent) OccurredAt

func (e MFOrderCancelledEvent) OccurredAt() time.Time

type MFOrderPlacedEvent

type MFOrderPlacedEvent struct {
	Email           string
	OrderID         string
	Tradingsymbol   string
	TransactionType string
	Amount          float64 // ISIN buy: rupees; sell-by-quantity: 0
	Quantity        float64 // sell-by-quantity: units; buy-by-amount: 0
	Tag             string
	Timestamp       time.Time
}

MFOrderPlacedEvent is emitted on successful broker placement of a one-off mutual-fund buy/sell order. Replaces the prior untyped appendAuxEvent("mf.order_placed", ...) payload. Carries the broker- assigned OrderID + the user-supplied params verbatim so a forensic walk can reconstruct the placement context without re-querying the broker.

Aggregate-ID rule: keyed by OrderID via MFAggregateID — pairs with MFOrderRejectedEvent (cancel-source) under the same key when both fire on the same broker order. Same shape as appendAuxEvent's existing aggregate ID for "mf.order_placed" so existing audit rows and new typed events sort under one stream.

func (MFOrderPlacedEvent) EventType

func (e MFOrderPlacedEvent) EventType() string

func (MFOrderPlacedEvent) OccurredAt

func (e MFOrderPlacedEvent) OccurredAt() time.Time

type MFOrderRejectedEvent

type MFOrderRejectedEvent struct {
	Email     string
	OrderID   string // MF order ID or SIP ID; empty for place_* failures
	Source    string // "place_order" / "cancel_order" / "place_sip" / "cancel_sip"
	Reason    string // broker error message, best-effort
	Timestamp time.Time
}

MFOrderRejectedEvent is emitted when a mutual-fund mutation fails at the broker round-trip — distinct from real OrderRejectedEvent (equity/derivatives) so projector consumers can filter MF surface without parsing OrderID prefixes (Kite assigns separate ID namespaces for MF orders, MF SIPs, and equity orders).

Source distinguishes the four MF mutation paths:

  • "place_order" — PlaceMFOrder broker call failed (insufficient funds, fund not allowed, market closed for MF).
  • "cancel_order" — CancelMFOrder broker call failed (already processed, not found).
  • "place_sip" — PlaceMFSIP broker call failed.
  • "cancel_sip" — CancelMFSIP broker call failed.

Aggregate-ID rule: when OrderID is non-empty (cancel paths, where the caller supplied an existing MF order/SIP ID), the rejection joins the MF aggregate stream rooted on that ID. When OrderID is empty (place rejection — broker never assigned one), falls back to the synthetic "mf-rejected:<email>:<rfc3339-nanos>" key so each rejection lands in its own slot. Mirrors OrderRejectedAggregateID's empty-ID handling.

func (MFOrderRejectedEvent) EventType

func (e MFOrderRejectedEvent) EventType() string

func (MFOrderRejectedEvent) OccurredAt

func (e MFOrderRejectedEvent) OccurredAt() time.Time

type MFSIPCancelledEvent

type MFSIPCancelledEvent struct {
	Email     string
	SIPID     string
	Timestamp time.Time
}

MFSIPCancelledEvent is emitted on successful broker cancellation of an MF SIP. Aggregate-ID is SIPID — pairs with MFSIPPlacedEvent.

func (MFSIPCancelledEvent) EventType

func (e MFSIPCancelledEvent) EventType() string

func (MFSIPCancelledEvent) OccurredAt

func (e MFSIPCancelledEvent) OccurredAt() time.Time

type MFSIPPlacedEvent

type MFSIPPlacedEvent struct {
	Email         string
	SIPID         string
	Tradingsymbol string
	Amount        float64
	Frequency     string  // "monthly", "weekly", etc.
	Instalments   int     // -1 = perpetual; positive = fixed count
	InitialAmount float64 // optional one-time first instalment
	InstalmentDay int     // day-of-month for monthly SIPs
	Tag           string
	Timestamp     time.Time
}

MFSIPPlacedEvent is emitted on successful broker creation of a new systematic investment plan. SIPs are recurring MF orders with their own ID namespace (SIPID, distinct from MFOrder OrderID), so they key under the SIPID via MFAggregateID — disjoint stream from one-off MF orders.

func (MFSIPPlacedEvent) EventType

func (e MFSIPPlacedEvent) EventType() string

func (MFSIPPlacedEvent) OccurredAt

func (e MFSIPPlacedEvent) OccurredAt() time.Time

type Money

type Money = money.Money

Money is the canonical monetary value object. As of Slice 6e it lives in the leaf package kc/money so the broker package can import it without inverting the existing kc/domain → broker import direction (kc/domain itself imports broker for its rich Holding / Position / Order wrappers; broker → kc/domain would be a cycle).

This type alias preserves the established Slice 1-6d API surface — 65+ files, 372+ constructor sites — without churn. domain.Money is structurally identical to money.Money: fields (Amount, Currency), methods (Add, Sub, Multiply, GreaterThan, Float64, IsPositive, IsZero, IsNegative, String), and struct literals all keep working.

New code may import kc/money directly OR keep using kc/domain for the existing convention; both compile to the same type at the call site.

type NativeAlertDeletedEvent

type NativeAlertDeletedEvent struct {
	Email     string
	UUID      string
	Timestamp time.Time
}

NativeAlertDeletedEvent is emitted on successful broker deletion of a native alert. One event per UUID — DeleteNativeAlertUseCase loops over UUIDs and emits per-alert (matching the existing appendAuxEvent loop shape).

func (NativeAlertDeletedEvent) EventType

func (e NativeAlertDeletedEvent) EventType() string

func (NativeAlertDeletedEvent) OccurredAt

func (e NativeAlertDeletedEvent) OccurredAt() time.Time

type NativeAlertModifiedEvent

type NativeAlertModifiedEvent struct {
	Email     string
	UUID      string
	Timestamp time.Time
}

NativeAlertModifiedEvent is emitted on successful broker modification of an existing native alert. UUID is required — the modify use case validates it upfront.

func (NativeAlertModifiedEvent) EventType

func (e NativeAlertModifiedEvent) EventType() string

func (NativeAlertModifiedEvent) OccurredAt

func (e NativeAlertModifiedEvent) OccurredAt() time.Time

type NativeAlertPlacedEvent

type NativeAlertPlacedEvent struct {
	Email     string
	UUID      string // optional — broker may assign lazily
	Timestamp time.Time
}

NativeAlertPlacedEvent is emitted on successful broker creation of a server-side (Kite-native) price alert. Replaces the prior untyped appendAuxEvent("native_alert.placed", ...) emit.

UUID may be empty at place time — the broker assigns the alert UUID lazily and the immediate response doesn't always carry it. The aggregate ID falls back to email in that case (matching the prior PlaceNativeAlertUseCase behaviour where keying-by-email was the documented choice). Once the broker exposes the UUID via list, a future "native_alert.uuid_assigned" event could close the loop.

Distinct from AlertCreatedEvent (custom in-process alerts) which fires from kc/alerts/store.go on user-side alert creation.

func (NativeAlertPlacedEvent) EventType

func (e NativeAlertPlacedEvent) EventType() string

func (NativeAlertPlacedEvent) OccurredAt

func (e NativeAlertPlacedEvent) OccurredAt() time.Time

type NotSpec

type NotSpec[T any] struct {
	Inner Spec[T]
	// contains filtered or unexported fields
}

NotSpec negates the inner specification.

func Not

func Not[T any](inner Spec[T]) *NotSpec[T]

Not returns a new specification that is the logical negation of inner.

func (*NotSpec[T]) IsSatisfiedBy

func (s *NotSpec[T]) IsSatisfiedBy(candidate T) bool

func (*NotSpec[T]) Reason

func (s *NotSpec[T]) Reason() string

type OAuthToken

type OAuthToken = string

OAuthToken is the JWT issued by this server's OAuth layer.

type OrSpec

type OrSpec[T any] struct {
	Left  Spec[T]
	Right Spec[T]
	// contains filtered or unexported fields
}

OrSpec is satisfied when at least one of Left or Right is satisfied.

func Or

func Or[T any](left, right Spec[T]) *OrSpec[T]

Or returns a new specification that is the logical disjunction of two specs.

func (*OrSpec[T]) IsSatisfiedBy

func (s *OrSpec[T]) IsSatisfiedBy(candidate T) bool

func (*OrSpec[T]) Reason

func (s *OrSpec[T]) Reason() string

type Order

type Order struct {
	// contains filtered or unexported fields
}

Order is the rich domain entity for a broker order. It wraps a broker.Order DTO (no duplicate field storage) and exposes lifecycle methods that encapsulate the business rules of an order's state transitions.

Consumers previously duplicated these checks inline (e.g. `if o.Status == "OPEN" || o.Status == "TRIGGER PENDING"`). The Order entity centralises them so that a change in status semantics (e.g. Kite adding a new intermediate state) requires a single edit here.

The wrapped DTO stays the source of truth — see DTO() for passthrough to existing broker / persistence code.

func NewOrderFromBroker

func NewOrderFromBroker(b broker.Order) Order

NewOrderFromBroker lifts a broker.Order DTO into the rich domain entity.

func ToDomainOrder

func ToDomainOrder(b broker.Order) Order

ToDomainOrder is a converter alias — identical to NewOrderFromBroker, named for ergonomic use at adapter boundaries. Example: `d := domain.ToDomainOrder(brokerOrder)`.

func (Order) CanCancel

func (o Order) CanCancel() bool

CanCancel reports whether the order is in a cancellable state. An order is cancellable only while it is resting in the book awaiting fill (OPEN) or awaiting its stop-loss trigger (TRIGGER PENDING).

func (Order) DTO

func (o Order) DTO() broker.Order

DTO returns the underlying broker DTO for passthrough to code that still consumes broker.Order directly (persistence, broker adapters).

func (Order) FillPercentage

func (o Order) FillPercentage() float64

FillPercentage returns the percentage of the order quantity that has been filled, in the range [0, 100]. Returns 0 when Quantity is zero (avoids a divide-by-zero) and clamps to 100 when FilledQuantity > Quantity (which should not happen but has been observed on partial-then-modified orders).

func (Order) ID

func (o Order) ID() string

ID returns the broker-assigned order identifier.

func (Order) IsCancelled

func (o Order) IsCancelled() bool

IsCancelled reports whether the order reached the CANCELLED state. Case-insensitive.

func (Order) IsComplete

func (o Order) IsComplete() bool

IsComplete reports whether the order reached the COMPLETE state. Case-insensitive — matches the broker's reported status regardless of casing variations observed on edge transports.

func (Order) IsPending

func (o Order) IsPending() bool

IsPending reports whether the order is in any non-terminal, in-flight state. This is the complement of IsTerminal excluding the zero-value/empty status, which is treated as neither pending nor terminal (unknown).

func (Order) IsRejected

func (o Order) IsRejected() bool

IsRejected reports whether the order reached the REJECTED state. Case-insensitive.

func (Order) IsTerminal

func (o Order) IsTerminal() bool

IsTerminal reports whether the order has reached a terminal state and will not transition further. Terminal states are COMPLETE / CANCELLED / REJECTED.

func (Order) Status

func (o Order) Status() string

Status returns the broker's current status string (as Kite reports it). Prefer IsTerminal / IsPending / CanCancel for state queries.

type OrderCancelledEvent

type OrderCancelledEvent struct {
	Email     string
	OrderID   string
	Timestamp time.Time
}

OrderCancelledEvent is emitted after an order is successfully cancelled.

func (OrderCancelledEvent) EventType

func (e OrderCancelledEvent) EventType() string

func (OrderCancelledEvent) OccurredAt

func (e OrderCancelledEvent) OccurredAt() time.Time

type OrderCandidate

type OrderCandidate struct {
	Quantity        int
	Price           Money
	Exchange        string
	Tradingsymbol   string
	TransactionType string // "BUY" or "SELL"
	OrderType       string // "MARKET", "LIMIT", "SL", "SL-M"
}

OrderCandidate bundles the fields needed for composite order validation. This is a transient value — not persisted, just passed through specs. Price is a Money value object (currency-aware); MARKET / SL-M orders carry the zero-Money by convention, and OrderSpec.IsSatisfiedBy skips the price check on those order types.

type OrderFilledEvent

type OrderFilledEvent struct {
	Email       string
	OrderID     string
	FilledQty   Quantity
	FilledPrice Money
	Status      string
	Timestamp   time.Time
}

OrderFilledEvent is emitted after an order is filled by the exchange.

Status carries the broker-reported terminal status (T4):

  • "COMPLETE" — full quantity filled, single tranche. The pre-T4 default; fill_watcher only emitted on this case.
  • "PARTIAL" — partial fill (multi-tranche execution or kill-fill timeout). Downstream consumers should treat FilledQty as the actual matched quantity, not the order's requested quantity.
  • "AMO" — after-market order, queued for next session. Emitted when the broker accepts the order outside trading hours; the "fill" here is logical placement, not an exchange match.

Empty Status means the producer didn't set it (legacy emitters). New emit sites must set Status explicitly so the projection / activity feed can distinguish partial fills.

func (OrderFilledEvent) EventType

func (e OrderFilledEvent) EventType() string

func (OrderFilledEvent) OccurredAt

func (e OrderFilledEvent) OccurredAt() time.Time

type OrderFreezeReason

type OrderFreezeReason = string

OrderFreezeReason documents why a per-user freeze was applied.

type OrderModifiedEvent

type OrderModifiedEvent struct {
	Email     string
	OrderID   string
	Timestamp time.Time
}

OrderModifiedEvent is emitted after an order is successfully modified.

func (OrderModifiedEvent) EventType

func (e OrderModifiedEvent) EventType() string

func (OrderModifiedEvent) OccurredAt

func (e OrderModifiedEvent) OccurredAt() time.Time

type OrderPlacedEvent

type OrderPlacedEvent struct {
	Email           string
	OrderID         string
	Instrument      InstrumentKey
	Qty             Quantity
	Price           Money
	TransactionType string // "BUY" or "SELL"
	Timestamp       time.Time
}

OrderPlacedEvent is emitted after an order is successfully placed.

func (OrderPlacedEvent) EventType

func (e OrderPlacedEvent) EventType() string

func (OrderPlacedEvent) OccurredAt

func (e OrderPlacedEvent) OccurredAt() time.Time

type OrderPlacement

type OrderPlacement struct {
	// contains filtered or unexported fields
}

OrderPlacement is the DDD aggregate root for a not-yet-submitted order. It binds validated value objects (instrument, qty, price) with the transaction/order-type strings and enforces placement-time invariants at construction: price must be positive for non-MARKET orders, and transaction type must be BUY or SELL.

Service/use-case code constructs one of these and reads the validated fields back rather than re-checking primitives inline. Follow-on code (riskguard, broker call) operates on a known-good placement or an error — the placement cannot be in a halfway-valid state.

func NewOrderPlacement

func NewOrderPlacement(
	instrument InstrumentKey,
	qty Quantity,
	price Money,
	transactionType string,
	orderType string,
) (OrderPlacement, error)

NewOrderPlacement constructs a validated OrderPlacement. All value objects are checked for validity; transaction type must be BUY or SELL; and for non-MARKET orders the price must be positive (Money.IsPositive). MARKET / SL-M orders carry a zero-value Money by convention — those are accepted.

func (OrderPlacement) Instrument

func (p OrderPlacement) Instrument() InstrumentKey

Instrument returns the validated instrument key.

func (OrderPlacement) Notional

func (p OrderPlacement) Notional() Money

Notional returns the placement's price × quantity as a Money value in the price's currency. For MARKET / SL-M orders the price is zero-Money by convention; Notional in those cases returns a zero-Money in the SAME currency as the configured price (which is INR by default for unset prices). Callers that need a meaningful notional for MARKET orders must estimate from LTP separately — this aggregate intentionally does not reach for live market data.

func (OrderPlacement) OrderType

func (p OrderPlacement) OrderType() string

OrderType returns MARKET, LIMIT, SL, or SL-M.

func (OrderPlacement) Price

func (p OrderPlacement) Price() Money

Price returns the placement's price. May be zero-value Money for MARKET / SL-M orders — check with Price().IsPositive() before use.

func (OrderPlacement) Quantity

func (p OrderPlacement) Quantity() Quantity

Quantity returns the validated quantity value object.

func (OrderPlacement) TransactionType

func (p OrderPlacement) TransactionType() string

TransactionType returns BUY or SELL.

type OrderRejectedEvent

type OrderRejectedEvent struct {
	Email     string
	OrderID   string // may be empty for place_order failures (no broker-assigned ID)
	ToolName  string // "place_order" / "modify_order" / "cancel_order"
	Reason    string // broker error message, best-effort
	Timestamp time.Time
}

OrderRejectedEvent is emitted when the broker fails an order mutation — place, modify, or cancel — for any reason that surfaces from the Kite API (rate limit, exchange reject, insufficient margin, invalid symbol, etc.). Captures the user-visible failure path on the order pipeline so the audit/projection stream isn't silent on broker rejections; previously these errors were logged but not event-sourced, leaving a hole in the order aggregate's lifecycle.

Distinct from RiskLimitBreachedEvent (pre-broker, riskguard-internal): OrderRejected fires only AFTER riskguard allowed the call AND the broker round-trip returned an error. Together the two events cover the two failure surfaces — internal pre-trade gates and external post-broker rejections — so an auditor walking the user's stream sees every reason an order didn't reach a placed/modified/cancelled terminal state.

OrderID may be empty when the rejection is on place_order — the broker never assigned an ID before failing. For modify/cancel the caller-supplied OrderID is preserved so the rejection joins the existing order aggregate stream. ToolName tags the failure surface ("place_order", "modify_order", "cancel_order") so projector queries can partition rejections by mutation type without parsing Reason.

Reason is the broker error message (best-effort string); kept verbose so a forensic walk can distinguish "MARGIN_INSUFFICIENT" from "RATE_LIMIT" without re-querying the broker. Caller is responsible for stripping PII from the error before emit (Kite errors don't carry user identifiers in practice — but the contract is "don't ship plaintext credentials").

func (OrderRejectedEvent) EventType

func (e OrderRejectedEvent) EventType() string

func (OrderRejectedEvent) OccurredAt

func (e OrderRejectedEvent) OccurredAt() time.Time

type OrderSpec

type OrderSpec struct {
	QtySpec   *QuantitySpec
	PriceSpec *PriceSpec
	// contains filtered or unexported fields
}

OrderSpec composes quantity and price specs into a single order-level check. Additional rules (valid exchange, valid order type) are included.

func NewOrderSpec

func NewOrderSpec(qtySpec *QuantitySpec, priceSpec *PriceSpec) *OrderSpec

NewOrderSpec creates a composite order specification.

func (*OrderSpec) IsSatisfiedBy

func (s *OrderSpec) IsSatisfiedBy(o OrderCandidate) bool

func (*OrderSpec) Reason

func (s *OrderSpec) Reason() string

type PaperOrderRejectedEvent

type PaperOrderRejectedEvent struct {
	Email     string
	OrderID   string
	Reason    string // human-readable rejection reason (cash shortage, LTP unavailable)
	Source    string // "place_market" / "place_limit" / "fill_immediate" / "fill_monitor"
	Timestamp time.Time
}

PaperOrderRejectedEvent is emitted when the paper-trading engine rejects a virtual order. Sources:

  • "place_market" — MARKET order rejected because LTP unavailable (no LTP provider, instrument not subscribed).
  • "place_limit" — LIMIT BUY rejected at place time because the notional exceeds the user's cash balance.
  • "fill_immediate" — fillOrder rejected a BUY because the snap-to- LTP price exceeded cash balance (covers the market path and marketable-LIMIT path).
  • "fill_monitor" — background monitor rejected a queued BUY at fill time when cash dropped below required notional between place and fill.

Distinct event type from real OrderRejectedEvent so projector consumers (activity feeds, dashboards) can filter "real broker rejection" vs "virtual-account rejection" without parsing OrderID prefixes — paper IDs use "PAPER_<n>" but relying on prefix-sniffing for projection-side classification is fragile, so we surface the distinction at the event type itself.

Aggregate-ID rule: keyed by OrderID via PaperOrderAggregateID. Paper order IDs ("PAPER_<n>") are already process-unique via the atomic orderSeq counter in kc/papertrading/engine.go, so no email prefix is needed to disambiguate. Empty OrderID falls back to "paper:unknown" — never observed in practice (orderID is assigned before any rejection branch) but defence in depth keeps malformed dispatches out of real rows.

func (PaperOrderRejectedEvent) EventType

func (e PaperOrderRejectedEvent) EventType() string

func (PaperOrderRejectedEvent) OccurredAt

func (e PaperOrderRejectedEvent) OccurredAt() time.Time

type PaperTradingDisabledEvent

type PaperTradingDisabledEvent struct {
	Email     string
	Timestamp time.Time
}

PaperTradingDisabledEvent is emitted on successful deactivation of a user's paper-trading account. Pairs with PaperTradingEnabledEvent under the same email-keyed aggregate.

func (PaperTradingDisabledEvent) EventType

func (e PaperTradingDisabledEvent) EventType() string

func (PaperTradingDisabledEvent) OccurredAt

func (e PaperTradingDisabledEvent) OccurredAt() time.Time

type PaperTradingEnabledEvent

type PaperTradingEnabledEvent struct {
	Email       string
	InitialCash float64
	Timestamp   time.Time
}

PaperTradingEnabledEvent is emitted on successful activation of a user's paper-trading account. Captures InitialCash so a forensic walk can reconstruct the seed condition without re-querying the engine. Replaces the prior untyped appendAuxEvent("paper.enabled", ...) emit.

Aggregate-ID rule: keyed by email via PaperTradingAggregateID. The full enable -> N resets -> disable lifecycle replays under one stream. Disjoint from PaperOrderRejectedEvent (per-paper-order) which keys by OrderID.

func (PaperTradingEnabledEvent) EventType

func (e PaperTradingEnabledEvent) EventType() string

func (PaperTradingEnabledEvent) OccurredAt

func (e PaperTradingEnabledEvent) OccurredAt() time.Time

type PaperTradingResetEvent

type PaperTradingResetEvent struct {
	Email     string
	Timestamp time.Time
}

PaperTradingResetEvent is emitted on successful reset of the virtual portfolio (cash balance back to InitialCash, positions / holdings cleared). Pairs with PaperTradingEnabledEvent / PaperTradingDisabledEvent under the same email-keyed aggregate so a full lifecycle replays cleanly.

func (PaperTradingResetEvent) EventType

func (e PaperTradingResetEvent) EventType() string

func (PaperTradingResetEvent) OccurredAt

func (e PaperTradingResetEvent) OccurredAt() time.Time

type PluginRegisteredEvent

type PluginRegisteredEvent struct {
	PluginName string
	Path       string
	Timestamp  time.Time
}

PluginRegisteredEvent is emitted when a plugin binary is registered for hot-reload watching via WatchPluginBinary. Captures the (plugin-name, path) pair so the plugin-watcher aggregate stream lets auditors / read-side projectors reconstruct the full set of currently- watched binaries by replaying the (registered, unregistered) pairs.

PluginName is the logical plugin identifier (typically the basename of Path, derived inside WatchPluginBinary). Path is the absolute filesystem path that subscribed to fsnotify events. Both fields are required at emit time.

func (PluginRegisteredEvent) EventType

func (e PluginRegisteredEvent) EventType() string

func (PluginRegisteredEvent) OccurredAt

func (e PluginRegisteredEvent) OccurredAt() time.Time

type PluginReloadTriggeredEvent

type PluginReloadTriggeredEvent struct {
	PluginName string
	Path       string
	Timestamp  time.Time
}

PluginReloadTriggeredEvent is emitted when the watcher debounce timer fires Close() on a registered BinaryReloadable, signaling that the plugin's subprocess will be relaunched on the next tool invocation. This is the workhorse event of the plugin-watcher aggregate: every dev-loop rebuild surfaces here, giving the audit log an explicit reload boundary that read-side projectors can use to compute "reloads/hour by plugin" without scanning fsnotify-level traces.

func (PluginReloadTriggeredEvent) EventType

func (e PluginReloadTriggeredEvent) EventType() string

func (PluginReloadTriggeredEvent) OccurredAt

func (e PluginReloadTriggeredEvent) OccurredAt() time.Time

type PluginUnregisteredEvent

type PluginUnregisteredEvent struct {
	PluginName string
	Path       string
	Timestamp  time.Time
}

PluginUnregisteredEvent is emitted when a plugin path is removed from the watcher's registry — currently only via ClearPluginWatches (test path), but reserved for a future production unregister API. Pairs with PluginRegisteredEvent so the watcher aggregate stream is closed (registered + unregistered events have matching Path).

func (PluginUnregisteredEvent) EventType

func (e PluginUnregisteredEvent) EventType() string

func (PluginUnregisteredEvent) OccurredAt

func (e PluginUnregisteredEvent) OccurredAt() time.Time

type PluginWatcherStartedEvent

type PluginWatcherStartedEvent struct {
	Timestamp time.Time
}

PluginWatcherStartedEvent is emitted exactly once per StartPluginBinaryWatcher invocation that actually starts the watcher goroutine (idempotent re-Start calls are silent). Captures the fsnotify watcher coming online so the aggregate stream has explicit "watcher up" boundaries between reload bursts.

func (PluginWatcherStartedEvent) EventType

func (e PluginWatcherStartedEvent) EventType() string

func (PluginWatcherStartedEvent) OccurredAt

func (e PluginWatcherStartedEvent) OccurredAt() time.Time

type PluginWatcherStoppedEvent

type PluginWatcherStoppedEvent struct {
	Timestamp time.Time
}

PluginWatcherStoppedEvent is emitted exactly once per StopPluginBinaryWatcher invocation that actually stops a running watcher (no-op stops on never-started watchers are silent). Pairs with PluginWatcherStartedEvent so the aggregate stream's lifecycle transitions are symmetric.

func (PluginWatcherStoppedEvent) EventType

func (e PluginWatcherStoppedEvent) EventType() string

func (PluginWatcherStoppedEvent) OccurredAt

func (e PluginWatcherStoppedEvent) OccurredAt() time.Time

type Position

type Position struct {
	// contains filtered or unexported fields
}

Position is the rich domain entity for a broker trading position. It wraps a broker.Position DTO and exposes behaviour that was previously duplicated: IsIntraday (product == "MIS"), Direction (LONG/SHORT/FLAT), RealizedPnL, UnrealizedPnL, and InstrumentKey projection.

The wrapped DTO is the source of truth; existing broker / persistence code continues to use broker.Position directly. Use ToDomainPosition or NewPositionFromBroker at the adapter boundary.

func NewPositionFromBroker

func NewPositionFromBroker(b broker.Position) Position

NewPositionFromBroker lifts a broker.Position DTO into the rich domain entity.

func ToDomainPosition

func ToDomainPosition(b broker.Position) Position

ToDomainPosition is a converter alias — identical to NewPositionFromBroker, named for ergonomic use at adapter boundaries.

func (Position) DTO

func (p Position) DTO() broker.Position

DTO returns the underlying broker DTO for passthrough to code that still consumes broker.Position directly.

func (Position) Direction

func (p Position) Direction() string

Direction returns "LONG" when quantity > 0, "SHORT" when quantity < 0, and "FLAT" when quantity == 0.

func (Position) InstrumentKey

func (p Position) InstrumentKey() InstrumentKey

InstrumentKey returns the canonical (EXCHANGE:SYMBOL) identifier for this position's instrument. Useful when joining positions with LTP maps keyed by instrument key.

func (Position) IsIntraday

func (p Position) IsIntraday() bool

IsIntraday reports whether the position is intraday-only (MIS product). MIS positions are force-squared-off at market close unless converted to NRML/CNC.

func (Position) IsOpen

func (p Position) IsOpen() bool

IsOpen reports whether the position still has non-zero quantity. A flat position (Quantity == 0) may still appear in the broker's position list because it carried realized P&L earlier in the session, but it is not "open" in the sense of carrying market exposure.

func (Position) PnL

func (p Position) PnL() Money

PnL returns the broker-reported net P&L as a Money value. As of Slice 6e c2, broker.Position.PnL is already Money — the adapter wraps gokiteconnect's INR float at the boundary. This accessor passes through.

Prefer UnrealizedPnL when you want to compute P&L at a specific LTP that may differ from the broker's last-seen LTP.

func (Position) UnrealizedPnL

func (p Position) UnrealizedPnL(ltp Money) Money

UnrealizedPnL computes the mark-to-market P&L at the given LTP as (ltp - averagePrice) * quantity, wrapped in a Money value.

For a long position (quantity > 0), profit accrues when ltp rises above averagePrice. For a short position (quantity < 0), the sign flips naturally because the quantity factor is negative — no special-casing required.

If ltp is in a different currency than INR, the result carries the LTP's currency. Callers may normalise via Money.Add error handling.

type PositionClosedEvent

type PositionClosedEvent struct {
	Email           string
	OrderID         string // the closing order's ID
	Instrument      InstrumentKey
	Product         string // MIS, CNC, NRML — part of aggregate key
	Qty             Quantity
	TransactionType string // opposite direction used to close
	Timestamp       time.Time
}

PositionClosedEvent is emitted after a position is closed via close_position. OrderID is the closing order (fresh from Kite), not the opening one — use PositionAggregateID() to join with the corresponding open event.

func (PositionClosedEvent) EventType

func (e PositionClosedEvent) EventType() string

func (PositionClosedEvent) OccurredAt

func (e PositionClosedEvent) OccurredAt() time.Time

type PositionConvertedEvent

type PositionConvertedEvent struct {
	Email           string
	Instrument      InstrumentKey
	TransactionType string // "BUY" or "SELL" (the position direction being converted)
	Quantity        int
	OldProduct      string // MIS, CNC, NRML — pre-conversion product
	NewProduct      string // MIS, CNC, NRML — post-conversion product
	PositionType    string // "day" or "overnight" — Kite Convert API param
	Timestamp       time.Time
}

PositionConvertedEvent is emitted when a user converts a position from one product type to another (e.g. CNC->MIS for intraday squaring, MIS->CNC to carry forward overnight). Replaces the prior untyped appendAuxEvent("position.converted", map[string]any{...}) emit in kc/usecases/convert_position.go so the audit stream uses a typed domain.Event with stable field names — projector consumers no longer need to type-assert against an opaque map[string]any payload.

Aggregate-ID rule: keyed by (email, exchange, tradingsymbol, OLD product) via PositionConvertedAggregateID. Using the OLD product (not the new) means a CNC->MIS->CNC sequence threads through a stable aggregate stream rooted on the original holding's product. Stable across reverse conversions, matches the pre-ES key shape so existing rows aren't orphaned by the migration.

PositionType ("day" / "overnight") is preserved verbatim from the Kite ConvertPosition API param so a forensic walk can distinguish intraday squaring from carry-forward conversions without re-querying position state.

func (PositionConvertedEvent) EventType

func (e PositionConvertedEvent) EventType() string

func (PositionConvertedEvent) OccurredAt

func (e PositionConvertedEvent) OccurredAt() time.Time

type PositionOpenedEvent

type PositionOpenedEvent struct {
	Email           string
	PositionID      string // opening order ID (historical trace only)
	Instrument      InstrumentKey
	Product         string // MIS, CNC, NRML — part of aggregate key
	Qty             Quantity
	AvgPrice        Money
	TransactionType string // "BUY" or "SELL"
	Timestamp       time.Time
}

PositionOpenedEvent is emitted when a new position is opened.

Aggregate-ID rule: positions do not have a broker-assigned unique ID — Kite's Position struct doesn't expose an "opening order" field — so we can't reliably join open and close events by ID. Instead, we key position events by the natural tuple (email, exchange, symbol, product) via PositionAggregateID() below. A single user-instrument-product aggregate may contain multiple open→close lifecycles across time; walking the event stream makes lifecycle boundaries visible.

Product is required for the aggregate ID. PositionID is kept for tracing — it equals the opening order ID from place_order — but is no longer the aggregate key.

func (PositionOpenedEvent) EventType

func (e PositionOpenedEvent) EventType() string

func (PositionOpenedEvent) OccurredAt

func (e PositionOpenedEvent) OccurredAt() time.Time

type PriceSpec

type PriceSpec struct {
	MaxPrice Money // zero-Money means no upper bound
	// contains filtered or unexported fields
}

PriceSpec validates that a price is positive and within an optional ceiling. A zero-Money MaxPrice means no upper bound. Currency-aware: a candidate price in a different currency than the configured ceiling is rejected with an explanatory reason — never silently coerced.

func NewPriceSpec

func NewPriceSpec(maxPrice Money) *PriceSpec

NewPriceSpec creates a price specification with the given ceiling. Pass a zero-Money (e.g. domain.Money{}) for no upper bound; the standard constructor for "no bound" is the literal zero value, which IsSatisfiedBy treats as "skip ceiling check" via IsZero.

func (*PriceSpec) IsSatisfiedBy

func (s *PriceSpec) IsSatisfiedBy(price Money) bool

IsSatisfiedBy reports whether the candidate price is positive and (when the spec has a non-zero ceiling) does not exceed MaxPrice. Cross-currency comparisons are rejected — a USD ceiling cannot validate an INR candidate, so the spec records a currency-mismatch reason rather than performing implicit conversion.

func (*PriceSpec) Reason

func (s *PriceSpec) Reason() string

type Profile

type Profile struct {
	UserID    string
	UserName  string
	Email     string
	Broker    string
	Exchanges []string
	Products  []string
}

Profile is the domain-layer representation of a trading account's identity + capability surface. Matches the broker.Profile wire DTO field-for-field so NewProfileFromBroker is a pure refcopy, but adds business-meaning methods that live on the entity rather than being duplicated in every tool handler that needs them.

Scope:

  • Exchange/product capability checks — used by pretrade validation, compliance reporting, and the admin user page to show "what can this user actually trade?". Previously callers would string-search profile.Exchanges[] inline (4 prod sites and counting) and do a case-sensitive comparison, producing subtle bugs when Kite returns "NSE" but config uses "nse".

Explicitly NOT modeled here — and NOT a DDD gap:

  • IsKycComplete / AccountTenure / IsCorporateAccount — the Kite Profile response carries NONE of these fields (no kyc_status, no account_created_at, no account_type). Modeling them today would be speculative entities that hold no data. If Zerodha's API ever exposes these, add them to broker.Profile first, then the methods land here trivially.

func NewProfileFromBroker

func NewProfileFromBroker(p broker.Profile) Profile

NewProfileFromBroker converts a broker.Profile DTO into the rich domain entity. Zero-value input returns a zero-value Profile — not an error — because Profile is a read-side snapshot; the "invalid profile" condition is the absence of one, not a malformed one.

func (Profile) HasCommodityAccess

func (p Profile) HasCommodityAccess() bool

HasCommodityAccess reports whether the profile can trade commodity futures (MCX). Separate enablement from equity derivatives.

func (Profile) HasDerivativesAccess

func (p Profile) HasDerivativesAccess() bool

HasDerivativesAccess reports whether the profile can trade F&O (NFO, BFO). Segment-enabled brokers have explicit NFO activation; default retail accounts don't.

func (Profile) HasEquityAccess

func (p Profile) HasEquityAccess() bool

HasEquityAccess reports whether the profile can trade on any equity exchange (NSE or BSE). Convenience predicate used by the pretrade check to short-circuit stock orders for F&O-only accounts.

func (Profile) IsIntradayEligible

func (p Profile) IsIntradayEligible() bool

IsIntradayEligible reports whether the account can place intraday orders. Accounts with MIS product access can trade intraday; pure delivery accounts cannot.

func (Profile) IsZerodhaAccount

func (p Profile) IsZerodhaAccount() bool

IsZerodhaAccount returns true if the broker name identifies a Zerodha Kite account. Useful for segment-specific code paths that depend on Kite-only features like ATO alerts.

func (Profile) SupportsExchange

func (p Profile) SupportsExchange(exchange string) bool

SupportsExchange reports whether the account can trade on the given exchange (NSE, BSE, NFO, BFO, MCX, ...). Case-insensitive because Kite responses are upper-case but user input + configs are mixed.

func (Profile) SupportsProduct

func (p Profile) SupportsProduct(product string) bool

SupportsProduct reports whether the account can place orders with the given product type (CNC, MIS, NRML, MTF, ...). Case-insensitive for the same reason as SupportsExchange.

type Quantity

type Quantity struct {
	// contains filtered or unexported fields
}

Quantity is a value object representing an order quantity. It is always a positive integer — zero and negative quantities are invalid.

func NewQuantity

func NewQuantity(v int) (Quantity, error)

NewQuantity creates a validated Quantity. Returns an error if v <= 0.

func (Quantity) Int

func (q Quantity) Int() int

Int returns the underlying integer value.

func (Quantity) IsValid

func (q Quantity) IsValid() bool

IsValid returns true if the quantity has a positive value. A zero-value Quantity (e.g., from var q Quantity) is invalid.

func (Quantity) MarshalJSON

func (q Quantity) MarshalJSON() ([]byte, error)

MarshalJSON encodes the quantity as a JSON number.

func (Quantity) String

func (q Quantity) String() string

String returns the quantity as a string.

func (*Quantity) UnmarshalJSON

func (q *Quantity) UnmarshalJSON(data []byte) error

UnmarshalJSON decodes a JSON number into a Quantity. Accepts zero for cases where the quantity is intentionally unset (e.g., command defaults).

type QuantitySpec

type QuantitySpec struct {
	Min int
	Max int
	// contains filtered or unexported fields
}

QuantitySpec validates that an integer quantity falls within [Min, Max]. Min defaults to 1 if zero; Max of 0 means no upper bound.

func NewQuantitySpec

func NewQuantitySpec(min, max int) *QuantitySpec

NewQuantitySpec creates a quantity specification with the given bounds.

func (*QuantitySpec) IsSatisfiedBy

func (s *QuantitySpec) IsSatisfiedBy(qty int) bool

func (*QuantitySpec) Reason

func (s *QuantitySpec) Reason() string

type RiskLimitBreachedEvent

type RiskLimitBreachedEvent struct {
	Email     string
	Reason    string // matches riskguard.RejectionReason values
	Message   string
	ToolName  string
	Timestamp time.Time
}

RiskLimitBreachedEvent is emitted when riskguard blocks an order.

func (RiskLimitBreachedEvent) EventType

func (e RiskLimitBreachedEvent) EventType() string

func (RiskLimitBreachedEvent) OccurredAt

func (e RiskLimitBreachedEvent) OccurredAt() time.Time

type RiskguardDailyCounterResetEvent

type RiskguardDailyCounterResetEvent struct {
	UserEmail string
	Reason    string
	Timestamp time.Time
}

RiskguardDailyCounterResetEvent is emitted when riskguard rolls a user's per-day counters (DailyOrderCount, DailyPlacedValue) on the 9:15 AM IST trading-day boundary. Mostly observable for forensic timeline replay — auditors who walk the counters aggregate stream see explicit reset boundaries rather than having to infer them from gaps in the event log.

Reason is currently always "trading_day_boundary"; reserved as a string so future paths (admin-forced reset, end-of-week rollover) can tag themselves without bumping the event schema.

func (RiskguardDailyCounterResetEvent) EventType

func (RiskguardDailyCounterResetEvent) OccurredAt

func (e RiskguardDailyCounterResetEvent) OccurredAt() time.Time

type RiskguardKillSwitchTrippedEvent

type RiskguardKillSwitchTrippedEvent struct {
	UserEmail string // empty for global kill-switch (the canonical case)
	FrozenBy  string
	Reason    string
	Active    bool // true=tripped (frozen), false=lifted (unfrozen)
	Timestamp time.Time
}

RiskguardKillSwitchTrippedEvent is emitted when riskguard's global trading freeze (a.k.a. the "kill switch") is engaged at the riskguard layer — distinct from the admin-tool-level GlobalFreezeEvent which records the admin command. This event captures the riskguard state mutation itself (kill switch went 0→1), making the counters aggregate's freeze lifecycle fully reconstructable from the event stream.

Active is true when the kill switch was tripped (off→on); when an operator unfreezes globally, a separate event with Active=false is emitted so the on/off transitions are symmetric in the audit log.

FrozenBy carries the operator/system tag passed into FreezeGlobal — typically an admin email but may be a synthetic tag like "riskguard:auto" in future auto-trip scenarios.

func (RiskguardKillSwitchTrippedEvent) EventType

func (RiskguardKillSwitchTrippedEvent) OccurredAt

func (e RiskguardKillSwitchTrippedEvent) OccurredAt() time.Time

type RiskguardRejectionEvent

type RiskguardRejectionEvent struct {
	UserEmail string
	Reason    string
	Timestamp time.Time
}

RiskguardRejectionEvent is emitted when the circuit-breaker's sliding rejection window for a user is incremented (recordRejection called). Distinct from RiskLimitBreachedEvent which is emitted at the use-case layer when an order is blocked: this event captures the COUNTER mutation inside the riskguard aggregate, so the counters aggregate stream can be replayed to reconstruct the auto-freeze state machine without joining against the order pipeline.

Reason mirrors the RejectionReason that drove the recordRejection call (e.g. "order_value_limit", "daily_value_limit", "anomaly_high"). Used by future read-side projectors that count by reason to surface "user X is hitting the order_value cap repeatedly" without scanning the full audit log.

func (RiskguardRejectionEvent) EventType

func (e RiskguardRejectionEvent) EventType() string

func (RiskguardRejectionEvent) OccurredAt

func (e RiskguardRejectionEvent) OccurredAt() time.Time

type Session

type Session struct {
	// contains filtered or unexported fields
}

Session is the rich domain entity representing a user's Kite broker session — specifically, the authenticated access token and the metadata needed to reason about its validity.

Kite access tokens expire once per trading day at 06:00 IST, regardless of when they were issued. A token issued at 09:15 IST will therefore live for ~20h 45m, while a token issued at 05:59 IST will live for just 1 minute. The IsExpired / TokenAgeHours methods encapsulate this broker-specific rule so that callers (middleware, scheduled jobs, briefing, dashboards) do not need to re-derive the expiry calendar themselves.

This entity intentionally wraps a flat DTO (SessionData) rather than the kc.KiteTokenEntry struct, so that kc/domain retains zero upward deps. Converters live in kc (see kc.ToDomainSession) to map between the two.

func NewSessionFromData

func NewSessionFromData(d SessionData) Session

NewSessionFromData constructs a Session from its flat DTO.

func ToDomainSession

func ToDomainSession(d SessionData) Session

ToDomainSession is a converter alias — identical to NewSessionFromData, named for ergonomic use at adapter boundaries.

func (Session) AccessToken

func (s Session) AccessToken() string

AccessToken returns the underlying Kite access token value.

func (Session) DTO

func (s Session) DTO() SessionData

DTO returns the underlying session DTO for passthrough.

func (Session) Email

func (s Session) Email() string

Email returns the OAuth email that owns the session.

func (Session) HasToken

func (s Session) HasToken() bool

HasToken reports whether the session carries a non-empty access token. Useful as a cheap pre-check before deciding whether re-auth is required.

func (Session) IsAuthenticated

func (s Session) IsAuthenticated() bool

IsAuthenticated is the wall-clock variant of IsAuthenticatedAt.

func (Session) IsAuthenticatedAt

func (s Session) IsAuthenticatedAt(now time.Time) bool

IsAuthenticatedAt reports whether the session can be used to call the Kite API *at the given moment*: a token is present AND it has not crossed the daily 06:00 IST refresh boundary. Collapses the two-step `hasToken && !IsExpired` check that was previously duplicated in OAuth middleware and lifecycle tests.

func (Session) IsExpired

func (s Session) IsExpired() bool

IsExpired reports whether the session has passed the daily 06:00 IST refresh boundary. Compared against time.Now.

Rule: a token is expired if it was issued before today's 06:00 IST (or before yesterday's 06:00 IST if the current time is itself before today's 06:00 IST — in that case the "last expiry tick" was yesterday).

Mirrors kc.IsKiteTokenExpired but lives on the rich entity so consumers can write `session.IsExpired()` instead of threading the StoredAt timestamp through a package-level function.

func (Session) IsExpiredAt

func (s Session) IsExpiredAt(now time.Time) bool

IsExpiredAt is the testable variant of IsExpired — takes "now" explicitly so unit tests don't need to monkey-patch time.Now. Production callers should prefer IsExpired().

func (Session) IssuedAt

func (s Session) IssuedAt() time.Time

IssuedAt returns the wall-clock time the token was issued.

func (Session) TokenAgeHours

func (s Session) TokenAgeHours() float64

TokenAgeHours returns how many hours have elapsed since IssuedAt, computed against time.Now. Negative values (IssuedAt in the future) are clamped to 0. Used for display ("token is 3.2 hours old") and for alerting on unusually fresh/stale tokens in admin tools.

func (Session) TokenAgeHoursAt

func (s Session) TokenAgeHoursAt(now time.Time) float64

TokenAgeHoursAt is the testable variant of TokenAgeHours.

type SessionClearedEvent

type SessionClearedEvent struct {
	SessionID string
	Reason    string // "post_credential_register" / "profile_check_failed" / "admin_action"
	Timestamp time.Time
}

SessionClearedEvent is emitted when the Kite session data attached to an MCP session is cleared (without terminating the session itself). Phase C ES: append-only audit record of the clear, keyed by session ID.

func (SessionClearedEvent) EventType

func (e SessionClearedEvent) EventType() string

func (SessionClearedEvent) OccurredAt

func (e SessionClearedEvent) OccurredAt() time.Time

type SessionCreatedEvent

type SessionCreatedEvent struct {
	Email     string
	SessionID string
	Broker    string // "zerodha", "angelone", etc.
	Timestamp time.Time
}

SessionCreatedEvent is emitted when a new MCP session is established.

func (SessionCreatedEvent) EventType

func (e SessionCreatedEvent) EventType() string

func (SessionCreatedEvent) OccurredAt

func (e SessionCreatedEvent) OccurredAt() time.Time

type SessionData

type SessionData struct {
	// Email is the OAuth-authenticated email that owns the session.
	Email string
	// AccessToken is the Kite Connect access token value. The domain entity
	// does not use it directly — included so callers that need to pass the
	// session through to a broker client retain one-stop access.
	AccessToken string
	// IssuedAt is the wall-clock time the token was obtained / stored.
	// Must be in a timezone that can be normalised to IST for the 06:00 rule.
	IssuedAt time.Time
}

SessionData is the flat DTO view of a Kite session. Matches the relevant subset of kc.KiteTokenEntry; additional fields (UserID, UserName) are irrelevant to expiry reasoning and are omitted here.

type SessionID

type SessionID struct {
	// contains filtered or unexported fields
}

SessionID is the value object for an MCP session identifier. It is always non-empty — the NewSessionID constructor is the only way to produce a valid one. Callers that receive a zero-value SessionID (e.g., var id SessionID) can detect it via IsValid().

func NewSessionID

func NewSessionID(s string) (SessionID, error)

NewSessionID constructs a validated SessionID. Empty IDs are rejected so that downstream services don't have to re-check the invariant.

func (SessionID) IsValid

func (id SessionID) IsValid() bool

IsValid reports whether the SessionID carries a non-empty value. Zero-value SessionID (e.g., from var id SessionID) is invalid.

func (SessionID) String

func (id SessionID) String() string

String returns the underlying ID.

type SessionInvalidatedEvent

type SessionInvalidatedEvent struct {
	SessionID string
	Reason    string // "expired" / "admin_action" / "logout"
	Timestamp time.Time
}

SessionInvalidatedEvent is emitted when an MCP session is ended (the SessionRegistry entry is evicted, cleanup hooks run). Distinct from SessionClearedEvent which keeps the session alive without broker data.

func (SessionInvalidatedEvent) EventType

func (e SessionInvalidatedEvent) EventType() string

func (SessionInvalidatedEvent) OccurredAt

func (e SessionInvalidatedEvent) OccurredAt() time.Time

type Spec

type Spec[T any] interface {
	// IsSatisfiedBy returns true if the candidate meets this specification.
	IsSatisfiedBy(candidate T) bool
	// Reason returns a human-readable explanation when IsSatisfiedBy returns false.
	// Callers may use this to build rejection messages.
	Reason() string
}

Spec is a predicate over a value of type T. Implementations encode a single business rule ("quantity is within lot limits", "price is positive", "order passes all pre-trade checks").

type TelegramChatBoundEvent

type TelegramChatBoundEvent struct {
	UserEmail string
	OldChatID int64
	NewChatID int64
	Timestamp time.Time
}

TelegramChatBoundEvent is emitted when an existing Telegram subscriber re-binds to a different chat ID (rotation) — e.g. the user lost access to the old chat or moved to a new device. OldChatID and NewChatID are both captured so projector consumers can render the rotation history without joining against a separate snapshot.

Stays silent for no-op writes (same chat ID in, same chat ID out) so the event log reflects real state transitions, not redundant setup-tool replays. Pattern mirrors TierChangedEvent's "real transitions only" contract.

Aggregate-ID rule: same as TelegramSubscribedEvent — keyed by TelegramSubscriptionAggregateID(UserEmail). The full chat-bind lifecycle for a user lives under one aggregate stream regardless of how many times the chat ID rotates.

func (TelegramChatBoundEvent) EventType

func (e TelegramChatBoundEvent) EventType() string

func (TelegramChatBoundEvent) OccurredAt

func (e TelegramChatBoundEvent) OccurredAt() time.Time

type TelegramSubscribedEvent

type TelegramSubscribedEvent struct {
	UserEmail string
	ChatID    int64
	Timestamp time.Time
}

TelegramSubscribedEvent is emitted the first time a user binds a Telegram chat ID to their account (no prior chat-ID mapping in the alerts.Store). Distinct from TelegramChatBoundEvent so auditors can tell first-time onboarding from re-binding without walking the full Telegram-subscription history.

Aggregate-ID rule: keyed by TelegramSubscriptionAggregateID(UserEmail) — one logical Telegram subscription per user. ChatID can change over time (re-bind to a new chat, e.g. user lost device); the email is the stable aggregate identifier.

func (TelegramSubscribedEvent) EventType

func (e TelegramSubscribedEvent) EventType() string

func (TelegramSubscribedEvent) OccurredAt

func (e TelegramSubscribedEvent) OccurredAt() time.Time

type TierChangedEvent

type TierChangedEvent struct {
	UserEmail string
	FromTier  int
	ToTier    int
	Amount    Money
	Reason    string
	Timestamp time.Time
}

TierChangedEvent is emitted when a user's billing subscription tier transitions — free→paid (upgrade), paid→free (cancellation), or paid→paid (cross-grade between Pro/Premium/SoloPro). Emitted from the billing.Store on every successful SetSubscription call where the effective tier differs from the prior persisted tier. Stays silent for no-op writes (same tier in, same tier out) so the audit log reflects real state transitions, not redundant webhook replays.

FromTier and ToTier are integer codes matching billing.Tier (0=Free, 1=Pro, 2=Premium, 3=SoloPro). Stored as int rather than the typed billing.Tier to keep kc/domain free of an upward dependency on kc/billing — the convention mirrors UserFrozenEvent.FrozenBy / Reason which carry semantic strings without importing their producer.

Reason tags the lifecycle narrative ("stripe_checkout", "stripe_subscription_updated", "stripe_subscription_deleted", "admin_set_billing_tier") so auditors can distinguish webhook-driven changes from operator-driven changes without joining against another table.

Amount (Money VO Slice 4) is the to-tier's canonical monthly INR amount — the same value billing.TierMonthlyINR(ToTier) returns. Free transitions surface zero Money (IsZero() = true), paid tiers surface the published list price. Audit consumers compute MRR delta by summing Amount across emitted events without a join into billing. Stays a Money value rather than a bare float64 so a future multi- currency split (USD enterprise, INR retail) trips a currency-mismatch error in any downstream Add/GreaterThan call rather than silently corrupting the figure.

func (TierChangedEvent) EventType

func (e TierChangedEvent) EventType() string

func (TierChangedEvent) OccurredAt

func (e TierChangedEvent) OccurredAt() time.Time

type TrailingStopCancelledEvent

type TrailingStopCancelledEvent struct {
	Email          string
	TrailingStopID string
	Timestamp      time.Time
}

TrailingStopCancelledEvent is emitted on successful deactivation of a trailing stop. Pairs with TrailingStopSetEvent under the same TrailingStopID for full lifecycle replay.

func (TrailingStopCancelledEvent) EventType

func (e TrailingStopCancelledEvent) EventType() string

func (TrailingStopCancelledEvent) OccurredAt

func (e TrailingStopCancelledEvent) OccurredAt() time.Time

type TrailingStopSetEvent

type TrailingStopSetEvent struct {
	Email          string
	TrailingStopID string
	Instrument     InstrumentKey
	OrderID        string  // the SL order being trailed
	Variety        string  // "regular" / "co" / etc.
	Direction      string  // "long" or "short"
	TrailAmount    float64 // absolute trail in rupees
	TrailPct       float64 // percentage trail
	CurrentStop    float64 // initial SL trigger price
	ReferencePrice float64 // HWM at activation
	Timestamp      time.Time
}

TrailingStopSetEvent is emitted on successful creation of a new trailing stop. Replaces the prior untyped appendAuxEvent payload with a stable typed schema. ReferencePrice is the HighWaterMark at activation (the price the trailing window anchors on); both TrailAmount and TrailPct are preserved verbatim because the user may set either form (the manager picks the one it sees as positive in evaluateOne).

Aggregate-ID rule: keyed by TrailingStopID via TrailingStopAggregateID — pairs with TrailingStopTriggeredEvent (per-trigger event) and TrailingStopCancelledEvent under the same key, giving the trailing stop's full set->triggers->cancel lifecycle a coherent aggregate stream.

func (TrailingStopSetEvent) EventType

func (e TrailingStopSetEvent) EventType() string

func (TrailingStopSetEvent) OccurredAt

func (e TrailingStopSetEvent) OccurredAt() time.Time

type TrailingStopTriggeredEvent

type TrailingStopTriggeredEvent struct {
	Email          string
	TrailingStopID string
	OrderID        string // the SL order being modified
	Instrument     InstrumentKey
	Direction      string // "long" or "short"
	OldStop        float64
	NewStop        float64
	HighWaterMark  float64 // best price seen since activation (post-update)
	ModifyCount    int     // total modifications fired so far (post-increment)
	Timestamp      time.Time
}

TrailingStopTriggeredEvent is emitted when a trailing-stop fires — i.e. evaluateOne in kc/alerts/trailing.go decides the SL order's trigger price needs to move (long: HWM rose enough; short: HWM fell enough) AND the post-modify Kite call succeeded. Captures the full state transition (oldStop -> newStop, ModifyCount) plus the underlying SL OrderID so a forensic walk of the SL order ID sees trailing-stop modifications inline with place/modify/cancel events.

Failures of the underlying ModifyOrder broker call don't fire this event (the trailing stop's CurrentStop wasn't actually moved on the broker side). A future TrailingStopRejectedEvent could close that loop — flagged as follow-up.

Aggregate-ID rule: keyed by TrailingStopID via TrailingStopAggregateID. The trailing-stop ID is uuid-derived (8-char prefix from kc/alerts/trailing.go Add()), stable across the trailing-stop's full lifecycle (set -> N triggers -> cancel), so projector consumers see the trigger sequence under one aggregate.

func (TrailingStopTriggeredEvent) EventType

func (e TrailingStopTriggeredEvent) EventType() string

func (TrailingStopTriggeredEvent) OccurredAt

func (e TrailingStopTriggeredEvent) OccurredAt() time.Time

type UserFrozenEvent

type UserFrozenEvent struct {
	Email     string
	FrozenBy  string // "admin", "riskguard:circuit-breaker"
	Reason    string
	Timestamp time.Time
}

UserFrozenEvent is emitted when a user's trading is frozen (manual or auto).

func (UserFrozenEvent) EventType

func (e UserFrozenEvent) EventType() string

func (UserFrozenEvent) OccurredAt

func (e UserFrozenEvent) OccurredAt() time.Time

type UserSuspendedEvent

type UserSuspendedEvent struct {
	Email     string
	By        string // admin email
	Reason    string
	Timestamp time.Time
}

UserSuspendedEvent is emitted when an admin suspends a user account.

func (UserSuspendedEvent) EventType

func (e UserSuspendedEvent) EventType() string

func (UserSuspendedEvent) OccurredAt

func (e UserSuspendedEvent) OccurredAt() time.Time

type WatchlistCreatedEvent

type WatchlistCreatedEvent struct {
	Email       string
	WatchlistID string
	Name        string
	Timestamp   time.Time
}

WatchlistCreatedEvent is emitted when a new watchlist is created.

func (WatchlistCreatedEvent) EventType

func (e WatchlistCreatedEvent) EventType() string

func (WatchlistCreatedEvent) OccurredAt

func (e WatchlistCreatedEvent) OccurredAt() time.Time

type WatchlistDeletedEvent

type WatchlistDeletedEvent struct {
	Email       string
	WatchlistID string
	Name        string // captured before deletion for audit trail
	ItemCount   int    // captured before deletion so auditors see the scope
	Timestamp   time.Time
}

WatchlistDeletedEvent is emitted when a watchlist is deleted.

func (WatchlistDeletedEvent) EventType

func (e WatchlistDeletedEvent) EventType() string

func (WatchlistDeletedEvent) OccurredAt

func (e WatchlistDeletedEvent) OccurredAt() time.Time

type WatchlistItemAddedEvent

type WatchlistItemAddedEvent struct {
	Email       string
	WatchlistID string
	Instrument  InstrumentKey
	Timestamp   time.Time
}

WatchlistItemAddedEvent is emitted when an instrument is added to a watchlist.

func (WatchlistItemAddedEvent) EventType

func (e WatchlistItemAddedEvent) EventType() string

func (WatchlistItemAddedEvent) OccurredAt

func (e WatchlistItemAddedEvent) OccurredAt() time.Time

type WatchlistItemRemovedEvent

type WatchlistItemRemovedEvent struct {
	Email       string
	WatchlistID string
	ItemID      string
	Timestamp   time.Time
}

WatchlistItemRemovedEvent is emitted when an instrument is removed from a watchlist.

func (WatchlistItemRemovedEvent) EventType

func (e WatchlistItemRemovedEvent) EventType() string

func (WatchlistItemRemovedEvent) OccurredAt

func (e WatchlistItemRemovedEvent) OccurredAt() time.Time

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL