Checks / verify (push) Failing after 9m52s
统一单入口 Go 工程与前端依赖目录,增加后台供应商配置、候选审核、计划预算、成交冲正、持仓复盘和站内通知。接入策略版本、Python 回测、可配置 A 股公开行情及 zap 文件轮转日志,补充迁移、契约与验证。
153 lines
5.5 KiB
Go
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)
|
|
}
|