From a2dc1dd2f6ebc9917c304971c465f9418b369ed5 Mon Sep 17 00:00:00 2001 From: ZHU MINCHAO <48154958+MinchaoZhu@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:35:48 +0800 Subject: [PATCH] Terminal: buy-average price per coin in Poinbase and Web3; drop the price line on the Poinbase card Co-Authored-By: Claude Sonnet 5.5 --- src/Decentralized.Domain/Wallet.cs | 19 +++++++++++++++++++ .../ModFiles/Localization/en.csv | 3 +++ .../ModFiles/Localization/zh.csv | 3 +++ src/Decentralized/TerminalWindow.cs | 12 +++++++++--- tests/Decentralized.Domain.Tests/Program.cs | 14 ++++++++++++++ 5 files changed, 48 insertions(+), 3 deletions(-) diff --git a/src/Decentralized.Domain/Wallet.cs b/src/Decentralized.Domain/Wallet.cs index 0212fb3..9133c3a 100644 --- a/src/Decentralized.Domain/Wallet.cs +++ b/src/Decentralized.Domain/Wallet.cs @@ -19,11 +19,30 @@ namespace Decentralized.Domain /// 被执法标记。 public bool Flagged; + /// 买入记录:每种币买入的数量与花费(信用点等值),用来显示买入均价。卖出或转出时按比例减少。 + public Dictionary BuyQty = new Dictionary(), BuyCost = new Dictionary(); + + public void RecordBuy(string asset, double qty, double costCredits) + { + if (qty <= 0) return; + BuyQty[asset] = (BuyQty.TryGetValue(asset, out var q) ? q : 0) + qty; + BuyCost[asset] = (BuyCost.TryGetValue(asset, out var c) ? c : 0) + Math.Max(0, costCredits); + } + + /// 买入均价(信用点);没有买入记录(例如全是挖出来的)返回 null。 + public double? AvgBuy(string asset) => BuyQty.TryGetValue(asset, out var q) && q > 1e-9 ? BuyCost[asset] / q : (double?)null; + public double Get(string asset) => Balances.TryGetValue(asset, out var v) ? v : 0; public void Add(string asset, double amount) { if (amount == 0) return; + if (amount < 0 && BuyQty.TryGetValue(asset, out var bq)) + { + double before = Get(asset), keep = before > 1e-12 ? Math.Max(0, 1 + amount / before) : 0; + BuyQty[asset] = bq * keep; BuyCost[asset] *= keep; + if (BuyQty[asset] < 1e-9) { BuyQty.Remove(asset); BuyCost.Remove(asset); } + } double v = Get(asset) + amount; if (v < -1e-9) throw new InvalidOperationException($"{Address}: {asset} 余额不足"); if (Math.Abs(v) < 1e-12) Balances.Remove(asset); diff --git a/src/Decentralized/ModFiles/Localization/en.csv b/src/Decentralized/ModFiles/Localization/en.csv index 9081b60..ad4f72d 100644 --- a/src/Decentralized/ModFiles/Localization/en.csv +++ b/src/Decentralized/ModFiles/Localization/en.csv @@ -148,3 +148,6 @@ bank.rent,"Rent: {0} credits, due in {1} days" bank.crc,"Poinbase account (CRC): {0}" bank.hint,"Deposit turns credits into CRC; withdrawing turns CRC back into credits and passes compliance review." poin.account,"Poinbase account: CRC {0}, PSC {1}" +avg.line,"{0} average buy price: {1} credits" +avg.short,"avg. buy {0}" +avg.none,"—" diff --git a/src/Decentralized/ModFiles/Localization/zh.csv b/src/Decentralized/ModFiles/Localization/zh.csv index 6f85c3b..9fa0a11 100644 --- a/src/Decentralized/ModFiles/Localization/zh.csv +++ b/src/Decentralized/ModFiles/Localization/zh.csv @@ -147,3 +147,6 @@ bank.rent,"房租:{0} 信用点,{1} 天后支付" bank.crc,"Poinbase 账户(CRC):{0}" bank.hint,"存入把信用点换成 CRC,提取把 CRC 换回信用点,提取要过合规审查。" poin.account,"Poinbase 账户:CRC {0},PSC {1}" +avg.line,"{0} 买入均价:{1} 信用点" +avg.short,"买入均价 {0}" +avg.none,"—" diff --git a/src/Decentralized/TerminalWindow.cs b/src/Decentralized/TerminalWindow.cs index 9ce6825..4e05092 100644 --- a/src/Decentralized/TerminalWindow.cs +++ b/src/Decentralized/TerminalWindow.cs @@ -56,7 +56,7 @@ namespace Decentralized b.AddImage(ModInfo.Id + ":" + icon, 96f, 96f); b.AddHeader(name); b.AddLabel(line1); - b.AddLabel(line2); + if (line2 != null) b.AddLabel(line2); b.AddFlexibleSpace(); b.AddButton(L.T("home.enter"), () => Go(target)); b.End(); @@ -70,7 +70,7 @@ namespace Decentralized b.AddSpace(6f); b.BeginRow(8f, TextAnchor.UpperCenter); Entry(b, "ui_bank", L.T("entry.bank"), L.T("entry.bank.l1"), L.T("entry.bank.l2", PlayerStore.Instance.playerCash), Page.Bank); - Entry(b, "ui_poinbase", L.T("entry.poinbase"), L.T("entry.poinbase.l1"), L.T("entry.poinbase.l2", Fmt.Num(d.Market.Price)), Page.Poinbase); + Entry(b, "ui_poinbase", L.T("entry.poinbase"), L.T("entry.poinbase.l1"), null, Page.Poinbase); Entry(b, "ui_web3", L.T("entry.web3"), L.T("entry.web3.l1"), L.T("entry.web3.l2", Fmt.Credits(chain)), Page.Web3); b.End(); b.AddSpace(8f); @@ -125,6 +125,7 @@ namespace Decentralized var w = Session.Data.Hot; b.AddHeader(L.T("entry.poinbase")); b.AddLabel(L.T("poin.account", Fmt.Num(w.Get(Assets.CRC)), Fmt.Num(w.Get(Assets.PSC)))); + b.AddLabel(L.T("avg.line", Assets.PSC, AvgText(w, Assets.PSC))); b.AddLabel(L.T("trade.hint")); AmountRow(b); b.AddSeparator(); @@ -138,7 +139,7 @@ namespace Decentralized b.AddHeader(L.T("entry.web3")); if (w.Balances.Count == 0) b.AddLabel(L.T("wallet.empty")); foreach (var kv in w.Balances.OrderBy(k => k.Key)) - b.AddLabel(L.T("wallet.line", kv.Key, Fmt.Num(kv.Value), Fmt.Credits(kv.Value * Session.Price(kv.Key)))); + b.AddLabel(L.T("wallet.line", kv.Key, Fmt.Num(kv.Value), Fmt.Credits(kv.Value * Session.Price(kv.Key))) + " " + L.T("avg.short", AvgText(w, kv.Key))); b.AddLabel(L.T("wallet.total", Fmt.Credits(w.Value(Session.Price)))); if (w.Taint > 0.5) b.AddLabel(L.T("wallet.taint", Fmt.Credits(w.Taint))); b.AddSeparator(); @@ -148,6 +149,8 @@ namespace Decentralized TradeRows(b, Venue.Dex); } + private static string AvgText(Wallet w, string asset) => w.AvgBuy(asset) is double a ? Fmt.Num(a) : L.T("avg.none"); + private static void ReportPage(CustomUIBuilder b) { var r = Session.Data.LastNight; @@ -181,6 +184,7 @@ namespace Decentralized ModHelper.AddCredit(-credits, false); var w = Session.Data.Hot; w.Add(Assets.CRC, credits / Session.Data.Stable.Crc); + w.RecordBuy(Assets.CRC, credits / Session.Data.Stable.Crc, credits); w.Basis += credits; _message = L.T("msg.deposit", credits); Session.Save(); @@ -213,8 +217,10 @@ namespace Decentralized string pay = buy ? p.Quote : p.Base, get = buy ? p.Base : p.Quote; if (w.Get(pay) + 1e-9 < v) { _message = L.T("msg.balance"); Open(); return; } double got = buy ? p.BuyBase(v) : p.SellBase(v); + double spent = v * Session.Price(pay); w.Add(pay, -v); w.Add(get, got); + w.RecordBuy(get, got, spent); _message = L.T("msg.trade", Fmt.Num(v), pay, Fmt.Num(got), get); Session.Save(); Open(); diff --git a/tests/Decentralized.Domain.Tests/Program.cs b/tests/Decentralized.Domain.Tests/Program.cs index 78e66ce..20938d6 100644 --- a/tests/Decentralized.Domain.Tests/Program.cs +++ b/tests/Decentralized.Domain.Tests/Program.cs @@ -248,6 +248,20 @@ internal static class Program Check(hi.Price <= 300 + 1e-9, $"上限 3 倍:{hi.Price:0.0}"); }); + Test("钱包:买入均价按买入记录算,卖出和转出按比例减少,挖出的币不计入", () => + { + var w = new Wallet { Address = "x" }; + w.Add("PSC", 10); // 挖出的 10 个,无买入记录 + Check(w.AvgBuy("PSC") == null, "没有买入记录时没有均价"); + w.Add("PSC", 5); w.RecordBuy("PSC", 5, 400); + w.Add("PSC", 5); w.RecordBuy("PSC", 5, 600); + Check(Near(w.AvgBuy("PSC").Value, 100, 1e-9), "买 5 个花 400 + 买 5 个花 600:均价 100"); + w.Add("PSC", -10); // 卖掉一半(20 个里的 10 个) + Check(Near(w.AvgBuy("PSC").Value, 100, 1e-9) && Near(w.BuyQty["PSC"], 5, 1e-9), "卖出后均价不变,买入记录按比例减少"); + w.Add("PSC", -10); + Check(w.AvgBuy("PSC") == null, "清仓后记录清除"); + }); + Test("市场:减半抬高价格锚(逐日靠拢),减半前一周有预期行情", () => { Check(Near(Market.SupplyTarget(10, Tuning.BlockReward), 1.0, 1e-9), "减半前很远:目标因子 1");