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