Checks / verify (push) Failing after 9m52s
统一单入口 Go 工程与前端依赖目录,增加后台供应商配置、候选审核、计划预算、成交冲正、持仓复盘和站内通知。接入策略版本、Python 回测、可配置 A 股公开行情及 zap 文件轮转日志,补充迁移、契约与验证。
145 lines
5.8 KiB
Go
145 lines
5.8 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 testTradeLedger(t *testing.T, ctx context.Context, pool *pgxpool.Pool, auth *service.AuthService) {
|
|
t.Helper()
|
|
user, err := auth.ProvisionUser(ctx, "ledger_user", "ledger-test-password", "Ledger", "USER")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cash := "10000.00"
|
|
account, err := service.NewAccountService(repository.NewAccountRepository(pool)).Create(ctx, user.ID, "simulation", "ledger", &cash)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
svc := service.NewTradeFillService(repository.NewTradeFillRepository(pool), true)
|
|
now := time.Now().UTC().Add(-time.Minute)
|
|
settlement := now.Add(20 * time.Second)
|
|
input := service.FillInput{AccountID: account.ID, IdempotencyKey: uuid.NewString(), Side: "BUY", Symbol: "600000", Exchange: "SSE", Name: "测试证券", Quantity: "200", Price: "10.00", Fee: "5.01", OccurredAt: now, SellableAt: &settlement}
|
|
var wg sync.WaitGroup
|
|
results := make(chan model.TradeFill, 2)
|
|
failures := make(chan error, 2)
|
|
for i := 0; i < 2; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
f, err := svc.Record(ctx, user, input, uuid.NewString())
|
|
if err != nil {
|
|
failures <- err
|
|
} else {
|
|
results <- f
|
|
}
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
close(results)
|
|
close(failures)
|
|
for err := range failures {
|
|
t.Fatal(err)
|
|
}
|
|
one, two := <-results, <-results
|
|
if one.ID != two.ID {
|
|
t.Fatal("idempotent fill duplicated")
|
|
}
|
|
input.Price = "11.00"
|
|
if _, err := svc.Record(ctx, user, input, uuid.NewString()); !errors.Is(err, apperror.ErrConflict) {
|
|
t.Fatal("same idempotency key accepted different input")
|
|
}
|
|
input.Price = "10.00"
|
|
portfolio, err := svc.Portfolio(ctx, user, account.ID)
|
|
if err != nil || portfolio.CashBalance != "7994.99" || len(portfolio.Positions) != 1 || portfolio.Positions[0].CostBasis != "2005.01" || portfolio.Positions[0].MarketValue != nil {
|
|
t.Fatalf("buy ledger %+v %v", portfolio, err)
|
|
}
|
|
stranger := user
|
|
stranger.ID = uuid.NewString()
|
|
if _, err := svc.Portfolio(ctx, stranger, account.ID); !errors.Is(err, apperror.ErrNotFound) {
|
|
t.Fatal("portfolio owner isolation")
|
|
}
|
|
sell := input
|
|
sell.Side = "SELL"
|
|
sell.SellableAt = nil
|
|
sell.IdempotencyKey = uuid.NewString()
|
|
sell.Quantity = "100"
|
|
sell.Price = "12.00"
|
|
sell.Fee = "3.00"
|
|
sell.OccurredAt = now.Add(10 * time.Second)
|
|
if _, err := svc.Record(ctx, user, sell, uuid.NewString()); !errors.Is(err, apperror.ErrConflict) {
|
|
t.Fatal("unsettled shares sold")
|
|
}
|
|
sell.OccurredAt = now.Add(30 * time.Second)
|
|
sale, err := svc.Record(ctx, user, sell, uuid.NewString())
|
|
if err != nil || sale.RealizedPnL != "194.50" {
|
|
t.Fatalf("FIFO sale %+v %v", sale, err)
|
|
}
|
|
portfolio, err = svc.Portfolio(ctx, user, account.ID)
|
|
if err != nil || portfolio.CashBalance != "9191.99" || portfolio.RealizedPnL != "194.50" || portfolio.Review.TradeCount != 2 || portfolio.Review.TotalFees != "8.01" || portfolio.Review.BuyOutflow != "2005.01" || portfolio.Review.SellInflow != "1197.00" || portfolio.Review.ProfitableSellCount != 1 || portfolio.Positions[0].CostBasis != "1002.51" || portfolio.Positions[0].Quantity != "100" {
|
|
t.Fatal("FIFO rounding or cash mismatch")
|
|
}
|
|
if _, err := svc.Reverse(ctx, user, one.ID, "非最新成交", uuid.NewString()); !errors.Is(err, apperror.ErrConflict) {
|
|
t.Fatal("dependent buy reversed")
|
|
}
|
|
// Fail the cash reversal write and assert no partial restoration survives.
|
|
if _, err := pool.Exec(ctx, `ALTER TABLE veyra_cash_entry ADD CONSTRAINT veyra_test_reverse_failure CHECK(entry_type!='REVERSAL') NOT VALID`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := svc.Reverse(ctx, user, sale.ID, "回滚检查", uuid.NewString()); err == nil {
|
|
t.Fatal("injected cash error ignored")
|
|
}
|
|
portfolio, err = svc.Portfolio(ctx, user, account.ID)
|
|
if err != nil || portfolio.Positions[0].Quantity != "100" || portfolio.CashBalance != "9191.99" {
|
|
t.Fatal("failed reversal changed positions")
|
|
}
|
|
if _, err := pool.Exec(ctx, `ALTER TABLE veyra_cash_entry DROP CONSTRAINT veyra_test_reverse_failure`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
reversed, err := svc.Reverse(ctx, user, sale.ID, "纠正测试成交", uuid.NewString())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
duplicate, err := svc.Reverse(ctx, user, sale.ID, "纠正测试成交", uuid.NewString())
|
|
if err != nil || duplicate.ID != reversed.ID {
|
|
t.Fatal("duplicate reversal applied twice")
|
|
}
|
|
portfolio, err = svc.Portfolio(ctx, user, account.ID)
|
|
if err != nil || portfolio.CashBalance != "7994.99" || portfolio.RealizedPnL != "0.00" || portfolio.Positions[0].CostBasis != "2005.01" {
|
|
t.Fatal("sell reversal restoration mismatch")
|
|
}
|
|
if _, err := svc.Reverse(ctx, user, one.ID, "纠正买入", uuid.NewString()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
portfolio, err = svc.Portfolio(ctx, user, account.ID)
|
|
if err != nil || portfolio.CashBalance != "10000.00" || len(portfolio.Positions) != 0 {
|
|
t.Fatal("buy reversal did not restore initial ledger")
|
|
}
|
|
fills, err := svc.List(ctx, user, account.ID)
|
|
if err != nil || len(fills) != 2 || !fills[0].Reversed || !fills[1].Reversed {
|
|
t.Fatal("original fill facts lost")
|
|
}
|
|
if _, err := pool.Exec(ctx, `UPDATE veyra_trade_fill SET quantity=1 WHERE id=$1`, one.ID); err == nil {
|
|
t.Fatal("posted fill mutation allowed")
|
|
}
|
|
input.IdempotencyKey = uuid.NewString()
|
|
input.Quantity = "1000000"
|
|
input.Price = "100.00"
|
|
if _, err := svc.Record(ctx, user, input, uuid.NewString()); !errors.Is(err, apperror.ErrConflict) {
|
|
t.Fatal("insufficient cash accepted")
|
|
}
|
|
var entries int
|
|
if err := pool.QueryRow(ctx, `SELECT count(*) FROM veyra_cash_entry WHERE account_id=$1`, account.ID).Scan(&entries); err != nil || entries != 5 {
|
|
t.Fatal("failed request left cash or duplicate cash entries")
|
|
}
|
|
}
|