Diversify a portfolio with OpenQARP
examples/openqarp_portfolio.py
The problem
Section titled “The problem”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]'.
The walkthrough
Section titled “The walkthrough”The market graph
Section titled “The market graph”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, correlationqarp.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.
The engine swap
Section titled “The engine swap”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), clientThe 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 on the toy market
Section titled “QAOA on the toy market” 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 on the whole market
Section titled “PCE on the whole market” 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.
Reading off the basket
Section titled “Reading off the basket”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, corr0Max-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.
Sizing a run before you pay for it
Section titled “Sizing a run before you pay for it”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:
python examples/openqarp_portfolio.py --estimate --restarts 1QAOA — 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/jobPCE — 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/jobThe 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.
Running it
Section titled “Running it”QUBITRA_API_KEY=qpk_... python examples/openqarp_portfolio.py --max-iterations 3 --skip-pceOutput, with the optimiser’s own iteration table trimmed:
running on: sim-openqarp-26market: 50 assets across 5 sectors, 500 trading daysmarket graph: 50 nodes, 229 weighted edgeswhole-universe average pairwise correlation: 0.153
QAOA — 8 assets, 8 qubits, 3 layers, parameter-shift gradient, 3 itersQAOA 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 PUBsThirteen 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.
Where to go next
Section titled “Where to go next”- 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.