Skip to content

Diversify a portfolio with OpenQARP

examples/openqarp_portfolio.py

Turn a portfolio into a graph and diversification becomes Max-Cut. Assets are nodes, pairwise return correlation is the edge weight, and maximising the cut pushes strongly correlated pairs onto opposite sides — separating them is what earns the objective the most weight — while weakly or negatively correlated pairs are cheap to leave together. Each side of the resulting partition is a more mutually diversified basket than a random pick of the same size.

The example builds a synthetic fifty-asset market — five sectors, five hundred trading days, no data download — and solves the same objective on it twice:

  • QAOA on an eight-asset toy market, one qubit per asset, checked against a brute-force optimum the toy is small enough to enumerate.
  • PCE (Pauli Correlation Encoding) on the full fifty assets. Order-3 Pauli correlators encode all fifty binary decision variables into five qubits, so the problem that would need fifty qubits under QAOA’s encoding fits on a small register.

Both are OpenQARP algorithms driven through a qarp.engines.Engine, so the platform stands where a local simulator normally does.

Needs the extra: pip install 'qubitra-sdk[openqarp]'.

def market_graph(returns: np.ndarray, threshold: float) -> tuple[Graph, np.ndarray]:
correlation = np.corrcoef(returns, rowvar=False)
n_assets = returns.shape[1]
graph = Graph()
graph.add_nodes_from(range(n_assets))
for i in range(n_assets):
for j in range(i + 1, n_assets):
weight = float(correlation[i, j])
if abs(weight) >= threshold:
graph.add_edge(i, j, weight=weight)
return graph, correlation

qarp.graphs.Graph extends networkx.Graph, and both algorithms read node labels directly as qubit indices — so nodes are the contiguous integers 0 .. n_assets - 1 and asset i is node i. Correlations below the threshold are dropped, which keeps the Hamiltonian smaller and prunes sampling noise; at 0.15 only genuinely same-sector pairs survive, leaving a graph close to five disjoint near-cliques.

def engine_factory(
mode: str, backend_id: str, counted: Counted
) -> tuple[EngineFactory, QubitraClient | None]:
if mode == "estimate":
return lambda seed: EstimatingEngine(counted, seed=seed), None
if mode == "local":
return lambda seed: QarpEngine(seed=seed), None
client = counting_client(counted)
return lambda _seed: QubitraEngine(backend_id, client=client), client

The whole integration, and the only difference between running on the platform and running on a laptop. Everything downstream takes an engine and does not care which it got: QAOA(..., engine=engine) and the PCE restart loop’s engine=. The seed is a local engine’s shot-sampling stream — the platform seeds its own execution, so QubitraEngine takes none.

qaoa = QAOA(
problem=graph,
n_layers=QAOA_LAYERS,
use_rzz=True,
verbose=True,
initial_parameters=[0.3] * 2 * QAOA_LAYERS,
gradient=QAOA_GRADIENT,
optimizer=ScipyOptimizer("CG", options=options),
engine=make_engine(RNG_SEED),
).build()
qaoa.run()

gradient="parameter-shift" is the line that decides what the run costs. QAOA optimises with SciPy’s conjugate gradient, and offered no gradient SciPy differentiates numerically — for six parameters that is seven separate objective evaluations, each its own platform job. Asking the engine for the gradient instead puts every stencil point of a step on one PUB inside one job: 741 jobs for the whole optimisation rather than 2,927. --faithful restores the source notebook’s setting if you want to see the difference.

The optimum is then read off by sampling:

circuit = deepcopy(qaoa.get_final_state_block())
circuit.measure([(qubit, qubit) for qubit in range(circuit.n_qubits)])
sampler = Sampler(ket=circuit, n_shots=QAOA_SHOTS)
readout = make_engine(RNG_SEED)
readout.build([sampler])
readout.run({})

One more job: the ket bound at the optimum, measured, and the most likely bitstring is the partition.

pce = PCE(
graph=graph,
order=PCE_ORDER,
ket=ansatz,
primitive=Sampler(),
merging=True,
initial_parameters=initial,
verbose=False,
optimizer=ScipyOptimizer("COBYQA"),
engine=make_engine(PCE_ENGINE_SEED + restart),
).build()
_, _, solution = pce.run()

PCE’s landscape is non-convex, so the example runs several random initial-parameter draws and keeps the best; --restarts sets how many. Each restart gets its own engine, exactly as it would locally.

