Skip to content

docs: add portfolio pilot architecture guide#2146

Open
Roasbeef wants to merge 1 commit into
lightninglabs:mainfrom
Roasbeef:docs-portfolio-pilot
Open

docs: add portfolio pilot architecture guide#2146
Roasbeef wants to merge 1 commit into
lightninglabs:mainfrom
Roasbeef:docs-portfolio-pilot

Conversation

@Roasbeef

Copy link
Copy Markdown
Member

In this PR, we add a new architecture doc for the portfolio pilot
subsystem under docs/. The doc is meant to serve as a one-stop reference
for devs (and agents) building custom market-making, OTC desks, or
exchange flows on top of the RFQ layer, in the same spirit as the existing
docs/rfq_architecture.md.

The portfolio pilot is the decision layer that sits between the RFQ
negotiator and the price oracle. ResolveRequest decides whether to take
an inbound quote and at what rate, VerifyAcceptQuote is our last look at
the peer's accept, and QueryAssetRates is pure rate discovery. The
interface is small but the policy surface it opens up is large, and the
existing source comments only get you partway.

What the doc covers

The doc walks the PortfolioPilot interface, the in-process
InternalPortfolioPilot, and the gRPC RpcPortfolioPilot. From there it
covers the limit-order model the protocol enforces on every accept (rate
bounds, min/max amounts, IOC vs FOK execution policy, fill caps, and the
unified RequestConstraints view), and the hint/bind/qualify intent
lifecycle that distinguishes indicative pricing from binding commitments.

There's also a section pinning down the vocabulary: the order vs request
vs accept vs rate distinction, the asset-units-per-BTC convention, the
perspective flip on incoming requests (a peer's BuyRequest is a sell
opportunity for us), and how RequestConstraints.RateBoundCmp collapses
buy and sell into a single check.

Custom pilots

The doc includes Go sketches for non-trivial pilots:

  • An in-memory order-book pilot showing the resolve/verify/query path
    against resting orders.
  • A circular-swap wrapper that coordinates two RFQ legs against a combined
    slippage tolerance.
  • A hedging composition pattern that reserves external capacity at the
    moment of commitment.

These are illustrative, not production-ready (called out as such), but
they're complete enough that a pilot author can grow their own
implementation from there. They also point at
docs/examples/basic-portfolio-pilot
as the shortest path from interface to running daemon.

OTC and exchange considerations

Dedicated sections cover the things an OTC desk or an exchange operator
should think about before treating tapd as a market-making front-end:
customer tiering via PriceOracleMetadata, spread/skew/inventory pricing,
last-look mechanics (and the caveats around them), layering a continuous
double auction matching engine onto the pilot, reconciliation of internal
trade ledgers against RFQ IDs, and crash recovery / audit requirements.

Test plan

  • Doc-only change, no code touched.
  • Render check on GitHub once the PR is up.

Document the portfolio pilot subsystem as a one-stop reference for
developers and agents building custom market-making, OTC, or exchange
flows on top of the RFQ layer. The guide walks the PortfolioPilot
interface and its two built-in implementations, the limit-order
constraint model (rate bounds, min/max amounts, IOC/FOK execution
policy, fill caps, and the unified RequestConstraints view), and the
hint/bind/qualify price-query intents that distinguish indicative
lookups from binding quotes.

It also includes runnable Go sketches for a custom pilot: an
in-memory order-book pilot, a circular-swap wrapper that coordinates
two RFQ legs against a combined slippage tolerance, and a hedging
pilot composition pattern. Sections on OTC desks and exchanges
cover customer tiering via PriceOracleMetadata, spread/skew/inventory
pricing, last-look mechanics, layering a continuous double auction
onto the protocol, and reconciliation and audit requirements for any
operator treating tapd as a settlement front end.

The doc complements docs/rfq_architecture.md and points at the
existing examples/basic-portfolio-pilot example as the shortest path
from interface to running daemon.
@gemini-code-assist

Copy link
Copy Markdown

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request introduces a detailed architecture guide for the Portfolio Pilot subsystem, providing developers with a centralized reference for building custom market-making and exchange flows on top of the RFQ layer. The documentation clarifies the decision-making interface, protocol-enforced constraints, and integration patterns, facilitating the development of sophisticated trading strategies.

