feat(gold): remove XAU/USD fallback and use VNAppMob SJC only

This commit is contained in:
2026-06-15 11:04:06 +07:00
parent 35c719cd4b
commit c8fb143532
5 changed files with 53 additions and 126 deletions
+15 -32
View File
@@ -3,7 +3,6 @@ package gold
import (
"context"
"github.com/tiennm99/miti99bot/internal/log"
"github.com/tiennm99/miti99bot/internal/storage"
)
@@ -13,24 +12,22 @@ type sjcPriceFetcher interface {
FetchSJCPrice(ctx context.Context) (buy, sell float64, err error)
}
// compositePriceFetcher prefers VNAppMob SJC prices and falls back to the
// existing XAU/USD-derived chain when SJC is unavailable.
// compositePriceFetcher uses VNAppMob SJC prices only. If VNAppMob fails,
// the error is surfaced to the user instead of falling back to XAU/USD.
type compositePriceFetcher struct {
vnappmob sjcPriceFetcher
fallback priceFetcher
}
// NewCompositePriceFetcherFromEnv builds the production price fetcher using
// env-driven VNAppMob and fallback clients.
// only the env-driven VNAppMob client.
func NewCompositePriceFetcherFromEnv(kv storage.KVStore) priceFetcher {
return &compositePriceFetcher{
vnappmob: NewVNAppMobClientFromEnv(kv),
fallback: NewGoldPriceClientFromEnv(),
}
}
// FetchLuongPrice returns a representative VND/lượng price. It uses the SJC
// mid price when available, otherwise the XAU/USD-derived spot price.
// FetchLuongPrice returns the SJC mid price. It errors when VNAppMob is
// unavailable so callers can show a failure message.
func (f *compositePriceFetcher) FetchLuongPrice(ctx context.Context) (float64, error) {
buy, sell, err := f.FetchLuongPrices(ctx)
if err != nil {
@@ -39,36 +36,22 @@ func (f *compositePriceFetcher) FetchLuongPrice(ctx context.Context) (float64, e
return (buy + sell) / 2, nil
}
// FetchLuongPrices returns SJC buy/sell VND/lượng when VNAppMob is healthy,
// otherwise the XAU/USD-derived spot price for both sides.
// FetchLuongPrices returns SJC buy/sell VND/lượng from VNAppMob.
func (f *compositePriceFetcher) FetchLuongPrices(ctx context.Context) (float64, float64, error) {
buy, sell, err := f.vnappmob.FetchSJCPrice(ctx)
if err == nil {
return buy, sell, nil
}
log.Warn("vnappmob_sjc_failed", "err", err)
p, err := f.fallback.FetchLuongPrice(ctx)
return p, p, err
return f.vnappmob.FetchSJCPrice(ctx)
}
// FetchPrice returns a GoldPrice. When VNAppMob succeeds the struct carries
// SJC buy/sell data and Source "vnappmob-sjc"; otherwise it falls back to the
// XAU/USD chain with Source "xau-fallback".
// FetchPrice returns a GoldPrice with SJC buy/sell data and Source
// "vnappmob-sjc". It errors when VNAppMob is unavailable.
func (f *compositePriceFetcher) FetchPrice(ctx context.Context) (GoldPrice, error) {
buy, sell, err := f.vnappmob.FetchSJCPrice(ctx)
if err == nil {
mid := (buy + sell) / 2
return GoldPrice{
VNDPerLuong: mid,
Source: "vnappmob-sjc",
SJC: &SJCPrice{Buy: buy, Sell: sell},
}, nil
}
log.Warn("vnappmob_sjc_failed", "err", err)
p, err := f.fallback.FetchPrice(ctx)
if err != nil {
return GoldPrice{}, err
}
p.Source = "xau-fallback"
return p, nil
mid := (buy + sell) / 2
return GoldPrice{
VNDPerLuong: mid,
Source: "vnappmob-sjc",
SJC: &SJCPrice{Buy: buy, Sell: sell},
}, nil
}
+23 -73
View File
@@ -12,7 +12,7 @@ import (
// stubVNAppMobClient implements just enough of the VNAppMob client contract
// for composite fetcher tests.
type stubVNAppMobClient struct {
buy float64
buy float64
sell float64
err error
}
@@ -21,30 +21,9 @@ func (s *stubVNAppMobClient) FetchSJCPrice(context.Context) (float64, float64, e
return s.buy, s.sell, s.err
}
type stubFallbackFetcher struct {
price float64
err error
}
func (s *stubFallbackFetcher) FetchLuongPrice(context.Context) (float64, error) {
return s.price, s.err
}
func (s *stubFallbackFetcher) FetchLuongPrices(context.Context) (float64, float64, error) {
return s.price, s.price, s.err
}
func (s *stubFallbackFetcher) FetchPrice(context.Context) (GoldPrice, error) {
if s.err != nil {
return GoldPrice{}, s.err
}
return GoldPrice{VNDPerLuong: s.price, Source: "xau-fallback"}, nil
}
func TestCompositeFetcher_PrefersVNAppMob(t *testing.T) {
func TestCompositeFetcher_UsesVNAppMob(t *testing.T) {
f := &compositePriceFetcher{
vnappmob: &stubVNAppMobClient{buy: 90_000_000, sell: 91_000_000},
fallback: &stubFallbackFetcher{err: errors.New("fallback unavailable")},
}
p, err := f.FetchPrice(context.Background())
@@ -79,63 +58,37 @@ func TestCompositeFetcher_PrefersVNAppMob(t *testing.T) {
}
}
func TestCompositeFetcher_FallsBack(t *testing.T) {
func TestCompositeFetcher_ErrorsWhenVNAppMobFails(t *testing.T) {
wantErr := errors.New("vnappmob down")
f := &compositePriceFetcher{
vnappmob: &stubVNAppMobClient{err: errors.New("vnappmob down")},
fallback: &stubFallbackFetcher{price: 88_000_000},
vnappmob: &stubVNAppMobClient{err: wantErr},
}
p, err := f.FetchPrice(context.Background())
if err != nil {
t.Fatalf("FetchPrice: %v", err)
if _, err := f.FetchPrice(context.Background()); err == nil {
t.Fatal("FetchPrice: expected error")
}
if p.Source != "xau-fallback" {
t.Fatalf("source: got %q, want xau-fallback", p.Source)
if _, err := f.FetchLuongPrice(context.Background()); err == nil {
t.Fatal("FetchLuongPrice: expected error")
}
if p.VNDPerLuong != 88_000_000 {
t.Fatalf("VNDPerLuong: got %v, want 88000000", p.VNDPerLuong)
}
mid, err := f.FetchLuongPrice(context.Background())
if err != nil {
t.Fatalf("FetchLuongPrice: %v", err)
}
if mid != 88_000_000 {
t.Fatalf("FetchLuongPrice: got %v, want 88000000", mid)
if _, _, err := f.FetchLuongPrices(context.Background()); err == nil {
t.Fatal("FetchLuongPrices: expected error")
}
}
func TestGoldPriceLines(t *testing.T) {
t.Run("sjc", func(t *testing.T) {
lines := goldPriceLines(GoldPrice{
VNDPerLuong: 90_500_000,
Source: "vnappmob-sjc",
SJC: &SJCPrice{Buy: 90_000_000, Sell: 91_000_000},
})
want := []string{"Gold Spot Price (SJC)", "Buy:", "Sell:"}
if len(lines) != len(want) {
t.Fatalf("got %d lines, want %d: %v", len(lines), len(want), lines)
}
for i, w := range want {
if !strings.Contains(lines[i], w) {
t.Fatalf("line %d missing %q in %v", i, w, lines)
}
}
lines := goldPriceLines(GoldPrice{
VNDPerLuong: 90_500_000,
Source: "vnappmob-sjc",
SJC: &SJCPrice{Buy: 90_000_000, Sell: 91_000_000},
})
t.Run("fallback", func(t *testing.T) {
lines := goldPriceLines(GoldPrice{
XAUUSD: 3000,
USDVND: 25000,
VNDPerLuong: 90_000_000,
Source: "xau-fallback",
})
want := []string{"Gold Spot Price", "XAU:", "Rate:", "VND:"}
for i, w := range want {
if i >= len(lines) || !strings.Contains(lines[i], w) {
t.Fatalf("line %d missing %q in %v", i, w, lines)
}
want := []string{"Gold Spot Price (SJC)", "Buy:", "Sell:"}
if len(lines) != len(want) {
t.Fatalf("got %d lines, want %d: %v", len(lines), len(want), lines)
}
for i, w := range want {
if !strings.Contains(lines[i], w) {
t.Fatalf("line %d missing %q in %v", i, w, lines)
}
})
}
}
func TestNewCompositePriceFetcherFromEnv(t *testing.T) {
@@ -146,7 +99,4 @@ func TestNewCompositePriceFetcherFromEnv(t *testing.T) {
if f.vnappmob == nil {
t.Fatal("vnappmob client is nil")
}
if f.fallback == nil {
t.Fatal("fallback client is nil")
}
}
-7
View File
@@ -49,13 +49,6 @@ func FormatPnL(currentValue, invested float64) string {
return sign + FormatVND(diff) + " (" + sign + strconv.FormatFloat(pct, 'f', 2, 64) + "%)"
}
func FormatUSD(n float64) string {
if math.IsNaN(n) || math.IsInf(n, 0) {
return "invalid USD"
}
return "$" + strconv.FormatFloat(n, 'f', 2, 64)
}
func absInt64(n int64) int64 {
if n < 0 {
return -n
+3 -11
View File
@@ -32,18 +32,10 @@ func (s *state) handlePrice(ctx context.Context, b *bot.Bot, update *models.Upda
}
func goldPriceLines(p GoldPrice) []string {
if p.Source == "vnappmob-sjc" && p.SJC != nil {
return []string{
"Gold Spot Price (SJC)",
"Buy: " + FormatVND(p.SJC.Buy) + "/luong",
"Sell: " + FormatVND(p.SJC.Sell) + "/luong",
}
}
return []string{
"Gold Spot Price",
"XAU: " + FormatUSD(p.XAUUSD) + " USD/oz",
"Rate: " + FormatVND(p.USDVND) + "/USD",
"VND: " + FormatVND(p.VNDPerLuong) + "/luong",
"Gold Spot Price (SJC)",
"Buy: " + FormatVND(p.SJC.Buy) + "/luong",
"Sell: " + FormatVND(p.SJC.Sell) + "/luong",
}
}
+12 -3
View File
@@ -29,7 +29,11 @@ func (f fakePriceFetcher) FetchPrice(context.Context) (GoldPrice, error) {
if f.err != nil {
return GoldPrice{}, f.err
}
return GoldPrice{XAUUSD: 3000, USDVND: 25000, VNDPerLuong: f.price}, nil
return GoldPrice{
VNDPerLuong: f.price,
Source: "vnappmob-sjc",
SJC: &SJCPrice{Buy: f.price, Sell: f.price},
}, nil
}
func newTestState(price float64, err error) *state {
@@ -188,7 +192,12 @@ func (f spreadPriceFetcher) FetchPrice(context.Context) (GoldPrice, error) {
if f.err != nil {
return GoldPrice{}, f.err
}
return GoldPrice{XAUUSD: 3000, USDVND: 25000, VNDPerLuong: (f.buy + f.sell) / 2}, nil
mid := (f.buy + f.sell) / 2
return GoldPrice{
VNDPerLuong: mid,
Source: "vnappmob-sjc",
SJC: &SJCPrice{Buy: f.buy, Sell: f.sell},
}, nil
}
func modDepsForTest() modules.Deps {
@@ -202,7 +211,7 @@ func TestHandlePrice(t *testing.T) {
t.Fatalf("handlePrice: %v", err)
}
text := rb.LastSent().Text()
for _, want := range []string{"Gold Spot Price", "XAU:", "USD/oz", "VND:", "/luong"} {
for _, want := range []string{"Gold Spot Price (SJC)", "Buy:", "Sell:", "/luong"} {
if !strings.Contains(text, want) {
t.Fatalf("price missing %q in %q", want, text)
}