alt/services/worker/internal/socket/live_mapping.go
toki c0db4b24c1 feat(live-trading): 계좌 동기화와 감사 추적을 추가한다
Live Trading Boundary의 남은 account-sync/audit-trail 작업을 완료해 운영자 headless workflow와 roadmap 완료 후보 상태를 함께 반영한다.
2026-06-08 11:18:41 +09:00

209 lines
6.6 KiB
Go

package socket
import (
"errors"
"fmt"
altv1 "git.toki-labs.com/toki/alt/packages/contracts/gen/go/alt/v1"
"git.toki-labs.com/toki/alt/packages/domain/market"
"git.toki-labs.com/toki/alt/packages/domain/trading"
"git.toki-labs.com/toki/alt/services/worker/internal/livetrading"
)
func accountSnapshotToProto(snap trading.AccountSnapshot) *altv1.LiveAccountSnapshot {
proto := &altv1.LiveAccountSnapshot{
AccountId: snap.AccountID,
Broker: string(snap.Broker),
SyncedAtUnixMs: snap.SyncedAt.UnixMilli(),
Stale: snap.Stale,
}
for _, cb := range snap.Cash {
proto.Cash = append(proto.Cash, &altv1.LiveCashBalance{
Currency: currencyToProto(cb.Currency),
Amount: &altv1.Decimal{Value: cb.Amount.Value},
})
}
for _, pos := range snap.Positions {
proto.Positions = append(proto.Positions, positionSnapshotToProto(pos))
}
return proto
}
func positionSnapshotToProto(pos trading.PositionSnapshot) *altv1.LivePositionSnapshot {
p := &altv1.LivePositionSnapshot{
InstrumentId: string(pos.InstrumentID),
Side: string(pos.Side),
MarketValue: &altv1.Decimal{Value: pos.MarketValue.Value},
Pnl: &altv1.Decimal{Value: pos.PnL.Value},
}
if pos.Quantity.Amount.Value != "" {
p.Quantity = &altv1.Quantity{Amount: &altv1.Decimal{Value: pos.Quantity.Amount.Value}}
}
if pos.AvgPrice.Amount.Value != "" {
p.AvgPrice = priceToProto(pos.AvgPrice)
}
if pos.CurrentPrice.Amount.Value != "" {
p.CurrentPrice = priceToProto(pos.CurrentPrice)
}
return p
}
func liveCapabilityToProto(c trading.BrokerCapability) *altv1.LiveBrokerCapability {
cap := &altv1.LiveBrokerCapability{
Broker: string(c.Broker),
SupportsAmend: c.SupportsAmend,
SupportsCancel: c.SupportsCancel,
SupportsAccount: c.SupportsAccount,
SupportsAuditTags: c.SupportsAuditTags,
CheckedAtUnixMs: c.CheckedAt.UnixMilli(),
}
for m := range c.Markets {
cap.Markets = append(cap.Markets, marketToProto(m))
}
for v := range c.Venues {
cap.Venues = append(cap.Venues, venueToProto(v))
}
for _, t := range c.OrderTypes {
cap.OrderTypes = append(cap.OrderTypes, string(t))
}
return cap
}
// protoToSubmitRequest maps a SubmitLiveOrderRequest proto onto the domain
// SubmitRequest type. The intent.type field is preserved as-is so custom
// broker order type strings are not narrowed to market/limit.
// Returns an error for unsupported limit_price currency values.
func protoToSubmitRequest(req *altv1.SubmitLiveOrderRequest) (livetrading.SubmitRequest, error) {
intent := req.GetIntent()
domainIntent := trading.OrderIntent{}
if intent != nil {
domainIntent = trading.OrderIntent{
InstrumentID: market.InstrumentID(intent.GetInstrumentId()),
Side: trading.OrderSide(intent.GetSide()),
Type: trading.OrderType(intent.GetType()),
TimeInForce: trading.OrderTimeInForce(intent.GetTimeInForce()),
}
if q := intent.GetQuantity(); q != nil {
if amt := q.GetAmount(); amt != nil {
domainIntent.Quantity = market.Quantity{Amount: market.Decimal{Value: amt.GetValue()}}
}
}
if lp := intent.GetLimitPrice(); lp != nil {
if amt := lp.GetAmount(); amt != nil {
currency, err := currencyFromProto(lp.GetCurrency())
if err != nil {
return livetrading.SubmitRequest{}, fmt.Errorf("limit_price: %w", err)
}
domainIntent.LimitPrice = market.Price{
Currency: currency,
Amount: market.Decimal{Value: amt.GetValue()},
}
}
}
if tags := intent.GetCustomTags(); len(tags) > 0 {
domainIntent.CustomTags = tags
}
}
conf := req.GetOperatorConfirmation()
domainConf := livetrading.OperatorConfirmation{}
if conf != nil {
domainConf = livetrading.OperatorConfirmation{
Confirmed: conf.GetConfirmed(),
OperatorID: conf.GetOperatorId(),
Reason: conf.GetReason(),
ConfirmedAtMs: conf.GetConfirmedAtUnixMs(),
}
}
return livetrading.SubmitRequest{
AccountID: req.GetAccountId(),
Intent: domainIntent,
Confirmation: domainConf,
IdempotencyKey: req.GetIdempotencyKey(),
}, nil
}
// liveOrderToProto converts a domain LiveOrder to the proto wire type.
func liveOrderToProto(o livetrading.LiveOrder) *altv1.LiveOrder {
proto := &altv1.LiveOrder{
Id: o.ID,
BrokerId: o.BrokerOrderID,
AccountId: o.AccountID,
InstrumentId: string(o.Intent.InstrumentID),
Side: string(o.Intent.Side),
Type: string(o.Intent.Type),
TimeInForce: string(o.Intent.TimeInForce),
Status: string(o.Status),
BrokerStatus: string(o.BrokerStatus),
RejectionReason: o.RejectionReason,
CreatedAtUnixMs: o.CreatedAt.UnixMilli(),
UpdatedAtUnixMs: o.UpdatedAt.UnixMilli(),
}
if o.Intent.Quantity.Amount.Value != "" {
proto.Quantity = &altv1.Quantity{Amount: &altv1.Decimal{Value: o.Intent.Quantity.Amount.Value}}
}
if o.Intent.LimitPrice.Amount.Value != "" {
proto.LimitPrice = priceToProto(o.Intent.LimitPrice)
}
return proto
}
// isLiveErr unwraps err and checks whether it matches target using errors.Is.
func isLiveErr(err, target error) bool {
return errors.Is(err, target)
}
func riskPolicyToProto(p trading.RiskPolicy) *altv1.LiveRiskPolicy {
proto := &altv1.LiveRiskPolicy{
MaxDailyOrders: int32(p.MaxDailyOrders),
MaxOpenOrders: int32(p.MaxOpenOrders),
AllowShortSelling: p.AllowShortSelling,
}
if len(p.MaxOrderNotionalByCurrency) > 0 {
proto.MaxOrderNotionalByCurrency = make(map[string]string, len(p.MaxOrderNotionalByCurrency))
for cur, dec := range p.MaxOrderNotionalByCurrency {
proto.MaxOrderNotionalByCurrency[string(cur)] = dec.Value
}
}
return proto
}
func killSwitchToProto(ks trading.KillSwitchState) *altv1.LiveKillSwitchState {
return &altv1.LiveKillSwitchState{
Halted: ks.Halted,
Reason: ks.Reason,
}
}
func riskDecisionToProto(d trading.RiskDecision) *altv1.LiveRiskDecision {
return &altv1.LiveRiskDecision{
Allowed: d.Allowed,
Reason: d.Reason,
}
}
func protoToAuditFilter(req *altv1.ListLiveAuditEventsRequest) trading.AuditFilter {
return trading.AuditFilter{
AccountID: req.GetAccountId(),
OrderID: req.GetOrderId(),
EventType: trading.AuditEventType(req.GetEventType()),
Limit: int(req.GetLimit()),
}
}
func auditEventToProto(ev trading.AuditEvent) *altv1.LiveAuditEvent {
return &altv1.LiveAuditEvent{
EventId: ev.EventID,
Type: string(ev.Type),
TimestampUnixMs: ev.Timestamp.UnixMilli(),
Broker: string(ev.Broker),
AccountId: ev.AccountID,
Payload: ev.Payload,
OrderId: ev.OrderID,
Status: ev.Status,
Reason: ev.Reason,
Actor: ev.Actor,
CorrelationId: ev.CorrelationID,
}
}