- Archive completed subtasks (02+01_risk_command, 03+02_order_lifecycle) - Add paper_order_lifecycle test data and expected output - Update paper trading proto and regenerate code (Dart, Go) - Fix order lifecycle handling in CLI operator, API socket, worker socket - Update parser maps across CLI, API, and worker services - Update backtest and paper trading tests
254 lines
9 KiB
Go
254 lines
9 KiB
Go
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
|
|
}
|