Local Python utilities for analyzing a Schwab portfolio export on your own machine. The current workflows in this repo have been tested against a Schwab portfolio CSV export format.
The current primary script in this repo is final_portfolio_noise_checker_v2.py. It is a mark-noise and sanity-check tool, not an official P/L calculator, accounting system, or tax report. Its purpose is to help reconcile Schwab's displayed option day P/L against simpler economic checks for a narrow set of call spreads and short puts.
Install dependencies:
python -m pip install pandas yfinance numpy openpyxl requestsRun the latest checker against a Schwab holdings CSV:
python.exe .\final_portfolio_noise_checker_v2.py .\my_holdings.csvOptional: force yfinance IV plus Black-Scholes delta instead of any CSV delta column:
python.exe .\final_portfolio_noise_checker_v2.py .\my_holdings.csv --use-yf-delta-onlyOptional: override rate and dividend assumptions for the Black-Scholes fallback:
python.exe .\final_portfolio_noise_checker_v2.py .\my_holdings.csv --risk-free-rate 0.04 --dividend-yield 0.01Estimate after-hours portfolio P/L from a Schwab holdings export:
python.exe .\after_hours_portfolio_pnl.py .\my_holdings.csvUse Coinbase equity perpetuals as the preferred overnight price source for supported names such as MU, BE, NVDA, and TSM:
python.exe .\after_hours_portfolio_pnl.py .\my_holdings.csv --prefer-perpForce the DRAM ETF to use a component basket proxy when you want memory-stock overnight moves reflected through its holdings:
python.exe .\after_hours_portfolio_pnl.py .\my_holdings.csv --prefer-etf-proxyList currently supported perp-driven price sources for the tickers in your holdings:
python.exe .\after_hours_portfolio_pnl.py .\my_holdings.csv --list-perpsRun the Nasdaq-100 quant model scanner:
python.exe .\nasdaq100_quant_model.py scan --top 12 --csv .\my_holdings.csvRun the Nasdaq-100 quant model backtest:
python.exe .\nasdaq100_quant_model.py backtest --years 5 --top 12final_portfolio_noise_checker_v2.py looks for two specific situations:
- Same-expiration vertical call spreads that should have clean intrinsic behavior but Schwab shows a negative day P/L.
- Out-of-the-money short puts where a simple delta-based expected P/L can be compared to Schwab's displayed day P/L.
It then estimates a clean add-back for apparent mark noise.
This is not official P/L. It is not a broker replacement, not a tax number, and not a full options valuation engine.
This project is designed for local use only.
- Do not commit or share
my_holdings.csv. - Do not commit generated
.csv,.xls, or.xlsxoutputs. - Keep the project folder local if it contains real account data.
- If you share the code, share only Python files and
README.md, not your account exports.
The included .gitignore is set up to help prevent accidental commits of raw Schwab exports and generated spreadsheet outputs.
The scripts expect a Schwab portfolio CSV export with Schwab-style columns such as:
SymbolDescriptionQtyorQuantityDay Chng $or similar day P/L columnPriceDeltaorOption Deltaif available
Option symbols are expected in Schwab-style text like:
MU 03/19/2027 590 C
TSLA 04/17/2026 250 P
or descriptions like:
CALL MICRON TECHNOLOGY I$590 EXP 03/19/27
PUT TESLA INC $250 EXP 04/17/26
Primary script:
final_portfolio_noise_checker_v2.py
Additional utility:
after_hours_portfolio_pnl.pynasdaq100_quant_model.py
If other older scripts exist in the repo, treat this file as the intended final noise-checker version unless you are explicitly debugging an older workflow.
after_hours_portfolio_pnl.py estimates after-hours or overnight mark changes for the holdings in a Schwab export.
For stocks and ETFs:
estimated_ah_pl =
shares × (after_hours_price - regular_price)
For options, the script:
- reads the regular-session option mark from the CSV
- infers implied volatility from that mark
- reprices the option using the updated after-hours underlying price
- falls back to an intrinsic-change approximation if IV inference fails
Default behavior:
- use Yahoo
postMarketPricewhen available - otherwise use Yahoo
preMarketPrice - otherwise fall back to Coinbase equity perpetual prices for supported names
Perpetual-futures support:
--list-perpsprints which held tickers currently have supported perp-based pricing sources--prefer-perpmakes the script prefer the supported perpetual price even if Yahoo post-market data exists
Current supported held names are determined live from Coinbase's public equity perpetual feed and Hyperliquid direct perp overrides and, when last checked, included names such as:
AAPLBEINTCMETAMSFTMUNVDASNDKTSLATSM
Direct perp overrides currently include:
DRAMvia Hyperliquidxyz
For perpetual pricing, the script prefers:
- Coinbase perpetual
index_price - Coinbase perpetual last
price - Coinbase perpetual
mid_market_price
This keeps the overnight proxy closer to the underlying reference than to a noisy single trade.
When you use --prefer-perp, certain income or proxy ETFs can inherit QQQ's overnight move when they do not have a better direct after-hours signal.
Current QQQ-linked proxy coverage includes:
QQQIXQQI
These are treated as approximate 1.0x QQQ move proxies for overnight estimation, so they are useful for a fast portfolio read but should not be treated as an official NAV calculation.
DRAM can also be estimated from its memory-stock basket when you want moves in names such as MU, SNDK, SK Hynix, Samsung, or Kioxia to flow through the ETF estimate.
Use:
python.exe .\after_hours_portfolio_pnl.py .\my_holdings.csv --prefer-etf-proxyOr combine it with perpetual pricing:
python.exe .\after_hours_portfolio_pnl.py .\my_holdings.csv --prefer-perp --prefer-etf-proxyNotes:
- by default, if
DRAMitself has a real Yahoo after-hours quote, the script uses that direct quote --prefer-etf-proxyoverrides that and uses the basket-based estimate instead- the proxy may be partial if not every component has a usable live quote, so the output includes
etf_proxy_coverage_pct
The position-level CSV produced by after_hours_portfolio_pnl.py --output ... includes fields such as:
regular_underlyingafter_hours_underlyingafter_hours_sourceperp_symbolperp_index_priceperp_last_priceetf_proxy_coverage_pctestimated_ah_priceestimated_ah_plpricing_method
nasdaq100_quant_model.py is a practical hybrid model for ranking current Nasdaq-100 names and producing buy / hold / sell / reduce flags.
It has three modes:
universe: fetch the current public Nasdaq-100 constituent list from Nasdaqscan: score the current universe and optionally tag names from a Schwab holdings CSVbacktest: run a simple weekly-rebalance historical test on the same score
The live scanner combines technical and fundamental buckets:
trend_score:8EMA,21EMA,50MA,200MA, and price-vs-average structurers_score: 1-month, 3-month, and 6-month relative-strength ranking across the Nasdaq-100confirm_score:MACDhistogram,RSI, and Bollinger-band contextpenalty_score: overextension penalties for very highRSI, stretched price-vs-ATR, and extreme Bollinger extensionfundamental_score: growth, quality, and valuation signals from liveyfinancefundamentalsrisk_quality_score: margin quality plus debt-discipline overlay
The final score is:
technical_score =
0.35 * trend_score
+ 0.30 * rs_score
+ 0.20 * confirm_score
- 0.15 * penalty_score
composite_score =
0.50 * technical_score
+ 0.35 * fundamental_score
+ 0.15 * risk_quality_score
The current fundamental layer uses:
forward_peprice_to_sales_ttmas the live sales-multiple proxyp_to_fcfcomputed frommarketCap / freeCashflowwhen free cash flow is positiverevenue_growthearnings_growthgross_marginoperating_marginprofit_marginfcf_marginreturn_on_equitydebt_to_equity
Because yfinance does not consistently expose a true forward price-to-sales field, the model currently uses trailing price-to-sales as the sales-multiple input.
The model uses QQQ as a regime filter. It only flips fully risk-on when:
QQQ > 200MA50MA > 200MAQQQ > 21EMA
If that filter is not satisfied, the scanner shifts to WAIT / REDUCE / SELL behavior and the backtest moves to cash.
The backtest currently uses the current public Nasdaq-100 constituent list, not a point-in-time historical membership database. That means the backtest has survivorship bias and should be treated as a research tool, not a production-grade historical result.
Also, the backtest is currently technical-only. The live scan includes the fundamental layer, but the historical backtest does not yet use point-in-time fundamental data.
The script parses option rows from a Schwab holdings CSV, fetches stock quotes, and applies two separate rule sets:
- intrinsic-only logic for qualifying ITM same-expiration vertical call spreads
- delta-based logic for qualifying OTM short puts
It writes both CSV and Excel outputs and prints a terminal summary including TOTAL CLEAN ADD-BACK.
The script only flags same-expiration vertical call spreads where all of the following are true:
- same ticker
- same expiration
- long lower-strike call
- short higher-strike call
- current stock price is greater than the short call strike, meaning the spread is ITM and capped
- stock day change is positive
- net spread day P/L is negative
Call spreads do not use delta in this script. They use intrinsic value only.
For a spread with lower strike L, upper strike U, stock price S, and contracts contracts:
spread_intrinsic =
max(S - L, 0)
-
max(S - U, 0)
Then cap it:
spread_intrinsic = min(max(spread_intrinsic, 0), U - L)
Then convert intrinsic to position value:
spread_value = spread_intrinsic × contracts × 100
Then compute expected day P/L from yesterday to today:
intrinsic_expected_day_pl =
spread_value_today - spread_value_yesterday
The script compares that expected intrinsic move to Schwab's displayed spread day P/L:
diff_to_add_back = intrinsic_expected_day_pl - schwab_net_day_pl
If a spread was fully capped yesterday and is still fully capped today, intrinsic_expected_day_pl is usually zero even if Schwab shows a large negative day P/L.
Example:
MU 590/650 C, 50 contracts- if MU was already above 650 yesterday and is still above 650 today, the spread intrinsic is
$60both days - expected day P/L is
$0 - if Schwab shows
-$5,716, the add-back is about$5,716
Another example:
XLK 175/190 C, 50 contracts- width =
$15 - max value =
$75,000 - if yesterday XLK was about
186.8, intrinsic was about11.8 × 50 × 100 = $59,000 - if today XLK is above
190, intrinsic is capped at15 × 50 × 100 = $75,000 - expected intrinsic gain is about
$16,000 - if Schwab showed
-$1,843, then the add-back is about$17,843
The script only checks short puts where:
- current stock price is greater than the put strike
For those rows, expected day P/L is:
expected_day_pl =
abs(put_delta) × stock_day_change × contracts × 100
If stock_day_change is negative, expected P/L is negative.
The comparison to Schwab is:
diff_to_add_back =
delta_expected_day_pl - schwab_day_pl
Only positive put diff_to_add_back values are counted in the final put add-back summary.
Example:
- short 50
MU 600P - put delta =
0.14 - stock up
$47 - expected P/L =
0.14 × 47 × 50 × 100 = $32,900 - if Schwab shows
+$9,994, the add-back is about$22,906
Preferred source:
- if the CSV contains
DeltaorOption Delta, the script uses that broker/platform delta by default
The script normalizes these formats correctly for short-put calculations:
-0.140.14-1414
All of the above are treated as an absolute put delta of 0.14 for expected short-put P/L.
Fallback behavior:
- if no usable CSV delta exists, or if you run with
--use-yf-delta-only, the script fetches option-chain implied volatility fromyfinance - it then computes a Black-Scholes long-put delta
Formula used:
put_delta = -exp(-qT) × N(-d1)
where:
d1 = [ln(S/K) + (r - q + 0.5σ²)T] / [σ√T]
Definitions:
S= current stock priceK= strikeT= years to expirationr= risk-free rateq= dividend yieldσ= implied volatilityN= standard normal CDF
Defaults:
- risk-free rate =
4.5% - dividend yield =
0%
CLI override example:
python.exe .\final_portfolio_noise_checker_v2.py .\my_holdings.csv --risk-free-rate 0.04 --dividend-yield 0.01The script must parse strikes from the Schwab option symbol or description, not from the Schwab Price column.
A prior bug incorrectly treated Schwab's Price column as if it were Strike Price, which produced fake spreads like:
590.495/647.361 C
Those are option prices, not strikes.
Correct spreads should look like:
590/650 C
340/350 C
175/190 C
Debugging note:
- if the output shows decimal strike spreads that look like option prices, the parser is wrong or the wrong script version is being run
Calendar spreads and diagonal spreads are intentionally not included in the intrinsic-only call-spread add-back rule.
They depend on factors such as:
- front-month delta
- back-month delta
- theta
- vega
- term structure
- dividends
- early assignment risk
- bid/ask marks
Because of that, they should not be judged using the same intrinsic-only rule used for same-expiration vertical call spreads.
The script writes these files:
bad_itm_upday_call_spreads.csvotm_short_put_delta_check.csvfinal_noise_summary.csvfinal_portfolio_noise_report.xlsx
Excel workbook sheets:
SummaryBad ITM Up-Day CallsOTM Short PutsQuotesParsed Options
For call spreads:
tickerexpirationspreadcontractsstock_changelong_day_plshort_day_plschwab_net_day_plintrinsic_expected_day_pldiff_to_add_back
For short puts:
tickerexpirationshort_putcontractsstock_changedelta_used_absdelta_sourceschwab_day_pldelta_expected_day_pldiff_to_add_backpositive_addbackcsv_deltayf_ivyf_matched_expirationyf_note
Notes on put columns:
positive_addbackis created for the final put summary and clips negative values to zerodelta_sourceis typicallycsv/broker_deltaoryf_iv_black_scholesyf_notehelps explain expiration or strike matching behavior whenyfinancefallback is used
TOTAL CLEAN ADD-BACK is the estimated amount by which Schwab's displayed day P/L may be understated under this final rule set.
Interpretation formula:
cleaner estimated day P/L =
broker displayed day P/L + TOTAL CLEAN ADD-BACK
Example:
- broker day P/L =
-$300,000 TOTAL CLEAN ADD-BACK = $72,000- cleaner estimate =
-$228,000
Again, this is not official P/L and not an accounting or tax number.
If the script prints:
No bad ITM up-day call spreads found.
No OTM short puts found.
TOTAL CLEAN ADD-BACK: $0.00
check the following:
- did the script parse option rows at all
- open
final_portfolio_noise_report.xlsxand inspect theParsed Optionssheet - are strikes correct, such as
590,650,175,190 - or are they bogus decimal option prices like
590.495 - open the
Quotessheet - are
last,prev_close, andstock_changepopulated - were the stocks actually up on the day being checked
- are the call spreads truly current price greater than short strike
- are the puts truly current price greater than put strike
- does the CSV contain a usable day P/L column and quantity column
- are you running the correct latest script
yfinance can be delayed, stale, incomplete, or missing for some expirations and strikes.
Limitations of the fallback:
yfinanceoption data may be delayed or staleyfinanceusually does not provide Greeks directly- Black-Scholes here is only a fallback approximation
- Schwab or Thinkorswim delta may differ because broker models may use full volatility surfaces, dividends, rates, skew, early exercise assumptions, and American-option logic
- for best accuracy, export a
Deltacolumn from the broker if possible
If quote fetching fails and a quote-debug version is present in the repo, use:
final_portfolio_noise_checker_v3_quote_debug.py
It supports:
python.exe .\final_portfolio_noise_checker_v3_quote_debug.py .\my_holdings.csv --quote-csv .\quotes.csvManual quote CSV format can be either:
ticker,last,prev_close
MU,971,923.52
XLK,191,186.83or:
ticker,last,stock_change
MU,971,47.48
XLK,191,4.17If Schwab changes its export format and you see parsing errors, inspect the CSV header row and verify that the expected symbol, quantity, day P/L, and description fields still exist.
If Excel export fails, install openpyxl:
python -m pip install openpyxlBefore sharing the repo or code:
- Remove
my_holdings.csv. - Remove generated
.csv,.xls, and.xlsxfiles. - Share only code and docs, not account data.