alt/services/worker/internal/socket/paper_mapping.go
toki aaf1af6387 feat(paper-trading): paper 상태 모니터링을 연결한다
Paper trading readiness에서 API/worker/CLI가 같은 protobuf 계약으로 paper state를 시작하고 조회할 수 있어야 한다. Headless 운영 경로를 먼저 닫기 위해 contract, worker runtime, API forwarding, CLI scenario, client parser map과 검증 artifact를 함께 반영한다.
2026-06-05 15:14:41 +09:00

112 lines
3.7 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),
}
}
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] = &altv1.BacktestTrade{
InstrumentId: string(f.InstrumentID),
Side: string(f.Side),
Quantity: quantityToProto(f.Quantity),
Price: priceToProto(f.Price),
TimestampUnixMs: timeToUnixMs(f.Timestamp),
}
}
return out
}