mirror of
https://github.com/multipleof4/KalBot.git
synced 2026-03-17 05:51:02 +00:00
Refactor: Per-strategy isolated balance and PnL
This commit is contained in:
@@ -2,54 +2,92 @@ import { db } from '../db.js';
|
|||||||
import { notify } from '../notify.js';
|
import { notify } from '../notify.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Paper Trading Engine.
|
* Per-Strategy Paper Trading Engine.
|
||||||
* Executes virtual trades, tracks PnL, stores in SurrealDB.
|
* Each strategy gets its own isolated balance, PnL, and trade history.
|
||||||
*/
|
*/
|
||||||
export class PaperEngine {
|
class StrategyPaperAccount {
|
||||||
constructor(initialBalance = 1000) {
|
constructor(strategyName, initialBalance = 1000) {
|
||||||
|
this.strategyName = strategyName;
|
||||||
this.balance = initialBalance;
|
this.balance = initialBalance;
|
||||||
|
this.initialBalance = initialBalance;
|
||||||
this.openPositions = new Map(); // ticker -> [positions]
|
this.openPositions = new Map(); // ticker -> [positions]
|
||||||
this.tradeHistory = [];
|
|
||||||
this.totalPnL = 0;
|
this.totalPnL = 0;
|
||||||
this.wins = 0;
|
this.wins = 0;
|
||||||
this.losses = 0;
|
this.losses = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getStats() {
|
||||||
|
const openPositionsList = [];
|
||||||
|
for (const [, positions] of this.openPositions) {
|
||||||
|
openPositionsList.push(...positions);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
strategy: this.strategyName,
|
||||||
|
balance: parseFloat(this.balance.toFixed(2)),
|
||||||
|
initialBalance: this.initialBalance,
|
||||||
|
totalPnL: parseFloat(this.totalPnL.toFixed(2)),
|
||||||
|
wins: this.wins,
|
||||||
|
losses: this.losses,
|
||||||
|
winRate: this.wins + this.losses > 0
|
||||||
|
? parseFloat(((this.wins / (this.wins + this.losses)) * 100).toFixed(1))
|
||||||
|
: 0,
|
||||||
|
openPositions: openPositionsList,
|
||||||
|
totalTrades: this.wins + this.losses
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PaperEngine {
|
||||||
|
constructor(initialBalancePerStrategy = 1000) {
|
||||||
|
this.initialBalancePerStrategy = initialBalancePerStrategy;
|
||||||
|
this.accounts = new Map(); // strategyName -> StrategyPaperAccount
|
||||||
|
}
|
||||||
|
|
||||||
|
_getAccount(strategyName) {
|
||||||
|
if (!this.accounts.has(strategyName)) {
|
||||||
|
this.accounts.set(strategyName, new StrategyPaperAccount(strategyName, this.initialBalancePerStrategy));
|
||||||
|
}
|
||||||
|
return this.accounts.get(strategyName);
|
||||||
|
}
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
// Load state from SurrealDB
|
|
||||||
try {
|
try {
|
||||||
const state = await db.query('SELECT * FROM paper_state ORDER BY timestamp DESC LIMIT 1');
|
// Load saved per-strategy states
|
||||||
const saved = state[0]?.[0];
|
const states = await db.query('SELECT * FROM paper_strategy_state ORDER BY timestamp DESC');
|
||||||
if (saved) {
|
const rows = states[0] || [];
|
||||||
this.balance = saved.balance;
|
const seen = new Set();
|
||||||
this.totalPnL = saved.totalPnL;
|
for (const saved of rows) {
|
||||||
this.wins = saved.wins;
|
if (!saved.strategyName || seen.has(saved.strategyName)) continue;
|
||||||
this.losses = saved.losses;
|
seen.add(saved.strategyName);
|
||||||
console.log(`[Paper] Restored state: $${this.balance.toFixed(2)} balance, ${this.wins}W/${this.losses}L`);
|
const acct = this._getAccount(saved.strategyName);
|
||||||
|
acct.balance = saved.balance;
|
||||||
|
acct.totalPnL = saved.totalPnL;
|
||||||
|
acct.wins = saved.wins;
|
||||||
|
acct.losses = saved.losses;
|
||||||
|
console.log(`[Paper:${saved.strategyName}] Restored: $${acct.balance.toFixed(2)}, ${acct.wins}W/${acct.losses}L`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load open positions
|
// Load open positions
|
||||||
const positions = await db.query('SELECT * FROM paper_positions WHERE settled = false');
|
const positions = await db.query('SELECT * FROM paper_positions WHERE settled = false');
|
||||||
if (positions[0]) {
|
if (positions[0]) {
|
||||||
for (const pos of positions[0]) {
|
for (const pos of positions[0]) {
|
||||||
const list = this.openPositions.get(pos.ticker) || [];
|
const acct = this._getAccount(pos.strategy);
|
||||||
|
const list = acct.openPositions.get(pos.ticker) || [];
|
||||||
list.push(pos);
|
list.push(pos);
|
||||||
this.openPositions.set(pos.ticker, list);
|
acct.openPositions.set(pos.ticker, list);
|
||||||
}
|
}
|
||||||
console.log(`[Paper] Restored ${this.openPositions.size} open position(s)`);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[Paper] Init error (fresh start):', e.message);
|
console.error('[Paper] Init error (fresh start):', e.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute a paper trade from a strategy signal.
|
|
||||||
*/
|
|
||||||
async executeTrade(signal, marketState) {
|
async executeTrade(signal, marketState) {
|
||||||
const cost = signal.size; // Each contract costs signal.price cents, but we simplify: $1 per contract unit
|
const acct = this._getAccount(signal.strategy);
|
||||||
if (this.balance < cost) {
|
const cost = signal.size;
|
||||||
console.log(`[Paper] Insufficient balance ($${this.balance.toFixed(2)}) for $${cost} trade`);
|
|
||||||
|
if (acct.balance < cost) {
|
||||||
|
console.log(`[Paper:${signal.strategy}] Insufficient balance ($${acct.balance.toFixed(2)}) for $${cost} trade`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,7 +96,7 @@ export class PaperEngine {
|
|||||||
strategy: signal.strategy,
|
strategy: signal.strategy,
|
||||||
ticker: signal.ticker,
|
ticker: signal.ticker,
|
||||||
side: signal.side,
|
side: signal.side,
|
||||||
price: signal.price, // Entry price in cents
|
price: signal.price,
|
||||||
size: signal.size,
|
size: signal.size,
|
||||||
cost,
|
cost,
|
||||||
reason: signal.reason,
|
reason: signal.reason,
|
||||||
@@ -74,40 +112,37 @@ export class PaperEngine {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
this.balance -= cost;
|
acct.balance -= cost;
|
||||||
|
|
||||||
const list = this.openPositions.get(trade.ticker) || [];
|
const list = acct.openPositions.get(trade.ticker) || [];
|
||||||
list.push(trade);
|
list.push(trade);
|
||||||
this.openPositions.set(trade.ticker, list);
|
acct.openPositions.set(trade.ticker, list);
|
||||||
|
|
||||||
// Store in SurrealDB
|
|
||||||
try {
|
try {
|
||||||
await db.create('paper_positions', trade);
|
await db.create('paper_positions', trade);
|
||||||
await this._saveState();
|
await this._saveState(acct);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[Paper] DB write error:', e.message);
|
console.error('[Paper] DB write error:', e.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
const msg = `📝 PAPER ${trade.side.toUpperCase()} @ ${trade.price}¢ ($${cost}) | ${trade.strategy} | ${trade.reason}`;
|
const msg = `📝 PAPER [${trade.strategy}] ${trade.side.toUpperCase()} @ ${trade.price}¢ ($${cost}) | ${trade.reason}`;
|
||||||
console.log(`[Paper] ${msg}`);
|
console.log(`[Paper] ${msg}`);
|
||||||
await notify(msg, 'Paper Trade');
|
await notify(msg, `Paper: ${trade.strategy}`);
|
||||||
|
|
||||||
return trade;
|
return trade;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Settle all positions for a ticker when the market resolves.
|
|
||||||
*/
|
|
||||||
async settle(ticker, result) {
|
async settle(ticker, result) {
|
||||||
const positions = this.openPositions.get(ticker);
|
const allSettled = [];
|
||||||
if (!positions || positions.length === 0) return;
|
|
||||||
|
|
||||||
console.log(`[Paper] Settling ${positions.length} position(s) for ${ticker}, result: ${result}`);
|
for (const [strategyName, acct] of this.accounts) {
|
||||||
|
const positions = acct.openPositions.get(ticker);
|
||||||
|
if (!positions || positions.length === 0) continue;
|
||||||
|
|
||||||
|
console.log(`[Paper:${strategyName}] Settling ${positions.length} position(s) for ${ticker}, result: ${result}`);
|
||||||
|
|
||||||
for (const pos of positions) {
|
for (const pos of positions) {
|
||||||
const won = pos.side === result;
|
const won = pos.side === result;
|
||||||
// Payout: if won, pay out at $1 per contract (100¢), minus cost
|
|
||||||
// If lost, lose the cost
|
|
||||||
const payout = won ? (100 / pos.price) * pos.cost : 0;
|
const payout = won ? (100 / pos.price) * pos.cost : 0;
|
||||||
const pnl = payout - pos.cost;
|
const pnl = payout - pos.cost;
|
||||||
|
|
||||||
@@ -116,62 +151,87 @@ export class PaperEngine {
|
|||||||
pos.pnl = parseFloat(pnl.toFixed(2));
|
pos.pnl = parseFloat(pnl.toFixed(2));
|
||||||
pos.settleTime = Date.now();
|
pos.settleTime = Date.now();
|
||||||
|
|
||||||
this.balance += payout;
|
acct.balance += payout;
|
||||||
this.totalPnL += pnl;
|
acct.totalPnL += pnl;
|
||||||
|
|
||||||
if (won) this.wins++;
|
if (won) acct.wins++;
|
||||||
else this.losses++;
|
else acct.losses++;
|
||||||
|
|
||||||
// Update in SurrealDB
|
|
||||||
try {
|
try {
|
||||||
await db.query(`UPDATE paper_positions SET settled = true, result = $result, pnl = $pnl, settleTime = $settleTime WHERE id = $id`, {
|
await db.query(
|
||||||
id: pos.id,
|
`UPDATE paper_positions SET settled = true, result = $result, pnl = $pnl, settleTime = $settleTime WHERE id = $id`,
|
||||||
result,
|
{ id: pos.id, result, pnl: pos.pnl, settleTime: pos.settleTime }
|
||||||
pnl: pos.pnl,
|
);
|
||||||
settleTime: pos.settleTime
|
|
||||||
});
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[Paper] Settle DB error:', e.message);
|
console.error('[Paper] Settle DB error:', e.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
const emoji = won ? '✅' : '❌';
|
const emoji = won ? '✅' : '❌';
|
||||||
const msg = `${emoji} ${pos.strategy} ${pos.side.toUpperCase()} ${won ? 'WON' : 'LOST'} | PnL: $${pnl.toFixed(2)} | Balance: $${this.balance.toFixed(2)}`;
|
const msg = `${emoji} [${strategyName}] ${pos.side.toUpperCase()} ${won ? 'WON' : 'LOST'} | PnL: $${pnl.toFixed(2)} | Bal: $${acct.balance.toFixed(2)}`;
|
||||||
console.log(`[Paper] ${msg}`);
|
console.log(`[Paper] ${msg}`);
|
||||||
await notify(msg, won ? 'Paper Win!' : 'Paper Loss');
|
await notify(msg, won ? `${strategyName} Win!` : `${strategyName} Loss`);
|
||||||
|
|
||||||
|
allSettled.push(pos);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.openPositions.delete(ticker);
|
acct.openPositions.delete(ticker);
|
||||||
await this._saveState();
|
await this._saveState(acct);
|
||||||
|
|
||||||
return positions;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return allSettled.length > 0 ? allSettled : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get combined stats (backward compat) — aggregates all strategies.
|
||||||
|
*/
|
||||||
getStats() {
|
getStats() {
|
||||||
const openPositionsList = [];
|
const allOpen = [];
|
||||||
for (const [ticker, positions] of this.openPositions) {
|
let totalBalance = 0;
|
||||||
openPositionsList.push(...positions);
|
let totalPnL = 0;
|
||||||
|
let totalWins = 0;
|
||||||
|
let totalLosses = 0;
|
||||||
|
|
||||||
|
for (const [, acct] of this.accounts) {
|
||||||
|
const s = acct.getStats();
|
||||||
|
totalBalance += s.balance;
|
||||||
|
totalPnL += s.totalPnL;
|
||||||
|
totalWins += s.wins;
|
||||||
|
totalLosses += s.losses;
|
||||||
|
allOpen.push(...s.openPositions);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
balance: parseFloat(this.balance.toFixed(2)),
|
balance: parseFloat(totalBalance.toFixed(2)),
|
||||||
totalPnL: parseFloat(this.totalPnL.toFixed(2)),
|
totalPnL: parseFloat(totalPnL.toFixed(2)),
|
||||||
wins: this.wins,
|
wins: totalWins,
|
||||||
losses: this.losses,
|
losses: totalLosses,
|
||||||
winRate: this.wins + this.losses > 0
|
winRate: totalWins + totalLosses > 0
|
||||||
? parseFloat(((this.wins / (this.wins + this.losses)) * 100).toFixed(1))
|
? parseFloat(((totalWins / (totalWins + totalLosses)) * 100).toFixed(1))
|
||||||
: 0,
|
: 0,
|
||||||
openPositions: openPositionsList,
|
openPositions: allOpen,
|
||||||
totalTrades: this.wins + this.losses
|
totalTrades: totalWins + totalLosses
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async _saveState() {
|
/**
|
||||||
|
* Get per-strategy stats for the paper dashboard.
|
||||||
|
*/
|
||||||
|
getPerStrategyStats() {
|
||||||
|
const result = {};
|
||||||
|
for (const [name, acct] of this.accounts) {
|
||||||
|
result[name] = acct.getStats();
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async _saveState(acct) {
|
||||||
try {
|
try {
|
||||||
await db.create('paper_state', {
|
await db.create('paper_strategy_state', {
|
||||||
balance: this.balance,
|
strategyName: acct.strategyName,
|
||||||
totalPnL: this.totalPnL,
|
balance: acct.balance,
|
||||||
wins: this.wins,
|
totalPnL: acct.totalPnL,
|
||||||
losses: this.losses,
|
wins: acct.wins,
|
||||||
|
losses: acct.losses,
|
||||||
timestamp: Date.now()
|
timestamp: Date.now()
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
Reference in New Issue
Block a user