A Sampler primitive makes PCE measure commuting groups of Pauli correlators rather than one operator at a time, and every group of an evaluation travels as its own PUB inside one job — three PUBs per job here. COBYQA is derivative-free, so there is no gradient to batch and the job count is simply the number of objective evaluations.

def diversified_and_complement(
correlation: np.ndarray, bitstring: Sequence[int]
) -> tuple[list[int], list[int], float, float]:
side0 = [i for i, bit in enumerate(bitstring) if bit == 0]
side1 = [i for i, bit in enumerate(bitstring) if bit == 1]
corr0, corr1 = mean_corr(correlation, side0), mean_corr(correlation, side1)
if corr0 <= corr1:
return side0, side1, corr0, corr1
return side1, side0, corr1, corr0

Max-Cut guarantees the total correlation left uncut is small, not that it splits evenly between the two sides. Which side is the diversified basket is therefore read off afterwards rather than assumed — a cheap post-processing step, and an honest one.

Every objective evaluation is one platform round trip, so the optimiser — not the circuit — sets the bill. --estimate runs the whole flow against a local counting engine that tallies what QubitraEngine would have submitted, and reports it without touching the platform:

Terminal window
python examples/openqarp_portfolio.py --estimate --restarts 1
QAOA — 8 assets, 8 qubits, 3 layers, parameter-shift gradient, uncapped
would submit : 741 jobs, 741 PUBs
would cost : 7,410 credits (10/job), ~49m at 4s/job
PCE — 50 assets in 5 qubits, order 3 (merging), 1 restarts
would submit : 283 jobs, 849 PUBs
would cost : 2,830 credits (10/job), ~19m at 4s/job
total
would submit : 1024 jobs, 1590 PUBs
would cost : 10,240 credits (10/job), ~68m at 4s/job

The credit rate and the per-job wall time are constants at the top of the file (CREDITS_PER_JOB, SECONDS_PER_JOB) — set them to what your deployment charges and measures. Jobs drive both: a job is billed whole whatever it carries, so the PUB count is what the batching bought rather than what it cost.

--max-iterations is the knob that makes the run affordable. Capping SciPy at three iterations costs thirteen jobs and lands within a percent of the optimum on this landscape, which is enough to prove the path end to end before committing to the full optimisation.

Terminal window
QUBITRA_API_KEY=qpk_... python examples/openqarp_portfolio.py --max-iterations 3 --skip-pce

Output, with the optimiser’s own iteration table trimmed:

running on: sim-openqarp-26
market: 50 assets across 5 sectors, 500 trading days
market graph: 50 nodes, 229 weighted edges
whole-universe average pairwise correlation: 0.153
QAOA — 8 assets, 8 qubits, 3 layers, parameter-shift gradient, 3 iters
QAOA minimization did NOT finish successfully
diversified basket : 4 assets, mean pairwise corr = -0.018
complement : 4 assets, mean pairwise corr = -0.010
whole toy market : 0.106
size-matched random : 0.105
basket : TECH01, ENRG02, FINL01, GOLD02
brute-force optimum : 3.161
QAOA sampled cut : 3.146 (ratio 0.995)
wall time : 56.4s
submitted : 13 jobs, 13 PUBs

Thirteen jobs, as the estimate said. The basket has slightly negative internal correlation against a toy universe averaging 0.106, and its cut is within half a percent of the brute-force optimum. QAOA minimization did NOT finish successfully is SciPy reporting that it stopped at the iteration limit rather than at its own convergence test — expected for a capped run, and not a failure.

--local runs the identical flow on QarpEngine and submits nothing. The optimiser takes the same path on both, energy for energy, because the backend’s estimator is exact. The readout is a 10,000-shot sample, though, and after three iterations the distribution is still flat enough for sampling to pick between near-equal partitions: the local run reads off TECH02, ENRG02, FINL02, GOLD01 at the exact optimum of 3.161.

The example exits non-zero if QAOA comes back below 99% of the optimum: the toy market is small enough to know the answer, so a worse number is a broken path rather than a hard problem.

The diversification benefit is real but modest for a single two-way split, by construction: Max-Cut on a same-sector clique only two-colours it, so it separates about half of each sector’s internal redundancy rather than all of it.

  • OpenQARP — the full adapter: which primitive targets run, what is refused, and how sweeps and gradients batch.
  • Find a ground state with OpenQARP — the same engine driving a VQE, where the gradient is the whole story.
  • Sessions — correlating a whole optimisation run.