Initial PlayBull release with Trilogy Hub integration.

Includes homelab deploy tooling, image position/zoom manifest persistence, and full trading/casino stack.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-02 08:31:36 +02:00
commit 94b6cc3be9
60 changed files with 9206 additions and 0 deletions

98
server/trading.js Normal file
View File

@@ -0,0 +1,98 @@
import { Users, Holdings, Trades } from './db.js';
import { getAsset } from './market.js';
// Position-Netting: kombiniert bestehende Position mit neuem Trade.
function applyPosition(q0, a0, delta, price) {
if (q0 === 0 || Math.sign(q0) === Math.sign(delta)) {
const newQ = q0 + delta;
const newA = (Math.abs(q0) * a0 + Math.abs(delta) * price) / (Math.abs(q0) + Math.abs(delta));
return { q: newQ, a: newA };
}
// Gegenrichtung
if (Math.abs(delta) < Math.abs(q0)) return { q: q0 + delta, a: a0 };
if (Math.abs(delta) === Math.abs(q0)) return { q: 0, a: 0 };
return { q: q0 + delta, a: price }; // Flip
}
/**
* side: 'buy' | 'sell'. qty > 0. 'sell' kann Short eroeffnen.
*/
export function executeTrade(userId, symbol, side, qty) {
const asset = getAsset(symbol);
if (!asset) return { ok: false, error: 'Unbekanntes Symbol' };
if (asset.halted) return { ok: false, error: `${symbol} ist pausiert (halted)` };
qty = Number(qty);
if (!(qty > 0)) return { ok: false, error: 'Menge muss > 0 sein' };
const user = Users.byId(userId);
if (!user) return { ok: false, error: 'User nicht gefunden' };
const price = asset.price;
const notional = qty * price;
const delta = side === 'buy' ? qty : -qty;
const h = Holdings.get(userId, symbol) || { quantity: 0, avg_price: 0 };
let newBalance;
if (side === 'buy') {
if (user.balance < notional) return { ok: false, error: 'Nicht genug Guthaben' };
newBalance = user.balance - notional;
} else {
// Verkauf bringt Cash. Short-Eroeffnung erlaubt, aber Sicherheit pruefen.
const result = applyPosition(h.quantity, h.avg_price, delta, price);
if (result.q < 0 && user.balance < Math.abs(result.q) * price * 0.5) {
return { ok: false, error: 'Nicht genug Sicherheit fuer Short (50% Margin)' };
}
newBalance = user.balance + notional;
}
const pos = applyPosition(h.quantity, h.avg_price, delta, price);
Users.setBalance(userId, newBalance);
if (Math.abs(pos.q) < 1e-9) Holdings.remove(userId, symbol);
else Holdings.upsert(userId, symbol, pos.q, pos.a);
Trades.add(userId, symbol, side, qty, price);
return {
ok: true,
symbol, side, qty, price: round(price),
balance: round(newBalance),
position: { quantity: round(pos.q, 6), avgPrice: round(pos.a) },
};
}
export function getPortfolio(userId) {
const user = Users.byId(userId);
if (!user) return null;
const holdings = Holdings.forUser(userId).map(h => {
const a = getAsset(h.symbol);
const price = a ? a.price : h.avg_price;
const value = h.quantity * price;
const cost = h.quantity * h.avg_price;
const pnl = value - cost; // funktioniert fuer Long & Short
const pnlPct = cost !== 0 ? (pnl / Math.abs(cost)) * 100 : 0;
return {
symbol: h.symbol,
name: a?.name || h.symbol,
emoji: a?.emoji || '',
quantity: round(h.quantity, 6),
avgPrice: round(h.avg_price),
price: round(price),
value: round(value),
pnl: round(pnl),
pnlPct: round(pnlPct, 2),
side: h.quantity >= 0 ? 'long' : 'short',
};
});
const holdingsValue = holdings.reduce((s, h) => s + h.value, 0);
return {
balance: round(user.balance),
holdingsValue: round(holdingsValue),
netWorth: round(user.balance + holdingsValue),
holdings,
};
}
function round(n, d = 2) {
const f = Math.pow(10, d);
return Math.round(n * f) / f;
}