@guiie/buda-mcp 1.3.0 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +41 -0
- package/PUBLISH_CHECKLIST.md +69 -70
- package/README.md +4 -4
- package/dist/http.js +17 -0
- package/dist/index.js +10 -0
- package/dist/tools/calculate_position_size.d.ts +48 -0
- package/dist/tools/calculate_position_size.d.ts.map +1 -0
- package/dist/tools/calculate_position_size.js +111 -0
- package/dist/tools/dead_mans_switch.d.ts +84 -0
- package/dist/tools/dead_mans_switch.d.ts.map +1 -0
- package/dist/tools/dead_mans_switch.js +236 -0
- package/dist/tools/market_sentiment.d.ts +30 -0
- package/dist/tools/market_sentiment.d.ts.map +1 -0
- package/dist/tools/market_sentiment.js +104 -0
- package/dist/tools/price_history.d.ts.map +1 -1
- package/dist/tools/price_history.js +2 -40
- package/dist/tools/simulate_order.d.ts +45 -0
- package/dist/tools/simulate_order.d.ts.map +1 -0
- package/dist/tools/simulate_order.js +139 -0
- package/dist/tools/technical_indicators.d.ts +39 -0
- package/dist/tools/technical_indicators.d.ts.map +1 -0
- package/dist/tools/technical_indicators.js +223 -0
- package/dist/types.d.ts +9 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/utils.d.ts +7 -1
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +47 -0
- package/marketplace/README.md +1 -1
- package/marketplace/claude-listing.md +35 -1
- package/marketplace/gemini-tools.json +230 -1
- package/marketplace/openapi.yaml +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
- package/src/http.ts +17 -0
- package/src/index.ts +10 -0
- package/src/tools/calculate_position_size.ts +141 -0
- package/src/tools/dead_mans_switch.ts +314 -0
- package/src/tools/market_sentiment.ts +141 -0
- package/src/tools/price_history.ts +2 -54
- package/src/tools/simulate_order.ts +182 -0
- package/src/tools/technical_indicators.ts +282 -0
- package/src/types.ts +12 -0
- package/src/utils.ts +53 -1
- package/test/run-all.ts +197 -0
- package/test/unit.ts +505 -1
package/test/unit.ts
CHANGED
|
@@ -9,9 +9,14 @@ import { MemoryCache } from "../src/cache.js";
|
|
|
9
9
|
import { validateMarketId } from "../src/validation.js";
|
|
10
10
|
import { handlePlaceOrder } from "../src/tools/place_order.js";
|
|
11
11
|
import { handleCancelOrder } from "../src/tools/cancel_order.js";
|
|
12
|
-
import { flattenAmount, getLiquidityRating } from "../src/utils.js";
|
|
12
|
+
import { flattenAmount, getLiquidityRating, aggregateTradesToCandles } from "../src/utils.js";
|
|
13
13
|
import { handleArbitrageOpportunities } from "../src/tools/arbitrage.js";
|
|
14
14
|
import { handleMarketSummary } from "../src/tools/market_summary.js";
|
|
15
|
+
import { handleSimulateOrder } from "../src/tools/simulate_order.js";
|
|
16
|
+
import { handleCalculatePositionSize } from "../src/tools/calculate_position_size.js";
|
|
17
|
+
import { handleMarketSentiment } from "../src/tools/market_sentiment.js";
|
|
18
|
+
import { handleTechnicalIndicators } from "../src/tools/technical_indicators.js";
|
|
19
|
+
import { handleScheduleCancelAll, handleRenewCancelTimer, handleDisarmCancelTimer } from "../src/tools/dead_mans_switch.js";
|
|
15
20
|
|
|
16
21
|
// ----------------------------------------------------------------
|
|
17
22
|
// Minimal test harness
|
|
@@ -655,6 +660,505 @@ await test("handleMarketSummary returns correct liquidity_rating from mocked API
|
|
|
655
660
|
}
|
|
656
661
|
});
|
|
657
662
|
|
|
663
|
+
// ----------------------------------------------------------------
|
|
664
|
+
// i. simulate_order — simulation outputs
|
|
665
|
+
// ----------------------------------------------------------------
|
|
666
|
+
|
|
667
|
+
section("i. simulate_order");
|
|
668
|
+
|
|
669
|
+
function makeMockFetchForSimulate(takerFee = "0.8"): typeof fetch {
|
|
670
|
+
const mockTicker = {
|
|
671
|
+
ticker: {
|
|
672
|
+
market_id: "BTC-CLP",
|
|
673
|
+
last_price: ["65000000", "CLP"],
|
|
674
|
+
max_bid: ["64900000", "CLP"],
|
|
675
|
+
min_ask: ["65100000", "CLP"],
|
|
676
|
+
volume: ["4.99", "BTC"],
|
|
677
|
+
price_variation_24h: "0.01",
|
|
678
|
+
price_variation_7d: "0.05",
|
|
679
|
+
},
|
|
680
|
+
};
|
|
681
|
+
const mockMarket = {
|
|
682
|
+
market: {
|
|
683
|
+
id: "btc-clp",
|
|
684
|
+
name: "BTC-CLP",
|
|
685
|
+
base_currency: "BTC",
|
|
686
|
+
quote_currency: "CLP",
|
|
687
|
+
taker_fee: takerFee,
|
|
688
|
+
maker_fee: "0.004",
|
|
689
|
+
minimum_order_amount: ["0.0001", "BTC"],
|
|
690
|
+
max_orders_per_minute: 50,
|
|
691
|
+
maker_discount_percentage: "0",
|
|
692
|
+
taker_discount_percentage: "0",
|
|
693
|
+
maker_discount_tiers: {},
|
|
694
|
+
taker_discount_tiers: {},
|
|
695
|
+
},
|
|
696
|
+
};
|
|
697
|
+
return async (url: string | URL): Promise<Response> => {
|
|
698
|
+
const urlStr = url.toString();
|
|
699
|
+
if (urlStr.includes("/ticker")) {
|
|
700
|
+
return new Response(JSON.stringify(mockTicker), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
701
|
+
}
|
|
702
|
+
return new Response(JSON.stringify(mockMarket), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
await test("market buy: estimated_fill_price = min_ask, simulation: true", async () => {
|
|
707
|
+
const savedFetch = globalThis.fetch;
|
|
708
|
+
globalThis.fetch = makeMockFetchForSimulate();
|
|
709
|
+
try {
|
|
710
|
+
const client = new BudaClient("https://www.buda.com/api/v2");
|
|
711
|
+
const cache = new MemoryCache();
|
|
712
|
+
const result = await handleSimulateOrder({ market_id: "BTC-CLP", side: "buy", amount: 0.01 }, client, cache);
|
|
713
|
+
assert(!result.isError, "should not be an error");
|
|
714
|
+
const parsed = JSON.parse(result.content[0].text) as {
|
|
715
|
+
simulation: boolean;
|
|
716
|
+
estimated_fill_price: number;
|
|
717
|
+
order_type_assumed: string;
|
|
718
|
+
fee_rate_pct: number;
|
|
719
|
+
};
|
|
720
|
+
assertEqual(parsed.simulation, true, "simulation flag must be true");
|
|
721
|
+
assertEqual(parsed.estimated_fill_price, 65100000, "market buy fills at min_ask");
|
|
722
|
+
assertEqual(parsed.order_type_assumed, "market", "order_type_assumed should be market");
|
|
723
|
+
assertEqual(parsed.fee_rate_pct, 0.8, "fee_rate_pct should be 0.8 for crypto (0.8% taker fee)");
|
|
724
|
+
} finally {
|
|
725
|
+
globalThis.fetch = savedFetch;
|
|
726
|
+
}
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
await test("market sell: estimated_fill_price = max_bid", async () => {
|
|
730
|
+
const savedFetch = globalThis.fetch;
|
|
731
|
+
globalThis.fetch = makeMockFetchForSimulate();
|
|
732
|
+
try {
|
|
733
|
+
const client = new BudaClient("https://www.buda.com/api/v2");
|
|
734
|
+
const cache = new MemoryCache();
|
|
735
|
+
const result = await handleSimulateOrder({ market_id: "BTC-CLP", side: "sell", amount: 0.01 }, client, cache);
|
|
736
|
+
assert(!result.isError, "should not be an error");
|
|
737
|
+
const parsed = JSON.parse(result.content[0].text) as { estimated_fill_price: number };
|
|
738
|
+
assertEqual(parsed.estimated_fill_price, 64900000, "market sell fills at max_bid");
|
|
739
|
+
} finally {
|
|
740
|
+
globalThis.fetch = savedFetch;
|
|
741
|
+
}
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
await test("limit order: order_type_assumed = 'limit'", async () => {
|
|
745
|
+
const savedFetch = globalThis.fetch;
|
|
746
|
+
globalThis.fetch = makeMockFetchForSimulate();
|
|
747
|
+
try {
|
|
748
|
+
const client = new BudaClient("https://www.buda.com/api/v2");
|
|
749
|
+
const cache = new MemoryCache();
|
|
750
|
+
const result = await handleSimulateOrder({ market_id: "BTC-CLP", side: "buy", amount: 0.01, price: 64000000 }, client, cache);
|
|
751
|
+
assert(!result.isError, "should not be an error");
|
|
752
|
+
const parsed = JSON.parse(result.content[0].text) as { order_type_assumed: string };
|
|
753
|
+
assertEqual(parsed.order_type_assumed, "limit", "order_type_assumed should be limit");
|
|
754
|
+
} finally {
|
|
755
|
+
globalThis.fetch = savedFetch;
|
|
756
|
+
}
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
await test("stablecoin market uses 0.5% fee", async () => {
|
|
760
|
+
const savedFetch = globalThis.fetch;
|
|
761
|
+
globalThis.fetch = makeMockFetchForSimulate("0.5");
|
|
762
|
+
try {
|
|
763
|
+
const client = new BudaClient("https://www.buda.com/api/v2");
|
|
764
|
+
const cache = new MemoryCache();
|
|
765
|
+
const result = await handleSimulateOrder({ market_id: "BTC-CLP", side: "buy", amount: 1 }, client, cache);
|
|
766
|
+
assert(!result.isError, "should not be an error");
|
|
767
|
+
const parsed = JSON.parse(result.content[0].text) as { fee_rate_pct: number };
|
|
768
|
+
assertEqual(parsed.fee_rate_pct, 0.5, "fee_rate_pct should be 0.5 for stablecoin (0.5% taker fee)");
|
|
769
|
+
} finally {
|
|
770
|
+
globalThis.fetch = savedFetch;
|
|
771
|
+
}
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
await test("simulate_order: invalid market_id returns INVALID_MARKET_ID error", async () => {
|
|
775
|
+
const fakeClient = {} as BudaClient;
|
|
776
|
+
const cache = new MemoryCache();
|
|
777
|
+
const result = await handleSimulateOrder({ market_id: "INVALID", side: "buy", amount: 1 }, fakeClient, cache);
|
|
778
|
+
assert(result.isError === true, "should be an error");
|
|
779
|
+
const parsed = JSON.parse(result.content[0].text) as { code: string };
|
|
780
|
+
assertEqual(parsed.code, "INVALID_MARKET_ID", "error code should be INVALID_MARKET_ID");
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
// ----------------------------------------------------------------
|
|
784
|
+
// j. calculate_position_size — position math
|
|
785
|
+
// ----------------------------------------------------------------
|
|
786
|
+
|
|
787
|
+
section("j. calculate_position_size");
|
|
788
|
+
|
|
789
|
+
await test("buy scenario: stop < entry, side = buy", () => {
|
|
790
|
+
const result = handleCalculatePositionSize({
|
|
791
|
+
market_id: "BTC-CLP",
|
|
792
|
+
capital: 1_000_000,
|
|
793
|
+
risk_pct: 2,
|
|
794
|
+
entry_price: 80_000_000,
|
|
795
|
+
stop_loss_price: 78_000_000,
|
|
796
|
+
});
|
|
797
|
+
assert(!result.isError, "should not be an error");
|
|
798
|
+
const parsed = JSON.parse(result.content[0].text) as {
|
|
799
|
+
side: string;
|
|
800
|
+
units: number;
|
|
801
|
+
capital_at_risk: number;
|
|
802
|
+
position_value: number;
|
|
803
|
+
fee_currency: string;
|
|
804
|
+
};
|
|
805
|
+
assertEqual(parsed.side, "buy", "side should be buy");
|
|
806
|
+
// capital_at_risk = 1_000_000 * 0.02 = 20_000; risk_per_unit = 2_000_000; units = 0.01
|
|
807
|
+
assertEqual(parsed.capital_at_risk, 20000, "capital_at_risk should be 20000");
|
|
808
|
+
assertEqual(parsed.units, 0.01, "units should be 0.01");
|
|
809
|
+
assertEqual(parsed.position_value, 800000, "position_value = 0.01 * 80_000_000");
|
|
810
|
+
assertEqual(parsed.fee_currency, "CLP", "fee_currency should be quote currency CLP");
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
await test("sell scenario: stop > entry, side = sell", () => {
|
|
814
|
+
const result = handleCalculatePositionSize({
|
|
815
|
+
market_id: "ETH-BTC",
|
|
816
|
+
capital: 1,
|
|
817
|
+
risk_pct: 1,
|
|
818
|
+
entry_price: 0.05,
|
|
819
|
+
stop_loss_price: 0.06,
|
|
820
|
+
});
|
|
821
|
+
assert(!result.isError, "should not be an error");
|
|
822
|
+
const parsed = JSON.parse(result.content[0].text) as { side: string; fee_currency: string };
|
|
823
|
+
assertEqual(parsed.side, "sell", "side should be sell");
|
|
824
|
+
assertEqual(parsed.fee_currency, "BTC", "fee_currency should be BTC");
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
await test("stop == entry returns INVALID_STOP_LOSS error", () => {
|
|
828
|
+
const result = handleCalculatePositionSize({
|
|
829
|
+
market_id: "BTC-CLP",
|
|
830
|
+
capital: 1_000_000,
|
|
831
|
+
risk_pct: 2,
|
|
832
|
+
entry_price: 80_000_000,
|
|
833
|
+
stop_loss_price: 80_000_000,
|
|
834
|
+
});
|
|
835
|
+
assert(result.isError === true, "should be an error");
|
|
836
|
+
const parsed = JSON.parse(result.content[0].text) as { code: string };
|
|
837
|
+
assertEqual(parsed.code, "INVALID_STOP_LOSS", "error code should be INVALID_STOP_LOSS");
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
await test("calculate_position_size: invalid market_id returns error", () => {
|
|
841
|
+
const result = handleCalculatePositionSize({
|
|
842
|
+
market_id: "bad",
|
|
843
|
+
capital: 100,
|
|
844
|
+
risk_pct: 1,
|
|
845
|
+
entry_price: 10,
|
|
846
|
+
stop_loss_price: 9,
|
|
847
|
+
});
|
|
848
|
+
assert(result.isError === true, "should be an error for invalid market_id");
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
// ----------------------------------------------------------------
|
|
852
|
+
// k. get_market_sentiment — scoring and labels
|
|
853
|
+
// ----------------------------------------------------------------
|
|
854
|
+
|
|
855
|
+
section("k. get_market_sentiment");
|
|
856
|
+
|
|
857
|
+
function makeMockFetchForSentiment(
|
|
858
|
+
priceVariation24h: string,
|
|
859
|
+
ask24h: string,
|
|
860
|
+
bid24h: string,
|
|
861
|
+
ask7d: string,
|
|
862
|
+
bid7d: string,
|
|
863
|
+
bid: string,
|
|
864
|
+
ask: string,
|
|
865
|
+
): typeof fetch {
|
|
866
|
+
const mockTicker = {
|
|
867
|
+
ticker: {
|
|
868
|
+
market_id: "BTC-CLP",
|
|
869
|
+
last_price: ["65000000", "CLP"],
|
|
870
|
+
max_bid: [bid, "CLP"],
|
|
871
|
+
min_ask: [ask, "CLP"],
|
|
872
|
+
volume: ["4.99", "BTC"],
|
|
873
|
+
price_variation_24h: priceVariation24h,
|
|
874
|
+
price_variation_7d: "0.05",
|
|
875
|
+
},
|
|
876
|
+
};
|
|
877
|
+
const mockVolume = {
|
|
878
|
+
volume: {
|
|
879
|
+
market_id: "BTC-CLP",
|
|
880
|
+
ask_volume_24h: [ask24h, "BTC"],
|
|
881
|
+
ask_volume_7d: [ask7d, "BTC"],
|
|
882
|
+
bid_volume_24h: [bid24h, "BTC"],
|
|
883
|
+
bid_volume_7d: [bid7d, "BTC"],
|
|
884
|
+
},
|
|
885
|
+
};
|
|
886
|
+
return async (url: string | URL): Promise<Response> => {
|
|
887
|
+
const urlStr = url.toString();
|
|
888
|
+
if (urlStr.includes("/volume")) {
|
|
889
|
+
return new Response(JSON.stringify(mockVolume), { status: 200 });
|
|
890
|
+
}
|
|
891
|
+
return new Response(JSON.stringify(mockTicker), { status: 200 });
|
|
892
|
+
};
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
await test("disclaimer is always present in sentiment output", async () => {
|
|
896
|
+
const savedFetch = globalThis.fetch;
|
|
897
|
+
globalThis.fetch = makeMockFetchForSentiment("0.01", "5", "5", "35", "35", "64900000", "65100000");
|
|
898
|
+
try {
|
|
899
|
+
const client = new BudaClient("https://www.buda.com/api/v2");
|
|
900
|
+
const cache = new MemoryCache();
|
|
901
|
+
const result = await handleMarketSentiment({ market_id: "BTC-CLP" }, client, cache);
|
|
902
|
+
assert(!result.isError, "should not be an error");
|
|
903
|
+
const parsed = JSON.parse(result.content[0].text) as { disclaimer: string };
|
|
904
|
+
assert(parsed.disclaimer.length > 0, "disclaimer should be present and non-empty");
|
|
905
|
+
} finally {
|
|
906
|
+
globalThis.fetch = savedFetch;
|
|
907
|
+
}
|
|
908
|
+
});
|
|
909
|
+
|
|
910
|
+
await test("neutral market produces label 'neutral' (score between -20 and 20)", async () => {
|
|
911
|
+
const savedFetch = globalThis.fetch;
|
|
912
|
+
// 0% price variation, volume ratio ~1 (neutral), spread at baseline
|
|
913
|
+
globalThis.fetch = makeMockFetchForSentiment("0", "5", "5", "35", "35", "64350000", "65000000");
|
|
914
|
+
try {
|
|
915
|
+
const client = new BudaClient("https://www.buda.com/api/v2");
|
|
916
|
+
const cache = new MemoryCache();
|
|
917
|
+
const result = await handleMarketSentiment({ market_id: "BTC-CLP" }, client, cache);
|
|
918
|
+
assert(!result.isError, "should not be an error");
|
|
919
|
+
const parsed = JSON.parse(result.content[0].text) as { label: string; score: number };
|
|
920
|
+
assert(
|
|
921
|
+
parsed.label === "neutral" || parsed.label === "bearish" || parsed.label === "bullish",
|
|
922
|
+
`label should be a valid sentiment: got ${parsed.label}`,
|
|
923
|
+
);
|
|
924
|
+
assert(typeof parsed.score === "number", "score should be a number");
|
|
925
|
+
} finally {
|
|
926
|
+
globalThis.fetch = savedFetch;
|
|
927
|
+
}
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
await test("strongly positive price variation produces bullish label", async () => {
|
|
931
|
+
const savedFetch = globalThis.fetch;
|
|
932
|
+
// +10% price variation → large positive price component → bullish
|
|
933
|
+
globalThis.fetch = makeMockFetchForSentiment("0.10", "10", "10", "35", "35", "64900000", "65100000");
|
|
934
|
+
try {
|
|
935
|
+
const client = new BudaClient("https://www.buda.com/api/v2");
|
|
936
|
+
const cache = new MemoryCache();
|
|
937
|
+
const result = await handleMarketSentiment({ market_id: "BTC-CLP" }, client, cache);
|
|
938
|
+
assert(!result.isError, "should not be an error");
|
|
939
|
+
const parsed = JSON.parse(result.content[0].text) as { label: string };
|
|
940
|
+
assertEqual(parsed.label, "bullish", "strong positive price change should yield bullish");
|
|
941
|
+
} finally {
|
|
942
|
+
globalThis.fetch = savedFetch;
|
|
943
|
+
}
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
await test("strongly negative price variation produces bearish label", async () => {
|
|
947
|
+
const savedFetch = globalThis.fetch;
|
|
948
|
+
// -10% price variation → bearish
|
|
949
|
+
globalThis.fetch = makeMockFetchForSentiment("-0.10", "5", "5", "35", "35", "64900000", "65100000");
|
|
950
|
+
try {
|
|
951
|
+
const client = new BudaClient("https://www.buda.com/api/v2");
|
|
952
|
+
const cache = new MemoryCache();
|
|
953
|
+
const result = await handleMarketSentiment({ market_id: "BTC-CLP" }, client, cache);
|
|
954
|
+
assert(!result.isError, "should not be an error");
|
|
955
|
+
const parsed = JSON.parse(result.content[0].text) as { label: string };
|
|
956
|
+
assertEqual(parsed.label, "bearish", "strong negative price change should yield bearish");
|
|
957
|
+
} finally {
|
|
958
|
+
globalThis.fetch = savedFetch;
|
|
959
|
+
}
|
|
960
|
+
});
|
|
961
|
+
|
|
962
|
+
await test("sentiment: invalid market_id returns error", async () => {
|
|
963
|
+
const fakeClient = {} as BudaClient;
|
|
964
|
+
const cache = new MemoryCache();
|
|
965
|
+
const result = await handleMarketSentiment({ market_id: "NOHYPHEN" }, fakeClient, cache);
|
|
966
|
+
assert(result.isError === true, "should be an error");
|
|
967
|
+
});
|
|
968
|
+
|
|
969
|
+
// ----------------------------------------------------------------
|
|
970
|
+
// l. get_technical_indicators — math and edge cases
|
|
971
|
+
// ----------------------------------------------------------------
|
|
972
|
+
|
|
973
|
+
section("l. get_technical_indicators");
|
|
974
|
+
|
|
975
|
+
await test("aggregateTradesToCandles: produces correct OHLCV from sorted trades", () => {
|
|
976
|
+
const now = Date.now();
|
|
977
|
+
const hourMs = 3600000;
|
|
978
|
+
const bucket = Math.floor(now / hourMs) * hourMs;
|
|
979
|
+
// 3 trades in the same 1h bucket
|
|
980
|
+
const entries: [string, string, string, string][] = [
|
|
981
|
+
[String(bucket + 1000), "0.1", "100", "buy"],
|
|
982
|
+
[String(bucket + 2000), "0.2", "110", "sell"],
|
|
983
|
+
[String(bucket + 3000), "0.05", "95", "buy"],
|
|
984
|
+
];
|
|
985
|
+
const candles = aggregateTradesToCandles(entries, "1h");
|
|
986
|
+
assertEqual(candles.length, 1, "should produce 1 candle");
|
|
987
|
+
const c = candles[0];
|
|
988
|
+
assertEqual(c.open, 100, "open = first trade price");
|
|
989
|
+
assertEqual(c.close, 95, "close = last trade price");
|
|
990
|
+
assertEqual(c.high, 110, "high = max trade price");
|
|
991
|
+
assertEqual(c.low, 95, "low = min trade price");
|
|
992
|
+
assertEqual(c.trade_count, 3, "trade_count = 3");
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
await test("technical indicators: insufficient candles returns warning", async () => {
|
|
996
|
+
const savedFetch = globalThis.fetch;
|
|
997
|
+
// Return only 5 trades — will produce < 50 candles
|
|
998
|
+
const now = Date.now();
|
|
999
|
+
const hourMs = 3600000;
|
|
1000
|
+
const entries = Array.from({ length: 5 }, (_, i) => {
|
|
1001
|
+
const ts = now - (50 - i) * hourMs - 1000;
|
|
1002
|
+
return [String(ts), "0.1", String(65000000 + i * 10000), "buy"];
|
|
1003
|
+
});
|
|
1004
|
+
const mockTrades = {
|
|
1005
|
+
trades: {
|
|
1006
|
+
market_id: "BTC-CLP",
|
|
1007
|
+
timestamp: String(now),
|
|
1008
|
+
last_timestamp: String(now),
|
|
1009
|
+
entries,
|
|
1010
|
+
},
|
|
1011
|
+
};
|
|
1012
|
+
globalThis.fetch = async (): Promise<Response> => {
|
|
1013
|
+
return new Response(JSON.stringify(mockTrades), { status: 200 });
|
|
1014
|
+
};
|
|
1015
|
+
try {
|
|
1016
|
+
const client = new BudaClient("https://www.buda.com/api/v2");
|
|
1017
|
+
const result = await handleTechnicalIndicators({ market_id: "BTC-CLP", period: "1h" }, client);
|
|
1018
|
+
assert(!result.isError, "should not be isError");
|
|
1019
|
+
const parsed = JSON.parse(result.content[0].text) as {
|
|
1020
|
+
warning: string;
|
|
1021
|
+
indicators: null;
|
|
1022
|
+
candles_available: number;
|
|
1023
|
+
minimum_required: number;
|
|
1024
|
+
};
|
|
1025
|
+
assertEqual(parsed.warning, "insufficient_data", "should return insufficient_data warning");
|
|
1026
|
+
assertEqual(parsed.indicators, null, "indicators should be null");
|
|
1027
|
+
assertEqual(parsed.minimum_required, 50, "minimum_required should be 50");
|
|
1028
|
+
} finally {
|
|
1029
|
+
globalThis.fetch = savedFetch;
|
|
1030
|
+
}
|
|
1031
|
+
});
|
|
1032
|
+
|
|
1033
|
+
await test("technical indicators: sufficient candles returns indicator values", async () => {
|
|
1034
|
+
const savedFetch = globalThis.fetch;
|
|
1035
|
+
// Generate 60 trades, each in its own 1h bucket, with steadily increasing prices
|
|
1036
|
+
const now = Date.now();
|
|
1037
|
+
const hourMs = 3600000;
|
|
1038
|
+
const entries = Array.from({ length: 60 }, (_, i) => {
|
|
1039
|
+
const ts = now - (60 - i) * hourMs - 1000;
|
|
1040
|
+
return [String(ts), "0.1", String(60_000_000 + i * 100_000), "buy"];
|
|
1041
|
+
});
|
|
1042
|
+
const mockTrades = {
|
|
1043
|
+
trades: {
|
|
1044
|
+
market_id: "BTC-CLP",
|
|
1045
|
+
timestamp: String(now),
|
|
1046
|
+
last_timestamp: String(now),
|
|
1047
|
+
entries,
|
|
1048
|
+
},
|
|
1049
|
+
};
|
|
1050
|
+
globalThis.fetch = async (): Promise<Response> => {
|
|
1051
|
+
return new Response(JSON.stringify(mockTrades), { status: 200 });
|
|
1052
|
+
};
|
|
1053
|
+
try {
|
|
1054
|
+
const client = new BudaClient("https://www.buda.com/api/v2");
|
|
1055
|
+
const result = await handleTechnicalIndicators({ market_id: "BTC-CLP", period: "1h" }, client);
|
|
1056
|
+
assert(!result.isError, "should not be isError");
|
|
1057
|
+
const parsed = JSON.parse(result.content[0].text) as {
|
|
1058
|
+
indicators: { rsi: number; sma_20: number; sma_50: number };
|
|
1059
|
+
signals: { rsi_signal: string };
|
|
1060
|
+
disclaimer: string;
|
|
1061
|
+
};
|
|
1062
|
+
assert(parsed.indicators !== null, "indicators should not be null");
|
|
1063
|
+
assert(typeof parsed.indicators.rsi === "number", "rsi should be a number");
|
|
1064
|
+
assert(typeof parsed.indicators.sma_20 === "number", "sma_20 should be a number");
|
|
1065
|
+
assert(typeof parsed.indicators.sma_50 === "number", "sma_50 should be a number");
|
|
1066
|
+
// Steadily increasing prices → RSI should be high (overbought)
|
|
1067
|
+
assertEqual(parsed.signals.rsi_signal, "overbought", "rising prices should yield overbought RSI");
|
|
1068
|
+
assert(parsed.disclaimer.length > 0, "disclaimer should be present");
|
|
1069
|
+
} finally {
|
|
1070
|
+
globalThis.fetch = savedFetch;
|
|
1071
|
+
}
|
|
1072
|
+
});
|
|
1073
|
+
|
|
1074
|
+
await test("technical indicators: invalid market_id returns error", async () => {
|
|
1075
|
+
const fakeClient = {} as BudaClient;
|
|
1076
|
+
const result = await handleTechnicalIndicators({ market_id: "BAD", period: "1h" }, fakeClient);
|
|
1077
|
+
assert(result.isError === true, "should be an error for invalid market_id");
|
|
1078
|
+
});
|
|
1079
|
+
|
|
1080
|
+
// ----------------------------------------------------------------
|
|
1081
|
+
// m. schedule_cancel_all (dead man's switch)
|
|
1082
|
+
// ----------------------------------------------------------------
|
|
1083
|
+
|
|
1084
|
+
section("m. schedule_cancel_all (dead man's switch)");
|
|
1085
|
+
|
|
1086
|
+
await test("schedule_cancel_all: requires CONFIRM token", async () => {
|
|
1087
|
+
const fakeClient = {} as BudaClient;
|
|
1088
|
+
const result = await handleScheduleCancelAll(
|
|
1089
|
+
{ market_id: "BTC-CLP", ttl_seconds: 30, confirmation_token: "yes" },
|
|
1090
|
+
fakeClient,
|
|
1091
|
+
);
|
|
1092
|
+
assert(result.isError === true, "should return error without CONFIRM");
|
|
1093
|
+
const parsed = JSON.parse(result.content[0].text) as { code: string };
|
|
1094
|
+
assertEqual(parsed.code, "CONFIRMATION_REQUIRED", "error code should be CONFIRMATION_REQUIRED");
|
|
1095
|
+
});
|
|
1096
|
+
|
|
1097
|
+
await test("schedule_cancel_all: invalid market_id returns error", async () => {
|
|
1098
|
+
const fakeClient = {} as BudaClient;
|
|
1099
|
+
const result = await handleScheduleCancelAll(
|
|
1100
|
+
{ market_id: "BAD", ttl_seconds: 30, confirmation_token: "CONFIRM" },
|
|
1101
|
+
fakeClient,
|
|
1102
|
+
);
|
|
1103
|
+
assert(result.isError === true, "should return error for invalid market_id");
|
|
1104
|
+
const parsed = JSON.parse(result.content[0].text) as { code: string };
|
|
1105
|
+
assertEqual(parsed.code, "INVALID_MARKET_ID", "error code should be INVALID_MARKET_ID");
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
await test("schedule_cancel_all: CONFIRM activates timer and returns expires_at", async () => {
|
|
1109
|
+
const fakeClient = {} as BudaClient;
|
|
1110
|
+
const before = Date.now();
|
|
1111
|
+
const result = await handleScheduleCancelAll(
|
|
1112
|
+
{ market_id: "BTC-USDT", ttl_seconds: 60, confirmation_token: "CONFIRM" },
|
|
1113
|
+
fakeClient,
|
|
1114
|
+
);
|
|
1115
|
+
const after = Date.now();
|
|
1116
|
+
assert(!result.isError, "should not be an error");
|
|
1117
|
+
const parsed = JSON.parse(result.content[0].text) as {
|
|
1118
|
+
active: boolean;
|
|
1119
|
+
expires_at: string;
|
|
1120
|
+
ttl_seconds: number;
|
|
1121
|
+
warning: string;
|
|
1122
|
+
};
|
|
1123
|
+
assertEqual(parsed.active, true, "active should be true");
|
|
1124
|
+
assertEqual(parsed.ttl_seconds, 60, "ttl_seconds should match");
|
|
1125
|
+
assert(parsed.warning.length > 0, "warning should be present");
|
|
1126
|
+
const expiresAt = new Date(parsed.expires_at).getTime();
|
|
1127
|
+
assert(expiresAt >= before + 60000 && expiresAt <= after + 60000, "expires_at should be ~60s from now");
|
|
1128
|
+
// Clean up timer
|
|
1129
|
+
handleDisarmCancelTimer({ market_id: "BTC-USDT" });
|
|
1130
|
+
});
|
|
1131
|
+
|
|
1132
|
+
await test("renew_cancel_timer: returns NO_ACTIVE_TIMER when no timer exists", () => {
|
|
1133
|
+
const fakeClient = {} as BudaClient;
|
|
1134
|
+
const result = handleRenewCancelTimer({ market_id: "ETH-CLP" }, fakeClient);
|
|
1135
|
+
assert(result.isError === true, "should return error if no timer active");
|
|
1136
|
+
const parsed = JSON.parse(result.content[0].text) as { code: string };
|
|
1137
|
+
assertEqual(parsed.code, "NO_ACTIVE_TIMER", "error code should be NO_ACTIVE_TIMER");
|
|
1138
|
+
});
|
|
1139
|
+
|
|
1140
|
+
await test("disarm_cancel_timer: no-op returns disarmed:false when no timer exists", () => {
|
|
1141
|
+
const result = handleDisarmCancelTimer({ market_id: "LTC-CLP" });
|
|
1142
|
+
assert(!result.isError, "disarm should not error even if no timer");
|
|
1143
|
+
const parsed = JSON.parse(result.content[0].text) as { disarmed: boolean };
|
|
1144
|
+
assertEqual(parsed.disarmed, false, "disarmed should be false when no timer existed");
|
|
1145
|
+
});
|
|
1146
|
+
|
|
1147
|
+
await test("disarm after arm: timer is cleared", async () => {
|
|
1148
|
+
const fakeClient = {} as BudaClient;
|
|
1149
|
+
await handleScheduleCancelAll(
|
|
1150
|
+
{ market_id: "BCH-CLP", ttl_seconds: 300, confirmation_token: "CONFIRM" },
|
|
1151
|
+
fakeClient,
|
|
1152
|
+
);
|
|
1153
|
+
const result = handleDisarmCancelTimer({ market_id: "BCH-CLP" });
|
|
1154
|
+
assert(!result.isError, "disarm should not error");
|
|
1155
|
+
const parsed = JSON.parse(result.content[0].text) as { disarmed: boolean };
|
|
1156
|
+
assertEqual(parsed.disarmed, true, "disarmed should be true after an active timer was cleared");
|
|
1157
|
+
// Confirm no timer remains
|
|
1158
|
+
const renewResult = handleRenewCancelTimer({ market_id: "BCH-CLP" }, fakeClient);
|
|
1159
|
+
assert(renewResult.isError === true, "should have no timer left after disarm");
|
|
1160
|
+
});
|
|
1161
|
+
|
|
658
1162
|
// ----------------------------------------------------------------
|
|
659
1163
|
// Summary
|
|
660
1164
|
// ----------------------------------------------------------------
|