Files
Veyra/internal/service/trade_fill.go
T
Blizzard 92329ddce2
Checks / verify (push) Failing after 9m52s
feat: 完成模拟交易闭环、App 与 A 股行情接入
统一单入口 Go 工程与前端依赖目录,增加后台供应商配置、候选审核、计划预算、成交冲正、持仓复盘和站内通知。接入策略版本、Python 回测、可配置 A 股公开行情及 zap 文件轮转日志,补充迁移、契约与验证。
2026-10-08 22:58:52 +08:00

153 lines
5.5 KiB
Go

package service
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
"git.sundynix.cn/Blizzard/Veyra/internal/model"
"github.com/google/uuid"
"regexp"
"strings"
"time"
"unicode/utf8"
)
type TradeFillRepository interface {
Record(context.Context, string, model.TradeFill, string) (model.TradeFill, error)
List(context.Context, string, string) ([]model.TradeFill, error)
Reverse(context.Context, string, string, string, string) (model.TradeReversal, error)
Portfolio(context.Context, string, string) (model.PortfolioSummary, error)
}
type TradeFillService struct {
repo TradeFillRepository
development bool
}
func NewTradeFillService(repo TradeFillRepository, development bool) *TradeFillService {
return &TradeFillService{repo, development}
}
func (s *TradeFillService) authorize(actor model.User) error {
if actor.Role != "USER" || actor.Status != "ACTIVE" {
return apperror.ErrForbidden
}
if s == nil || s.repo == nil || !s.development {
return apperror.ErrUnavailable
}
return nil
}
type FillInput struct {
AccountID string `json:"account_id"`
PlanID *string `json:"plan_id"`
IdempotencyKey string `json:"idempotency_key"`
Side string `json:"side"`
Symbol string `json:"symbol"`
Exchange string `json:"exchange"`
Name string `json:"name"`
Quantity string `json:"quantity"`
Price string `json:"price"`
Fee string `json:"fee"`
OccurredAt time.Time `json:"occurred_at"`
SellableAt *time.Time `json:"sellable_at"`
Note string `json:"note"`
}
var fillSymbolPattern = regexp.MustCompile(`^[0-9]{6}$`)
func (s *TradeFillService) Record(ctx context.Context, actor model.User, input FillInput, requestID string) (model.TradeFill, error) {
if err := s.authorize(actor); err != nil {
return model.TradeFill{}, err
}
for _, id := range []string{input.AccountID, input.IdempotencyKey, requestID} {
if _, err := uuid.Parse(id); err != nil {
return model.TradeFill{}, apperror.ErrInvalidInput
}
}
if input.PlanID != nil {
if _, err := uuid.Parse(*input.PlanID); err != nil {
return model.TradeFill{}, apperror.ErrInvalidInput
}
}
qty, err := ParseQuantity(input.Quantity)
if err != nil {
return model.TradeFill{}, err
}
price, err := model.ParseMoney(input.Price)
if err != nil || price == 0 {
return model.TradeFill{}, apperror.ErrInvalidInput
}
fee, err := model.ParseMoney(input.Fee)
if err != nil {
return model.TradeFill{}, err
}
gross := price * qty
if gross > 100000000000 || gross+fee > 100000000000 {
return model.TradeFill{}, apperror.ErrInvalidInput
}
if !fillSymbolPattern.MatchString(input.Symbol) || (input.Exchange != "SSE" && input.Exchange != "SZSE") || strings.TrimSpace(input.Name) == "" || utf8.RuneCountInString(input.Name) > 80 || utf8.RuneCountInString(input.Note) > 1000 || input.OccurredAt.IsZero() || input.OccurredAt.After(time.Now().Add(time.Minute)) {
return model.TradeFill{}, apperror.ErrInvalidInput
}
delta := gross - fee
switch input.Side {
case "BUY":
if qty%100 != 0 || input.SellableAt == nil || input.SellableAt.Before(input.OccurredAt) {
return model.TradeFill{}, apperror.ErrInvalidInput
}
delta = -gross - fee
case "SELL":
if input.SellableAt != nil || fee > gross || input.PlanID != nil {
return model.TradeFill{}, apperror.ErrInvalidInput
}
default:
return model.TradeFill{}, apperror.ErrInvalidInput
}
input.Price = model.FormatMoney(price)
input.Fee = model.FormatMoney(fee)
input.Name = strings.TrimSpace(input.Name)
input.Note = strings.TrimSpace(input.Note)
input.OccurredAt = input.OccurredAt.UTC()
if input.SellableAt != nil {
t := input.SellableAt.UTC()
input.SellableAt = &t
}
encoded, err := json.Marshal(input)
if err != nil {
return model.TradeFill{}, apperror.ErrInvalidInput
}
digest := sha256.Sum256(encoded)
fill := model.TradeFill{ID: uuid.NewString(), AccountID: input.AccountID, PlanID: input.PlanID, IdempotencyKey: input.IdempotencyKey, InputHash: hex.EncodeToString(digest[:]), Side: input.Side, Symbol: input.Symbol, Exchange: input.Exchange, Name: input.Name, Quantity: qty, PriceMinor: price, FeeMinor: fee, CashDeltaMinor: delta, OccurredAt: input.OccurredAt, SellableAt: input.SellableAt, Note: input.Note}
return s.repo.Record(ctx, actor.ID, fill, requestID)
}
func (s *TradeFillService) List(ctx context.Context, actor model.User, account string) ([]model.TradeFill, error) {
if err := s.authorize(actor); err != nil {
return nil, err
}
if _, err := uuid.Parse(account); err != nil {
return nil, apperror.ErrInvalidInput
}
return s.repo.List(ctx, actor.ID, account)
}
func (s *TradeFillService) Reverse(ctx context.Context, actor model.User, id, reason, requestID string) (model.TradeReversal, error) {
if err := s.authorize(actor); err != nil {
return model.TradeReversal{}, err
}
if _, err := uuid.Parse(id); err != nil {
return model.TradeReversal{}, apperror.ErrInvalidInput
}
if err := model.ValidateReason(reason); err != nil {
return model.TradeReversal{}, err
}
return s.repo.Reverse(ctx, actor.ID, id, reason, requestID)
}
func (s *TradeFillService) Portfolio(ctx context.Context, actor model.User, account string) (model.PortfolioSummary, error) {
if err := s.authorize(actor); err != nil {
return model.PortfolioSummary{}, err
}
if _, err := uuid.Parse(account); err != nil {
return model.PortfolioSummary{}, apperror.ErrInvalidInput
}
return s.repo.Portfolio(ctx, actor.ID, account)
}