diff --git a/CHANGELOG.md b/CHANGELOG.md
index 65938a1..0a74bd1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,12 @@
### 第六阶段:高收益与高风险(进行中)
+- Poinbase 永续合约(Poinbase 页"永续"子页):PSC-PERP 最高 5 倍、单仓名义上限 20000,SPARK-PERP 最高 3 倍、上限 5000;保证金用 Poinbase 账户的 CRC,一个品种一个净仓位(同品种不能多空同开),托管账户不用签名。成交价按名义价值占现货池深的比例滑点(深度倍数 3:名义 5000、池深 5 万时单边约 3.3%),开平仓各收 0.1%。
+- 标记价 = 当日均价(开盘价与收盘价的平均,SPARK 取当天价),强平只在夜间检查:权益低于名义价值的 10% 时强平,保证金没了,剩余权益(扣 2% 强平费后)进保险基金。每晚资金费 = 0.1 × (标记价 / 7 日均价 − 1),上下限 ±0.3%,价格偏高时多头付空头。开仓时按现价算权益若已低于强平线再加 2%,直接拒绝(滑点太大,开了就是被强平)。
+- 保险基金初始 10000,每晚 +150,手续费的 30% 进基金;NPC 在大行情(标记价日涨跌超过 6%)里穿仓会按涨跌幅的平方抽走基金(SPARK 崩盘能抽干)。基金见底时盈利仓位被自动减仓,少拿一半盈利。强平穿仓的缺口先由基金垫,不够的记成玩家的欠款。
+- 欠款每天计息 2%,每晚先用 Poinbase 账户里的 CRC 还;有欠款不能开仓、不能出金;7 天没还清,用游戏自己的没收接口(`FindRepoItems` / `GetAllRepoEligibleItems`,和房贷逾期同一套,不碰放贷人 Lou)没收店里物品抵债,抵不够的核销,永续账户拉黑 30 天。污点随保证金进仓位,平仓时按剩下的比例退回,盈利是干净的新币。
+- 校准(ops `research/perpsim`,真实 Market 和 PerpBook,120 天,起始 3000):没有滑点时"看 7 日均线反向做"+75%、"跟着资金费走"+46%、知道价格锚的上限 +148%——PSC 每晚向锚回归 15% 的规律被杠杆放大,资金费在这个方向上反而补贴了回归的一边(价格偏高时空头收钱)。加滑点后深度倍数 3:看均线 +19%、跟着资金费 +26%、上限 +88%,随机交易 −66%、5 倍死扛 −72%;资金费系数 0.03–0.25 几乎没有影响。
+- 游戏内验证:PSC 5× 满额(名义 20000,滑点 13%)开仓当场强平价 101 高于现价 100,因此加了开仓校验;SPARK 多头强平后日志"SPARK 多被强平,保证金 1,666 没了";砸 SPARK 两次、提光账户现金后:欠款 2,627,每天 +2%(2,679 … 2,958),第 7 天夜里没收物品抵 3,018,欠款清零;PSC 强平的剩余权益进基金,基金见底时盈利仓位被自动减仓的路径只有单元测试。没有在游戏里验证:没收单物品(没在探查里找到,可能柜台满了)、资金费真实收付、拉黑 30 天。
- CRA 质押(Web3 页"质押"子页):把 CRA 质押进革命军质押池,日息 0.5%(补贴池告急降到 0.2%),利息并入本金;取出要先申请,4 天后自动回到质押时的钱包,等待期间不计息。每个钱包一个仓位,污点与成本随币存入、按比例随币取出;仓位和解锁队列在质押页列出。质押、申请取出都是合约调用,要在钱包上签名;申请取出不花币,手续费用钱包里价值最高的币付。补贴池按玩家质押量一并消耗。
- 预兆新闻:螺旋预兆出现的那晚上报纸("质押池传出异动"),补贴池告急降息时再上一条。
- 平衡代币 SPARK:起始价 10 信用点,日波动 8%,每晚向 10 回归 3%,范围 3–30;革命军袭击上层 +15%,下层动荡 −10%;CRA 死亡螺旋三天里按螺旋前价格的 ×0.8 / ×0.3 / ×0.05 暴跌。可以和 CRA 按价格互相铸销,手续费 0.3%,也是合约调用。钱包的币种里多了 SPARK。
diff --git a/src/Decentralized.Domain/Perps.cs b/src/Decentralized.Domain/Perps.cs
new file mode 100644
index 0000000..e3bbe66
--- /dev/null
+++ b/src/Decentralized.Domain/Perps.cs
@@ -0,0 +1,228 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Decentralized.Domain
+{
+ /// 永续合约的一个净仓位。Size 是标的数量,Margin 是保证金(CRC,信用点等值),Taint / Basis 是存入时带来的污点与成本。
+ public sealed class PerpPosition
+ {
+ public string Product;
+ public bool Long;
+ public double Size, Entry, Margin, Taint, Basis;
+
+ public double Notional(double price) => Size * price;
+ public double Pnl(double price) => (Long ? 1 : -1) * Size * (price - Entry);
+ public double Equity(double price) => Margin + Pnl(price);
+ public double Leverage => Margin > 0 ? Size * Entry / Margin : 0;
+
+ /// 强平价:权益跌到名义价值的维持比例时。
+ public double LiqPrice => Long
+ ? Math.Max(0, (Size * Entry - Margin) / (Size * (1 - Tuning.PerpMaint)))
+ : (Margin + Size * Entry) / (Size * (1 + Tuning.PerpMaint));
+ }
+
+ public enum PerpResult { Ok, Banned, Debt, Leverage, Size, Funds, Opposite, NoPosition, Margin, Thin }
+
+ /// 夜间发生的事:强平、自动减仓、催收没收、核销。
+ public sealed class PerpEvent
+ {
+ public string Kind; // liq / adl / seize / writeoff / interest
+ public string Product;
+ public bool Long;
+ public double Amount; // 损失、减记、或欠款(信用点)
+ }
+
+ ///
+ /// Poinbase 永续合约(PSC-PERP、SPARK-PERP):保证金用 Poinbase 账户里的 CRC,一个品种一个净仓位。
+ /// 标记价是当日均价,强平只在夜间检查(白天插针不触发)。强平后的剩余权益进保险基金,权益为负先由基金垫付,
+ /// 基金不够的缺口记成玩家的欠款;NPC 在大行情里穿仓会消耗基金,基金见底时盈利的仓位被自动减仓(盈利打折)。
+ /// 欠款每天计息,每晚先用账户里的 CRC 还;逾期没还清由接入层没收物品抵债,剩下的核销并拉黑永续账户一段时间。
+ ///
+ public sealed class PerpBook
+ {
+ public List Positions = new List();
+ public double InsuranceFund = Tuning.PerpFund0;
+ public double Debt;
+ public int DebtSince = -1, BannedUntil = -1;
+ public Dictionary> Marks = new Dictionary>();
+ public Dictionary Funding = new Dictionary();
+ /// 资金费系数(费率 = 系数 × 标记价偏离 7 日均价的比例)。默认取配置,模拟里用来校准。
+ public double FundCoef = Tuning.PerpFundCoef;
+ /// 成交深度倍数(滑点 = 名义价值 / (倍数 × 池深)),模拟里用来校准。
+ public double DepthMult = Tuning.PerpDepthMult;
+
+ /// 成交价:买入(开多、平空)往上滑,卖出往下滑,滑点随名义价值占现货池深的比例增大,封顶 50%。
+ public double Exec(double price, double notional, bool buy, int day)
+ {
+ double slip = Math.Min(0.5, notional / (DepthMult * Tuning.PoolDepth(day)));
+ return price * (1 + (buy ? slip : -slip));
+ }
+
+ public static readonly string[] Products = { "PSC", "SPARK" };
+ public static int MaxLeverage(string product) => product == "SPARK" ? Tuning.PerpSparkLev : Tuning.PerpPscLev;
+ public static double MaxNotional(string product) => product == "SPARK" ? Tuning.PerpSparkCap : Tuning.PerpPscCap;
+
+ public PerpPosition Of(string product) => Positions.Find(p => p.Product == product);
+ public bool Banned(int day) => day <= BannedUntil;
+ public double Rate(string product) => Funding.TryGetValue(product, out var r) ? r : 0;
+
+ private static double Share(Wallet acct, double amount, Func price)
+ {
+ double total = acct.Value(price);
+ return total > 0 ? Math.Min(1, amount * price(Assets.CRC) / total) : 0;
+ }
+
+ /// 开仓或加仓(同方向):保证金 + 手续费从账户的 CRC 里扣,污点与成本按保证金的价值比例随保证金进仓位。
+ public PerpResult Open(Wallet acct, string product, bool isLong, double margin, int leverage, double price, int day, Func pr)
+ {
+ if (Banned(day)) return PerpResult.Banned;
+ if (Debt > 1e-9) return PerpResult.Debt;
+ if (leverage < 1 || leverage > MaxLeverage(product)) return PerpResult.Leverage;
+ if (margin <= 0 || price <= 0) return PerpResult.Margin;
+ var pos = Of(product);
+ if (pos != null && pos.Long != isLong) return PerpResult.Opposite;
+ double notional = margin * leverage, fee = notional * Tuning.PerpFee;
+ double spot = price;
+ price = Exec(price, notional, isLong, day);
+ if ((pos?.Notional(price) ?? 0) + notional > MaxNotional(product) + 1e-9) return PerpResult.Size;
+ if (acct.Avail(Assets.CRC) + 1e-9 < margin + fee) return PerpResult.Funds;
+ // 滑点太大:按现价算权益已经低于强平线(再留一点余地),开了就是被强平
+ double sizeNew = notional / price, sizeAll = (pos?.Size ?? 0) + sizeNew;
+ double entryAll = ((pos?.Size ?? 0) * (pos?.Entry ?? 0) + sizeNew * price) / sizeAll;
+ double eq0 = (pos?.Margin ?? 0) + margin + (isLong ? 1 : -1) * sizeAll * (spot - entryAll);
+ if (eq0 < (Tuning.PerpMaint + Tuning.PerpOpenBuffer) * sizeAll * spot) return PerpResult.Thin;
+ double share = Share(acct, margin, pr);
+ double taint = acct.Taint * share, basis = acct.Basis * share;
+ acct.Add(Assets.CRC, -(margin + fee)); acct.Taint -= taint; acct.Basis -= basis;
+ InsuranceFund += fee * Tuning.PerpFeeToFund;
+ if (pos == null) Positions.Add(pos = new PerpPosition { Product = product, Long = isLong, Entry = price });
+ double size = notional / price;
+ pos.Entry = (pos.Size * pos.Entry + size * price) / (pos.Size + size);
+ pos.Size += size; pos.Margin += margin; pos.Taint += taint; pos.Basis += basis;
+ return PerpResult.Ok;
+ }
+
+ /// 追加保证金(不收手续费),把强平价推远。
+ public PerpResult AddMargin(Wallet acct, string product, double amount, Func pr)
+ {
+ var pos = Of(product);
+ if (pos == null) return PerpResult.NoPosition;
+ if (amount <= 0) return PerpResult.Margin;
+ if (acct.Avail(Assets.CRC) + 1e-9 < amount) return PerpResult.Funds;
+ double share = Share(acct, amount, pr);
+ double taint = acct.Taint * share, basis = acct.Basis * share;
+ acct.Add(Assets.CRC, -amount); acct.Taint -= taint; acct.Basis -= basis;
+ pos.Margin += amount; pos.Taint += taint; pos.Basis += basis;
+ return PerpResult.Ok;
+ }
+
+ /// 平仓(按白天的现价):权益减手续费回到账户,污点按剩下的比例退回,盈利是干净的新币。权益不够付时缺口按穿仓处理。返回回到账户的 CRC。
+ public double Close(Wallet acct, string product, double price, int day, out double deficit)
+ {
+ deficit = 0;
+ var pos = Of(product);
+ if (pos == null) return 0;
+ Positions.Remove(pos);
+ price = Exec(price, pos.Notional(price), !pos.Long, day);
+ double fee = pos.Notional(price) * Tuning.PerpFee;
+ double payout = pos.Equity(price) - fee;
+ InsuranceFund += Math.Min(fee, Math.Max(0, pos.Equity(price))) * Tuning.PerpFeeToFund;
+ if (payout < 0) { deficit = -payout; Cover(deficit, day); return 0; }
+ Return(acct, pos, payout);
+ return payout;
+ }
+
+ private void Return(Wallet acct, PerpPosition pos, double payout)
+ {
+ double keep = pos.Margin > 0 ? Math.Min(1, payout / pos.Margin) : 0;
+ acct.Add(Assets.CRC, payout); acct.Taint += pos.Taint * keep; acct.Basis += pos.Basis * keep;
+ }
+
+ /// 穿仓缺口:保险基金先垫,不够的记成欠款。
+ private double Cover(double deficit, int day)
+ {
+ double take = Math.Min(InsuranceFund, deficit);
+ InsuranceFund -= take;
+ double rest = deficit - take;
+ if (rest > 1e-9) { Debt += rest; if (DebtSince < 0) DebtSince = day; }
+ return rest;
+ }
+
+ ///
+ /// 夜间结算。marks:各品种今天的标记价(日均价)。顺序:资金费 → NPC 穿仓消耗基金 → 强平 → 基金见底时自动减仓盈利仓位 →
+ /// 欠款计息并用账户 CRC 偿还 → 逾期的触发没收。
+ ///
+ public List Night(int day, IDictionary marks, Wallet acct, Func pr)
+ {
+ var evs = new List();
+ bool adl = false;
+ foreach (var kv in marks)
+ {
+ string product = kv.Key; double mark = kv.Value;
+ if (!Marks.TryGetValue(product, out var hist)) Marks[product] = hist = new List();
+ double prev = hist.Count > 0 ? hist[hist.Count - 1] : mark;
+ double rate = 0;
+ if (hist.Count >= 3) rate = Math.Max(-Tuning.PerpFundCap, Math.Min(Tuning.PerpFundCap, FundCoef * (mark / hist.Average() - 1)));
+ Funding[product] = rate;
+ hist.Add(mark); if (hist.Count > 7) hist.RemoveAt(0);
+ var pos = Of(product);
+ if (pos != null) pos.Margin -= (pos.Long ? 1 : -1) * rate * pos.Notional(mark);
+ double ret = prev > 0 ? Math.Abs(mark / prev - 1) : 0;
+ double shock = Math.Max(0, (ret - Tuning.PerpShockFree) / Tuning.PerpShockFree);
+ InsuranceFund += Tuning.PerpFundDrift - Tuning.PerpFundShock * shock * shock;
+ }
+ if (InsuranceFund < 0) { InsuranceFund = 0; adl = true; }
+ foreach (var pos in Positions.ToList())
+ {
+ if (!marks.TryGetValue(pos.Product, out var mark)) continue;
+ double eq = pos.Equity(mark), notional = pos.Notional(mark);
+ if (eq >= Tuning.PerpMaint * notional) continue;
+ Positions.Remove(pos);
+ double rem = eq - Tuning.PerpLiqFee * notional;
+ if (rem >= 0) InsuranceFund += rem; else Cover(-rem, day);
+ evs.Add(new PerpEvent { Kind = "liq", Product = pos.Product, Long = pos.Long, Amount = pos.Margin });
+ }
+ if (adl)
+ foreach (var pos in Positions.ToList())
+ {
+ if (!marks.TryGetValue(pos.Product, out var mark) || pos.Pnl(mark) <= 0) continue;
+ double pnl = pos.Pnl(mark), cut = pnl * Tuning.PerpAdlHaircut;
+ Positions.Remove(pos);
+ Return(acct, pos, pos.Margin + pnl - cut);
+ evs.Add(new PerpEvent { Kind = "adl", Product = pos.Product, Long = pos.Long, Amount = cut });
+ }
+ if (Debt > 1e-9)
+ {
+ double interest = Debt * Tuning.PerpDebtInterest;
+ Debt += interest;
+ double repay = Math.Min(Debt, acct.Avail(Assets.CRC));
+ if (repay > 1e-9)
+ {
+ double share = Share(acct, repay, pr);
+ double taint = acct.Taint * share, basis = acct.Basis * share;
+ acct.Add(Assets.CRC, -repay); acct.Taint -= taint; acct.Basis -= basis;
+ Debt -= repay;
+ evs.Add(new PerpEvent { Kind = "repaid", Amount = repay });
+ }
+ if (Debt < 1e-6) { Debt = 0; DebtSince = -1; }
+ else
+ {
+ evs.Add(new PerpEvent { Kind = "interest", Amount = Debt });
+ if (day - DebtSince >= Tuning.PerpDebtDays) evs.Add(new PerpEvent { Kind = "seize", Amount = Debt });
+ }
+ }
+ return evs;
+ }
+
+ /// 没收物品抵债之后:抵掉的价值从欠款里扣,还剩的核销并拉黑永续账户。返回核销的数额。
+ public double ApplySeizure(double value, int day)
+ {
+ Debt -= value;
+ double writtenOff = Math.Max(0, Debt);
+ if (writtenOff > 0.5) BannedUntil = day + Tuning.PerpBanDays;
+ Debt = 0; DebtSince = -1;
+ return writtenOff;
+ }
+ }
+}
diff --git a/src/Decentralized.Domain/Tuning.cs b/src/Decentralized.Domain/Tuning.cs
index 4ad92fb..bb974ff 100644
--- a/src/Decentralized.Domain/Tuning.cs
+++ b/src/Decentralized.Domain/Tuning.cs
@@ -162,6 +162,20 @@ namespace Decentralized.Domain
public const double TxPoisonChance = 0.35, TxHijackChance = 0.3, TxPhishChance = 0.05;
public const int TxPoisonDelayMax = 2;
+ /// 永续合约:开平仓手续费、强平费、维持保证金比例;保险基金初值、每晚手续费补充、NPC 大行情穿仓的冲击系数与免冲击涨跌幅、手续费进基金的比例;
+ /// 基金见底时盈利仓位自动减仓的盈利折扣;资金费系数与上下限(每晚);欠款日息、没收宽限天数、核销后拉黑天数;两个品种的最高杠杆与单仓名义价值上限。
+ public const double PerpFee = 0.001, PerpLiqFee = 0.02, PerpMaint = 0.10;
+ public const double PerpFund0 = 10000, PerpFundDrift = 150, PerpFundShock = 2000, PerpShockFree = 0.06, PerpFeeToFund = 0.3, PerpAdlHaircut = 0.5;
+ public const double PerpFundCoef = 0.10, PerpFundCap = 0.003;
+ /// 永续的成交深度占现货主池的比例:开平仓的平均滑点 = 名义价值 / (深度倍数 × 池深),和现货一样大单更贵。
+ public const double PerpDepthMult = 3.0;
+ /// 开仓时按现价算权益至少要高出强平线多少(占名义价值的比例),否则拒绝(滑点太大,开了就被强平)。
+ public const double PerpOpenBuffer = 0.02;
+ public const double PerpDebtInterest = 0.02;
+ public const int PerpDebtDays = 7, PerpBanDays = 30;
+ public const int PerpPscLev = 5, PerpSparkLev = 3;
+ public const double PerpPscCap = 20000, PerpSparkCap = 5000;
+
/// 主交易对(信用点一侧)的深度随天数增长。
public static double PoolDepth(int day) => PoolDepth0 * (1 + day / 60.0);
}
diff --git a/src/Decentralized/DealerStock.cs b/src/Decentralized/DealerStock.cs
index a55a476..fea2190 100644
--- a/src/Decentralized/DealerStock.cs
+++ b/src/Decentralized/DealerStock.cs
@@ -68,6 +68,15 @@ namespace Decentralized
Items.Give(item);
Log.Info($"买入测试:卡 #{item.uniqueId} 买前报价 {before},买下后价值 {item.unitValue},真实 {CardValue.True(Items.ToUnit(item))},说法 {(Items.GetClaim(item)?.Encode() ?? "无")}");
}));
+ ModHelper.AddDebugFunction(ModInfo.Id, ModInfo.Name, "Poinbase +5000 CRC, unlock (test)", () => Log.Guard("永续测试", () =>
+ {
+ var d = Session.Data;
+ d.Poinbase.Add(Assets.CRC, 5000); d.Poinbase.Basis += 5000;
+ if (!Unlock.IsUnlocked) ModHelper.GetNetworkUpgrade(Unlock.Id)?.Unlock();
+ Log.Info($"测试:Poinbase CRC {d.Poinbase.Get(Assets.CRC):0},解锁 {Unlock.IsUnlocked}");
+ }));
+ ModHelper.AddDebugFunction(ModInfo.Id, ModInfo.Name, "Perp fund 0 (test)", () => { Session.Data.Perps.InsuranceFund = 0; Log.Info("测试:保险基金清零"); });
+ ModHelper.AddDebugFunction(ModInfo.Id, ModInfo.Name, "SPARK x0.3 (test)", () => { var st = Session.Data.Stable; st.Spark *= 0.3; Log.Info($"测试:SPARK 价格 {st.Spark:0.00}"); });
ModHelper.AddDebugFunction(ModInfo.Id, ModInfo.Name, "CRA + PSC (test)", () => { var w = Session.Data.Hot; w.Add(Assets.CRA, 1000); w.Add(Assets.PSC, 20); w.Basis += 1000; Log.Info($"测试:{WalletWindow.NameOf(w)} CRA {w.Get(Assets.CRA):0}"); });
ModHelper.AddDebugFunction(ModInfo.Id, ModInfo.Name, "CRA spiral warning (test)", () =>
{
diff --git a/src/Decentralized/ModFiles/Localization/en.csv b/src/Decentralized/ModFiles/Localization/en.csv
index 5768a6b..c9af530 100644
--- a/src/Decentralized/ModFiles/Localization/en.csv
+++ b/src/Decentralized/ModFiles/Localization/en.csv
@@ -268,7 +268,7 @@ black.kind.Contraband.3,Contraband (high)
black.kind.Tech,High-tech (markup)
nightlog.bm.ready,"Black market: {0} arrived; a courier will come by once you open."
item.web3guide.name,Web3 Handbook
-item.web3guide.desc,"Bank: depositing turns credits into CRC in your Poinbase account; withdrawing turns CRC back into credits and goes through compliance screening, which looks at the Poinbase account's taint. Trading: buys are paid in the quote coin, sells in the traded coin. Mixer: pick a coin, amount and rounds; each round takes a fee and lowers taint, rounds equal days of waiting, and the coins come back to the same address; the more you deposit, the worse the cleaning. Hardware wallets: on-chain coins sit in PLEDGER hardware wallets, each with its own balances and taint; open and connect them on the terminal's Wallets page. The Web3 page's trading, black market and mixer use the connected wallet, and coin-paying customers and mining rewards go into it too. Payments (black market, mixer, Ponero desk, listing fees) start as an invoice on the terminal; type the payee address on the wallet and hold ✓ to sign. The last two characters of an address are a checksum, so a one-character typo is usually caught; once in a while it happens to be valid and the coins go to an address nobody uses. DEX swaps also need a signature on the wallet. Poinbase is a custodial account and can withdraw straight to the connected wallet; depositing from a wallet means sending to Poinbase's deposit address, which takes only CRC and PSC. More hardware wallets are sold by Dr. Jackson; drag one onto the trading terminal to add it, up to 5. The black market has cheaper ones. A wallet you no longer want can be destroyed on the Wallets page once its coins are moved out. Transfers pay a fee: slow, normal or fast, and gas prices rise and fall over roughly a week; when the network is congested fees rise and slow transactions may be dropped from the mempool. Check the full address before sending. Staking: CRA staked in the Revolution pool earns a daily rate that compounds into the stake; withdrawing means requesting first and waiting four days, with no interest while waiting and no way to take it early. The pool pays from a subsidy fund; when it runs low the rate is cut and word of big holders pulling out may spread. Treat that as a warning: once it collapses CRA is worthless within three days and four days of unlocking is too slow. The balance token SPARK can be minted and burned against CRA at the going price (with a fee); its price swings hard and crashes with the collapse. Staking, minting and burning are contract calls and need a signature on the wallet. Black market: coin only, no CRC, 30% above normal channels, with extra markup on high-tech goods and GPUs; the coin you pay leaves its share of taint with the seller and the goods carry no stolen tag; GPUs may be fake, inspect them yourself. Orders are cheaper than spot but take days and may be intercepted; merchants sell by type at their own price factor, 5+ items get 5% off and 10+ get 10% off, and a bulk merchant appears at random with the lowest prices but six days of waiting; escrow costs 5%, is released on delivery and refunded if the seller runs; advance release takes another 10% off, but a scammer keeps the money and only sellers with 30+ trades accept; the platform itself can rug, be taken over by enforcement, or a seller can be arrested, after which your orders get traced and crime logged; forum rumors can be warnings. An intercepted parcel sometimes turns into enforcement at your door: signing logs the crime on the spot and seizes the goods, refusing only costs the payment. Selling: on the black market's Sell tab, list contraband, high-tech goods and GPUs from storage at 0.6x to 1.8x the reference price. The higher the multiplier, the slower the sale (below 0.9x about a day, 1.0x about two, 1.8x about fourteen), and the likelier and larger the taint on the payout, which also grows with how much you sold in the last 7 days; at most 10 listings at a time, 5% listing fee, repricing a listing costs 2% and the sale speed follows the new multiplier, 8% commission, payout held 3 days before reaching your wallet; a fake card may be spotted and returned, hurting your rating; if the platform rugs, listed goods and unreleased payouts are gone. Anonymous-coin swap (Web3 trade page, needs black-market standing): Ponero is a privacy coin and swapping in wipes the taint on that value; 6% fee each way with a daily limit; coin swapped out of Ponero has no origin and cash-out screening gets stricter."
+item.web3guide.desc,"Bank: depositing turns credits into CRC in your Poinbase account; withdrawing turns CRC back into credits and goes through compliance screening, which looks at the Poinbase account's taint. Trading: buys are paid in the quote coin, sells in the traded coin. Mixer: pick a coin, amount and rounds; each round takes a fee and lowers taint, rounds equal days of waiting, and the coins come back to the same address; the more you deposit, the worse the cleaning. Hardware wallets: on-chain coins sit in PLEDGER hardware wallets, each with its own balances and taint; open and connect them on the terminal's Wallets page. The Web3 page's trading, black market and mixer use the connected wallet, and coin-paying customers and mining rewards go into it too. Payments (black market, mixer, Ponero desk, listing fees) start as an invoice on the terminal; type the payee address on the wallet and hold ✓ to sign. The last two characters of an address are a checksum, so a one-character typo is usually caught; once in a while it happens to be valid and the coins go to an address nobody uses. DEX swaps also need a signature on the wallet. Perps: the Perps tab on Poinbase uses your account CRC as margin, up to 5× on PSC and 3× on SPARK, one position per product, never long and short at once. Fills are worse than the quote (more so for bigger orders, like the spot pool) and opening and closing both cost a fee. Liquidation is checked only overnight against the day's average price: equity under 10% of notional wipes the margin. There is also a nightly funding payment; when the price is above its 7-day average, longs pay shorts. If a liquidation loses more than the margin, the insurance fund covers the gap; when it runs dry (a SPARK collapse can do that) winning positions are auto-deleveraged and give up half their profit; anything beyond that becomes your debt. Debt grows 2% a day, is repaid first from CRC in the account every night, blocks new positions and cash-outs, and after seven days items in your shop are seized to cover it; whatever is still short is written off and the perp account is banned for 30 days. Poinbase is a custodial account and can withdraw straight to the connected wallet; depositing from a wallet means sending to Poinbase's deposit address, which takes only CRC and PSC. More hardware wallets are sold by Dr. Jackson; drag one onto the trading terminal to add it, up to 5. The black market has cheaper ones. A wallet you no longer want can be destroyed on the Wallets page once its coins are moved out. Transfers pay a fee: slow, normal or fast, and gas prices rise and fall over roughly a week; when the network is congested fees rise and slow transactions may be dropped from the mempool. Check the full address before sending. Staking: CRA staked in the Revolution pool earns a daily rate that compounds into the stake; withdrawing means requesting first and waiting four days, with no interest while waiting and no way to take it early. The pool pays from a subsidy fund; when it runs low the rate is cut and word of big holders pulling out may spread. Treat that as a warning: once it collapses CRA is worthless within three days and four days of unlocking is too slow. The balance token SPARK can be minted and burned against CRA at the going price (with a fee); its price swings hard and crashes with the collapse. Staking, minting and burning are contract calls and need a signature on the wallet. Black market: coin only, no CRC, 30% above normal channels, with extra markup on high-tech goods and GPUs; the coin you pay leaves its share of taint with the seller and the goods carry no stolen tag; GPUs may be fake, inspect them yourself. Orders are cheaper than spot but take days and may be intercepted; merchants sell by type at their own price factor, 5+ items get 5% off and 10+ get 10% off, and a bulk merchant appears at random with the lowest prices but six days of waiting; escrow costs 5%, is released on delivery and refunded if the seller runs; advance release takes another 10% off, but a scammer keeps the money and only sellers with 30+ trades accept; the platform itself can rug, be taken over by enforcement, or a seller can be arrested, after which your orders get traced and crime logged; forum rumors can be warnings. An intercepted parcel sometimes turns into enforcement at your door: signing logs the crime on the spot and seizes the goods, refusing only costs the payment. Selling: on the black market's Sell tab, list contraband, high-tech goods and GPUs from storage at 0.6x to 1.8x the reference price. The higher the multiplier, the slower the sale (below 0.9x about a day, 1.0x about two, 1.8x about fourteen), and the likelier and larger the taint on the payout, which also grows with how much you sold in the last 7 days; at most 10 listings at a time, 5% listing fee, repricing a listing costs 2% and the sale speed follows the new multiplier, 8% commission, payout held 3 days before reaching your wallet; a fake card may be spotted and returned, hurting your rating; if the platform rugs, listed goods and unreleased payouts are gone. Anonymous-coin swap (Web3 trade page, needs black-market standing): Ponero is a privacy coin and swapping in wipes the taint on that value; 6% fee each way with a daily limit; coin swapped out of Ponero has no origin and cash-out screening gets stricter."
courier.odd1,...Package is here.
courier.odd2,"(His hand stays in his pocket.) Sign here."
courier.sign,Sign
@@ -477,3 +477,47 @@ news.crawarn.desc,Big holders are pulling out and the staking subsidy pool is ru
news.cralow.title,Revolution cuts staking rate
news.cralow.desc,The subsidy pool is nearly empty, and the Revolution has cut the CRA staking rate to a fraction.
stake.avail2,"SPARK {0}"
+poin.sub.spot,Spot
+poin.sub.perp,Perps
+perp.h,Perpetual futures
+perp.acct,"Margin account CRC {0}"
+perp.debt,"Debt {0}; items are seized in {1} days. No new positions or cash-outs until it is cleared."
+perp.banned,"Perp account banned for {0} more days."
+perp.none,No positions.
+perp.col.pos,Position
+perp.col.notional,Notional
+perp.col.price,Entry → price
+perp.col.pnl,PnL
+perp.col.liq,Liq. price
+perp.long,Long
+perp.short,Short
+perp.rate,"{0} price {1} funding {2}%/night"
+perp.fund,"Insurance fund {0}"
+perp.open,Open
+perp.margin,Margin (CRC)
+perp.preview,"Notional {0} fill {1}"
+perp.do.open,Open
+perp.do.add,Add margin
+perp.do.close,Close
+perp.opened,"Opened {0} {1}: margin {2} CRC, {3}×."
+perp.added,"Added {0} CRC of margin."
+perp.closed,"Closed; {0} CRC back in the account (PnL {1})."
+perp.deficit,"Equity fell short by {0}, covered by the insurance fund and debt."
+perp.err.Banned,The perp account is banned.
+perp.err.Debt,You have debt; no new positions.
+perp.err.Leverage,Leverage is above this product's limit.
+perp.err.Size,Above this product's position cap.
+perp.err.Funds,Not enough CRC in the account (the fee counts).
+perp.err.Opposite,You cannot be long and short the same product; close first.
+perp.err.NoPosition,No position.
+perp.err.Margin,Enter a margin amount.
+perp.debt.block,You owe the perps desk; no cash-outs.
+nightlog.perp.liq,"{0} was liquidated; the {1} margin is gone."
+nightlog.perp.adl,"The insurance fund ran dry; {0} was auto-deleveraged and gave up {1} of profit."
+nightlog.perp.interest,"Perp debt accrued interest; you now owe {0}."
+nightlog.perp.seize,"Overdue perp debt: seized items covered {0}."
+nightlog.perp.writeoff," {0} written off; the perp account is banned for {1} days."
+perp.slip.title,Perp debt seizure slip
+perp.preview2,"Fee {0} liq. {1}"
+nightlog.perp.repaid,"Perp deficit: {0} CRC was taken from the Poinbase account to repay it."
+perp.err.Thin,"Slippage on this order is so large it would be liquidated at once. Reduce the size or leverage."
diff --git a/src/Decentralized/ModFiles/Localization/zh.csv b/src/Decentralized/ModFiles/Localization/zh.csv
index dbcdb3d..1523119 100644
--- a/src/Decentralized/ModFiles/Localization/zh.csv
+++ b/src/Decentralized/ModFiles/Localization/zh.csv
@@ -267,7 +267,7 @@ black.kind.Contraband.3,违禁品(高)
black.kind.Tech,高科技物品(加价)
nightlog.bm.ready,"黑市:{0} 到货,开店后会有快递员上门。"
item.web3guide.name,Web3 手册
-item.web3guide.desc,"银行:存入把信用点换成 Poinbase 账户里的 CRC;提取把 CRC 换回信用点,要过合规审查,审查看 Poinbase 账户的污点。交易:买入按计价币的数量支付,卖出按所交易币的数量卖出。混币器:选币种、填数量和轮数,每轮扣手续费并降低污点,轮数即等待天数,到期提回同一个地址;存入越多,清洗越差。硬件钱包:链上的币放在 PLEDGER 硬件钱包里,每个钱包的余额和污点各算各的;在终端的钱包页打开、连接。Web3 页的交易、黑市、混币器用已连接的钱包,用币付款的顾客和挖矿收益也进它。付款(黑市、混币器、Ponero 兑换处、上架费)先在终端开账单,再在钱包里手输收款地址、按住 ✓ 签名;地址末尾两位是校验位,输错一位多半会被拦下,偶尔碰巧合法,币就转进了没人用的地址。DEX 兑换也要在钱包上签名。Poinbase 是托管账户,可以直接提到已连接的钱包;从钱包充值要转到 Poinbase 的充值地址,只收 CRC 和 PSC。更多硬件钱包向杰克逊博士购买,拖到交易终端上添加,最多 5 个;黑市上也有更便宜的。不要的钱包可以在钱包页销毁,要先把币转走。转账要付手续费,分慢、普通、快三档,gas 价格大约一周一个起落;网络拥堵时手续费变贵,慢档可能被挤出内存池。转账前核对完整地址。质押:把 CRA 质押进革命军质押池每天有日息,利息并入本金;取出要先申请,等四天才到账,等待期间没有利息,也不能提前拿。质押池靠补贴池付息,补贴池见底会降息,有时还会传出大户撤出的消息,那是预兆:一旦崩盘,三天内 CRA 就一文不值,四天的解锁期来不及。平衡代币 SPARK 可以和 CRA 按价格互相铸销(收手续费),价格波动很大,崩盘时会跟着暴跌。质押、铸销都是合约调用,要在钱包上签名。黑市:只收币,不收 CRC,价格比正常渠道贵 30%,高科技物品和显卡另有加价;付出去的币带着按比例的污点留在卖家那边,货不带赃物标签;显卡不保证真假,自己鉴定。订单比现货便宜,要等几天,路上可能被截获;商家按类型卖货,价格系数各不相同,一次订 5 件以上 95 折、10 件以上 9 折,大宗商家随机出现,价格最低但要等六天;托管多付 5% 托管费,收货后放款,卖家跑路时退款;提前放款再打九折,卖家若是骗子钱就没了,只有成交 30 单以上的卖家接受;平台自己也可能跑路,也可能被执法接管,或者卖家落网,之后你的订单会被追查、记罪证;论坛上的传闻可以当预兆。被截获的包裹有时会变成执法人员上门:签收当场记罪证并查封,拒签只损失货款。出售:在黑市的卖货页把储藏区里的违禁品、高科技物品和显卡上架,标价是参考价的 0.6 到 1.8 倍。倍率越高成交越慢(0.9 以下约一天,1.0 约两天,1.8 约十四天),货款带污点的可能性和比例也越高,近 7 天出售的量越大,污点越重;同时最多上架 10 件,上架收 5% 上架费,上架后可以改价(收 2% 改价费,标价按参考价重算,成交的快慢跟着新倍率走),成交扣 8% 抽成,货款托管 3 天后进钱包;卖假卡可能被买家看穿而退货,拉低好评;平台跑路时上架的货和未放款的货款都没了。匿名币兑换(Web3 交易页,要黑市声望):Ponero 是隐私币,兑入时这部分价值的污点被清除;兑入、兑出各收 6% 手续费,每天有额度;从 Ponero 兑出的币来路不明,出金审查会加重。"
+item.web3guide.desc,"银行:存入把信用点换成 Poinbase 账户里的 CRC;提取把 CRC 换回信用点,要过合规审查,审查看 Poinbase 账户的污点。交易:买入按计价币的数量支付,卖出按所交易币的数量卖出。混币器:选币种、填数量和轮数,每轮扣手续费并降低污点,轮数即等待天数,到期提回同一个地址;存入越多,清洗越差。硬件钱包:链上的币放在 PLEDGER 硬件钱包里,每个钱包的余额和污点各算各的;在终端的钱包页打开、连接。Web3 页的交易、黑市、混币器用已连接的钱包,用币付款的顾客和挖矿收益也进它。付款(黑市、混币器、Ponero 兑换处、上架费)先在终端开账单,再在钱包里手输收款地址、按住 ✓ 签名;地址末尾两位是校验位,输错一位多半会被拦下,偶尔碰巧合法,币就转进了没人用的地址。DEX 兑换也要在钱包上签名。Poinbase 是托管账户,可以直接提到已连接的钱包;从钱包充值要转到 Poinbase 的充值地址,只收 CRC 和 PSC。更多硬件钱包向杰克逊博士购买,拖到交易终端上添加,最多 5 个;黑市上也有更便宜的。不要的钱包可以在钱包页销毁,要先把币转走。转账要付手续费,分慢、普通、快三档,gas 价格大约一周一个起落;网络拥堵时手续费变贵,慢档可能被挤出内存池。转账前核对完整地址。质押:把 CRA 质押进革命军质押池每天有日息,利息并入本金;取出要先申请,等四天才到账,等待期间没有利息,也不能提前拿。质押池靠补贴池付息,补贴池见底会降息,有时还会传出大户撤出的消息,那是预兆:一旦崩盘,三天内 CRA 就一文不值,四天的解锁期来不及。平衡代币 SPARK 可以和 CRA 按价格互相铸销(收手续费),价格波动很大,崩盘时会跟着暴跌。质押、铸销都是合约调用,要在钱包上签名。永续合约:Poinbase 的"永续"页,用账户里的 CRC 当保证金,PSC 最高 5 倍、SPARK 最高 3 倍,一个品种只有一个仓位,不能同时多空。成交价比现价差一截(单越大差得越多,和现货池一样),开平仓还收手续费。强平只在夜里按当天均价检查,权益不到名义价值的 10% 就被强平,保证金没了;每晚还有资金费,价格比近 7 天均价高时多头付给空头。强平后亏光保证金还不够的缺口,先由保险基金垫,基金见底(SPARK 崩盘这类大行情会抽干它)后盈利的仓位会被自动减仓、少拿一半盈利,再不够就记成你的欠款:欠款每天涨 2%,账户里的 CRC 每晚先拿去还,没还清不能开仓、不能出金,七天不还就没收店里的物品抵债,抵不够的核销并拉黑永续账户 30 天。黑市:只收币,不收 CRC,价格比正常渠道贵 30%,高科技物品和显卡另有加价;付出去的币带着按比例的污点留在卖家那边,货不带赃物标签;显卡不保证真假,自己鉴定。订单比现货便宜,要等几天,路上可能被截获;商家按类型卖货,价格系数各不相同,一次订 5 件以上 95 折、10 件以上 9 折,大宗商家随机出现,价格最低但要等六天;托管多付 5% 托管费,收货后放款,卖家跑路时退款;提前放款再打九折,卖家若是骗子钱就没了,只有成交 30 单以上的卖家接受;平台自己也可能跑路,也可能被执法接管,或者卖家落网,之后你的订单会被追查、记罪证;论坛上的传闻可以当预兆。被截获的包裹有时会变成执法人员上门:签收当场记罪证并查封,拒签只损失货款。出售:在黑市的卖货页把储藏区里的违禁品、高科技物品和显卡上架,标价是参考价的 0.6 到 1.8 倍。倍率越高成交越慢(0.9 以下约一天,1.0 约两天,1.8 约十四天),货款带污点的可能性和比例也越高,近 7 天出售的量越大,污点越重;同时最多上架 10 件,上架收 5% 上架费,上架后可以改价(收 2% 改价费,标价按参考价重算,成交的快慢跟着新倍率走),成交扣 8% 抽成,货款托管 3 天后进钱包;卖假卡可能被买家看穿而退货,拉低好评;平台跑路时上架的货和未放款的货款都没了。匿名币兑换(Web3 交易页,要黑市声望):Ponero 是隐私币,兑入时这部分价值的污点被清除;兑入、兑出各收 6% 手续费,每天有额度;从 Ponero 兑出的币来路不明,出金审查会加重。"
courier.odd1,……东西到了。
courier.odd2,"(他的手一直放在口袋里)在这儿签个字。"
courier.sign,签收
@@ -476,3 +476,47 @@ news.crawarn.desc,有大户在大量撤出,质押补贴池告急。解锁需
news.cralow.title,革命军下调质押日息
news.cralow.desc,补贴池余额见底,革命军把 CRA 质押日息降到了原来的零头。
stake.avail2,"SPARK {0}"
+poin.sub.spot,现货
+poin.sub.perp,永续
+perp.h,永续合约
+perp.acct,"保证金账户 CRC {0}"
+perp.debt,"欠款 {0},{1} 天后没收物品抵债。没还清前不能开仓、不能出金。"
+perp.banned,"永续账户被拉黑,还有 {0} 天。"
+perp.none,没有仓位。
+perp.col.pos,仓位
+perp.col.notional,名义
+perp.col.price,开仓 → 现价
+perp.col.pnl,盈亏
+perp.col.liq,强平价
+perp.long,多
+perp.short,空
+perp.rate,"{0} 现价 {1} 资金费 {2}%/晚"
+perp.fund,"保险基金 {0}"
+perp.open,开仓
+perp.margin,保证金(CRC)
+perp.preview,"名义 {0} 成交价 {1}"
+perp.do.open,开仓
+perp.do.add,追加保证金
+perp.do.close,平仓
+perp.opened,"已开 {0} {1}:保证金 {2} CRC,{3}×。"
+perp.added,"已追加 {0} CRC 保证金。"
+perp.closed,"已平仓,{0} CRC 回到账户(盈亏 {1})。"
+perp.deficit,"权益不够付,缺口 {0} 由保险基金和欠款承担。"
+perp.err.Banned,永续账户被拉黑。
+perp.err.Debt,有欠款,不能开仓。
+perp.err.Leverage,杠杆超出这个品种的上限。
+perp.err.Size,超出这个品种的单仓名义价值上限。
+perp.err.Funds,账户里的 CRC 不够(还要付手续费)。
+perp.err.Opposite,同一品种不能同时多空,先平仓。
+perp.err.NoPosition,没有仓位。
+perp.err.Margin,请输入保证金数量。
+perp.debt.block,有永续欠款,不能出金。
+nightlog.perp.liq,"{0}被强平,保证金 {1} 没了。"
+nightlog.perp.adl,"保险基金见底,{0}的盈利被自动减仓,少拿了 {1}。"
+nightlog.perp.interest,"永续欠款计息,现在欠 {0}。"
+nightlog.perp.seize,"永续欠款逾期,没收的物品抵了 {0}。"
+nightlog.perp.writeoff,"还差 {0} 核销,永续账户被拉黑 {1} 天。"
+perp.slip.title,永续欠款没收单
+perp.preview2,"手续费 {0} 强平价 {1}"
+nightlog.perp.repaid,"永续穿仓欠款,从 Poinbase 账户扣了 {0} CRC 还款。"
+perp.err.Thin,"这个单子滑点太大,开了就会被强平。减小金额或杠杆。"
diff --git a/src/Decentralized/Night.cs b/src/Decentralized/Night.cs
index 12e3033..0712073 100644
--- a/src/Decentralized/Night.cs
+++ b/src/Decentralized/Night.cs
@@ -1,3 +1,4 @@
+using System;
using System.Collections.Generic;
using System.Linq;
using Decentralized.Domain;
@@ -107,6 +108,30 @@ namespace Decentralized
}
}
+ /// 欠款逾期:没收物品抵债(用游戏自己的没收接口,和房贷逾期同一套,发一张没收单);抵不够的核销并拉黑永续账户。
+ private static void SeizeForDebt(SaveData d, int day)
+ {
+ var ps = PlayerStore.Instance;
+ var items = ps.FindRepoItems(ps.GetAllRepoEligibleItems(), (int)Math.Ceiling(d.Perps.Debt));
+ double value = 0;
+ var names = new System.Text.StringBuilder();
+ foreach (var it in items)
+ {
+ if (it == null) continue;
+ value += it.GetCurrentValue(useRetailMarkup: false, withChild: true, includeEvents: true, forceMarkup: false, 0L);
+ names.AppendLine(it.name);
+ }
+ foreach (var it in items) it?.Destroy();
+ var slip = DirectoryMaster.Item("slip");
+ slip.name = L.T("perp.slip.title");
+ slip.ModifyTag("SLIP_TITLE", st => st.Enable().SetString(L.T("perp.slip.title")));
+ slip.ModifyTag("SLIP_BODY", st => st.Enable().SetString(names.ToString()));
+ ps.AddDirectSellingItemToTableMiddle(slip, true);
+ double off = d.Perps.ApplySeizure(value, day);
+ Log.Info($"永续欠款没收:抵 {value:0},核销 {off:0}");
+ ps.AddNightLog(L.T("nightlog.perp.seize", Fmt.Credits(value)) + (off > 0.5 ? L.T("nightlog.perp.writeoff", Fmt.Credits(off), Tuning.PerpBanDays) : ""), "cc7755");
+ }
+
private static void Settle()
{
var d = Session.Data;
@@ -156,6 +181,9 @@ namespace Decentralized
d.Network.Night(day, d.Market.Price, hash);
if (d.Network.Reward < rewardBefore) d.Market.OnHalving(d.Rng);
if (d.Network.Reward < rewardBefore) News.Show("pd_news_halving_" + day, L.T("news.halving.title"), L.T("news.halving.desc"), 2);
+ // 永续:用今天的标记价(当日均价)结算,要在今晚的价格更新之前
+ var perpEvents = d.Perps.Night(day, new Dictionary { ["PSC"] = d.Market.Mark, ["SPARK"] = d.Stable.Spark }, d.Poinbase, Session.Price);
+ if (perpEvents.Any(e => e.Kind == "seize")) SeizeForDebt(d, day);
var stakeEvents = d.Staking.Night(day, d.Stable.CraRate, d.WalletAt);
double crcFactor = d.Stable.Night(day, d.Rng, d.Staking.Earning);
if (d.Stable.CraWarnDay == day) News.Show("pd_news_crawarn_" + day, L.T("news.crawarn.title"), L.T("news.crawarn.desc"), 4);
@@ -192,6 +220,14 @@ namespace Decentralized
ps.AddNightLog(L.T("nightlog.drained", WalletWindow.NameOf(w), Fmt.Credits(v)), "cc7755");
}
ChainNight(d, day);
+ foreach (var e in perpEvents)
+ {
+ string who = e.Product + " " + L.T(e.Long ? "perp.long" : "perp.short");
+ if (e.Kind == "liq") ps.AddNightLog(L.T("nightlog.perp.liq", who, Fmt.Credits(e.Amount)), "cc7755");
+ else if (e.Kind == "adl") ps.AddNightLog(L.T("nightlog.perp.adl", who, Fmt.Credits(e.Amount)), "cc7755");
+ else if (e.Kind == "interest") ps.AddNightLog(L.T("nightlog.perp.interest", Fmt.Credits(e.Amount)), "cc7755");
+ else if (e.Kind == "repaid") ps.AddNightLog(L.T("nightlog.perp.repaid", Fmt.Credits(e.Amount)), "cc7755");
+ }
foreach (var e in stakeEvents)
{
var sw = d.Own(e.Wallet);
diff --git a/src/Decentralized/Session.cs b/src/Decentralized/Session.cs
index 5970676..c3a6ea6 100644
--- a/src/Decentralized/Session.cs
+++ b/src/Decentralized/Session.cs
@@ -31,6 +31,8 @@ namespace Decentralized
public Stablecoins Stable = new Stablecoins();
/// 革命军 CRA 质押池里玩家各钱包的仓位。
public Staking Staking = new Staking();
+ /// Poinbase 永续合约账本(保证金用 Poinbase 账户的 CRC)。
+ public PerpBook Perps = new PerpBook();
/// 质押日息下调的新闻已经发过。
public bool CraLowNews;
public Network Network = new Network();
diff --git a/src/Decentralized/TerminalWindow.cs b/src/Decentralized/TerminalWindow.cs
index 1748b20..33ddb24 100644
--- a/src/Decentralized/TerminalWindow.cs
+++ b/src/Decentralized/TerminalWindow.cs
@@ -228,10 +228,22 @@ namespace Decentralized
}
/// Poinbase:合规交易所。左边账户持仓,右边交易;信用点的存取在银行。
+ private enum PoinTab { Spot, Perp }
+ private static PoinTab _poinTab = PoinTab.Spot;
+
private static void ExchangePage(CustomUIBuilder b, Venue venue)
{
var d = Session.Data;
var w = d.Poinbase;
+ b.BeginRow();
+ foreach (PoinTab t in Enum.GetValues(typeof(PoinTab)))
+ {
+ var tab = t;
+ string name = L.T("poin.sub." + t.ToString().ToLowerInvariant());
+ b.AddButton(tab == _poinTab ? $"[{name}]" : name, () => { _poinTab = tab; _message = null; Open(); });
+ }
+ b.End();
+ if (_poinTab == PoinTab.Perp) { PerpPage(b); return; }
Columns(b, l =>
{
l.AddHeader(L.T("poin.account.h"));
@@ -261,6 +273,116 @@ namespace Decentralized
private static string _poinAsset = Assets.CRC;
+ private static string _perpProduct = "PSC";
+ private static bool _perpLong = true;
+ private static int _perpLev = 3;
+ private static readonly float[] PerpW = { 118f, 60f, 104f, 60f, 58f };
+
+ private static double PerpSpot(string p) => p == "SPARK" ? Session.Data.Stable.Spark : Session.Data.Market.Price;
+
+ /// 永续页:保证金账户、仓位、资金费与保险基金;开仓、追加保证金、平仓。Poinbase 是托管账户,不用签名。
+ private static void PerpPage(CustomUIBuilder b)
+ {
+ var d = Session.Data;
+ var bk = d.Perps;
+ var acct = d.Poinbase;
+ int day = StoreStation.GetDayCounter();
+ if (_perpLev > PerpBook.MaxLeverage(_perpProduct)) _perpLev = PerpBook.MaxLeverage(_perpProduct);
+ Columns(b, l =>
+ {
+ l.AddHeader(L.T("perp.h"));
+ l.AddLabel(L.T("perp.acct", Fmt.Num(acct.Avail(Assets.CRC))));
+ if (bk.Debt > 0) l.AddLabel(L.T("perp.debt", Fmt.Credits(bk.Debt), Math.Max(0, Tuning.PerpDebtDays - (day - bk.DebtSince))));
+ if (bk.Banned(day)) l.AddLabel(L.T("perp.banned", bk.BannedUntil - day + 1));
+ l.AddSeparator();
+ if (bk.Positions.Count == 0) l.AddLabel(L.T("perp.none"));
+ else
+ {
+ TableHeader(l, PerpW, L.T("perp.col.pos"), L.T("perp.col.notional"), L.T("perp.col.price"), L.T("perp.col.pnl"), L.T("perp.col.liq"));
+ foreach (var p in bk.Positions)
+ {
+ double spot = PerpSpot(p.Product);
+ TableRow(l, PerpW, p.Product + " " + L.T(p.Long ? "perp.long" : "perp.short") + " " + Math.Round(p.Leverage, 1) + "×", Fmt.Credits(p.Notional(spot)),
+ Fmt.Num(p.Entry) + " → " + Fmt.Num(spot), Fmt.Credits(p.Pnl(spot)), Fmt.Num(p.LiqPrice));
+ }
+ }
+ l.AddSeparator();
+ foreach (var p in PerpBook.Products)
+ l.AddLabel(L.T("perp.rate", p, Fmt.Num(PerpSpot(p)), (bk.Rate(p) * 100).ToString("+0.00;-0.00;0.00", CultureInfo.InvariantCulture)));
+ l.AddLabel(L.T("perp.fund", Fmt.Credits(bk.InsuranceFund)));
+ }, r =>
+ {
+ r.AddHeader(L.T("perp.open"));
+ r.BeginRow();
+ foreach (var p in PerpBook.Products)
+ {
+ var product = p;
+ r.AddButton(product == _perpProduct ? $"[{product}]" : product, () => { _perpProduct = product; Open(); });
+ }
+ r.End();
+ r.BeginRow();
+ r.AddButton(_perpLong ? "[" + L.T("perp.long") + "]" : L.T("perp.long"), () => { _perpLong = true; Open(); });
+ r.AddButton(!_perpLong ? "[" + L.T("perp.short") + "]" : L.T("perp.short"), () => { _perpLong = false; Open(); });
+ r.End();
+ r.BeginRow();
+ foreach (int lev in new[] { 1, 2, 3, 5 })
+ {
+ if (lev > PerpBook.MaxLeverage(_perpProduct)) continue;
+ int lv = lev;
+ r.AddButton(lv == _perpLev ? $"[{lv}×]" : lv + "×", () => { _perpLev = lv; Open(); });
+ }
+ r.End();
+ r.AddLabel(L.T("perp.margin"));
+ AmountRow(r);
+ if (double.TryParse((_amount ?? "").Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out double m) && m > 0)
+ {
+ double notional = m * _perpLev, spot = PerpSpot(_perpProduct), exec = bk.Exec(spot, notional, _perpLong, day);
+ var ghost = new PerpPosition { Long = _perpLong, Entry = exec, Size = notional / exec, Margin = m };
+ r.AddLabel(L.T("perp.preview", Fmt.Credits(notional), Fmt.Num(exec)));
+ r.AddLabel(L.T("perp.preview2", Fmt.Num(notional * Tuning.PerpFee), Fmt.Num(ghost.LiqPrice)));
+ }
+ r.BeginRow();
+ r.AddButton(L.T("perp.do.open"), PerpOpen);
+ r.AddButton(L.T("perp.do.add"), PerpAdd);
+ r.AddButton(L.T("perp.do.close"), PerpClose);
+ r.End();
+ });
+ }
+
+ private static string PerpErr(PerpResult res) => L.T("perp.err." + res);
+
+ private static void PerpOpen()
+ {
+ if (!ReadAmount(out double v)) return;
+ var d = Session.Data;
+ var res = d.Perps.Open(d.Poinbase, _perpProduct, _perpLong, v, _perpLev, PerpSpot(_perpProduct), StoreStation.GetDayCounter(), Session.Price);
+ _message = res == PerpResult.Ok ? L.T("perp.opened", _perpProduct, L.T(_perpLong ? "perp.long" : "perp.short"), Fmt.Num(v), _perpLev) : PerpErr(res);
+ if (res == PerpResult.Ok) Session.Save();
+ Open();
+ }
+
+ private static void PerpAdd()
+ {
+ if (!ReadAmount(out double v)) return;
+ var d = Session.Data;
+ var res = d.Perps.AddMargin(d.Poinbase, _perpProduct, v, Session.Price);
+ _message = res == PerpResult.Ok ? L.T("perp.added", Fmt.Num(v)) : PerpErr(res);
+ if (res == PerpResult.Ok) Session.Save();
+ Open();
+ }
+
+ private static void PerpClose()
+ {
+ var d = Session.Data;
+ var pos = d.Perps.Of(_perpProduct);
+ if (pos == null) { _message = PerpErr(PerpResult.NoPosition); Open(); return; }
+ double margin = pos.Margin;
+ double paid = d.Perps.Close(d.Poinbase, _perpProduct, PerpSpot(_perpProduct), StoreStation.GetDayCounter(), out double deficit);
+ _message = L.T("perp.closed", Fmt.Num(paid), (paid - margin >= 0 ? "+" : "") + Fmt.Num(paid - margin)) + (deficit > 0 ? "\n" + L.T("perp.deficit", Fmt.Credits(deficit)) : "");
+ Session.Save();
+ Open();
+ }
+
/// Poinbase 提到当前钱包:托管账户直接转,不用签名。污点按比例随币走。
private static void PoinbaseOut()
{
@@ -1061,6 +1183,7 @@ namespace Decentralized
{
if (!ReadAmount(out double v)) return;
var d = Session.Data;
+ if (d.Perps.Debt > 0) { _message = L.T("perp.debt.block"); Open(); return; }
if (d.Poinbase.Avail(Assets.CRC) + 1e-9 < v) { _message = L.T("msg.balance"); Open(); return; }
var r = d.Exchange.Cashout(d.Poinbase, v, Session.Price, StoreStation.GetDayCounter(), Session.Rent(), d.Mixer.RecentlyMixed(StoreStation.GetDayCounter()), d.Rng);
int got = (int)Math.Floor(r.Received - r.Tax);
diff --git a/tests/Decentralized.Domain.Tests/Program.cs b/tests/Decentralized.Domain.Tests/Program.cs
index 8f4220b..e808aa4 100644
--- a/tests/Decentralized.Domain.Tests/Program.cs
+++ b/tests/Decentralized.Domain.Tests/Program.cs
@@ -808,6 +808,76 @@ internal static class Program
Check(!ChainAddress.Valid("0x12345") && !ChainAddress.Valid("0xzzzzzzzz") && !ChainAddress.WellFormed("12345678ab"), "格式不对的地址不合法");
});
+ Test("永续合约:开平仓、强平、基金与欠款、自动减仓、没收", () =>
+ {
+ Func pr = a => a == Assets.PSC ? 100 : 1;
+ Wallet NewAcct(double crc, double taint = 0) { var w = new Wallet { Address = "pb" }; w.Add(Assets.CRC, crc); w.Taint = taint; w.Basis = crc; return w; }
+ // 滑点:名义价值占池深的比例,买入往上、卖出往下
+ var bs = new PerpBook();
+ double pool = Tuning.PoolDepth(1);
+ Check(Math.Abs(bs.Exec(100, 2000, true, 1) - 100 * (1 + 2000 / (Tuning.PerpDepthMult * pool))) < 1e-9 && bs.Exec(100, 2000, false, 1) < 100 && bs.Exec(100, 1e12, true, 1) <= 150.0001, "滑点:买入上滑、卖出下滑,封顶 50%");
+ // 滑点吃掉了强平线之间的空间:开仓被拒
+ var bt = new PerpBook { DepthMult = 0.05 }; var at = NewAcct(5000);
+ Check(bt.Open(at, "PSC", true, 1000, 5, 100, 1, pr) == PerpResult.Thin && bt.Positions.Count == 0 && at.Get(Assets.CRC) == 5000, "滑点大到一开就被强平的单子被拒,不扣钱");
+ // 开仓与手续费、保证金、污点
+ var b = new PerpBook { DepthMult = 1e12 }; var a = NewAcct(2000, 1000);
+ Check(b.Open(a, "PSC", true, 1000, 6, 100, 1, pr) == PerpResult.Leverage && b.Open(a, "PSC", true, 3000, 3, 100, 1, pr) == PerpResult.Funds, "杠杆超限、余额不足被拒");
+ Check(b.Open(a, "PSC", true, 1000, 5, 100, 1, pr) == PerpResult.Ok, "开 5× 多头");
+ var pos = b.Of("PSC");
+ double fee = 5000 * Tuning.PerpFee;
+ Check(Math.Abs(pos.Size - 50) < 1e-9 && Math.Abs(a.Get(Assets.CRC) - (1000 - fee)) < 1e-9 && Math.Abs(pos.Taint - 500) < 1e-9, $"名义 5000,手续费 {fee},污点 500 随保证金进仓位");
+ Check(b.Open(a, "PSC", false, 100, 2, 100, 1, pr) == PerpResult.Opposite, "不能同时多空");
+ Check(Math.Abs(pos.LiqPrice - (50 * 100 - 1000) / (50 * (1 - Tuning.PerpMaint))) < 1e-9 && pos.LiqPrice > 85 && pos.LiqPrice < 90, $"强平价 {pos.LiqPrice:0.0}(约 −11%)");
+ // 平仓盈利:干净的新币,污点原额退回
+ double paid = b.Close(a, "PSC", 110, 2, out double d0);
+ double expect = 1000 + 50 * 10 - 50 * 110 * Tuning.PerpFee;
+ Check(Math.Abs(paid - expect) < 1e-6 && d0 == 0 && Math.Abs(a.Taint - 1000) < 1e-6 && b.Positions.Count == 0, $"盈利平仓 {paid:0.00},污点全额退回");
+ // 平仓亏损:污点按剩下的比例退回
+ var a2 = NewAcct(2000, 1000); var b2 = new PerpBook { DepthMult = 1e12 };
+ b2.Open(a2, "PSC", true, 1000, 5, 100, 1, pr);
+ double paid2 = b2.Close(a2, "PSC", 95, 2, out _);
+ Check(paid2 > 700 && paid2 < 760 && a2.Taint < 1000 - 500 * (1 - paid2 / 1000) + 1, $"亏损平仓 {paid2:0.0},污点按比例少回");
+ // 夜间强平:权益不到 10% 名义价值;剩余进基金
+ var b3 = new PerpBook { DepthMult = 1e12 }; var a3 = NewAcct(5000);
+ b3.Open(a3, "PSC", true, 1000, 5, 100, 1, pr);
+ double fund0 = b3.InsuranceFund;
+ var ev = b3.Night(2, new Dictionary { ["PSC"] = 88 }, a3, pr);
+ Check(ev.Any(e => e.Kind == "liq") && b3.Positions.Count == 0 && b3.Debt == 0, "−12% 被强平,没有欠款");
+ Check(b3.InsuranceFund > fund0, "剩余权益进保险基金");
+ var b3b = new PerpBook { DepthMult = 1e12 }; var a3b = NewAcct(5000);
+ b3b.Open(a3b, "PSC", true, 1000, 5, 100, 1, pr);
+ Check(!b3b.Night(2, new Dictionary { ["PSC"] = 95 }, a3b, pr).Any(e => e.Kind == "liq") && b3b.Positions.Count == 1, "−5% 不强平");
+ // 穿仓:基金先垫,不够记欠款;有欠款不能开仓;账户 CRC 每晚先还
+ var b4 = new PerpBook { DepthMult = 1e12, InsuranceFund = 100 }; var a4 = NewAcct(1100);
+ b4.Open(a4, "SPARK", true, 1000, 3, 10, 1, pr);
+ double crc4 = a4.Get(Assets.CRC);
+ b4.Night(2, new Dictionary { ["SPARK"] = 5 }, a4, pr);
+ Check(b4.Positions.Count == 0 && b4.Debt > 0 && b4.InsuranceFund < 1 + Tuning.PerpFundDrift, $"大跌穿仓,基金见底,欠款 {b4.Debt:0}");
+ Check(b4.Open(a4, "SPARK", true, 10, 1, 5, 2, pr) == PerpResult.Debt, "有欠款不能开仓");
+ double debt0 = b4.Debt; a4.Add(Assets.CRC, 100);
+ b4.Night(3, new Dictionary { ["SPARK"] = 5 }, a4, pr);
+ Check(Math.Abs(b4.Debt - (debt0 * (1 + Tuning.PerpDebtInterest) - (crc4 + 100))) < 1e-6 || b4.Debt < debt0 * (1 + Tuning.PerpDebtInterest), "欠款计息,账户 CRC 先还");
+ // 逾期没收、核销、拉黑
+ var b5 = new PerpBook { DepthMult = 1e12, Debt = 500, DebtSince = 1 }; var a5 = NewAcct(0);
+ Check(!b5.Night(5, new Dictionary { ["PSC"] = 100 }, a5, pr).Any(e => e.Kind == "seize"), "没到 7 天不没收");
+ Check(b5.Night(8, new Dictionary { ["PSC"] = 100 }, a5, pr).Any(e => e.Kind == "seize"), "逾期触发没收");
+ double wo = b5.ApplySeizure(200, 8);
+ Check(wo > 300 && b5.Debt == 0 && b5.Banned(20) && !b5.Banned(8 + Tuning.PerpBanDays + 1), $"没收抵掉 200,核销 {wo:0},拉黑 {Tuning.PerpBanDays} 天");
+ // 资金费:价格高于 7 日均价时多头付、空头收
+ var b6 = new PerpBook { DepthMult = 1e12 }; var a6 = NewAcct(5000);
+ for (int d = 1; d <= 5; d++) b6.Night(d, new Dictionary { ["PSC"] = 100 }, a6, pr);
+ b6.Open(a6, "PSC", true, 1000, 2, 100, 5, pr);
+ double m0 = b6.Of("PSC").Margin;
+ b6.Night(6, new Dictionary { ["PSC"] = 106 }, a6, pr);
+ Check(b6.Rate("PSC") > 0 && b6.Of("PSC").Margin < m0, $"溢价时多头付资金费 {b6.Rate("PSC"):P2}");
+ // 自动减仓:NPC 大行情穿仓抽干基金,盈利的空头只拿到一部分
+ var b7 = new PerpBook { DepthMult = 1e12, InsuranceFund = 500 }; var a7 = NewAcct(5000);
+ b7.Open(a7, "SPARK", false, 1000, 3, 10, 1, pr);
+ b7.Night(2, new Dictionary { ["SPARK"] = 10 }, a7, pr);
+ var ev7 = b7.Night(3, new Dictionary { ["SPARK"] = 3 }, a7, pr);
+ Check(ev7.Any(e => e.Kind == "adl") && b7.Positions.Count == 0, "基金见底,盈利空头被自动减仓");
+ });
+
Test("CRA 质押池:计息、解锁期、污点随币、螺旋", () =>
{
Func price = a => a == Assets.PSC ? 100 : 1;