Highlights

  • Architecture Documentation: Added a comprehensive architecture guide for the Portfolio Pilot subsystem, serving as a one-stop reference for developers building custom market-making and exchange flows.
  • Interface and Protocol Details: Documented the PortfolioPilot interface, the protocol-enforced limit-order model, and the lifecycle of price query intents.
  • Implementation Examples: Provided Go sketches for non-trivial pilot implementations, including an in-memory order book, circular swap wrappers, and hedging composition patterns.
  • Operational Guidance: Included dedicated sections on OTC desk and exchange considerations, covering customer tiering, last-look mechanics, and crash recovery.
New Features

🧠 You can now enable Memory (public preview) to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a comprehensive architecture document for the Portfolio Pilot, which serves as the pluggable decision layer for Taproot Asset RFQs. The document covers the interface, limit-order model, internal and RPC pilot implementations, and provides Go code sketches for custom pilots. The review feedback focuses on correcting several issues within these Go code sketches: a logical bug in the order book matching helper (bestOrderLocked) that fails to find the best rate when queried without a rate bound, corresponding signature mismatches in its callers, style guide violations regarding missing comments on exported methods, and an anti-pattern involving variable reassignment inside a closure in the hedging pilot sketch.

}

cons := request.Constraints()
best := p.bestOrderLocked(assetID, side, cons.RateBoundCmp)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Update the call to bestOrderLocked to match the updated signature that eliminates the redundant rateBoundCmp parameter.

Suggested change
best := p.bestOrderLocked(assetID, side, cons.RateBoundCmp)
best := p.bestOrderLocked(assetID, side)

