Checks / verify (push) Failing after 9m52s
统一单入口 Go 工程与前端依赖目录,增加后台供应商配置、候选审核、计划预算、成交冲正、持仓复盘和站内通知。接入策略版本、Python 回测、可配置 A 股公开行情及 zap 文件轮转日志,补充迁移、契约与验证。
182 lines
6.2 KiB
Go
182 lines
6.2 KiB
Go
package integration
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
|
|
"git.sundynix.cn/Blizzard/Veyra/internal/model"
|
|
"git.sundynix.cn/Blizzard/Veyra/internal/repository"
|
|
"git.sundynix.cn/Blizzard/Veyra/internal/service"
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func testTradePlans(t *testing.T, ctx context.Context, pool *pgxpool.Pool, user model.User, candidate model.CandidateRevision) {
|
|
t.Helper()
|
|
svc := service.NewTradePlanService(repository.NewTradePlanRepository(pool), true)
|
|
accounts, err := service.NewAccountService(repository.NewAccountRepository(pool)).List(ctx, user.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var account string
|
|
for _, a := range accounts {
|
|
if a.Mode == "simulation" {
|
|
account = a.ID
|
|
}
|
|
}
|
|
input := service.PlanPreviewInput{AccountID: account, CandidateRevisionID: candidate.ID, ExpectedCandidateVersion: candidate.Version, Quantity: "9000", FeeBudget: "0"}
|
|
first, err := svc.Preview(ctx, user, input)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
second, err := svc.Preview(ctx, user, input)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var wg sync.WaitGroup
|
|
results := make(chan model.TradePlan, 2)
|
|
failures := make(chan error, 2)
|
|
for _, p := range []model.PlanPreview{first, second} {
|
|
wg.Add(1)
|
|
go func(p model.PlanPreview) {
|
|
defer wg.Done()
|
|
plan, err := svc.Confirm(ctx, user, p.ID, uuid.NewString())
|
|
if err != nil {
|
|
failures <- err
|
|
} else {
|
|
results <- plan
|
|
}
|
|
}(p)
|
|
}
|
|
wg.Wait()
|
|
close(results)
|
|
close(failures)
|
|
if len(results) != 1 || len(failures) != 1 {
|
|
t.Fatal("concurrent plans overspent available cash")
|
|
}
|
|
for err := range failures {
|
|
if !errors.Is(err, apperror.ErrConflict) {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
plan := <-results
|
|
same, err := svc.Confirm(ctx, user, plan.PreviewID, uuid.NewString())
|
|
if err != nil || same.ID != plan.ID {
|
|
t.Fatal("confirmation retry created duplicate plan")
|
|
}
|
|
budget, err := svc.Budget(ctx, user, account)
|
|
if err != nil || budget.ReservedAmount != "99000.00" || budget.AvailableAmount != "1000.01" {
|
|
t.Fatalf("reservation budget: %+v %v", budget, err)
|
|
}
|
|
stranger := user
|
|
stranger.ID = uuid.NewString()
|
|
if _, err := svc.Confirm(ctx, stranger, first.ID, uuid.NewString()); !errors.Is(err, apperror.ErrNotFound) {
|
|
t.Fatal("cross-user preview confirmation")
|
|
}
|
|
if _, err := svc.Cancel(ctx, user, plan.ID, plan.Version+1, uuid.NewString()); !errors.Is(err, apperror.ErrConflict) {
|
|
t.Fatal("stale plan cancellation")
|
|
}
|
|
if _, err := svc.Cancel(ctx, user, plan.ID, plan.Version, uuid.NewString()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
budget, err = svc.Budget(ctx, user, account)
|
|
if err != nil || budget.ReservedAmount != "0.00" || budget.CashBalance != "100000.01" {
|
|
t.Fatal("plan modified cash ledger or failed to release")
|
|
}
|
|
input.Quantity = "100"
|
|
input.FeeBudget = "5.00"
|
|
expired, err := svc.Preview(ctx, user, input)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := pool.Exec(ctx, `UPDATE veyra_plan_preview SET expires_at=$2 WHERE id=$1`, expired.ID, time.Now().Add(-time.Minute)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := svc.Confirm(ctx, user, expired.ID, uuid.NewString()); !errors.Is(err, apperror.ErrConflict) {
|
|
t.Fatal("expired preview confirmed")
|
|
}
|
|
input.ExpectedCandidateVersion++
|
|
if _, err := svc.Preview(ctx, user, input); !errors.Is(err, apperror.ErrConflict) {
|
|
t.Fatal("stale candidate preview accepted")
|
|
}
|
|
input.ExpectedCandidateVersion--
|
|
active, err := svc.Preview(ctx, user, input)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := svc.Confirm(ctx, user, active.ID, uuid.NewString()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
testNotifications(t, ctx, pool, user, candidate, account)
|
|
testLinkedFills(t, ctx, pool, user, candidate, account)
|
|
}
|
|
|
|
func testLinkedFills(t *testing.T, ctx context.Context, pool *pgxpool.Pool, user model.User, candidate model.CandidateRevision, account string) {
|
|
t.Helper()
|
|
plans := service.NewTradePlanService(repository.NewTradePlanRepository(pool), true)
|
|
fills := service.NewTradeFillService(repository.NewTradeFillRepository(pool), true)
|
|
preview, err := plans.Preview(ctx, user, service.PlanPreviewInput{AccountID: account, CandidateRevisionID: candidate.ID, ExpectedCandidateVersion: candidate.Version, Quantity: "200", FeeBudget: "5"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
plan, err := plans.Confirm(ctx, user, preview.ID, uuid.NewString())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
occurred := time.Now().UTC().Add(-time.Second)
|
|
settlement := occurred.Add(time.Hour)
|
|
input := service.FillInput{AccountID: account, PlanID: &plan.ID, IdempotencyKey: uuid.NewString(), Side: "BUY", Symbol: candidate.Snapshot.Symbol, Exchange: candidate.Snapshot.Exchange, Name: candidate.Snapshot.Name, Quantity: "100", Price: "10", Fee: "2", OccurredAt: occurred, SellableAt: &settlement}
|
|
first, err := fills.Record(ctx, user, input, uuid.NewString())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
list, err := plans.List(ctx, user)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, p := range list {
|
|
if p.ID == plan.ID && (p.Status != "PART_FILLED" || p.FilledQuantity != 100 || p.ReservedAmount != "1105.00") {
|
|
t.Fatal("partial fill reservation mismatch")
|
|
}
|
|
}
|
|
input.IdempotencyKey = uuid.NewString()
|
|
input.Price = "12"
|
|
input.Fee = "3"
|
|
input.OccurredAt = time.Now().UTC()
|
|
if _, err := fills.Record(ctx, user, input, uuid.NewString()); !errors.Is(err, apperror.ErrInvalidInput) {
|
|
t.Fatal("unexplained price deviation accepted")
|
|
}
|
|
input.Note = "实际模拟成交偏离参考区间"
|
|
last, err := fills.Record(ctx, user, input, uuid.NewString())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
list, err = plans.List(ctx, user)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, p := range list {
|
|
if p.ID == plan.ID && (p.Status != "COMPLETED" || p.FilledQuantity != 200 || p.ReservedMinor != 0) {
|
|
t.Fatal("completed plan kept reservation")
|
|
}
|
|
}
|
|
if _, err := fills.Reverse(ctx, user, last.ID, "纠正偏离录入", uuid.NewString()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := fills.Reverse(ctx, user, first.ID, "纠正第一笔录入", uuid.NewString()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
list, err = plans.List(ctx, user)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, p := range list {
|
|
if p.ID == plan.ID && (p.Status != "INVALIDATED" || p.FilledQuantity != 0 || p.ReservedMinor != 0) {
|
|
t.Fatal("reversal silently reopened plan")
|
|
}
|
|
}
|
|
}
|