mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-08-05 16:25:15 +00:00
feat(gold): remove XAU/USD fallback and use VNAppMob SJC only
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user