Comment on lines +685 to +710
// bestOrderLocked returns the most aggressive live order on the given
// side that the caller can match against. Caller must hold p.mu.
func (p *OrderBookPilot) bestOrderLocked(id asset.ID, side bookSide,
rateBoundCmp int) *restingOrder {

var best *restingOrder
now := time.Now()
for _, o := range p.book[id] {
if o.Side != side || o.Available == 0 || o.Expires.Before(now) {
continue
}
if best == nil {
best = o
continue
}
// A bid (peer is buying from us) is better when the rate is
// lower; an ask (peer is selling to us) is better when the
// rate is higher. RateBoundCmp tells us which way to lean.
if rateBoundCmp < 0 && o.Rate.Cmp(best.Rate) < 0 {
best = o
} else if rateBoundCmp > 0 && o.Rate.Cmp(best.Rate) > 0 {
best = o
}
}
return best
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In QueryAssetRates (line 766), bestOrderLocked is called with 0 as the rateBoundCmp argument. Because of this, both rateBoundCmp < 0 and rateBoundCmp > 0 evaluate to false, meaning the function never compares rates and simply returns the first non-expired order in the book instead of the actual best order.

Since side already uniquely determines whether we want a lower rate (bookBid) or a higher rate (bookAsk), we can eliminate the rateBoundCmp parameter entirely and determine the comparison direction directly from side.

// bestOrderLocked returns the most aggressive live order on the given
// side that the caller can match against. Caller must hold p.mu.
func (p *OrderBookPilot) bestOrderLocked(id asset.ID, side bookSide) *restingOrder {

    var best *restingOrder
    now := time.Now()
    for _, o := range p.book[id] {
        if o.Side != side || o.Available == 0 || o.Expires.Before(now) {
            continue
        }
        if best == nil {
            best = o
            continue
        }
        // A bid (peer is buying from us) is better when the rate is
        // lower; an ask (peer is selling to us) is better when the
        // rate is higher.
        if side == bookBid && o.Rate.Cmp(best.Rate) < 0 {
            best = o
        } else if side == bookAsk && o.Rate.Cmp(best.Rate) > 0 {
            best = o
        }
    }
    return best
}

Comment on lines +735 to +736
func (p *OrderBookPilot) VerifyAcceptQuote(_ context.Context,
accept rfqmsg.Accept) (rfq.QuoteRespStatus, error) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Exported functions/methods in Go sketches should have comments explaining their purpose and assumptions, starting with the function name as a complete sentence, to adhere to the repository style guide.

Suggested change
func (p *OrderBookPilot) VerifyAcceptQuote(_ context.Context,
accept rfqmsg.Accept) (rfq.QuoteRespStatus, error) {
// VerifyAcceptQuote verifies that an accepted quote from a peer meets
// acceptable conditions.
func (p *OrderBookPilot) VerifyAcceptQuote(_ context.Context,
accept rfqmsg.Accept) (rfq.QuoteRespStatus, error) {
References
  1. Exported functions require detailed comments for the caller. Every function must be commented with its purpose and assumptions, starting with the function name as a complete sentence. (link)

Comment on lines +750 to +751
func (p *OrderBookPilot) QueryAssetRates(_ context.Context,
q rfq.AssetRateQuery) (rfqmsg.AssetRate, error) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Exported functions/methods in Go sketches should have comments explaining their purpose and assumptions, starting with the function name as a complete sentence, to adhere to the repository style guide.

Suggested change
func (p *OrderBookPilot) QueryAssetRates(_ context.Context,
q rfq.AssetRateQuery) (rfqmsg.AssetRate, error) {
// QueryAssetRates returns current asset rate information for a given asset
// and direction.
func (p *OrderBookPilot) QueryAssetRates(_ context.Context,
q rfq.AssetRateQuery) (rfqmsg.AssetRate, error) {
References
  1. Exported functions require detailed comments for the caller. Every function must be commented with its purpose and assumptions, starting with the function name as a complete sentence. (link)

side = bookBid
}

best := p.bestOrderLocked(assetID, side, 0)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Update the call to bestOrderLocked to match the updated signature that eliminates the redundant rateBoundCmp parameter.

Suggested change
best := p.bestOrderLocked(assetID, side, 0)
best := p.bestOrderLocked(assetID, side)

), nil
}

func (p *OrderBookPilot) Close() error { return nil }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Exported functions/methods in Go sketches should have comments explaining their purpose and assumptions, starting with the function name as a complete sentence, to adhere to the repository style guide.

Suggested change
func (p *OrderBookPilot) Close() error { return nil }
// Close releases any resources held by the order book pilot.
func (p *OrderBookPilot) Close() error { return nil }
References
  1. Exported functions require detailed comments for the caller. Every function must be commented with its purpose and assumptions, starting with the function name as a complete sentence. (link)

Comment on lines +873 to +880
// We're about to commit. Reserve hedging capacity before
// returning the accept. If reservation fails, kill the trade.
resp.WhenAccept(func(rate rfqmsg.AssetRate) {
if !p.hedger.Reserve(request, rate, resp.FillAmount()) {
resp = rfq.NewRejectResolveResp(rfqmsg.ErrUnknownReject)
}
})
return resp, nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Reassigning the captured resp variable inside the closure passed to WhenAccept to modify the outer function's return value is an anti-pattern and can be confusing. It is cleaner and safer to use a local boolean flag to track if the reservation failed, and then return the rejection response from the outer function scope.

Suggested change
// We're about to commit. Reserve hedging capacity before
// returning the accept. If reservation fails, kill the trade.
resp.WhenAccept(func(rate rfqmsg.AssetRate) {
if !p.hedger.Reserve(request, rate, resp.FillAmount()) {
resp = rfq.NewRejectResolveResp(rfqmsg.ErrUnknownReject)
}
})
return resp, nil
// We're about to commit. Reserve hedging capacity before
// returning the accept. If reservation fails, kill the trade.
var reserveFailed bool
resp.WhenAccept(func(rate rfqmsg.AssetRate) {
if !p.hedger.Reserve(request, rate, resp.FillAmount()) {
reserveFailed = true
}
})
if reserveFailed {
return rfq.NewRejectResolveResp(rfqmsg.ErrUnknownReject), nil
}
return resp, nil

@darioAnongba darioAnongba left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

worth a quick review by @jtobin as well.

Comment on lines +370 to +371
or buggy counterparty from getting away with a rate that violates a limit
the protocol promised to enforce.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be good to add a warning like:

If you ship a custom RpcPortfolioPilot, the local tapd performs no independent verification of limit-order constraints. You must reproduce these checks in your pilot.

Somewhere for custom Pilots. We currently do not re-validate.

Note that I'm not personally fond of this behaviour as I think we could always validate even when using a custom pilot.


For pilots that must run out of process, `RpcPortfolioPilot` is a thin
gRPC client. It uses URIs of the form `portfoliopilotrpc://host:port` and
dials with self-signed TLS (or insecure if explicitly requested).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think custom portfolio pilots still have InsecureSkipVerify: true. So this statement is not true yet. We should implement TLS verification aligned with price oracle here.

cc: @jtobin

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Woah, yeah that's an oversight on my part if so. Will check (and patch).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PP cert verification added in #2147.

@lightninglabs-deploy

Copy link
Copy Markdown

@jtobin: review reminder
@Roasbeef, remember to re-request review from reviewers when ready

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: 🆕 New

Development

Successfully merging this pull request may close these issues.

4 participants