统一单入口 Go 工程与前端依赖目录,增加后台供应商配置、候选审核、计划预算、成交冲正、持仓复盘和站内通知。接入策略版本、Python 回测、可配置 A 股公开行情及 zap 文件轮转日志,补充迁移、契约与验证。
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user