Available Hire Me
← All Writing Betfair

Building a P&L Tracker for Automated Trading Systems in Java

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.

The Bet Lifecycle

Every bet on Betfair passes through these states:

PLACED → EXECUTABLE → MATCHED → SETTLED
  • PLACED: your placeOrders call succeeded and Betfair accepted the order. You have a betId.
  • EXECUTABLE: the order is sitting on the exchange ladder waiting to be matched. Your liability is reserved but nothing is confirmed.
  • MATCHED: the bet is fully or partially matched. A portion of your stake is committed.
  • SETTLED: the market has run. Betfair has applied winnings and losses and charged commission.

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.

The Core Data Model

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.

Recording Bets at Placement

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.

Reconciling via the Account API

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.

Strategy Attribution and Aggregation

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.

Estimating Real-Time P&L During a Session

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.

Storage

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.

A Note on Commission

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.

ProTips

  • Record on placement, reconcile on settlement. Never rely on a single data point. Placements give real-time visibility; settlement gives accuracy.
  • Use 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.
  • Store 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.
  • Flag lapsed and cancelled bets explicitly. An order that expired unmatched is not a financial loss, but it represents a failed execution opportunity. High lapse rates against a strategy signal a pricing or timing problem.
  • Run a daily reconciliation sweep. At the start of each session, call 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.

Samuel Jackson

Samuel Jackson

Senior Java Back End Developer & Contractor

Senior Java Back End Developer — Betfair Exchange API specialist, Spring Boot, AWS, and event-driven architecture. 20+ years delivering high-performance systems across betting, finance, energy, retail, and government. Available for Java contracting.