feat: 完成模拟交易闭环、App 与 A 股行情接入
Checks / verify (push) Failing after 9m52s

统一单入口 Go 工程与前端依赖目录,增加后台供应商配置、候选审核、计划预算、成交冲正、持仓复盘和站内通知。接入策略版本、Python 回测、可配置 A 股公开行情及 zap 文件轮转日志,补充迁移、契约与验证。
This commit is contained in:
2026-10-08 22:58:52 +08:00
parent bffdb3858b
commit 92329ddce2
145 changed files with 18593 additions and 587 deletions
+181
View File
@@ -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")
}
}
}