diff --git a/src/parser/calendar/data/PhutilCalendarRecurrenceRule.php b/src/parser/calendar/data/PhutilCalendarRecurrenceRule.php
--- a/src/parser/calendar/data/PhutilCalendarRecurrenceRule.php
+++ b/src/parser/calendar/data/PhutilCalendarRecurrenceRule.php
@@ -44,6 +44,9 @@
   private $initialYear;
   private $baseYear;
   private $isAllDay;
+  private $activeSet = array();
+  private $nextSet = array();
+  private $minimumEpoch;
 
   const FREQUENCY_SECONDLY = 'SECONDLY';
   const FREQUENCY_MINUTELY = 'MINUTELY';
@@ -336,6 +339,37 @@
     $this->stateMonth = null;
     $this->stateYear = null;
 
+    // If we have a BYSETPOS, we need to generate the entire set before we
+    // can filter it and return results. Normally, we start generating at
+    // the start date, but we need to go back one interval to generate
+    // BYSETPOS events so we can make sure the entire set is generated.
+    if ($this->getBySetPosition()) {
+      $frequency = $this->getFrequency();
+      $interval = $this->getInterval();
+      switch ($frequency) {
+        case self::FREQUENCY_YEARLY:
+          $this->cursorYear -= $interval;
+          break;
+        case self::FREQUENCY_MONTHLY:
+          $this->cursorMonth -= $interval;
+          while ($this->cursorMonth < 1) {
+            $this->cursorYear--;
+            $this->cursorMonth += 12;
+          }
+          break;
+        default:
+          throw new Exception(
+            pht(
+              'BYSETPOS not yet supported for FREQ "%s".',
+              $frequency));
+      }
+
+      $this->minimumEpoch = $this->getStartDateTime()->getEpoch();
+    } else {
+      $this->minimumEpoch = null;
+    }
+
+
     $this->initialMonth = $this->cursorMonth;
     $this->initialYear = $this->cursorYear;
 
@@ -355,30 +389,92 @@
   }
 
   public function getNextEvent($cursor) {
+    while (true) {
+      $event = $this->generateNextEvent();
+      if (!$event) {
+        break;
+      }
+
+      $epoch = $event->getEpoch();
+      if ($this->minimumEpoch) {
+        if ($epoch < $this->minimumEpoch) {
+          continue;
+        }
+      }
+
+      if ($epoch < $cursor) {
+        continue;
+      }
+
+      break;
+    }
+
+    return $event;
+  }
+
+  private function generateNextEvent() {
+    if ($this->activeSet) {
+      return array_pop($this->activeSet);
+    }
+
     $this->baseYear = $this->cursorYear;
 
-    if ($this->isAllDay) {
-      $this->nextDay();
-    } else {
-      $this->nextSecond();
+    $by_setpos = $this->getBySetPosition();
+    if ($by_setpos) {
+      $old_state = $this->getSetPositionState();
     }
 
-    $result = id(new PhutilCalendarAbsoluteDateTime())
-      ->setViewerTimezone($this->getViewerTimezone())
-      ->setYear($this->stateYear)
-      ->setMonth($this->stateMonth)
-      ->setDay($this->stateDay);
+    while (!$this->activeSet) {
+      $this->activeSet = $this->nextSet;
+      $this->nextSet = array();
 
-    if ($this->isAllDay) {
-      $result->setIsAllDay(true);
-    } else {
-      $result
-        ->setHour($this->stateHour)
-        ->setMinute($this->stateMinute)
-        ->setSecond($this->stateSecond);
+      while (true) {
+        if ($this->isAllDay) {
+          $this->nextDay();
+        } else {
+          $this->nextSecond();
+        }
+
+        $result = id(new PhutilCalendarAbsoluteDateTime())
+          ->setViewerTimezone($this->getViewerTimezone())
+          ->setYear($this->stateYear)
+          ->setMonth($this->stateMonth)
+          ->setDay($this->stateDay);
+
+        if ($this->isAllDay) {
+          $result->setIsAllDay(true);
+        } else {
+          $result
+            ->setHour($this->stateHour)
+            ->setMinute($this->stateMinute)
+            ->setSecond($this->stateSecond);
+        }
+
+        // If we don't have BYSETPOS, we're all done. We put this into the
+        // set and will immediately return it.
+        if (!$by_setpos) {
+          $this->activeSet[] = $result;
+          break;
+        }
+
+        // Otherwise, check if we've completed a set. The set is complete if
+        // the state has moved past the span we were examining (for example,
+        // with a YEARLY event, if the state is now in the next year).
+        $new_state = $this->getSetPositionState();
+        if ($new_state == $old_state) {
+          $this->activeSet[] = $result;
+          continue;
+        }
+
+        $this->activeSet = $this->applySetPos($this->activeSet, $by_setpos);
+        $this->activeSet = array_reverse($this->activeSet);
+        $this->nextSet[] = $result;
+        $old_state = $new_state;
+        break;
+      }
     }
 
-    return $result;
+    return array_pop($this->activeSet);
   }
 
 
@@ -392,7 +488,6 @@
     $interval = $this->getInterval();
     $is_secondly = ($frequency == self::FREQUENCY_SECONDLY);
     $by_second = $this->getBySecond();
-    $by_setpos = $this->getBySetPosition();
 
     while (!$this->setSeconds) {
       $this->nextMinute();
@@ -407,10 +502,6 @@
         );
       }
 
-      if ($is_secondly && $by_setpos) {
-        $seconds = $this->applySetPos($seconds, $by_setpos);
-      }
-
       $this->setSeconds = array_reverse($seconds);
     }
 
