Skip to content

Commit 39bd689

Browse files
committed
commit
1 parent a51a871 commit 39bd689

File tree

10 files changed

+1664
-14
lines changed

10 files changed

+1664
-14
lines changed
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
// Copyright 2026 Peter Edge
2+
//
3+
// All rights reserved.
4+
5+
// Package coveredcall implements the "holding covered-call" command group.
6+
package coveredcall
7+
8+
import (
9+
"buf.build/go/app/appcmd"
10+
"buf.build/go/app/appext"
11+
"github.com/bufdev/ibctl/cmd/ibctl/internal/command/holding/coveredcall/coveredcalllist"
12+
)
13+
14+
// NewCommand returns a new covered-call command group.
15+
func NewCommand(name string, builder appext.SubCommandBuilder) *appcmd.Command {
16+
return &appcmd.Command{
17+
Use: name,
18+
Short: "Discover covered call opportunities on existing holdings",
19+
SubCommands: []*appcmd.Command{
20+
coveredcalllist.NewCommand("list", builder),
21+
},
22+
}
23+
}
Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
// Copyright 2026 Peter Edge
2+
//
3+
// All rights reserved.
4+
5+
// Package coveredcalllist implements the "holding covered-call list" command.
6+
// It screens existing equity holdings for covered call opportunities by
7+
// fetching options chain data from a configured provider (marketdata.app
8+
// or Yahoo Finance) and displaying filtered, ranked suggestions.
9+
package coveredcalllist
10+
11+
import (
12+
"context"
13+
"fmt"
14+
"os"
15+
"strings"
16+
"time"
17+
18+
"buf.build/go/app/appcmd"
19+
"buf.build/go/app/appext"
20+
"github.com/bufdev/ibctl/cmd/ibctl/internal/ibctlcmd"
21+
"github.com/bufdev/ibctl/internal/ibctl/ibctlconfig"
22+
"github.com/bufdev/ibctl/internal/ibctl/ibctlcoveredcall"
23+
"github.com/bufdev/ibctl/internal/ibctl/ibctlfxrates"
24+
"github.com/bufdev/ibctl/internal/ibctl/ibctlholdings"
25+
"github.com/bufdev/ibctl/internal/ibctl/ibctlmerge"
26+
"github.com/bufdev/ibctl/internal/ibctl/ibctloptions"
27+
"github.com/bufdev/ibctl/internal/ibctl/ibctlpath"
28+
"github.com/bufdev/ibctl/internal/ibctl/ibctlrealtime"
29+
"github.com/bufdev/ibctl/internal/pkg/cliio"
30+
"github.com/bufdev/ibctl/internal/pkg/optionsdata"
31+
"github.com/bufdev/ibctl/internal/pkg/yahoofinance"
32+
"github.com/spf13/pflag"
33+
)
34+
35+
// NewCommand returns a new covered call list command.
36+
func NewCommand(name string, builder appext.SubCommandBuilder) *appcmd.Command {
37+
flags := newFlags()
38+
return &appcmd.Command{
39+
Use: name,
40+
Short: "List covered call opportunities on existing holdings",
41+
Long: `Screen existing equity holdings for covered call opportunities.
42+
43+
Fetches options chain data from the configured provider and displays OTM call
44+
options ranked by annualized premium yield. Only positions with ≥100 shares of
45+
USD-denominated equities are eligible.
46+
47+
PROVIDERS
48+
49+
Configure market_data_providers in ibctl.yaml to set the options data source:
50+
- marketdata_app: Data with greeks (delta, theta). Requires
51+
MARKETDATA_APP_TOKEN environment variable.
52+
- polygon: Data with greeks (delta, theta). Unlimited API calls. Requires
53+
POLYGON_TOKEN environment variable.
54+
- yahoo: Free, no auth required. No greeks (delta, theta columns hidden).
55+
56+
Default: ["yahoo"]. The first provider in the list is used.
57+
58+
COLUMNS
59+
60+
SYMBOL Underlying ticker
61+
PRICE Current stock price
62+
STRIKE Option strike price
63+
EXPIRY Expiration date (YYYY-MM-DD)
64+
DAYS Days to expiration
65+
% OTM Distance from current price to strike (%)
66+
BID Option bid price per share
67+
ASK Option ask price per share
68+
MID Midpoint of bid/ask
69+
CONTRACTS Number of contracts you can sell (position / 100)
70+
TOTAL PREM Total premium if all contracts sold (contracts * 100 * mid)
71+
ANN YIELD Annualized yield (mid / price * 365 / days * 100)
72+
VOLUME Option trading volume
73+
OPEN INT Open interest
74+
IV Implied volatility (%)
75+
DELTA Option delta (marketdata_app only)
76+
THETA Daily time decay per share (marketdata_app only)`,
77+
Args: appcmd.NoArgs,
78+
Run: builder.NewRunFunc(
79+
func(ctx context.Context, container appext.Container) error {
80+
return run(ctx, container, flags)
81+
},
82+
),
83+
BindFlags: flags.Bind,
84+
}
85+
}
86+
87+
type flags struct {
88+
// Format is the output format (table, csv, json).
89+
Format string
90+
// Download fetches fresh data before displaying.
91+
Download bool
92+
// BaseCurrency is the target currency for value conversion (e.g., "USD", "CAD").
93+
BaseCurrency string
94+
// Realtime fetches real-time quotes and FX rates from Yahoo Finance.
95+
Realtime bool
96+
// MinOTMPercent is the minimum % out of the money for the strike.
97+
MinOTMPercent float64
98+
// MaxOTMPercent is the maximum % out of the money for the strike.
99+
MaxOTMPercent float64
100+
// MinDays is the minimum days to expiration.
101+
MinDays int
102+
// MaxDays is the maximum days to expiration.
103+
MaxDays int
104+
// MinOpenInterest is the minimum open interest for liquidity filtering.
105+
MinOpenInterest int64
106+
// Symbol filters to a specific symbol (empty = all eligible).
107+
Symbol string
108+
// Sort controls the sort order (yield, premium, otm, expiry).
109+
Sort string
110+
}
111+
112+
func newFlags() *flags {
113+
return &flags{}
114+
}
115+
116+
// Bind registers the flag definitions with the given flag set.
117+
func (f *flags) Bind(flagSet *pflag.FlagSet) {
118+
flagSet.StringVar(&f.Format, "format", "table", "Output format (table, csv, json)")
119+
flagSet.BoolVar(&f.Download, "download", false, "Download fresh data before displaying")
120+
flagSet.StringVar(&f.BaseCurrency, "base-currency", "USD", "Base currency for value conversion (e.g., USD, CAD)")
121+
flagSet.BoolVar(&f.Realtime, "realtime", false, "Fetch real-time quotes and FX rates from Yahoo Finance")
122+
flagSet.Float64Var(&f.MinOTMPercent, "min-otm-pct", 3, "Minimum % out of the money for strike")
123+
flagSet.Float64Var(&f.MaxOTMPercent, "max-otm-pct", 50, "Maximum % out of the money for strike")
124+
flagSet.IntVar(&f.MinDays, "min-days", 14, "Minimum days to expiration")
125+
flagSet.IntVar(&f.MaxDays, "max-days", 45, "Maximum days to expiration")
126+
flagSet.Int64Var(&f.MinOpenInterest, "min-open-interest", 10, "Minimum open interest (liquidity filter)")
127+
flagSet.StringVar(&f.Symbol, "symbol", "", "Filter to a specific symbol")
128+
flagSet.StringVar(&f.Sort, "sort", "yield", "Sort by: yield, premium, otm, expiry")
129+
}
130+
131+
func run(ctx context.Context, container appext.Container, flags *flags) error {
132+
format, err := cliio.ParseFormat(flags.Format)
133+
if err != nil {
134+
return appcmd.NewInvalidArgumentError(err.Error())
135+
}
136+
sortField, err := ibctlcoveredcall.ParseSortField(flags.Sort)
137+
if err != nil {
138+
return appcmd.NewInvalidArgumentError(err.Error())
139+
}
140+
// Validate that min/max ranges are consistent.
141+
if flags.MinOTMPercent > flags.MaxOTMPercent {
142+
return appcmd.NewInvalidArgumentErrorf("--min-otm-pct (%.1f) must be <= --max-otm-pct (%.1f)", flags.MinOTMPercent, flags.MaxOTMPercent)
143+
}
144+
if flags.MinDays > flags.MaxDays {
145+
return appcmd.NewInvalidArgumentErrorf("--min-days (%d) must be <= --max-days (%d)", flags.MinDays, flags.MaxDays)
146+
}
147+
// Normalize base currency to uppercase for case-insensitive matching.
148+
baseCurrency := strings.ToUpper(flags.BaseCurrency)
149+
// Resolve the ibctl directory from the IBKR_DIR environment variable.
150+
dirPath, err := ibctlcmd.DirPath(container)
151+
if err != nil {
152+
return err
153+
}
154+
// Read and validate the configuration file from the base directory.
155+
config, err := ibctlconfig.ReadConfig(dirPath)
156+
if err != nil {
157+
return err
158+
}
159+
// Resolve the options data provider from the configured preference list.
160+
// Resolve the options data provider using the app container's env lookup.
161+
provider, err := ibctloptions.ResolveProvider(config.MarketDataProviders, container.Env)
162+
if err != nil {
163+
return err
164+
}
165+
container.Logger().Info("using options provider", "provider", provider.Name(), "has_greeks", provider.HasGreeks())
166+
// Download fresh data if --download is set.
167+
if flags.Download {
168+
downloader, err := ibctlcmd.NewDownloader(container, dirPath)
169+
if err != nil {
170+
return err
171+
}
172+
if err := downloader.Download(ctx); err != nil {
173+
return err
174+
}
175+
}
176+
// Merge seed lots + Activity Statement CSVs + Flex Query cached data across all accounts.
177+
mergedData, err := ibctlmerge.Merge(
178+
ibctlpath.DataAccountsDirPath(config.DirPath),
179+
ibctlpath.CacheAccountsDirPath(config.DirPath),
180+
ibctlpath.ActivityStatementsDirPath(config.DirPath),
181+
ibctlpath.SeedDirPath(config.DirPath),
182+
config.AccountAliases,
183+
config.Additions,
184+
)
185+
if err != nil {
186+
return err
187+
}
188+
// Load FX rates for base currency conversion.
189+
fxStore := ibctlfxrates.NewStore(ibctlpath.CacheFXDirPath(config.DirPath))
190+
// Override market prices and FX rates with real-time data from Yahoo Finance.
191+
if flags.Realtime {
192+
todayDate := time.Now().Format("2006-01-02")
193+
if err := ibctlrealtime.ApplyOverrides(ctx, container.Logger(), yahoofinance.NewClient(), mergedData.Positions, fxStore, config, baseCurrency, todayDate); err != nil {
194+
return err
195+
}
196+
}
197+
// Compute holdings via FIFO from all trade data.
198+
result, err := ibctlholdings.GetHoldingsOverview(mergedData.Trades, mergedData.Positions, mergedData.CashPositions, config, fxStore, baseCurrency)
199+
if err != nil {
200+
return err
201+
}
202+
// Filter holdings to eligible positions for covered calls.
203+
// Collect symbols for options chain fetch.
204+
var eligibleSymbols []string
205+
for _, h := range result.Holdings {
206+
if flags.Symbol != "" && h.Symbol != flags.Symbol {
207+
continue
208+
}
209+
if h.Currency == "USD" && h.Category == "EQUITY" {
210+
eligibleSymbols = append(eligibleSymbols, h.Symbol)
211+
}
212+
}
213+
if len(eligibleSymbols) == 0 {
214+
container.Logger().Info("no eligible holdings for covered calls")
215+
return nil
216+
}
217+
// Fetch options chains for all eligible symbols from the configured provider.
218+
container.Logger().Info("fetching options chains", "symbols", len(eligibleSymbols), "provider", provider.Name())
219+
optionsData, err := provider.GetOptionsChains(ctx, eligibleSymbols, optionsdata.FetchParams{
220+
MinDays: flags.MinDays,
221+
MaxDays: flags.MaxDays,
222+
})
223+
if err != nil {
224+
return fmt.Errorf("fetching options chains: %w", err)
225+
}
226+
// Compute covered call suggestions from holdings and options data.
227+
suggestions := ibctlcoveredcall.GetCoveredCallSuggestions(
228+
result.Holdings,
229+
optionsData,
230+
ibctlcoveredcall.Params{
231+
MinOTMPercent: flags.MinOTMPercent,
232+
MaxOTMPercent: flags.MaxOTMPercent,
233+
MinDays: flags.MinDays,
234+
MaxDays: flags.MaxDays,
235+
MinOpenInterest: flags.MinOpenInterest,
236+
SortField: sortField,
237+
HasGreeks: provider.HasGreeks(),
238+
},
239+
)
240+
// Write output in the requested format.
241+
writer := os.Stdout
242+
switch format {
243+
case cliio.FormatTable:
244+
headers := ibctlcoveredcall.Headers(provider.HasGreeks())
245+
rows := make([][]string, 0, len(suggestions))
246+
for _, s := range suggestions {
247+
rows = append(rows, ibctlcoveredcall.SuggestionToTableRow(s, provider.HasGreeks()))
248+
}
249+
return cliio.WriteTable(writer, headers, rows)
250+
case cliio.FormatCSV:
251+
headers := ibctlcoveredcall.Headers(true) // CSV always includes all columns.
252+
records := make([][]string, 0, len(suggestions)+1)
253+
records = append(records, headers)
254+
for _, s := range suggestions {
255+
records = append(records, ibctlcoveredcall.SuggestionToRow(s))
256+
}
257+
return cliio.WriteCSVRecords(writer, records)
258+
case cliio.FormatJSON:
259+
return cliio.WriteJSON(writer, suggestions...)
260+
default:
261+
return appcmd.NewInvalidArgumentErrorf("unsupported format: %s", format)
262+
}
263+
}

