""" Magic Formula by Joel Greenblatt 1. Establish a minimum market capitalization (usually greater than $50 million). 2. Exclude utility and financial stocks. 3. Exclude foreign companies (American Depositary Receipts). 4. Determine company's earnings yield = EBIT / enterprise value. 5. Determine company's return on capital = EBIT / (net fixed assets + working capital). 6. Rank all companies above chosen market capitalization by highest earnings yield and highest return on capital (ranked as percentages). 7. Invest in 20–30 highest ranked companies, accumulating 2–3 positions per month over a 12-month period. 8. Re-balance portfolio once per year, selling losers one week before the year-mark and winners one week after the year mark. Continue over a long-term (5–10+ year) period. net fixed assets = Total Assets - Total Current Assets - Total Intangibles & Goodwill #### Differences in the implementation here ### Rather than rebalancing once/year, this algo accumulates positions each month and holds them for just under or over 1 year depending on whether the return is down/up. This means that it takes a while to get up to full capacity, but the rebalancing is spread out over the course of the year rather than all at once. ##### To switch to the Acquirer's Multiple toggle the comments on lines 137/138 #### """ from datetime import timedelta import pandas as pd import numpy as np def initialize(context): #: Setting a few variables that we're using for our picks context.picks = None context.fundamental_dict = {} context.fundamental_data = None #: Choices are mf -> Magic Formula and am -> Acquirer's Multiple context.ranker_type = 'am' #: Time period in days days = 365 quarters = 4 context.time_periods = [(days/quarters)*i for i in range(0,quarters + 1)] # Total number of stocks to hold at any given time context.max_positions = 30 # Number of positions to accumulate each month context.positions_per_month = 3 context.minimum_market_cap = 500e6 #500e6 # Used to track the purchase dates of each security context.entry_dates = {} # In order to maximize post tax returns, losing positions should be sold before # the 1 year mark and winning positions should be held longer than one year context.hold_days = { 'win': timedelta(days=375), 'loss': timedelta(days=345) } # Update our historical fundamental data every month schedule_function(func=store_fundamental_data, time_rule=time_rules.market_open(), date_rule=date_rules.week_end()) # Buy Stocks at the beginning of each month schedule_function(func=buy_stocks, time_rule=time_rules.market_open(), date_rule=date_rules.week_end()) # Look to close positions every week schedule_function(func=sell_stocks, time_rule=time_rules.market_open(), date_rule=date_rules.week_start()) def before_trading_start(context): excluded_sectors = [103, 207] sector_code = fundamentals.asset_classification.morningstar_sector_code fundamental_df = get_fundamentals( query( sector_code, fundamentals.valuation.market_cap, fundamentals.valuation.enterprise_value, fundamentals.cash_flow_statement.capital_expenditure, fundamentals.operation_ratios.roic, fundamentals.income_statement.ebit, fundamentals.income_statement.ebitda, fundamentals.balance_sheet.total_assets, ) .filter(fundamentals.valuation.market_cap > context.minimum_market_cap) .filter(~sector_code.in_(excluded_sectors)) .order_by(fundamentals.valuation.market_cap.desc()) # .limit(200) ) df_length = len(fundamental_df.columns.values) if df_length < 100: log.info("Length of fundies dataframe error: %s" % len(fundamental_df.columns.values)) fundamental_df = fundamental_df.dropna(axis=1) context.fundamental_df = fundamental_df #: On the first of every month if get_datetime().day == 1: context.fundamental_dict[get_datetime()] = context.fundamental_df context.fundamental_data = pd.Panel(context.fundamental_dict) picks = run_historical_ranker(context) #: If it's not yet time to order, fill context.fundamental_df with an empty list if picks is None: context.picks = None else: context.picks = list(picks.index) if context.picks is None: universe = context.fundamental_df.columns.values[:200] else: universe = context.picks update_universe(universe) def buy_stocks(context, data): # Only accumulate positions if there's room in the portfolio stocks_owned = position_count(context, data) now = get_datetime() stocks_bought = 0 if context.picks is None: return for stock in context.picks: if stock not in data: continue if stocks_bought >= context.positions_per_month or stocks_owned >= context.max_positions: return # Skip stocks already owned if context.portfolio.positions[stock].amount: continue # Some securities throw an error, not sure why??? try: order_target_percent(stock, 1.0 / context.max_positions) context.entry_dates[stock] = now stocks_bought += 1 stocks_owned += 1 except Exception as e: log.debug(e) def sell_stocks(context, data): now = get_datetime() for stock in data: cost_basis = context.portfolio.positions[stock].cost_basis if cost_basis: returns = data[stock].price / cost_basis - 1 if returns >= 0: entry_date = context.entry_dates[stock] if now >= entry_date + context.hold_days['win']: order_target(stock, 0) elif returns < 0: entry_date = context.entry_dates[stock] if now >= entry_date + context.hold_days['loss']: order_target(stock, 0) def position_count(context, data): return sum(1 for stock in data if context.portfolio.positions[stock].amount > 0) def store_fundamental_data(context, data): """ Creates a historical fundamental data panel as well as creates stock picks to buy """ pass def run_historical_ranker(context): """ Ranks our stocks and returns the context.max_positions amount of them if available. Returns None if we currently don't have enough historical data """ #: Instantiate a few variables that we need trailing_metrics = {} fund_hist = context.fundamental_data time_periods = context.time_periods #: Check that we have data for the earliest date or before then if not check_earliest_date(time_periods, fund_hist): return None #: Create our ranks by putting them into a dict keyed by time period and the ranking for t in time_periods: temp_fund_df = find_closest_date(fund_hist, get_datetime() - timedelta(t)) if context.ranker_type == 'am': ranks = acquirers_multiple(context, temp_fund_df) elif context.ranker_type == 'mf': ranks = magic_formula_ranks(context, temp_fund_df) trailing_metrics[t] = ranks #: Create a DataFrame that we can simply call the mean() on to get a historical average trailing_metrics_df = pd.DataFrame(trailing_metrics).transpose() trailing_mean = trailing_metrics_df.mean() #: Magic Formula takes highest ranked stocks trailing_mean.sort(ascending=True) return trailing_mean.head(context.max_positions) def magic_formula_ranks(context, df): """ Creates the magic formula ranking for a given DataFrame """ #: Create our sorting mechanisms earnings_yield_ranking = df.ix['ebit'] / df.ix['enterprise_value'] roic_ranking = df.ix['roic'].copy() #: Sort our rankings earnings_yield_ranking.sort(ascending=False) roic_ranking.sort(ascending=False) #: Create our ranking mechanisms earnings_yield_ranking = pd.Series(range(len(earnings_yield_ranking)), index=earnings_yield_ranking.index) roic_ranking = pd.Series(range(len(roic_ranking)), index=roic_ranking.index) return earnings_yield_ranking + roic_ranking def acquirers_multiple(context, df): """ Creates the acquirer's multiple ranking for a given DataFrame """ multiples = df.ix['enterprise_value'] / (df.ix['ebitda'] + df.ix['capital_expenditure']) multiples.sort(ascending=True) return pd.Series(range(len(multiples)), index=multiples.index) def find_closest_date(df_panel, date): """ Finds the closest date if an exact match isn't possible, otherwise finds the exact match """ date_index = df_panel.items.searchsorted(date) date = df_panel.items[date_index] return df_panel[date] def check_earliest_date(time_periods, fund_history): """ Finds the earliest date and makes sure that we have data for that time """ earliest_date = get_datetime() - timedelta(max(time_periods)) if earliest_date < min(fund_history.items): return False return True def handle_data(context, data): record(leverage=context.account.leverage, stocks_owned=position_count(context, data)) def handle_data(context, data): #track_orders(context, data) #pvr(context, data) ; return # try uncommenting this record(current_cash = context.portfolio.cash) record(current_lvg = context.account.leverage) #if context.account.leverage > context.mx_lvrg: # context.mx_lvrg = context.account.leverage #record(max_lvrg = context.mx_lvrg) #if context.portfolio.cash < context.cash_low: # context.cash_low = context.portfolio.cash #record(cash_low = context.cash_low) return def track_orders(context, data): # Log orders created or filled. #def pvr(context, data): ''' Custom chart and/or log of profit_vs_risk returns and related information ''' # # # # # # # # # # Options # # # # # # # # # # record_max_lvrg = 1 # Maximum leverage encountered record_leverage = 0 # Leverage (context.account.leverage) record_q_return = 0 # Quantopian returns (percentage) record_pvr = 1 # Profit vs Risk returns (percentage) record_pnl = 0 # Profit-n-Loss record_shorting = 1 # Total value of any shorts record_risk = 0 # Risked, maximum cash spent or shorts in excess of cash at any time record_risk_hi = 1 # Highest risk overall record_cash = 0 # Cash available record_cash_low = 1 # Any new lowest cash level logging = 1 # Also log to the logging window conditionally (1) or not (0) log_method = 'risk_hi' # 'daily' or 'risk_hi' c = context # For brevity new_cash_low = 0 # To trigger logging in cash_low case date = str(get_datetime().date()) # To trigger logging in daily case cash = c.portfolio.cash if int(cash) < c.cash_low: # New cash low new_cash_low = 1 c.cash_low = int(cash) if record_cash_low: record(CashLow = int(c.cash_low)) pvr_rtrn = 0 # Profit vs Risk returns based on maximum spent profit_loss = 0 # Profit-n-loss shorts = 0 # Shorts value start = c.portfolio.starting_cash cash_dip = int(max(0, start - cash)) if record_cash: record(Cash = int(c.portfolio.cash)) # Cash if record_leverage: record(Lvrg = c.account.leverage) # Leverage if record_max_lvrg: if c.account.leverage > c.max_lvrg: c.max_lvrg = c.account.leverage record(MaxLv = c.max_lvrg) # Maximum leverage if record_pnl: profit_loss = c.portfolio.pnl record(PnL = profit_loss) # "Profit and Loss" in dollars for p in c.portfolio.positions: shrs = c.portfolio.positions[p].amount if shrs < 0: shorts += int(abs(shrs * data[p].price)) if record_shorting: record(Shorts = shorts) # Shorts value as a positve risk = int(max(cash_dip, shorts)) if record_risk: record(Risk = risk) # Amount in play, maximum of shorts or cash used new_risk_hi = 0 if risk > c.risk_hi: c.risk_hi = risk new_risk_hi = 1 if record_risk_hi: record(RiskHi = c.risk_hi) # Highest risk overall if record_pvr: # Profit_vs_Risk returns based on max amount actually spent (risk high) if c.risk_hi != 0: # Avoid zero-divide pvr_rtrn = 100 * (c.portfolio.portfolio_value - start) / c.risk_hi record(PvR = pvr_rtrn) # Profit_vs_Risk returns q_rtrn = 100 * (c.portfolio.portfolio_value - start) / start if record_q_return: record(QRet = q_rtrn) # Quantopian returns to compare to pvr returns curve from pytz import timezone if logging: if log_method == 'risk_hi' and new_risk_hi \ or log_method == 'daily' and c.date_prv != date \ or c.date_end == date \ or new_cash_low: qret = 'QRet ' + '%.1f' % q_rtrn mxlv = 'MaxLv ' + '%.1f' % c.max_lvrg if record_max_lvrg else '' pvr = 'PvR ' + '%.1f' % pvr_rtrn if record_pvr else '' pnl = 'PnL ' + '%.0f' % profit_loss if record_pnl else '' csh = 'Cash ' + '%.0f' % cash if record_cash else '' csh_lw = 'CshLw ' + '%.0f' % c.cash_low if record_cash_low else '' shrt = 'Shrt ' + '%.0f' % shorts if record_shorting else '' risk = 'Risk ' + '%.0f' % risk if record_risk else '' rsk_hi = 'RskHi ' + '%.0f' % c.risk_hi if record_risk_hi else '' minute = get_datetime().astimezone(timezone('US/Eastern')).time().minute log.info('{} {} {} {} {} {} {} {} {} {}'.format( minute, mxlv, qret, pvr, pnl, csh, csh_lw, shrt, risk, rsk_hi)) if c.date_end == date: # Log on last day, like cash 125199 portfolio 126890 log.info('cash {} portfolio {}'.format( int(cash), int(c.portfolio.portfolio_value))) c.date_prv = date # https://www.quantopian.com/posts/track-orders # modified here to log minute and cash #if 'orders' not in context: # context.orders = {} to_delete = [] for id in context.orders: o = get_order(id) sec = o.sid sym = sec.symbol bar_dt = get_datetime().astimezone(timezone('US/Eastern')) minute = (bar_dt.hour * 60) + bar_dt.minute - 570 # (-570 = 9:30a) if o.filled: # Filled at least some, status 1 is Filled trade = 'Bot' if o.amount > 0 else 'Sold' log.info(' {} {} {} {} at {} cash {}'.format(minute, trade, o.filled, sym, data[sec].price, int(context.portfolio.cash))) to_delete.append(o.id) else: log.info(' {} {} {} unfilled'.format( minute, o.sid.symbol, o.amount)) for sec, oo_for_sid in get_open_orders().iteritems(): # Open orders sym = sec.symbol for o in oo_for_sid: # Orders per security bar_dt = get_datetime().astimezone(timezone('US/Eastern')) minute = (bar_dt.hour * 60) + bar_dt.minute - 570 # (-570 = 9:30a) if o.id in to_delete: continue if o.status == 2: # Cancelled log.info(' Cancelled {} {} order'.format( trade, o.amount, sym, data[sec].price)) to_delete.append(o.id) elif o.id not in context.orders: # New context.orders[o.id] = 1 trade = 'Buy' if o.amount > 0 else 'Sell' if o.limit: # Limit order log.info(' {} {} {} now {} limit {}'.format( trade, o.amount, sym, data[sec].price, o.limit)) elif o.stop: # Stop order log.info(' {} {} {} now {} stop {}'.format( trade, o.amount, sym, data[sec].price, o.stop)) else: # Market order log.info(' {} {} {} {} at {} cash {}'.format(minute, trade, o.amount, sym, data[sec].price, int(context.portfolio.cash))) for d in to_delete: del context.orders[d]