def investment_performance(s, *, alternate_investment=None, signals=None, dates=None): '''Return interesting facts about an investment in a :code:`Performance` object.''' dates = dates or core.current_dates() alternate_investment = alternate_investment or constant(1) signals = signals and to_signals(signals) with core.date_scope(dates): equity = equity_line( s, signals, alternate_investment=alternate_investment) if signals else s vol_res = 1 - volatility(equity) dd_res = series_drawdown(equity).residual() annualized = gpa(equity) return Performance( volatility_residual=vol_res, drawdown_residual=dd_res, annualized=annualized, long_ratio=(long_ratio(signals) if signals else None), signal_count=(signals and series_count(signals)))
def conviction(s, N, dates=None): '''A conviction signal series avoids signal whipsaws by forcing signals to persist N periods before firing. The signal, if fired, is delayed by N periods as well. For instance, with a conviction of 1, two sequential signals are simply erased. If not erased, the signal values of the input are delayed by one period.''' dates = dates or core.current_dates() dv = [dt for dt in dates] vals = list(map(s.f, dates)) sigv = signalify_vector_copy(vals) last_ndx = -1000000 for (ndx, sig) in enumerate(sigv): if sig: if (ndx - last_ndx <= N): sigv[ndx] = None sigv[last_ndx] = None last_ndx = ndx fd = dates.first_date() ld = dates.last_date() outv = [None for dt in range(fd, ld + 1)] # warp for (sig, dt) in zip(sigv, dv[N:]): if sig: outv[dt - fd] = sig return core.vector_series(outv, fd)
def prepend(s, *, surrogate=None, dates=None): dates = (dates or core.current_dates()) if surrogate is None: raise Exception("surrogate required") ob = first_ob(add(s, surrogate), dates=dates) if ob is None: raise Exception("no observation") base_f = s.f surr_f = surrogate.f common_date = ob[0] ratio = s.f(ob[0]) / surr_f(ob[0]) dv = dates.vec fd = dates.first_date() ld = dates.last_date() outv = [None for dt in range(fd, ld + 1)] for (ndx, dt) in enumerate(dv): val = base_f(dt) if not core.is_valid_num(val) and dt < common_date: val = surr_f(dt) * ratio if core.is_valid_num(val): outv[dt - fd] = val return core.vector_series(outv, fd, name="prepend(...)")
def repeated(s, repeat_last=False, dates=None): '''Fill missing values in a series with preceding ones, optionally continuing the last valid observation.''' dates = dates or core.current_dates() vals = list(map(s.f, dates)) fd = dates.first_date() ld = dates.last_date() prev = vals[0] last_valid_date = fd for ndx, val in enumerate(vals): if ndx and not core.is_valid_num(val): vals[ndx] = prev else: last_valid_date = dates.vec[ndx] prev = vals[ndx] fd = dates.first_date() ld = dates.last_date() outv = [None for dt in range(fd, ld + 1)] for (dt, val) in zip(dates, vals): if (not repeat_last) and (dt > last_valid_date): break if core.is_valid_num(val): outv[dt - fd] = val return core.vector_series(outv, fd)
def mo_days(s, days, dates=None): '''Similar to :code:`mo(s,N)`, :code:`mo_days(s,days)` calculates the new/old ratios along the specified dates for each new, but each old-date is based upon calendar days rather than periods. If the calendar days back does not line up with a market day, the value of most recent available earlier observation is selected.''' sf = s.f sf_old = (fudge(s)).f if days < 1: raise Exception('expected positive days') dates = dates or core.current_dates() dv = dates.vec fd = dv[0] outv = [None for dt in range(dv[0], dv[-1] + 1)] for dt in dates: r = ratio(sf_old(dt - days), sf(dt)) if core.is_valid_num(r): outv[dt - fd] = r name = "mo_days({},{})".format(abbreviate(s), days) return core.vector_series(outv, fd, name=name)
def ma(s, N, dates=None): '''Create running N-period arithmetic average of input series.''' dates = dates or core.current_dates() vals = series_dates_values(s, dates) fd = dates.first_date() ld = dates.last_date() outv = [None for dt in range(fd, ld + 1)] total = 0 consecutive = 0 for ndx, dt in enumerate(dates): val = vals[ndx] if core.is_valid_num(val): total += val consecutive += 1 else: consecutive = 0 total = 0 if consecutive > N: total -= vals[ndx - N] if consecutive >= N: outv[dt - fd] = (total / N) return core.vector_series(outv, fd, name="SMA({})".format(N))
def first_ob(s, dates=None): '''Return first available (dt,value) tuple.''' dates = dates or core.current_dates() f = s.f for dt in dates: val = f(dt) if core.is_valid_num(val): return (dt, val) return None
def gpa(s, dates=None): '''Calculate the annualized gain of series s over dates.''' dates = dates or core.current_dates() early = first_ob(s, dates=dates) late = last_ob(s, dates=dates) days = late[0] - early[0] years = days / 365.2425 gain = late[1] / early[1] return (gain**(1 / years)) - 1
def series_count(s, dates=None): '''Returns the number of observations in a series.''' dates = dates or core.current_dates() def valid(dt): return core.is_valid_num(s.f(dt)) return sum((1 for dt in dates if valid(dt)))
def measure(name, thunk): ITERATIONS = 10 start = time.time() for i in range(ITERATIONS): v = thunk() f = v.f for dt in core.current_dates(): f(dt) elapsed = time.time() - start print('{:12}\t{}'.format(name, int(ITERATIONS / 1.0 / elapsed)))
def last_ob(s, dates=None): '''Return last available (dt,value) tuple.''' dates = dates or core.current_dates() dv = dates.vec f = s.f for ndx in range(len(dv) - 1, -1, -1): dt = dv[ndx] val = f(dt) if core.is_valid_num(val): return (dt, val) return None
def to_signals(s, dates=None): '''Transform a series into non-repeating negative-one (-1) where the input is negative non-repeating one (1) where the input is non-negative.''' dates = dates or core.current_dates() vals = list(map(s.f, dates.vec)) sigv = signalify_vector_copy(vals) fd = dates.first_date() ld = dates.last_date() outv = [None for dt in range(fd, ld + 1)] for (dt, sig) in zip(dates, sigv): if sig: outv[dt - fd] = sig return core.vector_series(outv, fd, name='sigs({})'.format(abbreviate(s)))
def dump(*seriess, first=None, last=None, dts=None): '''dump takes multiple series, with optional dates constraints and lists them in date order. It is only useful interactively, but in that case, very useful.''' def format(n): return '{:12.4f}'.format(n) sfs = [s.f for s in seriess] dts = dts or core.current_dates() fd = (first or dts.first_date()) ld = (last or dts.last_date()) for dt in range(fd, ld + 1): nums = [f(dt) for f in sfs] if sum([(1 if core.is_valid_num(n) else 0) for n in nums]): rounded = map( lambda n: (format(n) if core.is_valid_num(n) else ' '), nums) print("{} {}".format(core.jdate_to_text(dt), ' '.join(rounded)))
def series_drawdowns(s, *, max_residual=1.0, dates=None): '''series_drawdowns returns all non-overlapping drawdowns, constrained by max_residual. The result is ordered by the residual, i.e. worst drawdown first.''' dates = dates or core.current_dates() if (len(dates.vec) < 2): return [] dd = series_drawdown(s, dates=dates) if not dd or (dd.residual() > max_residual): return [] dd_left = series_drawdowns(s, max_residual=max_residual, dates=core.dates(dates, last=dd.max[0])) or [] dd_right = series_drawdowns( s, max_residual=max_residual, dates=core.dates(dates, first=dd.min[0])) or [] return sorted(dd_left + [dd] + dd_right, key=lambda a: a.residual())
def cross(*, slower=None, faster=None, upfactor=1.0, downfactor=1.0, dates=None): '''cross generates a signal series, i.e. series of [1,-1,None], set to 1 where the faster series moves above the slower series and -1 where it moves below. Changing the upfactor from its default 1.0 changes the border for crossing. For instance, upfactor=1.02 means that the faster series must exceed the lower series by 2% before generating the buy value of 1. Likewise, setting downfactor to .98 would require passing 2% below the faster series to generate a sell signal of -1. As usual, dates can be supplied, but default to the current_dates() value.''' if (not slower) or (not faster): raise Exception('slower,faster are required keyword arguments') dates = dates or core.current_dates() sv = series_dates_values(slower, dates) fv = series_dates_values(faster, dates) obs = [] prev_sig = None for ndx, dt in enumerate(dates): s_val = sv[ndx] f_val = fv[ndx] if not (core.is_valid_num(s_val) and core.is_valid_num(f_val)): continue if f_val > s_val * upfactor: sig = 1 elif f_val < s_val * downfactor: sig = -1 else: sig = None if sig and (sig != prev_sig): obs.append((dt, sig)) prev_sig = sig return obs_to_series(obs)
def warp(s, N, dates=None): '''warp creates an N-period shift of values within dateset. Negative periods shift data backward in time. Often a signal series if warped with N=1 to measure performance of a trading scheme if trading happens the next market day.''' dates = (dates or core.current_dates()) dv = dates.vec sf = s.f fd = dates.first_date() ld = dates.last_date() outv = [None for dt in range(fd, ld + 1)] for (ndx, dt) in enumerate(dv): val = sf(dt) if core.is_valid_num(val): new_ndx = ndx + N if 0 <= new_ndx < len(dv): new_dt = dv[new_ndx] outv[new_dt - fd] = val return core.vector_series(outv, fd, name="warp({})".format(N))
def fractional(s, fraction, dates=None): '''A fractional smoothes a series such that the current value is weighted by some fraction in (0..1) added to the previous value weighted by (1 - fraction).''' dates = dates or core.current_dates() fd, ld = s.first_date(), s.last_date() outv = [None for dt in range(fd, ld + 1)] remainder = 1 - fraction prev = None f = s.f for dt in dates: val = f(dt) newVal = ((fraction * val + remainder * prev) if (core.is_valid_num(prev) and core.is_valid_num(val)) else val) outv[dt - fd] = newVal prev = newVal return core.vector_series(outv, fd, name="fractional({},{})".format( abbreviate(s), fraction))
def window_series (s, N, proc, dates=None, missing_data_permitted=False): dates = (dates or core.current_dates()) dv = dates.vec sf = s.f fd = dates.first_date() ld = dates.last_date() vv = [(n if core.is_valid_num(n) else None) for n in (sf(dt) for dt in dv)] outv = [ None for dt in range(fd,ld+1) ] count = 0 for (ndx, dt) in enumerate(dv): val = sf(dt) count = (count+1 if (missing_data_permitted or core.is_valid_num(val)) else 0) if count >= N: stop = ndx+1 start = stop-N result = proc(vv[start:stop]) if core.is_valid_num(result): outv[dt - fd] = result return core.vector_series(outv, fd, name="window_series({})".format(N))
def mo(s, N, dates=None): '''Return the N-period ratio series of new/old values.''' if N < 1: raise Exception('requires period > 0') dates = dates or core.current_dates() sf = s.f dv = dates.vec shifted_dv = dv[min(N, len(dv)):] outv = [None for dt in range(dv[0], dv[-1] + 1)] fd = dv[0] for (early, late) in zip(dv, shifted_dv): e_val = sf(early) l_val = sf(late) r = ratio(e_val, l_val) if core.is_valid_num(r): outv[late - fd] = r name = "mo({},{})".format(abbreviate(s), N) return core.vector_series(outv, fd, name=name)
def unrepeated(s, dates=None): '''Copy the input series, suppressing repeated values.''' dates = dates or core.current_dates() vals = list(map(s.f, dates)) prev = vals[0] for ndx, val in enumerate(vals): if ndx and val == prev: vals[ndx] = None prev = val fd = dates.first_date() ld = dates.last_date() outv = [None for dt in range(fd, ld + 1)] for (dt, val) in zip(dates, vals): if core.is_valid_num(val): outv[dt - fd] = val return core.vector_series(outv, fd)
def min_max_obs(s, dates=None): '''Return a 2-tuple of (dt,value) observations representing the points of minimum and maximum values respectively.''' dates = dates or core.current_dates() min_ob = None max_ob = None f = s.f for dt in dates: val = f(dt) if core.is_valid_num(val): if not min_ob: min_ob = max_ob = (dt, val) if min_ob[1] > val: min_ob = (dt, val) if max_ob[1] < val: max_ob = (dt, val) if not min_ob: raise Exception("no observations") return min_ob, max_ob
def reversals(s, down_factor=1.0, up_factor=1.0, dates=None): '''When a series ascends above up-factor multiplied by a preceding local minimum, a buy (1) signal is produced. Upon descending below the product of a local maximum and down-factor, a sell (-1) signal is produced. ''' dates = dates or core.current_dates() fd, ld = core.first_date(dates), core.last_date(dates) outv = [None for dt in range(fd, ld + 1)] min_ob = max_ob = first_ob(s, dates) sf = s.f state = None for dt in dates: val = sf(dt) if not core.is_valid_num(val): continue if val > max_ob[1]: max_ob = (dt, val) if val < min_ob[1]: min_ob = (dt, val) if (1 != state) and (val > min_ob[1] * up_factor): max_ob = min_ob = (dt, val) outv[dt - fd] = 1 state = 1 elif (-1 != state) and (val < max_ob[1] * down_factor): max_ob = min_ob = (dt, val) outv[dt - fd] = -1 state = -1 return core.vector_series(outv, fd, name="reversals({})".format(abbreviate(s)))
def series_drawdown(s, dates=None): '''series_drawdown returns a drawdown object containing the beginning and ending observation points in an input series resulting in the greatest decrease in value. This answers the question "How much unrealized loss did this investment have?"''' dates = dates or core.current_dates() obs = series_to_obs(dates, s) acc = [] for ob in reversed(obs): if (not acc) or (ob[1] < acc[-1][1]): acc.append(ob) reversed_mins = list(reversed(acc)) acc = [] for ob in obs: if (not acc) or (ob[1] > acc[-1][1]): acc.append(ob) maxs = acc def find_following_min(mx_ob): for ob in reversed_mins: if ob[0] > mx_ob[0]: return ob return None def make_drawdown(mx_ob): mn_ob = find_following_min(mx_ob) return drawdown(mx_ob, mn_ob) if mn_ob else None def more_drawdown(a, b): return a if (a.residual() < b.residual()) else b pairs = list(filter(None, [make_drawdown(mx_ob) for mx_ob in maxs])) return functools.reduce(more_drawdown, pairs) if len(pairs) else None
def capture(s, benchmark, period, dates, predicate): if not benchmark: raise Exception("benchmark series must be specified") if not period: raise Exception("expected period > 0") dates = (dates or core.current_dates()) sf = mo(s, period).f bf = mo(benchmark, period).f B_total = 0 S_total = 0 for dt in dates.vec: B_val = bf(dt) if core.is_valid_num(B_val) and predicate(B_val): S_val = sf(dt) if core.is_valid_num(S_val): B_total = B_total + B_val S_total = S_total + S_val return (S_total / B_total) if B_total else None
def verify_two_series(self,a,b): f1 = a.f f2 = b.f for dt in core.current_dates(): self.assertEqual(approx(f1(dt)),approx(f2(dt)))
def setUp(self): self.TEST_SERIES = obs_to_series(TEST_SERIES_OBS) core.current_dates(core.dates(self.TEST_SERIES))
def volatility(s, days=365, dates=None): '''volatility is the standard deviation of the mo_days calculation.''' dates = dates or core.current_dates() vals = series_dates_values(mo_days(s, days=days, dates=dates), dates) return standard_deviation(vals)
def equity_line(s, signals, initial_value=100, alternate_investment=None, dates=None): '''The point of most signal generation is to go in and out of invested position on some series, optionally buying an alternate investment such as short-duration debt. The equity_line series represents the value of the investment after treating signals as entrance and exit points during the dateset indicated. ''' dates = dates or core.current_dates() dv = dates.vec fd = dates.first_date() ld = dates.last_date() alternate_investment = alternate_investment or constant(1) alt_vals = series_dates_values(alternate_investment, dates) inv_vals = series_dates_values(s, dates) sig_vals = series_dates_values(signals, dates) outv = [None for dt in range(fd, ld + 1)] product = 1.0 buy = True prev_inv = prev_alt = None first_sig_ob = first_ob(signals, dates=dates) if not first_sig_ob: raise Exception("signal series is empty") for (dt, alt, inv, sig) in zip(dv, alt_vals, inv_vals, sig_vals): if dt < first_sig_ob[0]: continue if sig or (core.is_valid_num(alt) and core.is_valid_num(inv)): change = None if not core.is_valid_num(inv): raise Exception("missing investment observation at {}".format( core.jdate_to_text(dt))) if not core.is_valid_num(alt): raise Exception( "missing alternate_investment observation at {}".format( core.jdate_to_text(dt))) if buy: if prev_inv: change = inv / prev_inv else: change = 1.0 else: if prev_alt: change = alt / prev_alt else: change = 1.0 # prior to having signal, nothing was invested if dt <= first_sig_ob[0]: change = 1.0 new_buy = (sig > 0) if sig else buy new_product = product * change outv[dt - fd] = new_product * initial_value product = new_product buy = new_buy prev_inv = inv prev_alt = alt return core.vector_series(outv, fd)