@@ -428,7 +519,6 @@
     $scale = $this->getFrequencyScale();
     $is_minutely = ($frequency === self::FREQUENCY_MINUTELY);
     $by_minute = $this->getByMinute();
-    $by_setpos = $this->getBySetPosition();
 
     while (!$this->setMinutes) {
       $this->nextHour();
@@ -447,10 +537,6 @@
         );
       }
 
-      if ($is_minutely && $by_setpos) {
-        $minutes = $this->applySetPos($minutes, $by_setpos);
-      }
-
       $this->setMinutes = array_reverse($minutes);
     }
 
@@ -468,7 +554,6 @@
     $scale = $this->getFrequencyScale();
     $is_hourly = ($frequency === self::FREQUENCY_HOURLY);
     $by_hour = $this->getByHour();
-    $by_setpos = $this->getBySetPosition();
 
     while (!$this->setHours) {
       $this->nextDay();
@@ -487,10 +572,6 @@
         );
       }
 
-      if ($is_hourly && $by_setpos) {
-        $hours = $this->applySetPos($hours, $by_setpos);
-      }
-
       $this->setHours = array_reverse($hours);
     }
 
@@ -513,7 +594,6 @@
     $by_monthday = $this->getByMonthDay();
     $by_yearday = $this->getByYearDay();
     $by_weekno = $this->getByWeekNumber();
-    $by_setpos = $this->getBySetPosition();
     $week_start = $this->getWeekStart();
 
     while (!$this->setDays) {
@@ -553,19 +633,9 @@
         }
       }
 
-      // Apply weekly BYSETPOS, if one exists.
-      if ($is_weekly && $by_setpos) {
-        $weeks = $this->applySetPos($weeks, $by_setpos);
-      }
-
       // Unpack the weeks into days.
       $days = array_mergev($weeks);
 
-      // Apply daily BYSETPOS, if one exists.
-      if ($is_daily && $by_setpos) {
-        $days = $this->applySetPos($days, $by_setpos);
-      }
-
       $this->setDays = array_reverse($days);
     }
 
@@ -584,7 +654,6 @@
     $is_monthly = ($frequency === self::FREQUENCY_MONTHLY);
 
     $by_month = $this->getByMonth();
-    $by_setpos = $this->getBySetPosition();
 
     // If we have a BYMONTHDAY, we consider that set of days in every month.
     // For example, "FREQ=YEARLY;BYMONTHDAY=3" means "the third day of every
@@ -618,10 +687,6 @@
         );
       }
 
-      if ($is_monthly && $by_setpos) {
-        $months = $this->applySetPos($months, $by_setpos);
-      }
-
       $this->setMonths = array_reverse($months);
     }
 
@@ -1097,6 +1162,8 @@
     }
 
     sort($select);
+    $select = array_unique($select);
+
     return array_select_keys($values, $select);
   }
 
@@ -1139,4 +1206,16 @@
     }
   }
 
+  private function getSetPositionState() {
+    $scale = $this->getFrequencyScale();
+
+    $parts = array();
+    $parts[] = $this->stateYear;
+    if ($scale < self::SCALE_YEARLY) {
+      $parts[] = $this->stateMonth;
+    }
+
+    return implode('/', $parts);
+  }
+
 }
