How to build a complete P&L tracker for Betfair automated systems in Java — bet lifecycle tracking, settlement via the Account API, strategy attribution, and real-time aggregation.
Most automated trading systems place bets correctly but track P&L badly. They look at the account balance at the start and end of a session and call the difference the result. That number is wrong — it doesn’t account for unsettled bets, pending withdrawal holds, or the timing of commission deductions. Worse, it tells you nothing about which strategy, which market type, or which time of day is generating your edge.
A proper P&L tracker records every bet at placement, reconciles it against settlement data from the Account API, attributes it to a strategy, and aggregates it in a way you can actually query. Here is how I build one.
Every bet on Betfair passes through these states:
PLACED → EXECUTABLE → MATCHED → SETTLED
placeOrders call succeeded and Betfair accepted the order. You have a betId.Your tracker needs to handle every transition. A common mistake is only recording settled bets — this means you have no visibility of open positions during a session, and no way to detect bets that were placed but never matched.
public record BetRecord(
String betId,
String marketId,
long selectionId,
String marketName,
String selectionName,
BetSide side,
double requestedPrice,
double requestedSize,
double matchedPrice,
double matchedSize,
BetStatus status,
String strategyId,
Instant placedAt,
Instant settledAt,
double profitLoss,
double commissionCharged
) {
public enum BetSide { BACK, LAY }
public enum BetStatus { PLACED, EXECUTABLE, MATCHED, SETTLED, CANCELLED, LAPSED, VOIDED }
public boolean isOpen() {
return status == BetStatus.PLACED
|| status == BetStatus.EXECUTABLE
|| status == BetStatus.MATCHED;
}
}
The strategyId field is critical. Without it you cannot break aggregate P&L down by strategy. Assign an identifier to each strategy — e.g. "back-steam-racing-v2" — and tag every order you place with it, either via the customerOrderRef field on the Betfair API or by storing the mapping yourself before placing.
When your system places an order, record it immediately — before you process the API response:
@Component
public class BetLedger {
private final ConcurrentMap<String, BetRecord> openBets = new ConcurrentHashMap<>();
private final BetRecordRepository repository;
public void recordPlaced(String betId,
PlaceInstruction instruction,
String strategyId,
String marketId,
String marketName) {
BetRecord record = new BetRecord(
betId,
marketId,
instruction.getSelectionId(),
marketName,
instruction.getSelectionName(),
instruction.getSide() == Side.BACK ? BetRecord.BetSide.BACK : BetRecord.BetSide.LAY,
instruction.getLimitOrder().getPrice(),
instruction.getLimitOrder().getSize(),
0.0,
0.0,
BetRecord.BetStatus.PLACED,
strategyId,
Instant.now(),
null,
0.0,
0.0
);
openBets.put(betId, record);
repository.save(record);
}
public void recordSettlement(SettledBet settled) {
BetRecord existing = openBets.remove(settled.getBetId());
BetRecord updated = applySettlement(existing, settled);
repository.save(updated);
}
public List<BetRecord> getOpenBetsForMarket(String marketId) {
return openBets.values().stream()
.filter(b -> marketId.equals(b.marketId()))
.toList();
}
public boolean isAlreadySettled(String betId) {
return repository.existsByBetIdAndStatus(betId, "SETTLED");
}
}
Store to a database immediately at placement. In-memory state is lost if the process restarts mid-session — and processes restart at the worst possible times.
Betfair exposes settled bets through listSettledBets on the Account API. Poll this after each market settles and reconcile against your ledger:
@Component
public class SettlementReconciler {
private final AccountApiClient accountApi;
private final BetLedger ledger;
public void reconcileMarket(String marketId) {
SettledBetsRequest request = new SettledBetsRequest();
request.setBetStatus(BetStatus.SETTLED);
request.setMarketIds(Set.of(marketId));
List<SettledBet> settled = accountApi.listSettledBets(request);
for (SettledBet bet : settled) {
if (!ledger.isAlreadySettled(bet.getBetId())) {
ledger.recordSettlement(bet);
log.info("Reconciled bet {} pnl={}", bet.getBetId(), bet.getProfitLoss());
}
}
// Any bet still EXECUTABLE for this market never matched — mark it lapsed
ledger.getOpenBetsForMarket(marketId).stream()
.filter(b -> b.status() == BetRecord.BetStatus.EXECUTABLE)
.forEach(b -> ledger.markLapsed(b.betId()));
}
}
Run reconciliation after every market you have traded. Don’t rely solely on streaming events for settlement — they can arrive out of order or be missed on reconnect. The Account API is the authoritative source.
Tagging every bet with a strategyId means you can generate a per-strategy breakdown:
public class PnlAggregator {
public StrategyReport buildReport(List<BetRecord> settled,
String strategyId,
LocalDate from,
LocalDate to) {
ZoneId london = ZoneId.of("Europe/London");
List<BetRecord> filtered = settled.stream()
.filter(b -> strategyId.equals(b.strategyId()))
.filter(b -> b.settledAt() != null)
.filter(b -> {
LocalDate d = b.settledAt().atZone(london).toLocalDate();
return !d.isBefore(from) && !d.isAfter(to);
})
.toList();
double netPnl = filtered.stream().mapToDouble(BetRecord::profitLoss).sum();
double commission = filtered.stream().mapToDouble(BetRecord::commissionCharged).sum();
long totalBets = filtered.size();
long winners = filtered.stream().filter(b -> b.profitLoss() > 0).count();
return new StrategyReport(strategyId, from, to, totalBets, winners, netPnl, commission);
}
public record StrategyReport(
String strategyId,
LocalDate from,
LocalDate to,
long totalBets,
long winners,
double netPnl,
double totalCommission
) {
public double winRate() {
return totalBets == 0 ? 0.0 : (double) winners / totalBets;
}
public double avgPnlPerBet() {
return totalBets == 0 ? 0.0 : netPnl / totalBets;
}
}
}
Run reports per strategy, per market type, and per time window. The question you want to answer is: which strategies are profitable, which are breaking even, and which are slowly bleeding volume without return.
Settled P&L is retrospective. During an active session you want a live estimate of where you stand across open positions. For pre-race back/lay positions, calculate the current estimated P&L by pricing the green-up at the current best lay:
public double estimatedSessionPnl(String marketId,
MarketBook book,
List<BetRecord> openBets) {
double total = 0.0;
for (BetRecord bet : openBets) {
if (!marketId.equals(bet.marketId()) || bet.matchedSize() <= 0) {
continue;
}
Optional<RunnerBook> runner = book.getRunners().stream()
.filter(r -> r.getSelectionId() == bet.selectionId())
.findFirst();
if (runner.isEmpty()) continue;
double bestLay = runner.get().getEx().getAvailableToLay().stream()
.findFirst()
.map(PriceSize::getPrice)
.orElse(0.0);
if (bestLay <= 0.0) continue;
// Estimated green-up profit at current lay price
double impliedReturn = bet.matchedSize() * bet.matchedPrice();
double greenStake = impliedReturn / bestLay;
total += impliedReturn - greenStake * bestLay;
}
return total;
}
This gives a rough live estimate. It is imprecise when spreads widen near the off, but it tells you whether you are sitting in profit or need to act. Combine it with your settled P&L for the day to get an overall session picture.
For a low-volume system (a few hundred bets per week), a PostgreSQL or SQLite table is sufficient. The schema mirrors BetRecord:
CREATE TABLE bet_records (
bet_id TEXT PRIMARY KEY,
market_id TEXT NOT NULL,
selection_id BIGINT,
market_name TEXT,
selection_name TEXT,
side TEXT NOT NULL,
requested_price REAL,
requested_size REAL,
matched_price REAL,
matched_size REAL,
status TEXT NOT NULL,
strategy_id TEXT,
placed_at TIMESTAMPTZ,
settled_at TIMESTAMPTZ,
profit_loss REAL,
commission REAL
);
CREATE INDEX idx_market_id ON bet_records(market_id);
CREATE INDEX idx_strategy_id ON bet_records(strategy_id);
CREATE INDEX idx_settled_at ON bet_records(settled_at);
Ad-hoc analysis then lives directly in SQL, with no reporting layer needed:
SELECT strategy_id,
COUNT(*) AS bets,
ROUND(SUM(profit_loss), 2) AS net_pnl,
ROUND(SUM(commission), 2) AS commission,
ROUND(AVG(profit_loss), 4) AS avg_per_bet
FROM bet_records
WHERE status = 'SETTLED'
AND settled_at >= NOW() - INTERVAL '30 days'
GROUP BY strategy_id
ORDER BY net_pnl DESC;
Run this query at the end of each week. It gives you a clear strategy breakdown without writing a single line of reporting code.
Betfair’s Account API returns profitLoss after standard market commission is deducted. If you are on a Premium Charge tier, that additional charge is assessed weekly at account level — it does not appear on individual settled bet records. Track your weekly gross winnings and commission paid separately so you can model your Premium Charge exposure correctly. The full maths is covered in the commission modelling post.
customerOrderRef to embed your strategyId directly in the Betfair order. This means you can reconstruct attribution from the Account API alone, even if your database is lost after a process failure.marketName at placement. It is available from your market catalogue at bet time. Post-settlement it is inconvenient to look up, and you will want it when reading records months later.listSettledBets for the previous 7 days and check for any records in your database that are still MATCHED but should have settled. Network interruptions and restarts cause gaps that compound if left unchecked.If you’re building automated trading infrastructure on Betfair and want to get the data architecture right from the start, get in touch.