cmd/ibctl/internal/command/holding/holding.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"buf.build/go/app/appcmd"
1010
"buf.build/go/app/appext"
1111
"github.com/bufdev/ibctl/cmd/ibctl/internal/command/holding/category"
12+
"github.com/bufdev/ibctl/cmd/ibctl/internal/command/holding/coveredcall"
1213
"github.com/bufdev/ibctl/cmd/ibctl/internal/command/holding/geo"
1314
"github.com/bufdev/ibctl/cmd/ibctl/internal/command/holding/holdinglist"
1415
"github.com/bufdev/ibctl/cmd/ibctl/internal/command/holding/holdingvalue"
@@ -23,6 +24,7 @@ func NewCommand(name string, builder appext.SubCommandBuilder) *appcmd.Command {
2324
Short: "Display holding information",
2425
SubCommands: []*appcmd.Command{
2526
category.NewCommand("category", builder),
27+
coveredcall.NewCommand("covered-call", builder),
2628
geo.NewCommand("geo", builder),
2729
holdinglist.NewCommand("list", builder),
2830
lot.NewCommand("lot", builder),

internal/ibctl/ibctlconfig/ibctlconfig.go

Lines changed: 36 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,11 @@ type ExternalConfigV1 struct {
9595
// InterestRates maps currency codes to annual interest rates on cash balances.
9696
// Used by projected-income to estimate cash interest income (e.g., {"USD": 0.0312}).
9797
InterestRates map[string]float64 `yaml:"interest_rates"`
98+
// MarketDataProviders is an ordered list of options data providers for covered call screening.
99+
// Valid values: "marketdata_app" (requires MARKETDATA_APP_TOKEN env var),
100+
// "polygon" (requires POLYGON_TOKEN env var), "yahoo" (free, no greeks).
101+
// Default: ["yahoo"].
102+
MarketDataProviders []string `yaml:"market_data_providers"`
98103
}
99104

100105
// ExternalTaxConfigV1 holds capital gains tax rate configuration.
@@ -180,6 +185,9 @@ type Config struct {
180185
TaxRateInterest float64
181186
// CashInterestRates maps currency codes to annual interest rates on cash balances.
182187
CashInterestRates map[string]float64
188+
// MarketDataProviders is the ordered list of options data providers for covered call screening.
189+
// Valid values: "marketdata_app", "polygon", "yahoo". Default: ["yahoo"].
190+
MarketDataProviders []string
183191
}
184192

185193
// SymbolConfig holds classification metadata for a symbol.
@@ -294,21 +302,35 @@ func NewConfigV1(externalConfig ExternalConfigV1, dirPath string) (*Config, erro
294302
if cashInterestRates == nil {
295303
cashInterestRates = make(map[string]float64)
296304
}
305+
// Parse and validate market data providers, defaulting to ["yahoo"] if not configured.
306+
marketDataProviders := externalConfig.MarketDataProviders
307+
if len(marketDataProviders) == 0 {
308+
marketDataProviders = []string{"yahoo"}
309+
}
310+
for _, providerName := range marketDataProviders {
311+
switch providerName {
312+
case "marketdata_app", "polygon", "yahoo":
313+
// Valid provider name.
314+
default:
315+
return nil, fmt.Errorf("invalid market_data_providers value %q, must be \"marketdata_app\", \"polygon\", or \"yahoo\"", providerName)
316+
}
317+
}
297318
return &Config{
298-
DirPath: dirPath,
299-
IBKRFlexQueryID: externalConfig.FlexQueryID,
300-
AccountAliases: accountAliases,
301-
AccountIDToAlias: accountIDToAlias,
302-
SymbolConfigs: symbolConfigs,
303-
CashAdjustments: cashAdjustments,
304-
TaxRateSTCG: taxRateSTCG,
305-
TaxRateLTCG: taxRateLTCG,
306-
TaxRateDividend: taxRateDividend,
307-
TaxRateInterest: taxRateInterest,
308-
Additions: additions,
309-
AdditionLastPrices: additionLastPrices,
310-
RealtimeSymbols: realtimeSymbols,
311-
CashInterestRates: cashInterestRates,
319+
DirPath: dirPath,
320+
IBKRFlexQueryID: externalConfig.FlexQueryID,
321+
AccountAliases: accountAliases,
322+
AccountIDToAlias: accountIDToAlias,
323+
SymbolConfigs: symbolConfigs,
324+
CashAdjustments: cashAdjustments,
325+
TaxRateSTCG: taxRateSTCG,
326+
TaxRateLTCG: taxRateLTCG,
327+
TaxRateDividend: taxRateDividend,
328+
TaxRateInterest: taxRateInterest,
329+
Additions: additions,
330+
AdditionLastPrices: additionLastPrices,
331+
RealtimeSymbols: realtimeSymbols,
332+
CashInterestRates: cashInterestRates,
333+
MarketDataProviders: marketDataProviders,
312334
}, nil
313335
}
314336

0 commit comments

Comments
 (0)