package socket import ( "fmt" altv1 "git.toki-labs.com/toki/alt/packages/contracts/gen/go/alt/v1" "git.toki-labs.com/toki/alt/packages/domain/backtest" "git.toki-labs.com/toki/alt/packages/domain/market" "git.toki-labs.com/toki/alt/services/worker/internal/papertrading" ) // paper_mapping.go owns paper-trading-specific conversions between the ALT // contract and the worker-owned paper runtime. As with backtest mapping, keeping // proto<->domain conversion in the worker honours the rail design where the // worker owns runtime shapes and the API stays a thin pass-through. // startPaperRequestFromProto validates an inbound StartPaperTradingRequest and // converts it into the paper service start request. Validation lives next to the // conversion so a malformed request never reaches the runtime. func startPaperRequestFromProto(req *altv1.StartPaperTradingRequest) (papertrading.StartRequest, error) { if req.GetAccountId() == "" { return papertrading.StartRequest{}, fmt.Errorf("account_id is required") } spec, err := runSpecFromProto(req.GetSpec()) if err != nil { return papertrading.StartRequest{}, err } cash, err := priceFromProto(req.GetStartingCash()) if err != nil { return papertrading.StartRequest{}, fmt.Errorf("starting_cash: %w", err) } return papertrading.StartRequest{ AccountID: backtest.PaperAccountID(req.GetAccountId()), Spec: spec, StartingCash: cash, }, nil } // priceFromProto converts a contract Price into the domain Price, requiring a // concrete currency and amount so paper cash math never runs on an empty value. func priceFromProto(price *altv1.Price) (market.Price, error) { if price == nil { return market.Price{}, fmt.Errorf("price is required") } currency, err := currencyFromProto(price.GetCurrency()) if err != nil { return market.Price{}, err } if price.GetAmount().GetValue() == "" { return market.Price{}, fmt.Errorf("amount is required") } return market.Price{ Currency: currency, Amount: market.Decimal{Value: price.GetAmount().GetValue()}, }, nil } func currencyFromProto(c altv1.Currency) (market.Currency, error) { switch c { case altv1.Currency_CURRENCY_KRW: return market.CurrencyKRW, nil case altv1.Currency_CURRENCY_USD: return market.CurrencyUSD, nil default: return "", fmt.Errorf("unsupported currency %q", c.String()) } } // paperStateToProto converts a paper service State into the contract state, // reusing the shared backtest run/position/trade/equity payload shapes. func paperStateToProto(accountID backtest.PaperAccountID, state papertrading.State) *altv1.PaperTradingState { return &altv1.PaperTradingState{ AccountId: string(accountID), Run: runToProto(state.Run), Cash: priceToProto(state.Cash), Positions: paperPositionsToProto(state.Positions), Fills: fillsToProto(state.Fills), EquityCurve: equityCurveToProto(state.EquityCurve), RiskRejections: paperRiskRejectionsToProto(state.Rejected), } } // paperRiskRejectionsToProto converts the engine's rejected orders into the // contract risk rejection summary. It uses the rejection's resolved Instrument // (always populated, even for the empty-order-id strategy bug case) so the // operator surface can distinguish a clear run from a blocked one. func paperRiskRejectionsToProto(rejected []papertrading.RejectedOrder) []*altv1.PaperRiskRejection { if len(rejected) == 0 { return nil } out := make([]*altv1.PaperRiskRejection, len(rejected)) for i, r := range rejected { out[i] = &altv1.PaperRiskRejection{ InstrumentId: string(r.Instrument), Side: string(r.Order.Side), Quantity: quantityToProto(r.Order.Quantity), Reason: r.Reason, TimestampUnixMs: timeToUnixMs(r.BarTime), } } return out } func paperPositionsToProto(positions []backtest.Position) []*altv1.BacktestPosition { if len(positions) == 0 { return nil } out := make([]*altv1.BacktestPosition, len(positions)) for i, p := range positions { out[i] = &altv1.BacktestPosition{ InstrumentId: string(p.InstrumentID), Quantity: quantityToProto(p.Quantity), LastPrice: priceToProto(p.LastPrice), } } return out } func fillsToProto(fills []backtest.Fill) []*altv1.BacktestTrade { if len(fills) == 0 { return nil } out := make([]*altv1.BacktestTrade, len(fills)) for i, f := range fills { out[i] = fillToProto(f) } return out } // fillToProto renders a single domain Fill as the shared BacktestTrade payload, // reused for paper fills and for a filled order's fill summary. func fillToProto(f backtest.Fill) *altv1.BacktestTrade { return &altv1.BacktestTrade{ InstrumentId: string(f.InstrumentID), Side: string(f.Side), Quantity: quantityToProto(f.Quantity), Price: priceToProto(f.Price), TimestampUnixMs: timeToUnixMs(f.Timestamp), } } // submitPaperOrderRequestFromProto validates an inbound SubmitPaperOrderRequest // and converts it into the paper service submit request. Validation lives next // to the conversion so a malformed order never reaches the runtime. func submitPaperOrderRequestFromProto(req *altv1.SubmitPaperOrderRequest) (papertrading.SubmitOrderRequest, error) { if req.GetAccountId() == "" { return papertrading.SubmitOrderRequest{}, fmt.Errorf("account_id is required") } intent, err := orderIntentFromProto(req.GetInstrumentId(), req.GetSide(), req.GetQuantity(), req.GetType(), req.GetLimitPrice()) if err != nil { return papertrading.SubmitOrderRequest{}, err } return papertrading.SubmitOrderRequest{ AccountID: backtest.PaperAccountID(req.GetAccountId()), Intent: intent, }, nil } // orderIntentFromProto converts the wire order fields into a domain OrderIntent. // A limit order requires a limit price; a market order ignores it. func orderIntentFromProto(instrumentID, side string, quantity *altv1.Quantity, orderType string, limitPrice *altv1.Price) (backtest.OrderIntent, error) { if instrumentID == "" { return backtest.OrderIntent{}, fmt.Errorf("instrument_id is required") } domainSide, err := orderSideFromProto(side) if err != nil { return backtest.OrderIntent{}, err } domainQuantity, err := quantityFromProto(quantity) if err != nil { return backtest.OrderIntent{}, err } domainType, err := orderTypeFromProto(orderType) if err != nil { return backtest.OrderIntent{}, err } intent := backtest.OrderIntent{ InstrumentID: market.InstrumentID(instrumentID), Side: domainSide, Quantity: domainQuantity, Type: domainType, } if domainType == backtest.OrderTypeLimit { price, err := priceFromProto(limitPrice) if err != nil { return backtest.OrderIntent{}, fmt.Errorf("limit_price: %w", err) } if err := papertrading.ValidateOrderDecimal("limit_price", price.Amount.Value, false); err != nil { return backtest.OrderIntent{}, err } intent.LimitPrice = price } return intent, nil } func orderSideFromProto(side string) (backtest.OrderSide, error) { switch side { case string(backtest.OrderSideBuy): return backtest.OrderSideBuy, nil case string(backtest.OrderSideSell): return backtest.OrderSideSell, nil default: return "", fmt.Errorf("unsupported order side %q (want \"buy\" or \"sell\")", side) } } // orderTypeFromProto maps the wire type onto the domain order type. An empty // type defaults to market, mirroring OrderIntent.OrderType. func orderTypeFromProto(orderType string) (backtest.OrderType, error) { switch orderType { case "", string(backtest.OrderTypeMarket): return backtest.OrderTypeMarket, nil case string(backtest.OrderTypeLimit): return backtest.OrderTypeLimit, nil default: return "", fmt.Errorf("unsupported order type %q (want \"market\" or \"limit\")", orderType) } } func quantityFromProto(quantity *altv1.Quantity) (market.Quantity, error) { if quantity == nil || quantity.GetAmount().GetValue() == "" { return market.Quantity{}, fmt.Errorf("quantity is required") } value := quantity.GetAmount().GetValue() if err := papertrading.ValidateOrderDecimal("quantity", value, true); err != nil { return market.Quantity{}, err } return market.Quantity{Amount: market.Decimal{Value: value}}, nil } // paperOrderToProto renders a lifecycle order onto the contract message. The // limit price is only set for limit orders and the fill summary only for filled // orders, keeping a pending/canceled order's payload minimal. func paperOrderToProto(order papertrading.PaperOrder) *altv1.PaperOrder { out := &altv1.PaperOrder{ OrderId: order.OrderID, AccountId: string(order.AccountID), InstrumentId: string(order.Intent.InstrumentID), Side: string(order.Intent.Side), Quantity: quantityToProto(order.Intent.Quantity), Type: string(order.Intent.OrderType()), Status: string(order.Status), Reason: order.Reason, CreatedAtUnixMs: timeToUnixMs(order.CreatedAt), UpdatedAtUnixMs: timeToUnixMs(order.UpdatedAt), } if order.Intent.OrderType() == backtest.OrderTypeLimit { out.LimitPrice = priceToProto(order.Intent.LimitPrice) } if order.Fill != nil { out.Fill = fillToProto(*order.Fill) } return out }