diff --git a/src/parser/calendar/data/__tests__/PhutilCalendarRecurrenceRuleTestCase.php b/src/parser/calendar/data/__tests__/PhutilCalendarRecurrenceRuleTestCase.php
--- a/src/parser/calendar/data/__tests__/PhutilCalendarRecurrenceRuleTestCase.php
+++ b/src/parser/calendar/data/__tests__/PhutilCalendarRecurrenceRuleTestCase.php
@@ -419,19 +419,16 @@
       '19970902T063010Z',
     );
 
-    // TODO: This does not pass yet because BYSETPOS is not implemented
-    // properly for YEARLY rules.
-
-    // $tests[] = array(
-    //   'BYMONTHDAY' => array(15),
-    //   'BYHOUR' => array(6, 18),
-    //   'BYSETPOS' => array(3, -3),
-    // );
-    // $expect[] = array(
-    //   '19971115T180000Z',
-    //   '19980215T060000Z',
-    //   '19981115T180000Z',
-    // );
+    $tests[] = array(
+      'BYMONTHDAY' => array(15),
+      'BYHOUR' => array(6, 18),
+      'BYSETPOS' => array(3, -3),
+    );
+    $expect[] = array(
+      '19971115T180000Z',
+      '19980215T060000Z',
+      '19981115T180000Z',
+    );
 
     $this->assertRules(
       array(
@@ -587,18 +584,15 @@
       '20010301',
     );
 
-    // TODO: This test does not pass yet because BYSETPOS is not implemented
-    // correctly.
-
-    // $tests[] = array(
-    //   'BYDAY' => array('MO', 'TU', 'WE', 'TH', 'FR'),
-    //   'BYSETPOS' => array(-1),
-    // );
-    // $expect[] = array(
-    //   '19970930',
-    //   '19971031',
-    //   '19971128',
-    // );
+    $tests[] = array(
+      'BYDAY' => array('MO', 'TU', 'WE', 'TH', 'FR'),
+      'BYSETPOS' => array(-1),
+    );
+    $expect[] = array(
+      '19970930',
+      '19971031',
+      '19971128',
+    );
 
     $tests[] = array(
       'BYDAY' => array('1MO', '1TU', '1WE', '1TH', '1FR', '-1FR'),
@@ -647,40 +641,38 @@
       '19971002T000006Z',
     );
 
-    // TODO: These tests rely on BYSETPOS and do not work yet.
-
-    // $tests[] = array(
-    //   'BYMONTHDAY' => array(13, 17),
-    //   'BYHOUR' => array(6, 18),
-    //   'BYSETPOS' => array(3, -3),
-    // );
-    // $expect[] = array(
-    //   '19970913T180000Z',
-    //   '19970917T060000Z',
-    //   '19971013T180000Z',
-    // );
-
-    // $tests[] = array(
-    //   'BYMONTHDAY' => array(13, 17),
-    //   'BYHOUR' => array(6, 18),
-    //   'BYSETPOS' => array(3, 3, -3),
-    // );
-    // $expect[] = array(
-    //   '19970913T180000Z',
-    //   '19970917T060000Z',
-    //   '19971013T180000Z',
-    // );
-
-    // $tests[] = array(
-    //   'BYMONTHDAY' => array(13, 17),
-    //   'BYHOUR' => array(6, 18),
-    //   'BYSETPOS' => array(4, -1),
-    // );
-    // $expect[] = array(
-    //   '19970917T180000Z',
-    //   '19971017T180000Z',
-    //   '19971117T180000Z',
-    // );
+    $tests[] = array(
+      'BYMONTHDAY' => array(13, 17),
+      'BYHOUR' => array(6, 18),
+      'BYSETPOS' => array(3, -3),
+    );
+    $expect[] = array(
+      '19970913T180000Z',
+      '19970917T060000Z',
+      '19971013T180000Z',
+    );
+
+    $tests[] = array(
+      'BYMONTHDAY' => array(13, 17),
+      'BYHOUR' => array(6, 18),
+      'BYSETPOS' => array(3, 3, -3),
+    );
+    $expect[] = array(
+      '19970913T180000Z',
+      '19970917T060000Z',
+      '19971013T180000Z',
+    );
+
+    $tests[] = array(
+      'BYMONTHDAY' => array(13, 17),
+      'BYHOUR' => array(6, 18),
+      'BYSETPOS' => array(4, -1),
+    );
+    $expect[] = array(
+      '19970917T180000Z',
+      '19971017T180000Z',
+      '19971117T180000Z',
+    );
 
     $this->assertRules(
       array(